From 2b4105ada84cfd5963759ff1c099411d06be9449 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 16:35:27 +0700 Subject: [PATCH 001/136] feat(evidence): implement version one runtime Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 5 + .github/scripts/test_ci_changes.py | 23 + .github/workflows/ci.yml | 28 + .gitignore | 4 + Cargo.lock | 49 + Cargo.toml | 3 + README.md | 13 +- crates/registry-evidence/Cargo.toml | 62 + .../examples/evidence-contracts.rs | 28 + crates/registry-evidence/src/audit.rs | 1167 ++++++ crates/registry-evidence/src/auth.rs | 458 +++ crates/registry-evidence/src/binding.rs | 391 ++ crates/registry-evidence/src/bundle.rs | 2026 ++++++++++ crates/registry-evidence/src/config.rs | 3312 +++++++++++++++++ crates/registry-evidence/src/contracts.rs | 1019 +++++ crates/registry-evidence/src/kernel.rs | 2171 +++++++++++ crates/registry-evidence/src/lib.rs | 32 + crates/registry-evidence/src/main.rs | 2418 ++++++++++++ crates/registry-evidence/src/model.rs | 428 +++ crates/registry-evidence/src/problem.rs | 126 + crates/registry-evidence/src/rate_limit.rs | 273 ++ crates/registry-evidence/src/rhai_runtime.rs | 2986 +++++++++++++++ crates/registry-evidence/src/runtime.rs | 1068 ++++++ crates/registry-evidence/src/runtime_tests.rs | 2332 ++++++++++++ crates/registry-evidence/src/secrets.rs | 384 ++ crates/registry-evidence/src/selector.rs | 1040 ++++++ crates/registry-evidence/src/server.rs | 671 ++++ crates/registry-evidence/src/signing.rs | 329 ++ crates/registry-evidence/src/source.rs | 1605 ++++++++ crates/registry-evidence/src/values.rs | 259 ++ crates/registry-evidence/src/verifier.rs | 679 ++++ crates/registry-evidence/tests/cli.rs | 117 + .../tests/deployment_projects.rs | 1243 +++++++ .../registry-evidence/tests/live_sources.rs | 738 ++++ .../tests/security_contract_traceability.rs | 135 + .../tests/selector_conformance.rs | 1637 ++++++++ .../tests/source_contracts.rs | 2217 +++++++++++ products/evidence/.env.example | 14 + .../contracts/audit-event.schema.yaml | 90 + .../contracts/authority-context.schema.yaml | 136 + .../evidence/contracts/bundle.schema.yaml | 676 ++++ .../contracts/cccev-field-mapping.yaml | 88 + .../evidence/contracts/evidence.schema.yaml | 104 + products/evidence/contracts/jws-profile.yaml | 59 + .../evidence/contracts/primitive-library.yaml | 99 + .../evidence/contracts/problem-contract.yaml | 36 + .../evidence/contracts/request.schema.yaml | 64 + products/evidence/contracts/rhai-abi.yaml | 137 + .../evidence/contracts/runtime.schema.yaml | 83 + .../contracts/security-invariant-matrix.yaml | 171 + .../contracts/security-test-traceability.yaml | 126 + .../evidence/contracts/selector-contract.yaml | 85 + .../evidence/contracts/source-contract.yaml | 131 + .../contracts/supported-value-forms.yaml | 63 + .../adapters/source-a-prepare.rhai | 15 + .../adult-status/adapters/source-a.rhai | 21 + .../derivations/adult-status.rhai | 11 + .../acceptance/adult-status/evidence.yaml | 83 + .../adult-status/fixtures/cases.yaml | 33 + .../schemas/adapter-parameters.schema.yaml | 7 + .../adult-status/schemas/facts.schema.yaml | 5 + .../adapters/adult-status-prepare.rhai | 4 + .../adapters/adult-status-source.rhai | 11 + .../legal-parent-relationship-prepare.rhai | 4 + .../legal-parent-relationship-source.rhai | 22 + .../professional-licence-prepare.rhai | 4 + .../adapters/professional-licence-source.rhai | 20 + .../adapters/residence-region-prepare.rhai | 4 + .../adapters/residence-region-source.rhai | 11 + .../professional-expiry-categories.yaml | 3 + .../professional-registry-regions.yaml | 3 + .../codelists/residence-region-map.yaml | 7 + .../derivations/adult-status.rhai | 11 + .../legal-parent-relationship.rhai | 5 + .../derivations/professional-licence.rhai | 25 + .../derivations/residence-region.rhai | 10 + .../acceptance/all-definitions/evidence.yaml | 287 ++ .../fixtures/adult-status-cases.yaml | 29 + .../legal-parent-relationship-cases.yaml | 62 + .../fixtures/professional-licence-cases.yaml | 65 + .../fixtures/residence-region-cases.yaml | 26 + ...dult-status-adapter-parameters.schema.yaml | 6 + .../schemas/adult-status-facts.schema.yaml | 5 + ...elationship-adapter-parameters.schema.yaml | 9 + ...egal-parent-relationship-facts.schema.yaml | 14 + ...nal-licence-adapter-parameters.schema.yaml | 6 + .../professional-licence-facts.schema.yaml | 7 + ...ence-region-adapter-parameters.schema.yaml | 6 + .../residence-region-facts.schema.yaml | 5 + .../adapters/source-d-prepare.rhai | 11 + .../adapters/source-d.rhai | 56 + .../legal-parent-relationship.rhai | 19 + .../legal-parent-relationship/evidence.yaml | 91 + .../fixtures/cases.yaml | 104 + .../schemas/adapter-parameters.schema.yaml | 15 + .../schemas/facts.schema.yaml | 14 + .../adapters/source-c-prepare.rhai | 12 + .../adapters/source-c.rhai | 33 + .../codelists/expiry-categories.yaml | 3 + .../codelists/registry-regions.yaml | 3 + .../derivations/professional-licence.rhai | 25 + .../professional-licence/evidence.yaml | 75 + .../professional-licence/fixtures/cases.yaml | 71 + .../schemas/adapter-parameters.schema.yaml | 6 + .../schemas/facts.schema.yaml | 7 + .../adapters/source-b-prepare.rhai | 11 + .../residence-region/adapters/source-b.rhai | 19 + .../codelists/region-map.yaml | 7 + .../derivations/residence-region.rhai | 10 + .../acceptance/residence-region/evidence.yaml | 63 + .../residence-region/fixtures/cases.yaml | 30 + .../schemas/adapter-parameters.schema.yaml | 7 + .../schemas/facts.schema.yaml | 5 + .../conformance/acquisition-postures.yaml | 27 + .../conformance/anti-reconstruction.yaml | 51 + .../fixtures/conformance/audit-events.yaml | 60 + .../fixtures/conformance/coverage-matrix.yaml | 175 + .../conformance/golden/adult-evidence.json | 27 + .../conformance/golden/adult-request.json | 17 + .../conformance/golden/licence-evidence.json | 31 + .../conformance/golden/licence-request.json | 16 + .../golden/relationship-evidence.json | 31 + .../golden/relationship-request.json | 21 + .../golden/residence-evidence.json | 27 + .../conformance/golden/residence-request.json | 15 + .../fixtures/conformance/jws-cases.yaml | 27 + .../oauth-query-credential-redaction.yaml | 38 + .../fixtures/conformance/selector-matrix.yaml | 151 + .../classification-source-prepare.rhai | 10 + .../adapters/classification-source.rhai | 6 + .../adapters/context-source-prepare.rhai | 14 + .../selectors/adapters/context-source.rhai | 6 + .../adapters/grant-source-prepare.rhai | 15 + .../selectors/adapters/grant-source.rhai | 6 + .../adapters/opaque-source-prepare.rhai | 14 + .../selectors/adapters/opaque-source.rhai | 6 + .../adapters/relationship-source-prepare.rhai | 29 + .../adapters/relationship-source.rhai | 6 + .../selectors/codelists/opaque-codes.yaml | 3 + .../selectors/derivations/classification.rhai | 3 + .../selectors/derivations/opaque.rhai | 3 + .../derivations/property-with-event.rhai | 3 + .../selectors/derivations/property.rhai | 3 + .../selectors/derivations/relationship.rhai | 3 + .../conformance/selectors/evidence.yaml | 327 ++ .../conformance/selectors/fixtures/cases.yaml | 12 + .../schemas/adapter-parameters.schema.yaml | 6 + .../selectors/schemas/facts.schema.yaml | 5 + .../conformance/supported-values.yaml | 115 + .../adapters/source-prepare.rhai | 11 + .../supported-values/adapters/source.rhai | 9 + .../codelists/categories.yaml | 3 + .../codelists/date-buckets.yaml | 3 + .../codelists/synthetic-codes.yaml | 3 + .../codelists/time-buckets.yaml | 3 + .../supported-values/derivations/values.rhai | 6 + .../supported-values/evidence.yaml | 114 + .../supported-values/fixtures/cases.yaml | 11 + .../schemas/adapter-parameters.schema.yaml | 7 + .../schemas/closed-structure.schema.yaml | 8 + .../schemas/facts.schema.yaml | 5 + .../adapters/nested-paged-rest-prepare.rhai | 15 + .../adapters/nested-paged-rest.rhai | 62 + .../dhis2-tracker-style/contract.yaml | 107 + .../responses/ambiguous.json | 18 + .../responses/error-envelope.json | 7 + .../responses/inconsistent-cardinality.json | 19 + .../dhis2-tracker-style/responses/match.json | 16 + .../responses/missing-fact.json | 14 + .../responses/no-match.json | 9 + .../schemas/adapter-parameters.schema.yaml | 11 + .../schemas/facts.schema.yaml | 5 + .../flat-rest/adapters/flat-rest-prepare.rhai | 23 + .../flat-rest/adapters/flat-rest.rhai | 32 + .../source-shapes/flat-rest/contract.yaml | 77 + .../flat-rest/responses/ambiguous.json | 7 + .../flat-rest/responses/error-envelope.json | 6 + .../flat-rest/responses/match.json | 6 + .../flat-rest/responses/missing-fact.json | 4 + .../flat-rest/responses/no-match.json | 4 + .../schemas/adapter-parameters.schema.yaml | 6 + .../flat-rest/schemas/facts.schema.yaml | 5 + .../fixtures/source-shapes/index.yaml | 16 + .../adapters/event-search-json-prepare.rhai | 18 + .../adapters/event-search-json.rhai | 37 + .../contract.yaml | 116 + .../responses/ambiguous.json | 21 + .../responses/error-envelope.json | 7 + .../responses/inconsistent-cardinality.json | 4 + .../responses/match.json | 17 + .../responses/missing-fact.json | 14 + .../responses/no-match.json | 4 + .../schemas/adapter-parameters.schema.yaml | 8 + .../schemas/facts.schema.yaml | 5 + .../generated/evidence-request-v1.schema.json | 94 + .../generated/evidence-v1.schema.json | 247 ++ .../generated/flattened-jws-v1.schema.json | 29 + .../evidence/generated/jwks-v1.schema.json | 56 + .../evidence/generated/problem-v1.schema.json | 199 + .../generated/registry-evidence.openapi.json | 1421 +++++++ .../bundle/adapters/extract.rhai | 57 + .../bundle/adapters/prepare.rhai | 15 + .../bundle/derivations/adult-status.rhai | 18 + .../dhis2-adult-status/bundle/evidence.yaml | 130 + .../bundle/fixtures/cases.yaml | 111 + .../schemas/adapter-parameters.schema.yaml | 18 + .../bundle/schemas/facts.schema.yaml | 6 + .../dhis2-adult-status/runtime.yaml | 22 + .../bundle/adapters/birth-adult-extract.rhai | 43 + .../bundle/adapters/birth-event-prepare.rhai | 18 + .../adapters/birth-parents-extract.rhai | 63 + .../bundle/derivations/adult-status.rhai | 18 + .../registered-parent-references.rhai | 37 + .../registered-parent-relationship.rhai | 40 + .../bundle/evidence.yaml | 265 ++ .../bundle/fixtures/adult-status-cases.yaml | 66 + .../registered-parent-references-cases.yaml | 78 + .../registered-parent-relationship-cases.yaml | 116 + .../schemas/birth-adult-facts.schema.yaml | 6 + .../birth-adult-parameters.schema.yaml | 10 + .../schemas/birth-parents-facts.schema.yaml | 19 + .../birth-parents-parameters.schema.yaml | 33 + .../opencrvs-family-evidence/runtime.yaml | 20 + .../dhis2-tracker/expected-lookup-result.json | 6 + .../dhis2-tracker/expected-request-parts.json | 14 + .../dhis2-tracker/extract.rhai | 82 + .../dhis2-tracker/facts.schema.yaml | 8 + .../dhis2-tracker/parameters.schema.yaml | 27 + .../dhis2-tracker/prepare-input.json | 26 + .../dhis2-tracker/prepare.rhai | 59 + .../response-malformed-count.json | 16 + .../dhis2-tracker/response-match.json | 16 + .../request-adapter/dhis2-tracker/source.yaml | 51 + .../expected-lookup-result.json | 10 + .../expected-request-parts.json | 17 + .../opencrvs-event-search/extract.rhai | 66 + .../opencrvs-event-search/facts.schema.yaml | 19 + .../parameters.schema.yaml | 31 + .../opencrvs-event-search/prepare-input.json | 24 + .../opencrvs-event-search/prepare.rhai | 21 + .../response-malformed-count.json | 10 + .../opencrvs-event-search/response-match.json | 17 + .../opencrvs-event-search/source.yaml | 59 + products/evidence/scripts/check-contracts.sh | 28 + .../scripts/check-source-neutrality.sh | 149 + 245 files changed, 41402 insertions(+), 2 deletions(-) create mode 100644 crates/registry-evidence/Cargo.toml create mode 100644 crates/registry-evidence/examples/evidence-contracts.rs create mode 100644 crates/registry-evidence/src/audit.rs create mode 100644 crates/registry-evidence/src/auth.rs create mode 100644 crates/registry-evidence/src/binding.rs create mode 100644 crates/registry-evidence/src/bundle.rs create mode 100644 crates/registry-evidence/src/config.rs create mode 100644 crates/registry-evidence/src/contracts.rs create mode 100644 crates/registry-evidence/src/kernel.rs create mode 100644 crates/registry-evidence/src/lib.rs create mode 100644 crates/registry-evidence/src/main.rs create mode 100644 crates/registry-evidence/src/model.rs create mode 100644 crates/registry-evidence/src/problem.rs create mode 100644 crates/registry-evidence/src/rate_limit.rs create mode 100644 crates/registry-evidence/src/rhai_runtime.rs create mode 100644 crates/registry-evidence/src/runtime.rs create mode 100644 crates/registry-evidence/src/runtime_tests.rs create mode 100644 crates/registry-evidence/src/secrets.rs create mode 100644 crates/registry-evidence/src/selector.rs create mode 100644 crates/registry-evidence/src/server.rs create mode 100644 crates/registry-evidence/src/signing.rs create mode 100644 crates/registry-evidence/src/source.rs create mode 100644 crates/registry-evidence/src/values.rs create mode 100644 crates/registry-evidence/src/verifier.rs create mode 100644 crates/registry-evidence/tests/cli.rs create mode 100644 crates/registry-evidence/tests/deployment_projects.rs create mode 100644 crates/registry-evidence/tests/live_sources.rs create mode 100644 crates/registry-evidence/tests/security_contract_traceability.rs create mode 100644 crates/registry-evidence/tests/selector_conformance.rs create mode 100644 crates/registry-evidence/tests/source_contracts.rs create mode 100644 products/evidence/.env.example create mode 100644 products/evidence/contracts/audit-event.schema.yaml create mode 100644 products/evidence/contracts/authority-context.schema.yaml create mode 100644 products/evidence/contracts/bundle.schema.yaml create mode 100644 products/evidence/contracts/cccev-field-mapping.yaml create mode 100644 products/evidence/contracts/evidence.schema.yaml create mode 100644 products/evidence/contracts/jws-profile.yaml create mode 100644 products/evidence/contracts/primitive-library.yaml create mode 100644 products/evidence/contracts/problem-contract.yaml create mode 100644 products/evidence/contracts/request.schema.yaml create mode 100644 products/evidence/contracts/rhai-abi.yaml create mode 100644 products/evidence/contracts/runtime.schema.yaml create mode 100644 products/evidence/contracts/security-invariant-matrix.yaml create mode 100644 products/evidence/contracts/security-test-traceability.yaml create mode 100644 products/evidence/contracts/selector-contract.yaml create mode 100644 products/evidence/contracts/source-contract.yaml create mode 100644 products/evidence/contracts/supported-value-forms.yaml create mode 100644 products/evidence/fixtures/acceptance/adult-status/adapters/source-a-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai create mode 100644 products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai create mode 100644 products/evidence/fixtures/acceptance/adult-status/evidence.yaml create mode 100644 products/evidence/fixtures/acceptance/adult-status/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/acceptance/adult-status/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/adult-status/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/codelists/professional-expiry-categories.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/codelists/professional-registry-regions.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/codelists/residence-region-map.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/derivations/adult-status.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/derivations/legal-parent-relationship.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/derivations/professional-licence.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/derivations/residence-region.rhai create mode 100644 products/evidence/fixtures/acceptance/all-definitions/evidence.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/adapters/source-c-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai create mode 100644 products/evidence/fixtures/acceptance/professional-licence/codelists/expiry-categories.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/codelists/registry-regions.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai create mode 100644 products/evidence/fixtures/acceptance/professional-licence/evidence.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/adapters/source-b-prepare.rhai create mode 100644 products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai create mode 100644 products/evidence/fixtures/acceptance/residence-region/codelists/region-map.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai create mode 100644 products/evidence/fixtures/acceptance/residence-region/evidence.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/conformance/acquisition-postures.yaml create mode 100644 products/evidence/fixtures/conformance/anti-reconstruction.yaml create mode 100644 products/evidence/fixtures/conformance/audit-events.yaml create mode 100644 products/evidence/fixtures/conformance/coverage-matrix.yaml create mode 100644 products/evidence/fixtures/conformance/golden/adult-evidence.json create mode 100644 products/evidence/fixtures/conformance/golden/adult-request.json create mode 100644 products/evidence/fixtures/conformance/golden/licence-evidence.json create mode 100644 products/evidence/fixtures/conformance/golden/licence-request.json create mode 100644 products/evidence/fixtures/conformance/golden/relationship-evidence.json create mode 100644 products/evidence/fixtures/conformance/golden/relationship-request.json create mode 100644 products/evidence/fixtures/conformance/golden/residence-evidence.json create mode 100644 products/evidence/fixtures/conformance/golden/residence-request.json create mode 100644 products/evidence/fixtures/conformance/jws-cases.yaml create mode 100644 products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml create mode 100644 products/evidence/fixtures/conformance/selector-matrix.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/classification-source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/classification-source.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/context-source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/context-source.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/grant-source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/grant-source.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/opaque-source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/opaque-source.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/relationship-source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/adapters/relationship-source.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/codelists/opaque-codes.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/derivations/classification.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/derivations/opaque.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/derivations/property-with-event.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/derivations/property.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/derivations/relationship.rhai create mode 100644 products/evidence/fixtures/conformance/selectors/evidence.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/adapters/source-prepare.rhai create mode 100644 products/evidence/fixtures/conformance/supported-values/adapters/source.rhai create mode 100644 products/evidence/fixtures/conformance/supported-values/codelists/categories.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/codelists/date-buckets.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/codelists/synthetic-codes.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/codelists/time-buckets.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/derivations/values.rhai create mode 100644 products/evidence/fixtures/conformance/supported-values/evidence.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/fixtures/cases.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/schemas/closed-structure.schema.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest-prepare.rhai create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest.rhai create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/ambiguous.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/error-envelope.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/inconsistent-cardinality.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/match.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/missing-fact.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/no-match.json create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest-prepare.rhai create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest.rhai create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/contract.yaml create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/responses/ambiguous.json create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/responses/error-envelope.json create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/responses/match.json create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/responses/missing-fact.json create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/responses/no-match.json create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/schemas/facts.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/index.yaml create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json-prepare.rhai create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json.rhai create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/ambiguous.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/error-envelope.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/inconsistent-cardinality.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/match.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/missing-fact.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/no-match.json create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/facts.schema.yaml create mode 100644 products/evidence/generated/evidence-request-v1.schema.json create mode 100644 products/evidence/generated/evidence-v1.schema.json create mode 100644 products/evidence/generated/flattened-jws-v1.schema.json create mode 100644 products/evidence/generated/jwks-v1.schema.json create mode 100644 products/evidence/generated/problem-v1.schema.json create mode 100644 products/evidence/generated/registry-evidence.openapi.json create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-event-prepare.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/adult-status.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-references.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-relationship.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/expected-lookup-result.json create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/expected-request-parts.json create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/prepare-input.json create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/prepare.rhai create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/response-malformed-count.json create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/response-match.json create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/source.yaml create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/expected-lookup-result.json create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/expected-request-parts.json create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/prepare-input.json create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/prepare.rhai create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/response-malformed-count.json create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/response-match.json create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml create mode 100755 products/evidence/scripts/check-contracts.sh create mode 100755 products/evidence/scripts/check-source-neutrality.sh diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 2f8767da9..e6d0e0e5f 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -42,6 +42,7 @@ "xtask", ), "relay": ("registry-relay",), + "evidence": ("registry-evidence",), "developer-tools": ( "registry-config-report", "registry-language-server", @@ -50,6 +51,7 @@ } NOTARY_PACKAGES = frozenset(SHARDS["notary"]) +EVIDENCE_PACKAGES = frozenset(SHARDS["evidence"]) PLATFORM_PACKAGES = frozenset(SHARDS["platform"]) MANIFEST_PACKAGES = frozenset(SHARDS["manifest"]) TUTORIAL_PACKAGES = frozenset( @@ -309,6 +311,8 @@ def classify( continue if path.startswith("products/notary/"): seeds.update(NOTARY_PACKAGES) + elif path.startswith("products/evidence/"): + seeds.update(EVIDENCE_PACKAGES) elif path.startswith("products/manifest/"): seeds.update(MANIFEST_PACKAGES) elif path.startswith("products/platform/"): @@ -523,6 +527,7 @@ def classify( "platform_hygiene": platform_hygiene, "notary_contracts": bool(affected & NOTARY_PACKAGES), "relay_contracts": "registry-relay" in affected, + "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "project_authoring": "registryctl" in affected, "release_tool": release_tool, "release_source_proof": release_source_proof, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 764f44cb3..fc56b3385 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -85,6 +85,29 @@ def test_docs_only_change_skips_rust(self) -> None: self.assertTrue(outputs["docs"]) self.assertFalse(outputs["docs_archives"]) + def test_evidence_code_and_product_contracts_select_one_shard_and_drift_gate(self) -> None: + for path in ( + "crates/registry-evidence/src/source.rs", + "products/evidence/contracts/source-contract.yaml", + "products/evidence/reference/request-adapter/ADAPTER-API.md", + "products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml", + ): + with self.subTest(path=path): + outputs = classify(self.workspace, (path,)) + self.assertTrue(outputs["evidence_contracts"]) + self.assertIn("registry-evidence", outputs["rust_packages"]) + self.assertEqual( + {entry["name"] for entry in outputs["rust_matrix"]["include"]}, + {"evidence"}, + ) + + def test_evidence_contract_gate_is_required_by_the_rust_aggregate(self) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + rust_result = workflow.split("\n rust-result:\n", 1)[1].split( + "\n project-authoring-determinism:\n", 1 + )[0] + self.assertIn("\n - evidence-contracts\n", rust_result) + def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None: current_content = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dee59cb8d..b2fb2a4bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }} notary_contracts: ${{ steps.filter.outputs.notary_contracts }} relay_contracts: ${{ steps.filter.outputs.relay_contracts }} + evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} project_authoring: ${{ steps.filter.outputs.project_authoring }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} @@ -437,6 +438,32 @@ jobs: df -h / du -sh target 2>/dev/null || true + evidence-contracts: + name: Evidence contracts and source neutrality + needs: changes + if: needs.changes.outputs.evidence_contracts == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Reproduce Evidence generated contracts + run: products/evidence/scripts/check-contracts.sh + + - name: Enforce Evidence source-product neutrality + run: products/evidence/scripts/check-source-neutrality.sh + notary-contracts: name: Notary API contracts needs: changes @@ -552,6 +579,7 @@ jobs: - rust-policy - rust-quality - rust-tests + - evidence-contracts - notary-contracts - relay-contracts runs-on: ubuntu-24.04 diff --git a/.gitignore b/.gitignore index 73396c81e..631d658e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ __pycache__/ /.playwright-mcp/ /worktrees/ /*.png + +# Evidence local operator material. Never commit credentials or curl artifacts. +/products/evidence/.env +/products/evidence/.first-curl/ diff --git a/Cargo.lock b/Cargo.lock index 18d8c9eba..0607f0605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5344,6 +5344,54 @@ dependencies = [ "serde_json", ] +[[package]] +name = "registry-evidence" +version = "0.16.2" +dependencies = [ + "assert-json-diff", + "async-trait", + "axum", + "axum-test", + "base64", + "bytes", + "chrono", + "chrono-tz 0.10.4", + "clap", + "ed25519-dalek", + "fs2", + "http", + "jsonschema 0.18.3", + "jsonwebtoken", + "rand_core 0.6.4", + "rcgen", + "registry-platform-audit", + "registry-platform-crypto", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "reqwest 0.12.28", + "rhai", + "rustix", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.7.0", + "tracing", + "ulid", + "url", + "utoipa", + "wiremock", + "zeroize", +] + [[package]] name = "registry-language-server" version = "0.16.2" @@ -6287,6 +6335,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive", diff --git a/Cargo.toml b/Cargo.toml index 1632b91e5..fae995073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/registry-config-report", + "crates/registry-evidence", "crates/registry-platform-audit", "crates/registry-platform-authcommon", "crates/registry-platform-cache", @@ -52,6 +53,7 @@ unsafe_code = "forbid" [workspace.dependencies] registry-config-report = { path = "crates/registry-config-report", version = "0.16.2" } +registry-evidence = { path = "crates/registry-evidence", version = "0.16.2" } registry-language-server = { path = "crates/registry-language-server", version = "0.16.2" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.16.2" } registry-notary-client = { path = "crates/registry-notary-client", version = "0.16.2" } @@ -90,6 +92,7 @@ bytes = { version = "1" } calamine = { version = "0.36" } cel = { version = "0.13" } chrono = { version = "0.4" } +chrono-tz = { version = "0.10.4" } clap = { version = "4", features = ["derive", "env"] } crc32fast = { version = "1.4" } criterion = { version = "0.8", features = ["html_reports", "async_tokio"] } diff --git a/README.md b/README.md index 6d9d00460..4faaf586a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ release manifests, and docs. ## What It Includes -Registry Stack is organized around two runtime patterns: +Registry Stack contains three independent runtime patterns: - **Protected Registry APIs:** scoped, read-only HTTP APIs over existing files, extracts, databases, or legacy registry systems. Registry Relay implements @@ -43,6 +43,11 @@ Registry Stack is organized around two runtime patterns: credential issuance, disclosure policy, and audit provenance. Registry Notary implements claim evaluation and credential issuance; governed Registry Relay routes use the same Policy Decision Point pattern for protected reads. +- **Evidence:** a small service that returns signed, + minimum-disclosure assertion evidence from fixed authoritative-source + requests. Evidence is not a Registry Notary mode or rewrite. Its first + version excludes credentials, documents, federation, and a general policy + engine. The stack also includes Registry Manifest for portable metadata, Registry Platform shared primitives, `registryctl` adopter tooling, and release tooling @@ -54,6 +59,7 @@ flowchart LR manifest["Registry Manifest
describe"] relay["Registry Relay
expose protected reads"] notary["Registry Notary
certify evidence"] + evidence["Evidence
minimum-disclosure assertions"] caller["Approved service, verifier, or wallet"] source --> relay @@ -61,12 +67,15 @@ flowchart LR relay --> caller relay --> notary notary --> caller + source -. fixed request .-> evidence + evidence -. signed assertion .-> caller ``` ## Repository Layout - `crates/`: Rust crates and runnable binaries for Platform, Manifest, Notary, - Relay, `registryctl`, and shared release tooling. + Relay, Evidence, `registryctl`, and shared release tooling. Evidence lives in + one `crates/registry-evidence` crate with one `evidence` binary. - `products/`: product-owned docs, examples, Docker inputs, specs, security material, scripts, performance harnesses, and fixtures that are not normal workspace crates. diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml new file mode 100644 index 000000000..c7027109a --- /dev/null +++ b/crates/registry-evidence/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "registry-evidence" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Minimum-disclosure signed assertion evidence service." +repository.workspace = true +publish = false + +[[bin]] +name = "evidence" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +bytes.workspace = true +chrono = { workspace = true, features = ["serde"] } +chrono-tz.workspace = true +clap.workspace = true +ed25519-dalek.workspace = true +fs2.workspace = true +http.workspace = true +jsonschema.workspace = true +jsonwebtoken.workspace = true +registry-platform-audit.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-httputil.workspace = true +registry-platform-oidc.workspace = true +reqwest.workspace = true +rand_core.workspace = true +rhai.workspace = true +rustix.workspace = true +schemars = { workspace = true, features = ["chrono04"] } +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +ulid.workspace = true +url.workspace = true +utoipa.workspace = true +zeroize.workspace = true + +[dev-dependencies] +assert-json-diff.workspace = true +axum-test.workspace = true +rcgen.workspace = true +tempfile.workspace = true +tokio-rustls.workspace = true +wiremock.workspace = true diff --git a/crates/registry-evidence/examples/evidence-contracts.rs b/crates/registry-evidence/examples/evidence-contracts.rs new file mode 100644 index 000000000..77c05ac05 --- /dev/null +++ b/crates/registry-evidence/examples/evidence-contracts.rs @@ -0,0 +1,28 @@ +//! Generate deterministic Evidence Version 1 public contract artifacts. + +use std::{env, path::PathBuf, process::ExitCode}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("evidence contract generation failed: {message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut arguments = env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--output")) { + return Err("usage: evidence-contracts --output ".to_string()); + } + let output = arguments + .next() + .map(PathBuf::from) + .ok_or_else(|| "usage: evidence-contracts --output ".to_string())?; + if arguments.next().is_some() { + return Err("usage: evidence-contracts --output ".to_string()); + } + registry_evidence::contracts::write_documents(&output).map_err(|error| error.to_string()) +} diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs new file mode 100644 index 000000000..2a68f1c2f --- /dev/null +++ b/crates/registry-evidence/src/audit.rs @@ -0,0 +1,1167 @@ +//! Fail-closed native Evidence audit with a durable keyed JSONL chain. + +use std::{ + fs::{File, TryLockError}, + io::{BufRead as _, BufReader, Error as IoError, ErrorKind, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_audit::{ + verify_jsonl_lines_with_hasher, AuditChainHasher, AuditEnvelope, AuditError, AuditHashSecret, + AuditKeyHasher, AuditSink, ChainState, OptionalHashHex, +}; +use serde::Serialize; +use thiserror::Error; + +const AUDIT_SCHEMA: &str = "registry.evidence.audit/v1"; +const MAX_AUDIT_LINE_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditPhase { + AccessAttempt, + DisclosureRelease, + Denial, + TransientFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditDecision { + Authorized, + Released, + NoMatch, + Ambiguous, + FactMissing, + DependencyFailure, + EvaluationFailure, + SigningFailure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityKind { + Statutory, + Organizational, + Consent, + Delegated, + ExplicitRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditAuthority { + pub kind: AuthorityKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_pseudonym: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditSubject { + pub role: String, + pub selector_profile: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selector_bundle_pseudonym: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceAuditEvent { + pub schema: &'static str, + pub event_id: String, + pub occurred_at: String, + pub operation: String, + pub phase: AuditPhase, + pub requirement: String, + pub bundle_revision: String, + pub purpose: String, + pub requester_pseudonym: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor_pseudonym: Option, + pub authority: AuditAuthority, + pub subjects: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter_id: Option, + pub decision: AuditDecision, + #[serde(skip_serializing_if = "Option::is_none")] + pub disclosed_concepts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signing_key_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safe_error_category: Option, + pub duration_milliseconds: u64, +} + +impl EvidenceAuditEvent { + #[allow(clippy::too_many_arguments)] + pub fn new( + operation: String, + phase: AuditPhase, + requirement: String, + bundle_revision: String, + purpose: String, + requester_pseudonym: String, + authority: AuditAuthority, + subjects: Vec, + decision: AuditDecision, + duration_milliseconds: u64, + ) -> Self { + Self { + schema: AUDIT_SCHEMA, + event_id: format!("urn:ulid:{}", ulid::Ulid::new()), + occurred_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + operation, + phase, + requirement, + bundle_revision, + purpose, + requester_pseudonym, + actor_pseudonym: None, + authority, + subjects, + source_id: None, + adapter_id: None, + decision, + disclosed_concepts: None, + evidence_id: None, + signing_key_id: None, + safe_error_category: None, + duration_milliseconds, + } + } + + pub fn validate_phase_fields(&self) -> Result<(), EvidenceAuditError> { + let any_release_field = self.disclosed_concepts.is_some() + || self.evidence_id.is_some() + || self.signing_key_id.is_some(); + let all_release_fields = self.disclosed_concepts.is_some() + && self.evidence_id.is_some() + && self.signing_key_id.is_some(); + if (self.phase == AuditPhase::DisclosureRelease && !all_release_fields) + || (self.phase != AuditPhase::DisclosureRelease && any_release_field) + { + return Err(EvidenceAuditError::InvalidEvent); + } + if self.subjects.is_empty() + || self.subjects.len() > 8 + || !(16..=128).contains(&self.operation.len()) + || self.duration_milliseconds > 86_400_000 + { + return Err(EvidenceAuditError::InvalidEvent); + } + Ok(()) + } +} + +#[derive(Debug, Error)] +pub enum EvidenceAuditError { + #[error("audit configuration is invalid")] + Configuration, + #[error("audit event is invalid")] + InvalidEvent, + #[error("audit initialization or write failed")] + Audit(#[from] AuditError), +} + +pub struct EvidenceAuditLog { + sink: Arc, + chain: ChainState, + key_hasher: AuditKeyHasher, + key_version: u32, +} + +impl std::fmt::Debug for EvidenceAuditLog { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceAuditLog") + .field("path", &self.sink.path) + .field("key_version", &self.key_version) + .finish_non_exhaustive() + } +} + +impl EvidenceAuditLog { + pub async fn initialize( + path: impl Into, + maximum_file_bytes: u64, + master_secret: Vec, + key_version: u32, + ) -> Result { + if maximum_file_bytes == 0 || key_version == 0 { + return Err(EvidenceAuditError::Configuration); + } + let secret = AuditHashSecret::new(master_secret)?; + let chain_hasher = AuditChainHasher::keyed(secret.clone()); + let key_hasher = AuditKeyHasher::Keyed(secret); + let sink = Arc::new(DurableJsonlSink::open(path.into(), maximum_file_bytes)?); + let chain = ChainState::bootstrap_or_start_empty(sink.as_ref(), chain_hasher).await?; + Ok(Self { + sink, + chain, + key_hasher, + key_version, + }) + } + + pub fn pseudonym( + &self, + class: &str, + scope: &str, + protected_input: &[u8], + ) -> Result { + if protected_input.is_empty() { + return Err(EvidenceAuditError::InvalidEvent); + } + let transient = URL_SAFE_NO_PAD.encode(protected_input); + let digest = self + .key_hasher + .audit_reference_hash(class, scope, &transient) + .map_err(|_| EvidenceAuditError::InvalidEvent)?; + Ok(format!("hmac-sha256:v{}:{digest}", self.key_version)) + } + + pub async fn append( + &self, + event: EvidenceAuditEvent, + ) -> Result { + event.validate_phase_fields()?; + self.chain + .append(self.sink.as_ref(), event) + .await + .map_err(EvidenceAuditError::Audit) + } + + pub async fn ready(&self) -> bool { + let Some(expected_tail) = self.chain.try_last_hash() else { + return false; + }; + self.sink.ready(expected_tail).await + } +} + +struct DurableJsonlSink { + path: PathBuf, + lock_path: PathBuf, + maximum_file_bytes: u64, + state: tokio::sync::Mutex, + audit_file: File, + _writer_lock: File, + #[cfg(test)] + full_verifications: AtomicUsize, +} + +#[derive(Clone, Copy)] +struct SinkState { + verified: bool, + fingerprint: FileFingerprint, + tail_hash: Option<[u8; 32]>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct FileFingerprint { + length: u64, + #[cfg(unix)] + modified_seconds: i64, + #[cfg(unix)] + modified_nanoseconds: i64, + #[cfg(unix)] + changed_seconds: i64, + #[cfg(unix)] + changed_nanoseconds: i64, + #[cfg(not(unix))] + modified: Option, +} + +impl std::fmt::Debug for DurableJsonlSink { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DurableJsonlSink") + .field("path", &self.path) + .field("maximum_file_bytes", &self.maximum_file_bytes) + .finish_non_exhaustive() + } +} + +impl DurableJsonlSink { + fn open(path: PathBuf, maximum_file_bytes: u64) -> Result { + if !path.is_absolute() { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path must be absolute", + ))); + } + let parent = path.parent().ok_or_else(|| { + AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path has no parent", + )) + })?; + if !parent.is_dir() { + return Err(AuditError::Io(IoError::new( + ErrorKind::NotFound, + "audit parent directory is unavailable", + ))); + } + + let created = !path.exists(); + let file = open_append_nofollow(&path)?; + validate_owner_only_regular_file(&file)?; + if file.metadata().map_err(AuditError::Io)?.len() > maximum_file_bytes { + return Err(file_size_error()); + } + file.sync_all().map_err(AuditError::Io)?; + if created { + sync_parent(parent)?; + } + + let lock_path = lock_path(&path); + let lock_created = !lock_path.exists(); + let writer_lock = open_lock_nofollow(&lock_path)?; + validate_owner_only_regular_file(&writer_lock)?; + match writer_lock.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => { + return Err(AuditError::SinkLocked { + path: lock_path.display().to_string(), + }); + } + Err(TryLockError::Error(error)) => return Err(AuditError::Io(error)), + } + writer_lock.sync_all().map_err(AuditError::Io)?; + if lock_created { + sync_parent(parent)?; + } + + let fingerprint = file_fingerprint(&file)?; + Ok(Self { + path, + lock_path, + maximum_file_bytes, + state: tokio::sync::Mutex::new(SinkState { + verified: false, + fingerprint, + tail_hash: None, + }), + audit_file: file, + _writer_lock: writer_lock, + #[cfg(test)] + full_verifications: AtomicUsize::new(0), + }) + } + + async fn ready(&self, expected_tail: Option<[u8; 32]>) -> bool { + // Readiness probes must never queue behind audit writes or one another. + // The startup scan establishes the authenticated chain head; steady + // state checks are constant-time fingerprint and pinned-file checks. + let Ok(state) = self.state.try_lock() else { + return false; + }; + if !state.verified || state.tail_hash != expected_tail { + return false; + } + let path = self.path.clone(); + let lock_path = self.lock_path.clone(); + let maximum = self.maximum_file_bytes; + let expected_fingerprint = state.fingerprint; + let Ok(file) = self.audit_file.try_clone() else { + return false; + }; + let Ok(writer_lock) = self._writer_lock.try_clone() else { + return false; + }; + tokio::task::spawn_blocking(move || -> Result { + validate_pinned_path(&path, &file)?; + validate_pinned_path(&lock_path, &writer_lock)?; + let metadata = file.metadata().map_err(AuditError::Io)?; + if !metadata.is_file() + || metadata.len() > maximum + || file_fingerprint(&file)? != expected_fingerprint + { + return Ok(false); + } + file.sync_all().map_err(AuditError::Io)?; + validate_pinned_path(&path, &file)?; + validate_pinned_path(&lock_path, &writer_lock)?; + Ok(true) + }) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(false) + } + + fn verify_and_tail( + file: File, + maximum_file_bytes: u64, + hasher: &AuditChainHasher, + ) -> Result, AuditError> { + let length = file.metadata().map_err(AuditError::Io)?.len(); + if length > maximum_file_bytes { + return Err(file_size_error()); + } + verify_reader(file, hasher) + } +} + +#[async_trait] +impl AuditSink for DurableJsonlSink { + async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { + let line = envelope.to_jsonl()?; + let expected_prev = envelope.prev_hash; + let mut state = self.state.lock().await; + if !state.verified || state.tail_hash != expected_prev { + return Err(AuditError::ChainForkDetected { + expected: OptionalHashHex(state.tail_hash), + found: OptionalHashHex(expected_prev), + }); + } + let path = self.path.clone(); + let lock_path = self.lock_path.clone(); + let maximum = self.maximum_file_bytes; + let expected_fingerprint = state.fingerprint; + let mut file = self.audit_file.try_clone().map_err(AuditError::Io)?; + let writer_lock = self._writer_lock.try_clone().map_err(AuditError::Io)?; + let next_fingerprint = tokio::task::spawn_blocking(move || { + validate_pinned_path(&path, &file)?; + validate_pinned_path(&lock_path, &writer_lock)?; + if file_fingerprint(&file)? != expected_fingerprint { + return Err(AuditError::Io(IoError::other( + "audit file changed outside the initialized writer", + ))); + } + let current = file.metadata().map_err(AuditError::Io)?.len(); + let incoming = u64::try_from(line.len()).map_err(|_| file_size_error())?; + if current.saturating_add(incoming) > maximum { + return Err(file_size_error()); + } + file.write_all(line.as_bytes()).map_err(AuditError::Io)?; + file.flush().map_err(AuditError::Io)?; + file.sync_all().map_err(AuditError::Io)?; + validate_pinned_path(&path, &file)?; + validate_pinned_path(&lock_path, &writer_lock)?; + let fingerprint = file_fingerprint(&file)?; + if fingerprint.length != current.saturating_add(incoming) { + return Err(AuditError::Io(IoError::other( + "audit file length changed during append", + ))); + } + Ok(fingerprint) + }) + .await + .map_err(|error| AuditError::Io(IoError::other(error)))??; + state.fingerprint = next_fingerprint; + state.tail_hash = Some(envelope.record_hash); + Ok(()) + } + + #[allow(deprecated)] + async fn tail_hash(&self) -> Result, AuditError> { + self.tail_hash_with_hasher(&AuditChainHasher::unkeyed_dev_only()) + .await + } + + async fn tail_hash_with_hasher( + &self, + hasher: &AuditChainHasher, + ) -> Result, AuditError> { + let mut state = self.state.lock().await; + let path = self.path.clone(); + let lock_path = self.lock_path.clone(); + let maximum = self.maximum_file_bytes; + let hasher = hasher.clone(); + let file = self.audit_file.try_clone().map_err(AuditError::Io)?; + let writer_lock = self._writer_lock.try_clone().map_err(AuditError::Io)?; + #[cfg(test)] + self.full_verifications.fetch_add(1, Ordering::Relaxed); + let (tail_hash, fingerprint) = tokio::task::spawn_blocking(move || { + validate_pinned_path(&path, &file)?; + validate_pinned_path(&lock_path, &writer_lock)?; + let tail_hash = + Self::verify_and_tail(file.try_clone().map_err(AuditError::Io)?, maximum, &hasher)?; + Ok((tail_hash, file_fingerprint(&file)?)) + }) + .await + .map_err(|error| AuditError::Io(IoError::other(error)))??; + state.verified = true; + state.fingerprint = fingerprint; + state.tail_hash = tail_hash; + Ok(tail_hash) + } +} + +#[cfg(unix)] +fn file_fingerprint(file: &File) -> Result { + use std::os::unix::fs::MetadataExt as _; + + let metadata = file.metadata().map_err(AuditError::Io)?; + Ok(FileFingerprint { + length: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(not(unix))] +fn file_fingerprint(file: &File) -> Result { + let metadata = file.metadata().map_err(AuditError::Io)?; + Ok(FileFingerprint { + length: metadata.len(), + modified: metadata.modified().ok(), + }) +} + +fn verify_reader( + mut file: File, + hasher: &AuditChainHasher, +) -> Result, AuditError> { + file.seek(SeekFrom::Start(0)).map_err(AuditError::Io)?; + let mut reader = BufReader::new(file); + let mut expected_previous = None; + let mut records = 0usize; + while let Some(line) = read_bounded_jsonl_line(&mut reader)? { + let verification = verify_jsonl_lines_with_hasher([line.trim_end_matches('\n')], hasher) + .map_err(AuditError::ChainVerification)?; + if records == 0 { + if verification.start_prev_hash.is_some() { + return Err(AuditError::ChainForkDetected { + expected: OptionalHashHex(None), + found: OptionalHashHex(verification.start_prev_hash), + }); + } + } else if verification.start_prev_hash != expected_previous { + return Err(AuditError::ChainForkDetected { + expected: OptionalHashHex(expected_previous), + found: OptionalHashHex(verification.start_prev_hash), + }); + } + expected_previous = verification.last_hash; + records += verification.records; + } + Ok(expected_previous) +} + +fn read_bounded_jsonl_line(reader: &mut BufReader) -> Result, AuditError> { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf().map_err(AuditError::Io)?; + if available.is_empty() { + if line.is_empty() { + return Ok(None); + } + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit JSONL has an incomplete final record", + ))); + } + let take = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |index| index + 1); + if line.len().saturating_add(take) > MAX_AUDIT_LINE_BYTES { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit JSONL record exceeds its bound", + ))); + } + let found_newline = available[take - 1] == b'\n'; + line.extend_from_slice(&available[..take]); + reader.consume(take); + if found_newline { + let line = String::from_utf8(line).map_err(|_| { + AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit JSONL is not UTF-8", + )) + })?; + return Ok(Some(line)); + } + } +} + +fn file_size_error() -> AuditError { + AuditError::Io(IoError::other("audit file size bound exceeded")) +} + +fn lock_path(path: &Path) -> PathBuf { + let mut value = path.as_os_str().to_owned(); + value.push(".lock"); + PathBuf::from(value) +} + +fn validate_pinned_path(path: &Path, pinned: &File) -> Result<(), AuditError> { + let candidate = open_read_nofollow(path)?; + validate_owner_only_regular_file(pinned)?; + validate_owner_only_regular_file(&candidate)?; + if !same_file(pinned, &candidate)? { + return Err(AuditError::Io(IoError::other( + "audit path no longer names the initialized file", + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_owner_only_regular_file(file: &File) -> Result<(), AuditError> { + use std::os::unix::fs::MetadataExt as _; + + let metadata = file.metadata().map_err(AuditError::Io)?; + if !metadata.is_file() + || metadata.nlink() != 1 + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.mode() & 0o077 != 0 + { + return Err(AuditError::Io(IoError::new( + ErrorKind::PermissionDenied, + "audit files must be owner-only, singly linked regular files", + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_owner_only_regular_file(file: &File) -> Result<(), AuditError> { + if file.metadata().map_err(AuditError::Io)?.is_file() { + Ok(()) + } else { + Err(AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit file is not regular", + ))) + } +} + +#[cfg(unix)] +fn same_file(left: &File, right: &File) -> Result { + use std::os::unix::fs::MetadataExt as _; + + let left = left.metadata().map_err(AuditError::Io)?; + let right = right.metadata().map_err(AuditError::Io)?; + Ok(left.dev() == right.dev() && left.ino() == right.ino()) +} + +#[cfg(not(unix))] +fn same_file(_left: &File, _right: &File) -> Result { + Ok(true) +} + +#[cfg(unix)] +fn open_append_nofollow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + rustix::fs::open( + path, + OFlags::RDWR | OFlags::APPEND | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_raw_mode(0o600), + ) + .map(File::from) + .map_err(|error| AuditError::Io(error.into())) +} + +#[cfg(not(unix))] +fn open_append_nofollow(path: &Path) -> Result { + reject_symlink(path)?; + std::fs::OpenOptions::new() + .create(true) + .read(true) + .append(true) + .open(path) + .map_err(AuditError::Io) +} + +#[cfg(unix)] +fn open_lock_nofollow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + rustix::fs::open( + path, + OFlags::WRONLY | OFlags::CREATE | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::from_raw_mode(0o600), + ) + .map(File::from) + .map_err(|error| AuditError::Io(error.into())) +} + +#[cfg(not(unix))] +fn open_lock_nofollow(path: &Path) -> Result { + reject_symlink(path)?; + std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(path) + .map_err(AuditError::Io) +} + +#[cfg(unix)] +fn open_read_nofollow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map(File::from) + .map_err(|error| AuditError::Io(error.into())) +} + +#[cfg(not(unix))] +fn open_read_nofollow(path: &Path) -> Result { + reject_symlink(path)?; + File::open(path).map_err(AuditError::Io) +} + +#[cfg(not(unix))] +fn reject_symlink(path: &Path) -> Result<(), AuditError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path is a symlink", + ))), + Ok(_) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(AuditError::Io(error)), + } +} + +fn sync_parent(parent: &Path) -> Result<(), AuditError> { + File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(AuditError::Io) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + "01K1EXAMPLE0000000000000000".to_string(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_string(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_string(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "person-v1".to_string(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + AuditDecision::Authorized, + 5, + ) + } + + #[test] + fn frozen_audit_fixture_matches_native_event_shape_and_phase_rules() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/audit-events.yaml" + )) + .expect("frozen audit fixture parses"); + assert_eq!( + fixture["fixture"], + serde_json::json!("registry.evidence.audit-events/v1") + ); + assert_eq!(fixture["synthetic_only"], serde_json::json!(true)); + + let access = EvidenceAuditEvent { + schema: AUDIT_SCHEMA, + event_id: "urn:example:fixture:audit:access-001".to_owned(), + occurred_at: "2026-08-02T00:00:00Z".to_owned(), + operation: "fixture-operation-00000001".to_owned(), + phase: AuditPhase::AccessAttempt, + requirement: "urn:example:fixture:requirement:property:v1".to_owned(), + bundle_revision: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_owned(), + purpose: "fixture-procedure".to_owned(), + requester_pseudonym: + "hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111" + .to_owned(), + actor_pseudonym: None, + authority: AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + subjects: vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "opaque-record-v1".to_owned(), + selector_bundle_pseudonym: Some( + "hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222" + .to_owned(), + ), + }], + source_id: Some("source-a".to_owned()), + adapter_id: Some("adapter-a".to_owned()), + decision: AuditDecision::Authorized, + disclosed_concepts: None, + evidence_id: None, + signing_key_id: None, + safe_error_category: None, + duration_milliseconds: 2, + }; + access + .validate_phase_fields() + .expect("fixture access event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&access).expect("access event serializes"), + fixture["access_attempt"] + ); + + let mut release = access.clone(); + release.event_id = "urn:example:fixture:audit:release-001".to_owned(); + release.occurred_at = "2026-08-02T00:00:01Z".to_owned(); + release.phase = AuditPhase::DisclosureRelease; + release.decision = AuditDecision::Released; + release.disclosed_concepts = Some(vec!["urn:example:fixture:concept:boolean-a".to_owned()]); + release.evidence_id = Some("urn:example:fixture:evidence:001".to_owned()); + release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + release.duration_milliseconds = 12; + release + .validate_phase_fields() + .expect("fixture release event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&release).expect("release event serializes"), + fixture["disclosure_release"] + ); + + let mut release_fields_on_access = access; + release_fields_on_access.disclosed_concepts = release.disclosed_concepts.clone(); + release_fields_on_access.evidence_id = release.evidence_id.clone(); + release_fields_on_access.signing_key_id = release.signing_key_id.clone(); + assert!(matches!( + release_fields_on_access.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + release.evidence_id = None; + assert!(matches!( + release.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + + assert_eq!( + fixture["order"], + serde_json::json!({ + "access_attempt_durable_before": ["credential-resolution", "source-access"], + "disclosure_release_durable_after": ["signing"], + "disclosure_release_durable_before": ["response-release"] + }) + ); + assert_eq!( + fixture["negative"], + serde_json::json!([ + "raw-principal", + "raw-actor-or-grant", + "raw-selector-value", + "separate-field-hash", + "plain-sha256-subject-hash", + "base64url-reencoded-audit-hmac", + "globally-stable-subject-pseudonym", + "source-or-supported-value", + "credential-token-or-private-key", + "candidate-count-score-hint-or-comparison", + "release-fields-on-access-event", + "missing-release-fields-on-release-event" + ]) + ); + } + + #[tokio::test] + async fn audit_is_durable_keyed_and_redacted() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + assert_eq!(log.sink.full_verifications.load(Ordering::Relaxed), 1); + assert!(log.ready().await, "an empty verified chain is ready"); + log.append(event(&log)).await.expect("event appends"); + assert!(log.ready().await); + assert_eq!( + log.sink.full_verifications.load(Ordering::Relaxed), + 1, + "steady-state appends and readiness must not rescan the audit file" + ); + + let contents = std::fs::read_to_string(&path).expect("audit reads"); + assert!(!contents.contains("principal-canary")); + assert!(!contents.contains("selector-canary")); + assert!(contents.contains("hmac-sha256:v1:")); + assert!(contents.ends_with('\n')); + + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("tamper audit file"); + assert!(!log.ready().await, "readiness detects chain tampering"); + } + + #[tokio::test] + async fn restart_verifies_a_nonempty_keyed_chain_before_accepting_appends() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let restarted = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a valid nonempty chain verifies on restart"); + assert!(restarted.ready().await); + assert_eq!(restarted.sink.full_verifications.load(Ordering::Relaxed), 1); + restarted + .append(event(&restarted)) + .await + .expect("verified restarted chain accepts an append"); + } + + #[tokio::test] + async fn restart_rejects_same_length_chain_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let mut external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for corruption"); + external + .seek(SeekFrom::Start(0)) + .and_then(|_| external.write_all(b"[")) + .and_then(|_| external.sync_all()) + .expect("same-length corruption persists"); + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a corrupted keyed chain" + ); + } + + #[tokio::test] + async fn restart_rejects_a_truncated_final_record() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + let original_length = std::fs::metadata(&path) + .expect("audit metadata reads") + .len(); + assert!(original_length > 8, "fixture record has truncation room"); + let external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for truncation"); + external + .set_len(original_length - 8) + .and_then(|_| external.sync_all()) + .expect("truncation persists"); + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a truncated keyed chain" + ); + } + + #[tokio::test] + async fn restart_rejects_the_wrong_audit_key() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + } + + assert!( + EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"fedcba9876543210fedcba9876543210".to_vec(), + 1, + ) + .await + .is_err(), + "restart must reject a keyed chain under a different audit secret" + ); + } + + #[tokio::test] + async fn same_length_external_mutation_fails_readiness_and_future_appends() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + + std::thread::sleep(std::time::Duration::from_millis(2)); + let mut external = std::fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("audit file opens for mutation"); + external + .seek(SeekFrom::Start(0)) + .and_then(|_| external.write_all(b"[")) + .and_then(|_| external.sync_all()) + .expect("same-length mutation persists"); + + assert!(!log.ready().await); + assert!(log.append(event(&log)).await.is_err()); + assert_eq!(log.sink.full_verifications.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn invalid_release_shape_and_size_limit_fail_closed() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = + EvidenceAuditLog::initialize(&path, 1, b"0123456789abcdef0123456789abcdef".to_vec(), 1) + .await + .expect("audit initializes"); + assert!(log.append(event(&log)).await.is_err()); + + let mut invalid = event(&log); + invalid.phase = AuditPhase::DisclosureRelease; + assert!(matches!( + log.append(invalid).await, + Err(EvidenceAuditError::InvalidEvent) + )); + } + + #[tokio::test] + async fn second_writer_is_rejected() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let first = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("first initializes"); + let second = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await; + assert!(second.is_err()); + drop(first); + } + + #[cfg(unix)] + #[tokio::test] + async fn pathname_replacement_never_redirects_the_pinned_audit_writer() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let displaced = directory.path().join("displaced.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 64 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + std::fs::rename(&path, &displaced).expect("initialized file is displaced"); + std::fs::write(&path, b"replacement-canary\n").expect("replacement is created"); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("replacement mode is owner-only"); + + assert!(log.append(event(&log)).await.is_err()); + assert!(!log.ready().await); + assert_eq!( + std::fs::read_to_string(&path).expect("replacement reads"), + "replacement-canary\n" + ); + assert_eq!( + std::fs::read_to_string(&displaced).expect("pinned file reads"), + "" + ); + } +} diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs new file mode 100644 index 000000000..da88369b7 --- /dev/null +++ b/crates/registry-evidence/src/auth.rs @@ -0,0 +1,458 @@ +//! Strict OIDC access-token authentication and configured claim extraction. + +use std::sync::Arc; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::parse_json_strict; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{ + JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig, VerifiedToken, +}; +use serde_json::{Map, Value}; +use thiserror::Error; + +use crate::config::{AccessTokenAlgorithm, AccessTokenType, AuthenticationConfig}; + +const MAX_TOKEN_BYTES: usize = 128 * 1024; +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_CLAIMS_BYTES: usize = 64 * 1024; +const MAX_PRINCIPAL_BYTES: usize = 512; +const MAX_TAGS: usize = 32; + +#[derive(Debug, Clone)] +pub struct AuthenticationClaimsConfig { + pub principal_claim: String, + pub requester_tags_claim: String, + pub evidence_audience_claim: String, + pub grant_id_claim: String, + pub grant_authority_claim: String, + pub actor_claim: Option, +} + +#[derive(Clone)] +pub struct Authenticator { + verifier: Arc, + claims: AuthenticationClaimsConfig, +} + +impl std::fmt::Debug for Authenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Authenticator") + .field("claims", &self.claims) + .finish_non_exhaustive() + } +} + +#[derive(Clone)] +pub struct AuthenticatedContext { + principal: String, + actor: Option, + requester_tags: Vec, + evidence_audience: String, + grant_id: Option, + grant_authority: Option, + verified_claims: Value, +} + +impl AuthenticatedContext { + pub fn principal(&self) -> &str { + &self.principal + } + + pub fn actor(&self) -> Option<&str> { + self.actor.as_deref() + } + + pub fn requester_tags(&self) -> &[String] { + &self.requester_tags + } + + pub fn evidence_audience(&self) -> &str { + &self.evidence_audience + } + + pub fn grant_id(&self) -> Option<&str> { + self.grant_id.as_deref() + } + + pub fn grant_authority(&self) -> Option<&str> { + self.grant_authority.as_deref() + } + + pub fn claim_path(&self, path: &str) -> Option<&Value> { + resolve_claim_path(&self.verified_claims, path) + } + + /// Construct a context for the bundle-owned, offline fixture command. + /// + /// This is crate-private so no production caller can bypass token + /// verification. The public fixture harness still runs the normal + /// authorization and selector-resolution functions over this context. + pub(crate) fn offline_fixture_context( + requester_tags: Vec, + evidence_audience: &str, + grant_id: Option<&str>, + grant_authority: Option<&str>, + verified_claims: Value, + ) -> Self { + Self { + principal: "offline-fixture-principal".to_owned(), + actor: None, + requester_tags, + evidence_audience: evidence_audience.to_owned(), + grant_id: grant_id.map(ToOwned::to_owned), + grant_authority: grant_authority.map(ToOwned::to_owned), + verified_claims, + } + } + + #[cfg(test)] + pub(crate) fn test_context( + principal: &str, + requester_tags: Vec, + evidence_audience: &str, + grant_id: Option<&str>, + grant_authority: Option<&str>, + verified_claims: Value, + ) -> Self { + let mut context = Self::offline_fixture_context( + requester_tags, + evidence_audience, + grant_id, + grant_authority, + verified_claims, + ); + context.principal = principal.to_owned(); + context + } +} + +impl std::fmt::Debug for AuthenticatedContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AuthenticatedContext") + .field("principal", &"") + .field("actor", &self.actor.as_ref().map(|_| "")) + .field("requester_tags", &"") + .field("evidence_audience", &self.evidence_audience) + .field("grant_id", &self.grant_id.as_ref().map(|_| "")) + .field( + "grant_authority", + &self.grant_authority.as_ref().map(|_| ""), + ) + .field("verified_claims", &"") + .finish() + } +} + +#[derive(Debug, Error)] +pub enum AuthenticationError { + #[error("access token is malformed")] + Malformed, + #[error("access token verification failed")] + Verification, + #[error("required authenticated context is missing or invalid")] + Context, +} + +impl Authenticator { + /// Build the one strict resource-server profile from the loaded bundle. + pub fn from_config(config: &AuthenticationConfig) -> Self { + let algorithms = config + .algorithms + .iter() + .map(|algorithm| match algorithm { + AccessTokenAlgorithm::EdDSA => jsonwebtoken::Algorithm::EdDSA, + AccessTokenAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256, + AccessTokenAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256, + }) + .collect(); + let token_types = config + .token_types + .iter() + .map(|token_type| match token_type { + AccessTokenType::AtJwt => "at+jwt".to_owned(), + AccessTokenType::ApplicationAtJwt => "application/at+jwt".to_owned(), + }) + .collect(); + let verifier_config = TokenVerifierConfig::access_token_profile( + config.issuer.clone(), + config.audiences.clone(), + algorithms, + token_types, + ); + let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( + config.jwks_uri.clone(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy { + allowed_schemes: vec!["https".to_owned()], + allow_localhost: true, + allow_http_private_network: false, + deny_private_ranges: false, + deny_cloud_metadata: true, + }, + )); + let verifier = Arc::new(TokenVerifier::new(verifier_config, fetcher)); + let claims = AuthenticationClaimsConfig { + principal_claim: config.principal_claim.clone(), + requester_tags_claim: config.requester_tags_claim.clone(), + evidence_audience_claim: config.evidence_audience_claim.clone(), + grant_id_claim: config.grant_id_claim.clone(), + grant_authority_claim: config.grant_authority_claim.clone(), + actor_claim: config.actor_claim.clone(), + }; + Self::new(verifier, claims) + } + + pub fn new(verifier: Arc, claims: AuthenticationClaimsConfig) -> Self { + Self { verifier, claims } + } + + pub async fn authenticate( + &self, + access_token: &str, + ) -> Result { + strict_jwt_preflight(access_token)?; + let verified = self + .verifier + .verify(access_token) + .await + .map_err(|_| AuthenticationError::Verification)?; + self.extract_context(verified) + } + + fn extract_context( + &self, + verified: VerifiedToken, + ) -> Result { + let claims = + serde_json::to_value(verified.claims).map_err(|_| AuthenticationError::Context)?; + let claims_object = claims.as_object().ok_or(AuthenticationError::Context)?; + + let principal = required_direct_string( + claims_object, + &self.claims.principal_claim, + MAX_PRINCIPAL_BYTES, + )?; + let evidence_audience = required_direct_string( + claims_object, + &self.claims.evidence_audience_claim, + MAX_PRINCIPAL_BYTES, + )?; + url::Url::parse(&evidence_audience).map_err(|_| AuthenticationError::Context)?; + let requester_tags = + required_string_array(claims_object, &self.claims.requester_tags_claim, MAX_TAGS)?; + let actor = self + .claims + .actor_claim + .as_deref() + .map(|claim| optional_direct_string(claims_object, claim, MAX_PRINCIPAL_BYTES)) + .transpose()? + .flatten(); + let grant_id = optional_direct_string( + claims_object, + &self.claims.grant_id_claim, + MAX_PRINCIPAL_BYTES, + )?; + let grant_authority = optional_direct_string( + claims_object, + &self.claims.grant_authority_claim, + MAX_PRINCIPAL_BYTES, + )?; + if grant_id.is_some() != grant_authority.is_some() { + return Err(AuthenticationError::Context); + } + + Ok(AuthenticatedContext { + principal, + actor, + requester_tags, + evidence_audience, + grant_id, + grant_authority, + verified_claims: claims, + }) + } +} + +pub fn strict_jwt_preflight(token: &str) -> Result<(), AuthenticationError> { + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return Err(AuthenticationError::Malformed); + } + let mut segments = token.split('.'); + let header = segments.next().ok_or(AuthenticationError::Malformed)?; + let claims = segments.next().ok_or(AuthenticationError::Malformed)?; + let signature = segments.next().ok_or(AuthenticationError::Malformed)?; + if segments.next().is_some() || header.is_empty() || claims.is_empty() || signature.is_empty() { + return Err(AuthenticationError::Malformed); + } + decode_strict_object(header, MAX_HEADER_BYTES)?; + decode_strict_object(claims, MAX_CLAIMS_BYTES)?; + let signature = URL_SAFE_NO_PAD + .decode(signature) + .map_err(|_| AuthenticationError::Malformed)?; + if signature.is_empty() || signature.len() > MAX_HEADER_BYTES { + return Err(AuthenticationError::Malformed); + } + Ok(()) +} + +fn decode_strict_object(segment: &str, maximum: usize) -> Result<(), AuthenticationError> { + let decoded = URL_SAFE_NO_PAD + .decode(segment) + .map_err(|_| AuthenticationError::Malformed)?; + if decoded.is_empty() || decoded.len() > maximum { + return Err(AuthenticationError::Malformed); + } + let value = parse_json_strict(&decoded).map_err(|_| AuthenticationError::Malformed)?; + if !value.is_object() { + return Err(AuthenticationError::Malformed); + } + Ok(()) +} + +fn required_direct_string( + claims: &Map, + name: &str, + maximum_bytes: usize, +) -> Result { + optional_direct_string(claims, name, maximum_bytes)?.ok_or(AuthenticationError::Context) +} + +fn optional_direct_string( + claims: &Map, + name: &str, + maximum_bytes: usize, +) -> Result, AuthenticationError> { + let Some(value) = claims.get(name) else { + return Ok(None); + }; + let value = value.as_str().ok_or(AuthenticationError::Context)?; + if value.is_empty() || value.len() > maximum_bytes { + return Err(AuthenticationError::Context); + } + Ok(Some(value.to_owned())) +} + +fn required_string_array( + claims: &Map, + name: &str, + maximum_items: usize, +) -> Result, AuthenticationError> { + let values = claims + .get(name) + .and_then(Value::as_array) + .ok_or(AuthenticationError::Context)?; + if values.is_empty() || values.len() > maximum_items { + return Err(AuthenticationError::Context); + } + let mut output = Vec::with_capacity(values.len()); + for value in values { + let value = value.as_str().ok_or(AuthenticationError::Context)?; + if value.is_empty() || value.len() > MAX_PRINCIPAL_BYTES { + return Err(AuthenticationError::Context); + } + output.push(value.to_owned()); + } + output.sort(); + output.dedup(); + Ok(output) +} + +fn resolve_claim_path<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> { + if path.is_empty() || path.len() > 512 { + return None; + } + let mut current = claims; + for segment in path.split('.') { + if !valid_claim_path_segment(segment) { + return None; + } + current = current.as_object()?.get(segment)?; + } + Some(current) +} + +fn valid_claim_path_segment(segment: &str) -> bool { + let mut bytes = segment.bytes(); + matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes.all(|byte| { + byte.is_ascii_uppercase() + || byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segment(input: &str) -> String { + URL_SAFE_NO_PAD.encode(input) + } + + #[test] + fn strict_preflight_accepts_three_strict_json_segments() { + let token = format!( + "{}.{}.{}", + segment(r#"{"alg":"EdDSA","kid":"key","typ":"at+jwt"}"#), + segment(r#"{"iss":"https://issuer.invalid","aud":"evidence","exp":2000000000}"#), + URL_SAFE_NO_PAD.encode([1_u8; 64]) + ); + strict_jwt_preflight(&token).expect("token structure is valid"); + } + + #[test] + fn strict_preflight_rejects_duplicate_members_and_bad_compact_shape() { + let duplicate_header = format!( + "{}.{}.{}", + segment(r#"{"alg":"EdDSA","alg":"RS256"}"#), + segment(r#"{"iss":"https://issuer.invalid"}"#), + URL_SAFE_NO_PAD.encode([1_u8; 64]) + ); + assert!(strict_jwt_preflight(&duplicate_header).is_err()); + for token in ["", "a.b", "a.b.c.d", "a..c", " a.b.c"] { + assert!(strict_jwt_preflight(token).is_err()); + } + } + + #[test] + fn claim_paths_are_exact_and_do_not_fallback() { + let claims = serde_json::json!({"grant": {"subject-id": "value"}, "sub": "principal"}); + assert_eq!( + resolve_claim_path(&claims, "grant.subject-id"), + Some(&Value::String("value".to_string())) + ); + assert!(resolve_claim_path(&claims, "grant.missing").is_none()); + assert!(resolve_claim_path(&claims, "grant..subject-id").is_none()); + } + + #[test] + fn authenticated_context_debug_redacts_claim_material() { + let mut context = AuthenticatedContext::test_context( + "principal-canary", + vec!["tag-canary".to_string()], + "urn:example:audience", + Some("grant-canary"), + Some("authority-canary"), + serde_json::json!({"protected": "claim-canary"}), + ); + context.actor = Some("actor-canary".to_string()); + let debug = format!("{context:?}"); + for canary in [ + "principal-canary", + "actor-canary", + "tag-canary", + "grant-canary", + "authority-canary", + "claim-canary", + ] { + assert!(!debug.contains(canary)); + } + } +} diff --git a/crates/registry-evidence/src/binding.rs b/crates/registry-evidence/src/binding.rs new file mode 100644 index 000000000..abac5fdeb --- /dev/null +++ b/crates/registry-evidence/src/binding.rs @@ -0,0 +1,391 @@ +//! Audience-scoped subject and source-entity reference projection. + +use std::fmt; + +use registry_platform_crypto::hmac_sha256_base64url_no_pad; +use thiserror::Error; + +const SUBJECT_DOMAIN: &[u8] = b"registry-evidence/subject-binding/v1"; +const ENTITY_DOMAIN: &[u8] = b"registry-evidence/entity-reference/v1"; +const MIN_KEY_BYTES: usize = 32; +const MAX_COMPONENT_BYTES: usize = 8 * 1024; +const MAX_SELECTOR_FIELDS: usize = 16; +const MAX_ENTITY_SEED_BYTES: usize = 512; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SelectorScalar<'a> { + String(&'a str), + Date(&'a str), + Integer(i64), + Boolean(bool), + ControlledCode(&'a str), +} + +impl fmt::Debug for SelectorScalar<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let form = match self { + Self::String(_) => "string", + Self::Date(_) => "date", + Self::Integer(_) => "integer", + Self::Boolean(_) => "boolean", + Self::ControlledCode(_) => "controlled-code", + }; + formatter + .debug_struct("SelectorScalar") + .field("form", &form) + .field("value", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct SelectorField<'a> { + pub name: &'a str, + pub value: SelectorScalar<'a>, +} + +impl fmt::Debug for SelectorField<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SelectorField") + .field("name", &self.name) + .field("value", &self.value) + .finish() + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum BindingError { + #[error("binding key material is too short")] + WeakKey, + #[error("binding key version is invalid")] + KeyVersion, + #[error("binding input is empty or exceeds its bound")] + Component, + #[error("selector field set is invalid")] + Fields, + #[error("selector date is not canonical")] + Date, + #[error("entity-reference seed is invalid")] + EntitySeed, +} + +pub struct SubjectBindingInput<'a> { + pub trust_domain: &'a str, + pub audience: &'a str, + pub purpose: &'a str, + pub role: &'a str, + pub profile: &'a str, + pub fields: &'a [SelectorField<'a>], +} + +pub fn subject_binding( + key: &[u8], + key_version: u32, + input: SubjectBindingInput<'_>, +) -> Result { + validate_key(key, key_version)?; + if input.fields.is_empty() || input.fields.len() > MAX_SELECTOR_FIELDS { + return Err(BindingError::Fields); + } + + let mut canonical = Vec::new(); + push_bytes(&mut canonical, SUBJECT_DOMAIN)?; + push_u32(&mut canonical, key_version); + push_str(&mut canonical, input.trust_domain)?; + push_str(&mut canonical, input.audience)?; + push_str(&mut canonical, input.purpose)?; + push_str(&mut canonical, input.role)?; + push_str(&mut canonical, input.profile)?; + push_u32( + &mut canonical, + u32::try_from(input.fields.len()).map_err(|_| BindingError::Fields)?, + ); + + for field in input.fields { + push_str(&mut canonical, field.name)?; + match field.value { + SelectorScalar::String(value) => { + canonical.push(0x01); + push_str(&mut canonical, value)?; + } + SelectorScalar::Date(value) => { + if !is_canonical_date(value) { + return Err(BindingError::Date); + } + canonical.push(0x02); + push_str(&mut canonical, value)?; + } + SelectorScalar::Integer(value) => { + canonical.push(0x03); + push_str(&mut canonical, &value.to_string())?; + } + SelectorScalar::Boolean(value) => { + canonical.push(0x04); + push_bytes(&mut canonical, &[u8::from(value)])?; + } + SelectorScalar::ControlledCode(value) => { + canonical.push(0x05); + push_str(&mut canonical, value)?; + } + } + } + + Ok(format!( + "urn:evidence:subject:v{key_version}_{}", + hmac_sha256_base64url_no_pad(key, &canonical) + )) +} + +pub fn entity_reference( + key: &[u8], + key_version: u32, + concept_id: &str, + audience: &str, + seed: &[u8], +) -> Result { + validate_key(key, key_version)?; + if seed.is_empty() || seed.len() > MAX_ENTITY_SEED_BYTES { + return Err(BindingError::EntitySeed); + } + let mut canonical = Vec::new(); + push_bytes(&mut canonical, ENTITY_DOMAIN)?; + push_u32(&mut canonical, key_version); + push_str(&mut canonical, concept_id)?; + push_str(&mut canonical, audience)?; + push_bytes(&mut canonical, seed)?; + Ok(format!( + "urn:evidence:entity:v{key_version}_{}", + hmac_sha256_base64url_no_pad(key, &canonical) + )) +} + +fn validate_key(key: &[u8], key_version: u32) -> Result<(), BindingError> { + if key.len() < MIN_KEY_BYTES { + return Err(BindingError::WeakKey); + } + if key_version == 0 { + return Err(BindingError::KeyVersion); + } + Ok(()) +} + +fn push_str(output: &mut Vec, input: &str) -> Result<(), BindingError> { + push_bytes(output, input.as_bytes()) +} + +fn push_bytes(output: &mut Vec, input: &[u8]) -> Result<(), BindingError> { + if input.is_empty() || input.len() > MAX_COMPONENT_BYTES { + return Err(BindingError::Component); + } + let length = u32::try_from(input.len()).map_err(|_| BindingError::Component)?; + push_u32(output, length); + output.extend_from_slice(input); + Ok(()) +} + +fn push_u32(output: &mut Vec, value: u32) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn is_canonical_date(value: &str) -> bool { + if value.len() != 10 { + return false; + } + let bytes = value.as_bytes(); + if bytes[4] != b'-' || bytes[7] != b'-' { + return false; + } + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map(|date| date.format("%Y-%m-%d").to_string() == value) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + + fn input<'a>(fields: &'a [SelectorField<'a>]) -> SubjectBindingInput<'a> { + SubjectBindingInput { + trust_domain: "urn:example:trust", + audience: "urn:example:relying-party", + purpose: "enrolment", + role: "subject", + profile: "person-v1", + fields, + } + } + + #[test] + fn subject_binding_is_stable_and_scoped() { + let fields = [ + SelectorField { + name: "name", + value: SelectorScalar::String("Synthetic Person"), + }, + SelectorField { + name: "birth_date", + value: SelectorScalar::Date("2000-02-29"), + }, + ]; + let first = subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"); + let second = subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"); + assert_eq!(first, second); + assert!(first.starts_with("urn:evidence:subject:v1_")); + assert_eq!(first.len(), "urn:evidence:subject:v1_".len() + 43); + + let mut other_audience = input(&fields); + other_audience.audience = "urn:example:other-party"; + assert_ne!( + first, + subject_binding(KEY, 1, other_audience).expect("binding succeeds") + ); + } + + #[test] + fn subject_binding_is_field_order_sensitive() { + let fields = [ + SelectorField { + name: "a", + value: SelectorScalar::Integer(1), + }, + SelectorField { + name: "b", + value: SelectorScalar::Boolean(true), + }, + ]; + let reversed = [fields[1], fields[0]]; + assert_ne!( + subject_binding(KEY, 1, input(&fields)).expect("binding succeeds"), + subject_binding(KEY, 1, input(&reversed)).expect("binding succeeds") + ); + } + + #[test] + fn every_subject_binding_scope_component_is_cryptographically_bound() { + let fields = [SelectorField { + name: "coordinate", + value: SelectorScalar::String("same-bytes"), + }]; + let baseline = subject_binding(KEY, 1, input(&fields)).expect("baseline binding succeeds"); + + let mut changed = input(&fields); + changed.trust_domain = "urn:example:other-trust"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed trust binding succeeds") + ); + let mut changed = input(&fields); + changed.audience = "urn:example:other-audience"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed audience binding succeeds") + ); + let mut changed = input(&fields); + changed.purpose = "other-purpose"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed purpose binding succeeds") + ); + let mut changed = input(&fields); + changed.role = "other-role"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed role binding succeeds") + ); + let mut changed = input(&fields); + changed.profile = "other-profile"; + assert_ne!( + baseline, + subject_binding(KEY, 1, changed).expect("changed profile binding succeeds") + ); + assert_ne!( + baseline, + subject_binding(KEY, 2, input(&fields)).expect("changed key version succeeds") + ); + + let changed_name = [SelectorField { + name: "other_coordinate", + value: SelectorScalar::String("same-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_name)).expect("changed name binding succeeds") + ); + let changed_type = [SelectorField { + name: "coordinate", + value: SelectorScalar::ControlledCode("same-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_type)).expect("changed type binding succeeds") + ); + let changed_value = [SelectorField { + name: "coordinate", + value: SelectorScalar::String("other-bytes"), + }]; + assert_ne!( + baseline, + subject_binding(KEY, 1, input(&changed_value)).expect("changed value binding succeeds") + ); + } + + #[test] + fn entity_reference_is_audience_and_concept_scoped() { + let first = entity_reference( + KEY, + 2, + "urn:example:concept:person", + "urn:example:audience:a", + b"protected-source-id", + ) + .expect("reference succeeds"); + let second = entity_reference( + KEY, + 2, + "urn:example:concept:person", + "urn:example:audience:b", + b"protected-source-id", + ) + .expect("reference succeeds"); + assert_ne!(first, second); + assert!(first.starts_with("urn:evidence:entity:v2_")); + } + + #[test] + fn invalid_dates_and_weak_keys_are_rejected() { + let fields = [SelectorField { + name: "birth_date", + value: SelectorScalar::Date("2025-02-29"), + }]; + assert_eq!( + subject_binding(KEY, 1, input(&fields)), + Err(BindingError::Date) + ); + assert_eq!( + entity_reference(b"short", 1, "concept", "audience", b"seed"), + Err(BindingError::WeakKey) + ); + } + + #[test] + fn selector_binding_helper_debug_never_exposes_values() { + let fields = [ + SelectorField { + name: "alpha", + value: SelectorScalar::String("selector-debug-canary"), + }, + SelectorField { + name: "delta", + value: SelectorScalar::Integer(8_192_125), + }, + ]; + let diagnostic = format!("{fields:?}"); + assert!(!diagnostic.contains("selector-debug-canary")); + assert!(!diagnostic.contains("8192125")); + assert!(diagnostic.contains("alpha")); + assert!(diagnostic.contains("[REDACTED]")); + } +} diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs new file mode 100644 index 000000000..c97318502 --- /dev/null +++ b/crates/registry-evidence/src/bundle.rs @@ -0,0 +1,2026 @@ +//! Immutable Evidence Version 1 deployment bundle loading. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs::{self, File, Metadata}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use jsonschema::{Draft, JSONSchema}; +use rhai::{Engine, AST}; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use serde_norway::Value as YamlValue; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use url::Url; + +use crate::config::{ + ArtifactPath, ConceptForm, EvidenceConfig, OrderedMap, RuntimeConfig, SelectorField, +}; + +pub const MAX_BUNDLE_FILES: usize = 1_024; +pub const MAX_BUNDLE_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_ARTIFACT_BYTES: u64 = 1024 * 1024; +pub const MAX_SCRIPT_BYTES: u64 = 64 * 1024; +pub const MAX_PUBLIC_JWK_BYTES: u64 = 64 * 1024; + +const CONFIG_FILE: &str = "evidence.yaml"; +const REVISION_DOMAIN: &[u8] = b"registry.evidence.bundle-revision/v1\0"; +const RUNTIME_REVISION_DOMAIN: &[u8] = b"registry.evidence.runtime-revision/v1\0"; +const MAX_CA_BUNDLE_BYTES: u64 = 1024 * 1024; +const ALLOWED_DIRECTORIES: [&str; 6] = [ + "adapters", + "derivations", + "schemas", + "codelists", + "fixtures", + "public-keys", +]; + +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum BundleError { + #[error("the Evidence deployment bundle is unavailable")] + Unavailable, + #[error("the Evidence deployment bundle is not an immutable read-only directory")] + NotImmutable, + #[error("the Evidence deployment bundle contains an unsupported filesystem entry")] + UnsupportedEntry, + #[error("the Evidence deployment bundle contains a prohibited path")] + InvalidPath, + #[error("the Evidence deployment bundle contains an unknown or unreferenced file")] + UnknownFile, + #[error("the Evidence deployment bundle exceeds a Version 1 size bound")] + TooLarge, + #[error("the Evidence deployment configuration is invalid: {0}")] + Config(#[from] crate::config::ConfigError), + #[error("an Evidence bundle artifact is invalid: {0}")] + InvalidArtifact(&'static str), + #[error("an Evidence Rhai script is invalid")] + InvalidScript, +} + +#[derive(Debug, Clone)] +pub struct CompiledScript { + pub source: String, + pub ast: AST, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum Codelist { + Codes { + id: String, + version: String, + codes: Vec, + }, + Mapping { + id: String, + version: String, + entries: BTreeMap, + allowed_outputs: Vec, + }, +} + +impl Codelist { + pub fn id(&self) -> &str { + match self { + Self::Codes { id, .. } | Self::Mapping { id, .. } => id, + } + } + + pub fn version(&self) -> &str { + match self { + Self::Codes { version, .. } | Self::Mapping { version, .. } => version, + } + } + + pub fn contains_output(&self, value: &str) -> bool { + match self { + Self::Codes { codes, .. } => codes.iter().any(|code| code == value), + Self::Mapping { + allowed_outputs, .. + } => allowed_outputs.iter().any(|code| code == value), + } + } +} + +/// One fully captured, validated bundle revision. +/// +/// Runtime consumers use these captured bytes and compiled artifacts. They do +/// not reopen the deployment directory, which prevents a later filesystem +/// change from partially replacing the revision used by a serving process. +#[derive(Debug, Clone)] +pub struct Bundle { + root: PathBuf, + pub config: EvidenceConfig, + revision: String, + files: BTreeMap>, + pub scripts: BTreeMap, + pub fact_schemas: BTreeMap, + pub codelists: BTreeMap, + pub fixtures: BTreeMap, + pub retired_public_jwks: BTreeMap, +} + +/// One captured operator runtime configuration and its bound trust anchors. +/// +/// Secret values and audit contents are deliberately not captured. The +/// runtime digest covers only the reviewed runtime YAML and private-CA bytes. +#[derive(Debug, Clone)] +pub struct RuntimeDocument { + path: PathBuf, + pub config: RuntimeConfig, + revision: String, + bytes: Vec, + pub ca_bundles: BTreeMap>, +} + +impl RuntimeDocument { + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.file_name().and_then(|name| name.to_str()) != Some("runtime.yaml") { + return Err(BundleError::InvalidPath); + } + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(BundleError::InvalidPath); + } + let filesystem_read_only = filesystem_is_read_only(path)?; + validate_read_only(&metadata, filesystem_read_only)?; + let bytes = read_stable_file( + path, + &metadata, + crate::config::MAX_CONFIG_BYTES as u64, + filesystem_read_only, + )?; + let config = RuntimeConfig::parse_yaml(&bytes)?; + validate_secret_root(Path::new(&config.secret_providers.file.root))?; + + let mut ca_bundles = BTreeMap::new(); + for (profile, binding) in config.outbound_tls.trust_profiles.iter() { + let ca_path = Path::new(&binding.ca_bundle_file); + let ca_metadata = + fs::symlink_metadata(ca_path).map_err(|_| BundleError::Unavailable)?; + if ca_metadata.file_type().is_symlink() || !ca_metadata.is_file() { + return Err(BundleError::InvalidPath); + } + let ca_filesystem_read_only = filesystem_is_read_only(ca_path)?; + validate_read_only(&ca_metadata, ca_filesystem_read_only)?; + let ca_bytes = read_stable_file( + ca_path, + &ca_metadata, + MAX_CA_BUNDLE_BYTES, + ca_filesystem_read_only, + )?; + validate_ca_bundle(&ca_bytes)?; + ca_bundles.insert(profile.to_owned(), ca_bytes); + } + let revision = compute_runtime_revision(&bytes, &ca_bundles)?; + Ok(Self { + path: path.to_path_buf(), + config, + revision, + bytes, + ca_bundles, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// The two independently captured, closed startup inputs. +#[derive(Debug, Clone)] +pub struct DeploymentInputs { + pub bundle: Bundle, + pub runtime: RuntimeDocument, +} + +impl DeploymentInputs { + pub fn load(runtime_path: impl AsRef) -> Result { + let runtime = RuntimeDocument::load(runtime_path)?; + let bundle = Bundle::load(&runtime.config.bundle_directory)?; + validate_runtime_bindings(&bundle.config, &runtime.config)?; + Ok(Self { bundle, runtime }) + } +} + +pub type EvidenceBundle = Bundle; + +impl Bundle { + pub fn load(root: impl AsRef) -> Result { + let root = root.as_ref(); + let files = capture_bundle_files(root)?; + let config_bytes = files.get(CONFIG_FILE).ok_or(BundleError::Unavailable)?; + let config = EvidenceConfig::parse_yaml(config_bytes)?; + validate_file_closure(&config, &files)?; + + let scripts = load_scripts(&config, &files)?; + let fact_schemas = load_fact_schemas(&config, &files)?; + let codelists = load_codelists(&config, &files)?; + validate_codelist_references(&config, &codelists)?; + let fixtures = load_fixtures(&config, &files)?; + let retired_public_jwks = load_retired_public_jwks(&config, &files)?; + let revision = compute_revision(&files)?; + + Ok(Self { + root: root.to_path_buf(), + config, + revision, + files, + scripts, + fact_schemas, + codelists, + fixtures, + retired_public_jwks, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn configuration_revision(&self) -> &str { + &self.revision + } + + pub fn revision(&self) -> &str { + self.configuration_revision() + } + + pub fn artifact(&self, path: &str) -> Option<&[u8]> { + self.files.get(path).map(Vec::as_slice) + } + + pub fn script(&self, path: &ArtifactPath) -> Option<&CompiledScript> { + self.scripts.get(path.as_str()) + } + + pub fn fact_schema(&self, path: &ArtifactPath) -> Option<&JsonValue> { + self.fact_schemas.get(path.as_str()) + } + + pub fn codelist(&self, path: &ArtifactPath) -> Option<&Codelist> { + self.codelists.get(path.as_str()) + } + + pub fn fixture(&self, path: &ArtifactPath) -> Option<&YamlValue> { + self.fixtures.get(path.as_str()) + } +} + +pub fn load_bundle(root: impl AsRef) -> Result { + Bundle::load(root) +} + +fn capture_bundle_files(root: &Path) -> Result>, BundleError> { + let root_metadata = fs::symlink_metadata(root).map_err(|_| BundleError::Unavailable)?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(BundleError::InvalidPath); + } + let filesystem_read_only = filesystem_is_read_only(root)?; + validate_read_only(&root_metadata, filesystem_read_only)?; + let canonical_root = fs::canonicalize(root).map_err(|_| BundleError::Unavailable)?; + let mut paths = Vec::new(); + collect_paths( + root, + root, + &canonical_root, + filesystem_read_only, + &mut paths, + )?; + paths.sort_by(|left, right| left.0.cmp(&right.0)); + if paths.len() > MAX_BUNDLE_FILES { + return Err(BundleError::TooLarge); + } + + let mut files = BTreeMap::new(); + let mut total = 0_u64; + for (relative, path, scanned_metadata) in paths { + let cap = file_size_cap(&relative); + let bytes = read_stable_file(&path, &scanned_metadata, cap, filesystem_read_only)?; + total = total + .checked_add(u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)?) + .ok_or(BundleError::TooLarge)?; + if total > MAX_BUNDLE_BYTES { + return Err(BundleError::TooLarge); + } + files.insert(relative, bytes); + } + if !files.contains_key(CONFIG_FILE) { + return Err(BundleError::Unavailable); + } + Ok(files) +} + +fn collect_paths( + root: &Path, + directory: &Path, + canonical_root: &Path, + filesystem_read_only: bool, + files: &mut Vec<(String, PathBuf, Metadata)>, +) -> Result<(), BundleError> { + let mut entries = fs::read_dir(directory) + .map_err(|_| BundleError::Unavailable)? + .collect::, _>>() + .map_err(|_| BundleError::Unavailable)?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() { + return Err(BundleError::InvalidPath); + } + validate_read_only(&metadata, filesystem_read_only)?; + let relative_path = path + .strip_prefix(root) + .map_err(|_| BundleError::InvalidPath)?; + let relative = path_to_bundle_string(relative_path)?; + let top = relative.split('/').next().ok_or(BundleError::InvalidPath)?; + if directory == root { + if metadata.is_dir() { + if !ALLOWED_DIRECTORIES.contains(&top) { + return Err(BundleError::InvalidPath); + } + } else if relative != CONFIG_FILE { + return Err(BundleError::UnknownFile); + } + } else if !ALLOWED_DIRECTORIES.contains(&top) { + return Err(BundleError::InvalidPath); + } + + let canonical = fs::canonicalize(&path).map_err(|_| BundleError::Unavailable)?; + if !canonical.starts_with(canonical_root) { + return Err(BundleError::InvalidPath); + } + if metadata.is_dir() { + collect_paths(root, &path, canonical_root, filesystem_read_only, files)?; + } else if metadata.is_file() { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if metadata.nlink() != 1 { + return Err(BundleError::InvalidPath); + } + } + files.push((relative, path, metadata)); + } else { + return Err(BundleError::UnsupportedEntry); + } + } + Ok(()) +} + +fn path_to_bundle_string(path: &Path) -> Result { + let value = path.to_str().ok_or(BundleError::InvalidPath)?; + if value.is_empty() + || value.starts_with('/') + || value.contains('\\') + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(BundleError::InvalidPath); + } + Ok(value.to_owned()) +} + +#[cfg(unix)] +fn validate_read_only(metadata: &Metadata, filesystem_read_only: bool) -> Result<(), BundleError> { + use std::os::unix::fs::PermissionsExt as _; + if !filesystem_read_only && metadata.permissions().mode() & 0o222 != 0 { + Err(BundleError::NotImmutable) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn validate_read_only(metadata: &Metadata, filesystem_read_only: bool) -> Result<(), BundleError> { + if filesystem_read_only || metadata.permissions().readonly() { + Ok(()) + } else { + Err(BundleError::NotImmutable) + } +} + +#[cfg(unix)] +fn filesystem_is_read_only(path: &Path) -> Result { + let status = rustix::fs::statvfs(path).map_err(|_| BundleError::Unavailable)?; + Ok(status + .f_flag + .contains(rustix::fs::StatVfsMountFlags::RDONLY)) +} + +#[cfg(not(unix))] +fn filesystem_is_read_only(_path: &Path) -> Result { + Ok(false) +} + +fn file_size_cap(path: &str) -> u64 { + if path.starts_with("adapters/") || path.starts_with("derivations/") { + MAX_SCRIPT_BYTES + } else if path.starts_with("public-keys/") { + MAX_PUBLIC_JWK_BYTES + } else { + MAX_ARTIFACT_BYTES + } +} + +fn read_stable_file( + path: &Path, + scanned: &Metadata, + cap: u64, + filesystem_read_only: bool, +) -> Result, BundleError> { + if scanned.len() > cap { + return Err(BundleError::TooLarge); + } + let mut file = open_no_follow(path)?; + let opened = file.metadata().map_err(|_| BundleError::Unavailable)?; + validate_read_only(&opened, filesystem_read_only)?; + if !opened.is_file() || !same_file(scanned, &opened) || opened.len() > cap { + return Err(BundleError::NotImmutable); + } + let mut bytes = Vec::new(); + file.by_ref() + .take(cap.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| BundleError::Unavailable)?; + if u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)? > cap { + return Err(BundleError::TooLarge); + } + let after = file.metadata().map_err(|_| BundleError::Unavailable)?; + if !same_file(&opened, &after) + || after.len() != u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)? + { + return Err(BundleError::NotImmutable); + } + Ok(bytes) +} + +#[cfg(unix)] +fn open_no_follow(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags}; + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| BundleError::Unavailable)?; + Ok(File::from(descriptor)) +} + +#[cfg(not(unix))] +fn open_no_follow(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() { + return Err(BundleError::InvalidPath); + } + File::open(path).map_err(|_| BundleError::Unavailable) +} + +#[cfg(unix)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + left.dev() == right.dev() + && left.ino() == right.ino() + && left.len() == right.len() + && left.permissions().mode() == right.permissions().mode() +} + +#[cfg(not(unix))] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + left.len() == right.len() + && left.permissions().readonly() == right.permissions().readonly() + && left.modified().ok() == right.modified().ok() +} + +fn validate_file_closure( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result<(), BundleError> { + let mut expected = BTreeSet::from([CONFIG_FILE.to_owned()]); + for (_, source) in config.sources.iter() { + expected.insert(source.request.prepare_script.as_str().to_owned()); + expected.insert(source.extract_script.as_str().to_owned()); + expected.insert(source.request.adapter_parameters_schema.as_str().to_owned()); + expected.insert(source.fact_schema.as_str().to_owned()); + } + for requirement in &config.requirements { + expected.insert(requirement.derivation.script.as_str().to_owned()); + expected.insert(requirement.fixtures.as_str().to_owned()); + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::ControlledCode + | ConceptForm::ControlledCategory + | ConceptForm::ControlledCodeList + ) { + expected.insert(concept_codelist_path(&concept.constraints)?.to_owned()); + } + } + } + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { codelist, .. } = field { + expected.insert(codelist.as_str().to_owned()); + } + } + } + for path in &config.signing.retired_public_jwk_files { + expected.insert(path.as_str().to_owned()); + } + expected.extend(reviewed_schema_paths(config, files)?); + expected.extend(reviewed_bucket_codelist_paths(config, files)?); + if files.keys().map(String::as_str).collect::>() + != expected.iter().map(String::as_str).collect() + { + return Err(BundleError::UnknownFile); + } + Ok(()) +} + +fn reviewed_bucket_codelist_paths( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let declarations = config + .requirements + .iter() + .flat_map(|requirement| &requirement.concepts) + .filter(|concept| { + matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) + }) + .map(|concept| { + Ok(( + concept_constraint_string(&concept.constraints, "bucketScheme")?, + concept_constraint_string(&concept.constraints, "schemeVersion")?, + )) + }) + .collect::, BundleError>>()?; + let mut paths = BTreeSet::new(); + for (identifier, version) in declarations { + let matches = files + .iter() + .filter(|(path, _)| path.starts_with("codelists/")) + .filter_map(|(path, bytes)| { + let document = std::str::from_utf8(bytes) + .ok() + .and_then(|text| serde_norway::from_str::(text).ok())?; + let mapping = document.as_mapping()?; + (mapping.get("id").and_then(YamlValue::as_str) == Some(identifier) + && mapping.get("version").and_then(YamlValue::as_str) == Some(version)) + .then(|| path.clone()) + }) + .collect::>(); + if matches.len() != 1 { + return Err(BundleError::InvalidArtifact( + "bucket scheme codelist is missing or ambiguous", + )); + } + paths.insert(matches[0].clone()); + } + Ok(paths) +} + +fn reviewed_schema_paths( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let identifiers = config + .requirements + .iter() + .flat_map(|requirement| &requirement.concepts) + .filter(|concept| concept.form == ConceptForm::ReviewedStructuredValue) + .map(|concept| concept_constraint_string(&concept.constraints, "schema")) + .collect::, _>>()?; + let mut paths = BTreeSet::new(); + for identifier in identifiers { + let matches = files + .iter() + .filter(|(path, _)| path.starts_with("schemas/")) + .filter_map(|(path, bytes)| { + let document = std::str::from_utf8(bytes) + .ok() + .and_then(|text| serde_norway::from_str::(text).ok())?; + (document.get("$id").and_then(JsonValue::as_str) == Some(identifier)) + .then(|| path.clone()) + }) + .collect::>(); + if matches.len() != 1 { + return Err(BundleError::InvalidArtifact( + "reviewed structured schema identifier is missing or ambiguous", + )); + } + paths.insert(matches[0].clone()); + } + Ok(paths) +} + +fn load_scripts( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut expected = BTreeMap::new(); + for (_, source) in config.sources.iter() { + insert_script_contract( + &mut expected, + source.request.prepare_script.as_str(), + ("prepare", 2), + )?; + insert_script_contract( + &mut expected, + source.extract_script.as_str(), + ("extract", 2), + )?; + } + for requirement in &config.requirements { + insert_script_contract( + &mut expected, + requirement.derivation.script.as_str(), + ("derive", 3), + )?; + } + let mut scripts = BTreeMap::new(); + for (path, (entrypoint, arity)) in expected { + let bytes = files + .get(path) + .ok_or(BundleError::InvalidArtifact("missing script"))?; + let source = std::str::from_utf8(bytes) + .map_err(|_| BundleError::InvalidArtifact("script is not UTF-8"))? + .to_owned(); + reject_prohibited_script_capabilities(&source)?; + let mut engine = Engine::new(); + engine.set_max_expr_depths(64, 64); + engine.set_max_call_levels(32); + engine.set_max_operations(100_000); + engine.set_max_array_size(256); + engine.set_max_map_size(256); + engine.set_max_string_size(16_384); + engine.set_max_modules(0); + let ast = engine + .compile(&source) + .map_err(|_| BundleError::InvalidScript)?; + let entrypoint_functions = ast + .iter_functions() + .filter(|function| function.name == entrypoint) + .map(|function| function.params.len()) + .collect::>(); + if entrypoint_functions != [arity] { + return Err(BundleError::InvalidScript); + } + scripts.insert(path.to_owned(), CompiledScript { source, ast }); + } + Ok(scripts) +} + +fn insert_script_contract<'a>( + scripts: &mut BTreeMap<&'a str, (&'static str, usize)>, + path: &'a str, + contract: (&'static str, usize), +) -> Result<(), BundleError> { + if scripts + .insert(path, contract) + .is_some_and(|existing| existing != contract) + { + return Err(BundleError::InvalidArtifact( + "one script path is assigned incompatible entry points", + )); + } + Ok(()) +} + +fn reject_prohibited_script_capabilities(source: &str) -> Result<(), BundleError> { + const PROHIBITED: [&str; 14] = [ + "import", + "eval", + "print", + "debug", + "get_env", + "environment", + "filesystem", + "network", + "process", + "random", + "Fn", + "call", + "curry", + "is_def_fn", + ]; + let mut identifier = String::new(); + let mut chars = source.chars().peekable(); + let mut quote = None; + while let Some(character) = chars.next() { + if let Some(delimiter) = quote { + if character == '\\' { + chars.next(); + } else if character == delimiter { + quote = None; + } + continue; + } + if matches!(character, '\'' | '"' | '`') { + quote = Some(character); + continue; + } + if character == '/' && chars.peek() == Some(&'/') { + chars.next(); + for next in chars.by_ref() { + if next == '\n' { + break; + } + } + continue; + } + if character.is_ascii_alphanumeric() || character == '_' { + identifier.push(character); + } else if !identifier.is_empty() { + if PROHIBITED.contains(&identifier.as_str()) { + return Err(BundleError::InvalidScript); + } + identifier.clear(); + } + } + if PROHIBITED.contains(&identifier.as_str()) { + return Err(BundleError::InvalidScript); + } + Ok(()) +} + +fn load_fact_schemas( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let parameter_paths = config + .sources + .iter() + .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) + .collect::>(); + let mut paths = config + .sources + .iter() + .flat_map(|(_, source)| { + [ + source.fact_schema.as_str(), + source.request.adapter_parameters_schema.as_str(), + ] + }) + .map(ToOwned::to_owned) + .collect::>(); + paths.extend(reviewed_schema_paths(config, files)?); + let mut schemas = BTreeMap::new(); + for path in paths { + let bytes = files + .get(&path) + .ok_or(BundleError::InvalidArtifact("missing fact schema"))?; + let text = std::str::from_utf8(bytes) + .map_err(|_| BundleError::InvalidArtifact("fact schema is not UTF-8"))?; + let schema: JsonValue = serde_norway::from_str(text) + .map_err(|_| BundleError::InvalidArtifact("fact schema YAML is invalid"))?; + validate_closed_schema(&schema, parameter_paths.contains(path.as_str()))?; + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .map_err(|_| BundleError::InvalidArtifact("fact schema is not valid JSON Schema"))?; + schemas.insert(path, schema); + } + for (_, source) in config.sources.iter() { + let schema = schemas + .get(source.request.adapter_parameters_schema.as_str()) + .ok_or(BundleError::InvalidArtifact( + "missing adapter-parameter schema", + ))?; + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .map_err(|_| { + BundleError::InvalidArtifact("adapter-parameter schema is not valid JSON Schema") + })?; + let parameters = + serde_json::to_value(&source.request.adapter_parameters).map_err(|_| { + BundleError::InvalidArtifact("adapter parameters are not JSON-compatible") + })?; + if !compiled.is_valid(¶meters) { + return Err(BundleError::InvalidArtifact( + "adapter parameters do not satisfy their closed schema", + )); + } + } + Ok(schemas) +} + +fn validate_closed_schema(schema: &JsonValue, allow_empty_root: bool) -> Result<(), BundleError> { + let root = schema.as_object().ok_or(BundleError::InvalidArtifact( + "fact schema must be an object", + ))?; + if root.get("type").and_then(JsonValue::as_str) != Some("object") + || root + .get("additionalProperties") + .and_then(JsonValue::as_bool) + != Some(false) + { + return Err(BundleError::InvalidArtifact( + "fact schema must close the root object", + )); + } + let properties = root + .get("properties") + .and_then(JsonValue::as_object) + .ok_or(BundleError::InvalidArtifact( + "fact schema must declare properties", + ))?; + if (!allow_empty_root && properties.is_empty()) || properties.len() > 64 { + return Err(BundleError::InvalidArtifact( + "fact schema property count is invalid", + )); + } + let required = + root.get("required") + .and_then(JsonValue::as_array) + .ok_or(BundleError::InvalidArtifact( + "fact schema must declare required fields", + ))?; + let required = required + .iter() + .map(JsonValue::as_str) + .collect::>>() + .ok_or(BundleError::InvalidArtifact( + "fact schema required fields are invalid", + ))?; + if required.len() != properties.len() + || properties + .keys() + .any(|property| !required.contains(property.as_str())) + { + return Err(BundleError::InvalidArtifact( + "fact schema must require its exact closed field set", + )); + } + validate_schema_node(schema) +} + +fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { + let object = node.as_object().ok_or(BundleError::InvalidArtifact( + "every schema node must be a typed object", + ))?; + let Some(value_type) = object.get("type").and_then(JsonValue::as_str) else { + if object + .keys() + .all(|key| matches!(key.as_str(), "$schema" | "$id" | "const")) + && object.get("const").is_some_and(schema_const_is_bounded) + { + return Ok(()); + } + return Err(BundleError::InvalidArtifact( + "every schema node must declare one type or one bounded const", + )); + }; + let allowed = match value_type { + "object" => &[ + "$schema", + "$id", + "type", + "additionalProperties", + "required", + "properties", + ][..], + "array" => &[ + "$schema", + "$id", + "type", + "minItems", + "maxItems", + "uniqueItems", + "items", + "const", + ][..], + "string" => &[ + "$schema", + "$id", + "type", + "minLength", + "maxLength", + "format", + "enum", + "const", + ][..], + "integer" => &[ + "$schema", "$id", "type", "minimum", "maximum", "enum", "const", + ][..], + "boolean" => &["$schema", "$id", "type", "enum", "const"][..], + _ => { + return Err(BundleError::InvalidArtifact( + "schema node type is outside the closed Version 1 subset", + )); + } + }; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(BundleError::InvalidArtifact( + "schema node uses a keyword outside the closed Version 1 subset", + )); + } + + match value_type { + "object" => { + if object + .get("additionalProperties") + .and_then(JsonValue::as_bool) + != Some(false) + { + return Err(BundleError::InvalidArtifact( + "nested schema objects must be closed", + )); + } + let properties = object + .get("properties") + .and_then(JsonValue::as_object) + .filter(|properties| !properties.is_empty() && properties.len() <= 64) + .ok_or(BundleError::InvalidArtifact( + "schema objects must declare bounded properties", + ))?; + let required = object + .get("required") + .and_then(JsonValue::as_array) + .and_then(|required| { + required + .iter() + .map(JsonValue::as_str) + .collect::>>() + }) + .ok_or(BundleError::InvalidArtifact( + "schema objects must declare required properties", + ))?; + if required.len() != properties.len() + || properties + .keys() + .any(|property| !required.contains(property.as_str())) + { + return Err(BundleError::InvalidArtifact( + "schema objects must require their exact property set", + )); + } + for property in properties.values() { + validate_schema_node(property)?; + } + } + "array" => { + if object + .get("uniqueItems") + .is_some_and(|value| value.as_bool() != Some(true)) + { + return Err(BundleError::InvalidArtifact( + "schema array uniqueness flag is invalid", + )); + } + if let Some(value) = object.get("const") { + if !value.is_array() || !schema_const_is_bounded(value) { + return Err(BundleError::InvalidArtifact( + "schema array const is invalid", + )); + } + } + let maximum = object.get("maxItems").and_then(JsonValue::as_u64).ok_or( + BundleError::InvalidArtifact("schema arrays must be bounded"), + )?; + if maximum == 0 || maximum > 256 { + return Err(BundleError::InvalidArtifact( + "schema array bound is invalid", + )); + } + if object + .get("minItems") + .and_then(JsonValue::as_u64) + .is_some_and(|minimum| minimum > maximum) + { + return Err(BundleError::InvalidArtifact( + "schema array bounds are invalid", + )); + } + validate_schema_node(object.get("items").ok_or(BundleError::InvalidArtifact( + "schema arrays must close their item type", + ))?)?; + } + "string" => { + if object + .get("format") + .and_then(JsonValue::as_str) + .is_some_and(|format| !matches!(format, "date" | "date-time")) + { + return Err(BundleError::InvalidArtifact( + "schema string format is outside the closed Version 1 subset", + )); + } + let bounded = object + .get("maxLength") + .and_then(JsonValue::as_u64) + .is_some_and(|maximum| maximum > 0 && maximum <= 65_536); + let formatted = matches!( + object.get("format").and_then(JsonValue::as_str), + Some("date" | "date-time") + ); + let enumerated = object + .get("enum") + .and_then(JsonValue::as_array) + .is_some_and(|values| { + !values.is_empty() + && values.len() <= 256 + && values.iter().all(|value| value.as_str().is_some()) + }); + let constant = object + .get("const") + .and_then(JsonValue::as_str) + .is_some_and(|value| value.len() <= 65_536); + if !bounded && !formatted && !enumerated && !constant { + return Err(BundleError::InvalidArtifact( + "schema strings must be bounded, formatted, or enumerated", + )); + } + } + "integer" => { + let bounded = object + .get("minimum") + .and_then(JsonValue::as_i64) + .zip(object.get("maximum").and_then(JsonValue::as_i64)); + let enumerated = object + .get("enum") + .and_then(JsonValue::as_array) + .is_some_and(|values| { + !values.is_empty() + && values.len() <= 256 + && values.iter().all(|value| value.as_i64().is_some()) + }); + let constant = object.get("const").and_then(JsonValue::as_i64).is_some(); + if bounded.is_none_or(|(minimum, maximum)| minimum > maximum) + && !enumerated + && !constant + { + return Err(BundleError::InvalidArtifact( + "schema integers must be bounded or enumerated", + )); + } + } + "boolean" => { + if object.get("const").is_some_and(|value| !value.is_boolean()) { + return Err(BundleError::InvalidArtifact( + "schema boolean const is invalid", + )); + } + if object.get("enum").is_some_and(|value| { + value.as_array().is_none_or(|values| { + values.is_empty() + || values.len() > 2 + || values.iter().any(|value| !value.is_boolean()) + }) + }) { + return Err(BundleError::InvalidArtifact( + "schema boolean enumeration is invalid", + )); + } + } + _ => unreachable!("type was closed above"), + } + Ok(()) +} + +fn schema_const_is_bounded(value: &JsonValue) -> bool { + match value { + JsonValue::Bool(_) => true, + JsonValue::Number(value) => value.as_i64().is_some(), + JsonValue::String(value) => value.len() <= 65_536, + JsonValue::Array(values) => { + values.len() <= 256 && values.iter().all(schema_const_is_bounded) + } + JsonValue::Object(values) => { + values.len() <= 256 + && values + .iter() + .all(|(name, value)| name.len() <= 1_024 && schema_const_is_bounded(value)) + } + JsonValue::Null => false, + } +} + +fn load_codelists( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut paths = BTreeSet::new(); + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { codelist, .. } = field { + paths.insert(codelist.as_str().to_owned()); + } + } + } + for requirement in &config.requirements { + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::ControlledCode + | ConceptForm::ControlledCategory + | ConceptForm::ControlledCodeList + ) { + paths.insert(concept_codelist_path(&concept.constraints)?.to_owned()); + } + } + } + paths.extend(reviewed_bucket_codelist_paths(config, files)?); + let mut codelists = BTreeMap::new(); + for path in paths { + let bytes = files + .get(&path) + .ok_or(BundleError::InvalidArtifact("missing codelist"))?; + let text = std::str::from_utf8(bytes) + .map_err(|_| BundleError::InvalidArtifact("codelist is not UTF-8"))?; + let document: CodelistDocument = serde_norway::from_str(text) + .map_err(|_| BundleError::InvalidArtifact("codelist YAML is invalid"))?; + let codelist = document.validate()?; + codelists.insert(path, codelist); + } + Ok(codelists) +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CodelistDocument { + Codes(CodeCodelistDocument), + Mapping(MappingCodelistDocument), +} + +impl CodelistDocument { + fn validate(self) -> Result { + match self { + Self::Codes(document) => document.validate(), + Self::Mapping(document) => document.validate(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CodeCodelistDocument { + id: String, + version: String, + codes: Vec, +} + +impl CodeCodelistDocument { + fn validate(self) -> Result { + validate_codelist_header(&self.id, &self.version)?; + validate_code_collection(&self.codes)?; + Ok(Codelist::Codes { + id: self.id, + version: self.version, + codes: self.codes, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct MappingCodelistDocument { + id: String, + version: String, + entries: BTreeMap, + allowed_outputs: Vec, +} + +impl MappingCodelistDocument { + fn validate(self) -> Result { + validate_codelist_header(&self.id, &self.version)?; + if self.entries.is_empty() || self.entries.len() > 4_096 { + return Err(BundleError::InvalidArtifact( + "codelist entry count is invalid", + )); + } + validate_code_collection(&self.allowed_outputs)?; + for (input, output) in &self.entries { + validate_code(input)?; + validate_code(output)?; + if !self.allowed_outputs.contains(output) { + return Err(BundleError::InvalidArtifact( + "codelist mapping output is not allowed", + )); + } + } + Ok(Codelist::Mapping { + id: self.id, + version: self.version, + entries: self.entries, + allowed_outputs: self.allowed_outputs, + }) + } +} + +fn validate_codelist_header(id: &str, version: &str) -> Result<(), BundleError> { + if id.len() > 512 + || Url::parse(id).is_err() + || version.is_empty() + || version.len() > 128 + || version.contains('\0') + { + return Err(BundleError::InvalidArtifact("codelist identity is invalid")); + } + Ok(()) +} + +fn validate_code_collection(codes: &[String]) -> Result<(), BundleError> { + if codes.is_empty() || codes.len() > 4_096 { + return Err(BundleError::InvalidArtifact( + "codelist code count is invalid", + )); + } + let mut seen = BTreeSet::new(); + for code in codes { + validate_code(code)?; + if !seen.insert(code.as_str()) { + return Err(BundleError::InvalidArtifact("codelist code is duplicated")); + } + } + Ok(()) +} + +fn validate_code(code: &str) -> Result<(), BundleError> { + let bytes = code.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !bytes[0].is_ascii_alphanumeric() + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) + { + return Err(BundleError::InvalidArtifact("codelist code is invalid")); + } + Ok(()) +} + +fn validate_codelist_references( + config: &EvidenceConfig, + codelists: &BTreeMap, +) -> Result<(), BundleError> { + for (_, profile) in config.selector_profiles.iter() { + for (_, field) in profile.fields.iter() { + if let SelectorField::ControlledCode { + codelist, + codelist_version, + .. + } = field + { + let loaded = codelists + .get(codelist.as_str()) + .ok_or(BundleError::InvalidArtifact("selector codelist is missing"))?; + if loaded.version() != codelist_version { + return Err(BundleError::InvalidArtifact( + "selector codelist version mismatch", + )); + } + } + } + } + for requirement in &config.requirements { + for concept in &requirement.concepts { + if matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) { + let identifier = concept_constraint_string(&concept.constraints, "bucketScheme")?; + let version = concept_constraint_string(&concept.constraints, "schemeVersion")?; + let matches = codelists + .values() + .filter(|codelist| codelist.id() == identifier && codelist.version() == version) + .count(); + if matches != 1 { + return Err(BundleError::InvalidArtifact( + "bucket scheme codelist identity mismatch", + )); + } + continue; + } + let version_key = match concept.form { + ConceptForm::ControlledCode | ConceptForm::ControlledCodeList => "codelistVersion", + ConceptForm::ControlledCategory => "schemeVersion", + _ => continue, + }; + let path = concept_codelist_path(&concept.constraints)?; + let version = concept_constraint_string(&concept.constraints, version_key)?; + let loaded = codelists + .get(path) + .ok_or(BundleError::InvalidArtifact("concept codelist is missing"))?; + if loaded.version() != version { + return Err(BundleError::InvalidArtifact( + "concept codelist version mismatch", + )); + } + } + } + Ok(()) +} + +fn load_fixtures( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut fixtures = BTreeMap::new(); + for requirement in &config.requirements { + let path = requirement.fixtures.as_str(); + if fixtures.contains_key(path) { + continue; + } + let bytes = files + .get(path) + .ok_or(BundleError::InvalidArtifact("fixture file is missing"))?; + let text = std::str::from_utf8(bytes) + .map_err(|_| BundleError::InvalidArtifact("fixture file is not UTF-8"))?; + let fixture: YamlValue = serde_norway::from_str(text) + .map_err(|_| BundleError::InvalidArtifact("fixture YAML is invalid"))?; + validate_fixture_coverage(&fixture)?; + fixtures.insert(path.to_owned(), fixture); + } + Ok(fixtures) +} + +fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { + let root = fixture.as_mapping().ok_or(BundleError::InvalidArtifact( + "fixture root must be a mapping", + ))?; + if root.get("synthetic_only").and_then(YamlValue::as_bool) != Some(true) { + return Err(BundleError::InvalidArtifact( + "fixtures must be synthetic-only", + )); + } + let cases = root + .get("cases") + .and_then(YamlValue::as_sequence) + .ok_or(BundleError::InvalidArtifact("fixture cases are missing"))?; + if cases.is_empty() || cases.len() > 256 { + return Err(BundleError::InvalidArtifact( + "fixture case count is invalid", + )); + } + let mut ids = BTreeSet::new(); + let mut categories = FixtureCategories::default(); + for case in cases { + let id = case + .as_mapping() + .and_then(|mapping| mapping.get("id")) + .and_then(YamlValue::as_str) + .ok_or(BundleError::InvalidArtifact("fixture case id is missing"))?; + if id.is_empty() || id.len() > 128 || !ids.insert(id) { + return Err(BundleError::InvalidArtifact( + "fixture case id is invalid or duplicated", + )); + } + categories.observe(id); + } + if !categories.complete() { + return Err(BundleError::InvalidArtifact( + "fixture category coverage is incomplete", + )); + } + Ok(()) +} + +#[derive(Default)] +struct FixtureCategories { + positive: bool, + negative: bool, + boundary: bool, + missing: bool, + no_match: bool, + ambiguous: bool, + source_failure: bool, + anti_reconstruction: bool, +} + +impl FixtureCategories { + fn observe(&mut self, id: &str) { + self.positive |= id == "positive"; + self.negative |= id.starts_with("negative"); + self.boundary |= id.starts_with("boundary"); + self.missing |= id.starts_with("missing"); + self.no_match |= id == "no-match"; + self.ambiguous |= id.starts_with("ambiguous"); + self.source_failure |= id == "source-failure"; + self.anti_reconstruction |= id == "anti-reconstruction"; + } + + fn complete(&self) -> bool { + self.positive + && self.negative + && self.boundary + && self.missing + && self.no_match + && self.ambiguous + && self.source_failure + && self.anti_reconstruction + } +} + +fn load_retired_public_jwks( + config: &EvidenceConfig, + files: &BTreeMap>, +) -> Result, BundleError> { + let mut keys = BTreeMap::new(); + for path in &config.signing.retired_public_jwk_files { + let bytes = files + .get(path.as_str()) + .ok_or(BundleError::InvalidArtifact( + "retired public JWK is missing", + ))?; + let object = parse_strict_json_object(bytes)?; + let kid = validate_public_jwk(&object, &config.signing.active_key_id)?; + if keys.insert(kid, JsonValue::Object(object)).is_some() { + return Err(BundleError::InvalidArtifact( + "retired public JWK kid is duplicated", + )); + } + } + Ok(keys) +} + +fn parse_strict_json_object(bytes: &[u8]) -> Result, BundleError> { + struct StrictObject(JsonMap); + impl<'de> Deserialize<'de> for StrictObject { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct ObjectVisitor; + impl<'de> Visitor<'de> for ObjectVisitor { + type Value = StrictObject; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object with unique members") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut object = JsonMap::new(); + while let Some((key, value)) = map.next_entry::()? { + if object.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate JSON member")); + } + } + Ok(StrictObject(object)) + } + } + deserializer.deserialize_map(ObjectVisitor) + } + } + + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let object = StrictObject::deserialize(&mut deserializer) + .map_err(|_| BundleError::InvalidArtifact("public JWK JSON is invalid"))?; + deserializer + .end() + .map_err(|_| BundleError::InvalidArtifact("public JWK has trailing data"))?; + Ok(object.0) +} + +fn validate_public_jwk( + object: &JsonMap, + active_key_id: &str, +) -> Result { + const ALLOWED: [&str; 7] = ["kty", "crv", "x", "kid", "alg", "use", "key_ops"]; + if object.keys().any(|key| !ALLOWED.contains(&key.as_str())) + || object.get("kty").and_then(JsonValue::as_str) != Some("OKP") + || object.get("crv").and_then(JsonValue::as_str) != Some("Ed25519") + || object.get("alg").and_then(JsonValue::as_str) != Some("EdDSA") + || object + .get("use") + .is_some_and(|value| value.as_str() != Some("sig")) + { + return Err(BundleError::InvalidArtifact( + "retired JWK is not an allowed public EdDSA key", + )); + } + let kid = object + .get("kid") + .and_then(JsonValue::as_str) + .filter(|kid| { + !kid.is_empty() + && kid.len() <= 256 + && !kid.chars().any(char::is_control) + && *kid != active_key_id + }) + .ok_or(BundleError::InvalidArtifact("retired JWK kid is invalid"))?; + let x = object + .get("x") + .and_then(JsonValue::as_str) + .ok_or(BundleError::InvalidArtifact( + "retired JWK public coordinate is missing", + ))?; + let decoded = URL_SAFE_NO_PAD + .decode(x) + .map_err(|_| BundleError::InvalidArtifact("retired JWK public coordinate is invalid"))?; + if decoded.len() != 32 { + return Err(BundleError::InvalidArtifact( + "retired JWK public coordinate has the wrong size", + )); + } + if let Some(operations) = object.get("key_ops") { + let operations = operations.as_array().ok_or(BundleError::InvalidArtifact( + "retired JWK key_ops is invalid", + ))?; + if operations.len() != 1 || operations[0].as_str() != Some("verify") { + return Err(BundleError::InvalidArtifact( + "retired JWK key_ops is not verify-only", + )); + } + } + Ok(kid.to_owned()) +} + +fn concept_codelist_path(constraints: &OrderedMap) -> Result<&str, BundleError> { + concept_constraint_string(constraints, "codelist") +} + +fn concept_constraint_string<'a>( + constraints: &'a OrderedMap, + key: &str, +) -> Result<&'a str, BundleError> { + constraints + .get(key) + .and_then(YamlValue::as_str) + .ok_or(BundleError::InvalidArtifact( + "concept codelist constraint is invalid", + )) +} + +fn validate_runtime_bindings( + bundle: &EvidenceConfig, + runtime: &RuntimeConfig, +) -> Result<(), BundleError> { + let required = bundle + .sources + .iter() + .filter_map(|(_, source)| source.tls_trust_profile.as_deref()) + .collect::>(); + let configured = runtime + .outbound_tls + .trust_profiles + .keys() + .collect::>(); + if required != configured { + return Err(BundleError::InvalidArtifact( + "runtime TLS trust profiles must exactly bind bundle source profiles", + )); + } + Ok(()) +} + +fn validate_secret_root(path: &Path) -> Result<(), BundleError> { + let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(BundleError::InvalidPath); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(BundleError::NotImmutable); + } + } + #[cfg(not(unix))] + if !metadata.permissions().readonly() { + return Err(BundleError::NotImmutable); + } + Ok(()) +} + +fn validate_ca_bundle(bytes: &[u8]) -> Result<(), BundleError> { + let text = std::str::from_utf8(bytes) + .map_err(|_| BundleError::InvalidArtifact("TLS CA bundle is not UTF-8 PEM"))?; + let mut in_certificate = false; + let mut encoded = String::new(); + let mut certificates = 0_usize; + for line in text.lines() { + match line { + "-----BEGIN CERTIFICATE-----" if !in_certificate => { + in_certificate = true; + encoded.clear(); + } + "-----END CERTIFICATE-----" if in_certificate => { + let der = base64::engine::general_purpose::STANDARD + .decode(encoded.as_bytes()) + .map_err(|_| BundleError::InvalidArtifact("TLS CA bundle PEM is invalid"))?; + if der.len() < 4 || der.first() != Some(&0x30) { + return Err(BundleError::InvalidArtifact( + "TLS CA bundle certificate is invalid", + )); + } + certificates = certificates.checked_add(1).ok_or(BundleError::TooLarge)?; + if certificates > 64 { + return Err(BundleError::TooLarge); + } + in_certificate = false; + } + "" if !in_certificate => {} + _ if in_certificate + && !line.is_empty() + && line.len() <= 76 + && line.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') + }) => + { + encoded.push_str(line); + } + _ => { + return Err(BundleError::InvalidArtifact( + "TLS CA bundle contains non-certificate PEM data", + )); + } + } + } + if in_certificate || certificates == 0 { + return Err(BundleError::InvalidArtifact( + "TLS CA bundle contains no complete certificate", + )); + } + Ok(()) +} + +fn compute_runtime_revision( + runtime_bytes: &[u8], + ca_bundles: &BTreeMap>, +) -> Result { + let mut files = BTreeMap::from([("runtime.yaml".to_owned(), runtime_bytes.to_vec())]); + for (profile, bytes) in ca_bundles { + files.insert(format!("trust-profile/{profile}.pem"), bytes.clone()); + } + compute_named_revision(RUNTIME_REVISION_DOMAIN, &files) +} + +fn compute_revision(files: &BTreeMap>) -> Result { + compute_named_revision(REVISION_DOMAIN, files) +} + +fn compute_named_revision( + domain: &[u8], + files: &BTreeMap>, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update( + u64::try_from(files.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + for (path, bytes) in files { + let path_bytes = path.as_bytes(); + hasher.update( + u64::try_from(path_bytes.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + hasher.update(path_bytes); + hasher.update( + u64::try_from(bytes.len()) + .map_err(|_| BundleError::TooLarge)? + .to_be_bytes(), + ); + hasher.update(bytes); + } + let digest = hasher.finalize(); + let mut revision = String::with_capacity("sha256:".len() + 64); + revision.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut revision, "{byte:02x}").map_err(|_| BundleError::TooLarge)?; + } + Ok(revision) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::OfflineKernel; + + #[cfg(unix)] + fn copy_acceptance_bundle(case: &str, destination: &Path) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(case); + copy_tree(&source, destination); + } + + #[cfg(unix)] + fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create destination"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source file type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy fixture artifact"); + } + } + } + + #[cfg(unix)] + fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + let metadata = fs::symlink_metadata(path).expect("tree metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read tree") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set directory mode"); + } else if metadata.is_file() { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set file mode"); + } + } + + #[test] + fn revision_binds_paths_and_exact_bytes_deterministically() { + let first = BTreeMap::from([ + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ("schemas/facts.yaml".to_owned(), b"type: object\n".to_vec()), + ]); + let same = BTreeMap::from([ + ("schemas/facts.yaml".to_owned(), b"type: object\n".to_vec()), + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ]); + assert_eq!(compute_revision(&first), compute_revision(&same)); + + let renamed = BTreeMap::from([ + ("evidence.yaml".to_owned(), b"version: 1\n".to_vec()), + ("schemas/other.yaml".to_owned(), b"type: object\n".to_vec()), + ]); + assert_ne!(compute_revision(&first), compute_revision(&renamed)); + } + + #[test] + fn fixture_coverage_is_case_neutral_but_complete() { + let fixture: YamlValue = serde_norway::from_str( + "synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: no-match}\n - {id: ambiguous}\n - {id: source-failure}\n - {id: anti-reconstruction}\n", + ) + .expect("fixture parses"); + assert!(validate_fixture_coverage(&fixture).is_ok()); + } + + #[test] + fn strict_public_jwk_rejects_private_material_and_duplicate_members() { + let private = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","d":"secret"}"#; + let object = parse_strict_json_object(private).expect("JSON parses"); + assert!(validate_public_jwk(&object, "active").is_err()); + + let duplicate = br#"{"kty":"OKP","kty":"OKP"}"#; + assert!(parse_strict_json_object(duplicate).is_err()); + + let control_kid = br#"{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"old\u000aidentifier","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#; + let object = parse_strict_json_object(control_kid).expect("JSON parses"); + assert!(validate_public_jwk(&object, "active").is_err()); + } + + #[cfg(unix)] + #[test] + fn all_coequal_immutable_acceptance_bundles_load_through_one_kernel() { + for case in [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ] { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle(case, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Bundle::load(directory.path()).expect("bundle loads"); + assert!(bundle.configuration_revision().starts_with("sha256:")); + assert_eq!(bundle.configuration_revision().len(), 71); + assert_eq!(bundle.scripts.len(), 3); + assert_eq!(bundle.fact_schemas.len(), 2); + assert_eq!(bundle.fixtures.len(), 1); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[test] + fn combined_acceptance_bundle_loads_as_one_atomic_revision() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("all-definitions", directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Bundle::load(directory.path()).expect("combined bundle loads"); + assert_eq!(bundle.config.requirements.len(), 4); + assert_eq!(bundle.config.sources.len(), 4); + assert_eq!(bundle.scripts.len(), 12); + assert_eq!(bundle.fact_schemas.len(), 8); + assert_eq!(bundle.fixtures.len(), 4); + assert_eq!(bundle.codelists.len(), 3); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn deployment_reference_projects_are_complete_compilable_bundles() { + let projects_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects"); + for project in ["dhis2-adult-status", "opencrvs-family-evidence"] { + let project_root = projects_root.join(project); + RuntimeConfig::parse_yaml( + &fs::read(project_root.join("runtime.yaml")).expect("read reference runtime"), + ) + .expect("reference runtime is closed and valid"); + + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_tree(&project_root.join("bundle"), directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = std::sync::Arc::new( + Bundle::load(directory.path()).expect("reference bundle loads atomically"), + ); + OfflineKernel::compile(bundle).expect("reference scripts compile through the ABI"); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[test] + fn reviewed_structured_schema_uri_resolves_exactly_one_bundle_artifact() { + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + let config_path = directory.path().join("evidence.yaml"); + let config = fs::read_to_string(&config_path).expect("read configuration"); + let original = " concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}]\n"; + let replacement = " concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}, {id: urn:example:fixture:concept:structured, form: reviewed-structured-value, required: false, constraints: {schema: urn:example:fixture:schema:structured:v1, maximumSerializedBytes: 512}}]\n"; + let config = config.replacen(original, replacement, 1); + assert_ne!( + config, + fs::read_to_string(&config_path).expect("read configuration") + ); + fs::write(&config_path, config).expect("write configuration"); + let schema = concat!( + "$schema: https://json-schema.org/draft/2020-12/schema\n", + "$id: urn:example:fixture:schema:structured:v1\n", + "type: object\n", + "additionalProperties: false\n", + "required: [status]\n", + "properties:\n", + " status: {type: string, enum: [A]}\n" + ); + fs::write(directory.path().join("schemas/structured.yaml"), schema) + .expect("write reviewed schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + let bundle = Bundle::load(directory.path()).expect("reviewed schema resolves"); + assert!(bundle.fact_schemas.contains_key("schemas/structured.yaml")); + + let schema_path = directory.path().join("schemas/structured.yaml"); + for invalid_property in [ + "{}", + "{type: object, additionalProperties: true, required: [nested], properties: {nested: {type: string, maxLength: 8}}}", + "{type: object, additionalProperties: false, required: [nested], properties: {nested: {}}}", + "{type: array, maxItems: 2, items: {}}", + "{type: array, items: {type: string, maxLength: 8}}", + "{type: string, maxLength: 8, format: custom-id}", + ] { + set_tree_mode(directory.path(), 0o755, 0o644); + fs::write( + &schema_path, + format!( + "$schema: https://json-schema.org/draft/2020-12/schema\n$id: urn:example:fixture:schema:structured:v1\ntype: object\nadditionalProperties: false\nrequired: [status]\nproperties:\n status: {invalid_property}\n" + ), + ) + .expect("write invalid reviewed schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!( + matches!( + Bundle::load(directory.path()), + Err(BundleError::InvalidArtifact(_)) + ), + "open nested schema must fail: {invalid_property}" + ); + } + + set_tree_mode(directory.path(), 0o755, 0o644); + fs::write(&schema_path, schema).expect("restore reviewed schema"); + fs::write(directory.path().join("schemas/duplicate.yaml"), schema) + .expect("write duplicate schema"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!(matches!( + Bundle::load(directory.path()), + Err(BundleError::InvalidArtifact( + "reviewed structured schema identifier is missing or ambiguous" + )) + )); + } + + #[cfg(unix)] + #[test] + fn writable_bundle_and_unknown_files_fail_closed() { + let writable = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", writable.path()); + assert!(matches!( + Bundle::load(writable.path()), + Err(BundleError::NotImmutable) + )); + + let unknown = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", unknown.path()); + fs::write( + unknown.path().join("fixtures/unreferenced.yaml"), + b"synthetic_only: true\n", + ) + .expect("write unknown artifact"); + set_tree_mode(unknown.path(), 0o555, 0o444); + assert!(matches!( + Bundle::load(unknown.path()), + Err(BundleError::UnknownFile) + )); + set_tree_mode(unknown.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn symlinked_artifact_fails_before_file_access() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", directory.path()); + let outside = tempfile::NamedTempFile::new().expect("outside script"); + let adapter = directory.path().join("adapters/source-a.rhai"); + fs::remove_file(&adapter).expect("remove copied adapter"); + symlink(outside.path(), adapter).expect("create symlink"); + set_tree_mode(directory.path(), 0o555, 0o444); + assert!(matches!( + Bundle::load(directory.path()), + Err(BundleError::InvalidPath) + )); + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[test] + fn runtime_and_ca_bytes_are_captured_under_an_independent_read_only_revision() { + use std::os::unix::fs::PermissionsExt as _; + + let directory = tempfile::tempdir().expect("temporary runtime root"); + let secret_root = directory.path().join("secrets"); + fs::create_dir(&secret_root).expect("create secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("lock secret root"); + let ca_path = directory.path().join("internal.pem"); + fs::write( + &ca_path, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write CA bundle"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)).expect("lock CA bundle"); + let runtime_path = directory.path().join("runtime.yaml"); + fs::write( + &runtime_path, + format!( + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n internal-pki: {{caBundleFile: {}}}\n", + secret_root.display(), + ca_path.display() + ), + ) + .expect("write runtime document"); + + assert!(matches!( + RuntimeDocument::load(&runtime_path), + Err(BundleError::NotImmutable) + )); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("lock runtime document"); + let runtime = RuntimeDocument::load(&runtime_path).expect("runtime loads"); + assert!(runtime.revision().starts_with("sha256:")); + assert_eq!(runtime.revision().len(), 71); + assert_eq!(runtime.ca_bundles.len(), 1); + assert_eq!( + runtime.bytes(), + fs::read(&runtime_path).expect("read runtime") + ); + } +} diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs new file mode 100644 index 000000000..02a16ec3c --- /dev/null +++ b/crates/registry-evidence/src/config.rs @@ -0,0 +1,3312 @@ +//! Typed Evidence Version 1 deployment configuration. +//! +//! Configuration is trusted deployment data, but it is still parsed as a +//! closed contract. Secret-bearing fields contain only [`SecretRef`] values; +//! this module never resolves them. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::net::{IpAddr, Ipv6Addr}; +use std::path::{Component, Path}; +use std::str::FromStr; + +use serde::de::{self, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_norway::Value as YamlValue; +use thiserror::Error; +use url::{Host, Url}; + +pub const MAX_CONFIG_BYTES: usize = 1024 * 1024; +pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +#[derive(Debug, Error, Clone, Eq, PartialEq)] +pub enum ConfigError { + #[error("configuration YAML does not match the Evidence Version 1 schema")] + InvalidYaml, + #[error("configuration exceeds the Evidence Version 1 size limit")] + TooLarge, + #[error("configuration violates the Evidence Version 1 contract: {0}")] + Invalid(&'static str), +} + +/// A mapping that rejects duplicate keys and preserves declaration order. +/// +/// Selector declaration order is part of canonical selector encoding, so a +/// sorted map is not sufficient for this contract. +#[derive(Clone, Eq, PartialEq)] +pub struct OrderedMap(Vec<(String, T)>); + +impl Default for OrderedMap { + fn default() -> Self { + Self(Vec::new()) + } +} + +impl OrderedMap { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, key: &str) -> Option<&T> { + self.0 + .iter() + .find_map(|(candidate, value)| (candidate == key).then_some(value)) + } + + pub fn contains_key(&self, key: &str) -> bool { + self.get(key).is_some() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(key, value)| (key.as_str(), value)) + } + + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(key, _)| key.as_str()) + } +} + +impl fmt::Debug for OrderedMap { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_map() + .entries(self.0.iter().map(|(key, value)| (key, value))) + .finish() + } +} + +impl<'de, T> Deserialize<'de> for OrderedMap +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct OrderedMapVisitor(std::marker::PhantomData); + + impl<'de, T> Visitor<'de> for OrderedMapVisitor + where + T: Deserialize<'de>, + { + type Value = OrderedMap; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a mapping with unique string keys") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + let mut seen = BTreeSet::new(); + while let Some((key, value)) = map.next_entry::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom("duplicate mapping key")); + } + entries.push((key, value)); + } + Ok(OrderedMap(entries)) + } + } + + deserializer.deserialize_map(OrderedMapVisitor(std::marker::PhantomData)) + } +} + +impl Serialize for OrderedMap { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (key, value) in &self.0 { + map.serialize_entry(key, value)?; + } + map.end() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceConfig { + pub version: u8, + pub service: ServiceConfig, + pub issuer: IssuerConfig, + pub authentication: AuthenticationConfig, + pub audit: AuditConfig, + pub subject_binding: SubjectBindingConfig, + pub rate_limits: RateLimitConfig, + pub signing: SigningConfig, + pub selector_profiles: OrderedMap, + pub sources: OrderedMap, + pub authority_profiles: OrderedMap, + pub requirements: Vec, +} + +pub type SourceSelectorSet = Vec<(String, String)>; + +impl EvidenceConfig { + pub fn parse_yaml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge); + } + let text = std::str::from_utf8(bytes).map_err(|_| ConfigError::InvalidYaml)?; + let config: Self = serde_norway::from_str(text).map_err(|_| ConfigError::InvalidYaml)?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return invalid("version must equal 1"); + } + validate_uri(&self.service.provider_id)?; + validate_uri(&self.service.trust_domain)?; + validate_uri(&self.issuer.id)?; + self.authentication.validate()?; + self.audit.validate()?; + self.subject_binding.validate()?; + self.rate_limits.validate()?; + self.signing.validate()?; + validate_named_map(&self.selector_profiles, 1, 128, |profile| { + profile.validate() + })?; + validate_named_map(&self.sources, 1, 128, SourceConfig::validate)?; + validate_named_map(&self.authority_profiles, 1, 128, |profile| { + profile.validate() + })?; + validate_len(self.requirements.len(), 1, 128, "requirements")?; + + let mut requirement_ids = BTreeSet::new(); + let mut evidence_types = BTreeSet::new(); + let mut concept_ids = BTreeSet::new(); + let mut disclosure_families = BTreeSet::new(); + let fact_schemas = self + .sources + .iter() + .map(|(_, source)| source.fact_schema.as_str()) + .collect::>(); + let parameter_schemas = self + .sources + .iter() + .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) + .collect::>(); + if !fact_schemas.is_disjoint(¶meter_schemas) { + return invalid("fact and adapter-parameter schema roles must not overlap"); + } + for requirement in &self.requirements { + requirement.validate()?; + if !requirement_ids.insert(requirement.id.as_str()) { + return invalid("requirement identifiers must be unique"); + } + if !evidence_types.insert(requirement.evidence_type.as_str()) { + return invalid("Evidence Type identifiers must be unique"); + } + for concept in &requirement.concepts { + if !concept_ids.insert(concept.id.as_str()) { + return invalid("concept identifiers must be unique"); + } + } + for family in &requirement.disclosure_guard.families { + if !disclosure_families.insert(family.as_str()) { + return invalid("enabled requirements share a disclosure family"); + } + } + } + + self.validate_cross_references() + } + + /// Return the complete selector tuple sets that an authorized request may + /// activate for one source. The configuration has already proven that + /// every grant is complete and references the named requirement source. + pub fn source_selector_sets(&self, source_id: &str) -> Vec { + let Some(source) = self.sources.get(source_id) else { + return Vec::new(); + }; + let requirement_sources = self + .requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement.source.as_str())) + .collect::>(); + let mut sets = BTreeSet::new(); + for (_, authority) in self.authority_profiles.iter() { + for grant in &authority.grants { + if requirement_sources.get(grant.requirement.as_str()) != Some(&source_id) { + continue; + } + let mut set = grant + .subjects + .iter() + .filter(|subject| { + source.request.selector_inputs.iter().any(|input| { + input.role == subject.role + && input.alternatives.iter().any(|alternative| { + alternative.profile == subject.selector_profile + }) + }) + }) + .map(|subject| (subject.role.clone(), subject.selector_profile.clone())) + .collect::(); + if set.is_empty() { + continue; + } + set.sort(); + sets.insert(set); + } + } + sets.into_iter().collect() + } + + fn validate_cross_references(&self) -> Result<(), ConfigError> { + for (_, source) in self.sources.iter() { + for input in &source.request.selector_inputs { + for alternative in &input.alternatives { + let profile = self.selector_profiles.get(&alternative.profile).ok_or( + ConfigError::Invalid( + "source selector input references an unknown selector profile", + ), + )?; + if alternative + .fields + .iter() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid( + "source selector input references an unknown selector field", + ); + } + } + } + for (_, binding) in source.request.path_bindings.iter() { + let profile = + self.selector_profiles + .get(&binding.profile) + .ok_or(ConfigError::Invalid( + "source path binding references an unknown selector profile", + ))?; + if !profile.fields.contains_key(&binding.field) { + return invalid("source path binding references an unknown selector field"); + } + if !source.request.selector_inputs.iter().any(|input| { + input.role == binding.role + && input.alternatives.iter().any(|alternative| { + alternative.profile == binding.profile + && alternative.fields.contains(&binding.field) + }) + }) { + return invalid("source path binding is not declared as a selector input"); + } + } + } + + for requirement in &self.requirements { + if !self.sources.contains_key(&requirement.source) { + return invalid("requirement references an unknown source"); + } + if requirement.validity_seconds > self.signing.maximum_assertion_validity_seconds { + return invalid("requirement validity exceeds signing maximum validity"); + } + for role in &requirement.subject_roles { + for profile_id in &role.selector_profiles { + self.selector_profiles + .get(profile_id) + .ok_or(ConfigError::Invalid( + "requirement references an unknown selector profile", + ))?; + } + } + validate_derivation_selector_inputs(requirement, &self.selector_profiles)?; + } + + let requirements = self + .requirements + .iter() + .map(|requirement| (requirement.id.as_str(), requirement)) + .collect::>(); + let mut authorized_combinations = BTreeSet::new(); + let mut source_selector_sets: BTreeMap> = + BTreeMap::new(); + for (_, authority) in self.authority_profiles.iter() { + for grant in &authority.grants { + let requirement = + requirements + .get(grant.requirement.as_str()) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown requirement", + ))?; + if !requirement + .purposes + .iter() + .any(|purpose| purpose == &grant.purpose) + { + return invalid("authority grant references an unauthorized purpose"); + } + if grant.subjects.len() != requirement.subject_roles.len() { + return invalid("authority grant must bind the complete subject-role set"); + } + let source = self + .sources + .get(&requirement.source) + .ok_or(ConfigError::Invalid( + "requirement references an unknown source", + ))?; + let mut seen_roles = BTreeSet::new(); + let mut source_selector_set = Vec::with_capacity(grant.subjects.len()); + for subject in &grant.subjects { + if !seen_roles.insert(subject.role.as_str()) { + return invalid("authority grant subject roles must be unique"); + } + let role = requirement + .subject_roles + .iter() + .find(|role| role.role == subject.role) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown subject role", + ))?; + if !role + .selector_profiles + .iter() + .any(|profile| profile == &subject.selector_profile) + { + return invalid("authority grant selector profile is not allowed for role"); + } + let profile = self + .selector_profiles + .get(&subject.selector_profile) + .ok_or(ConfigError::Invalid( + "authority grant references an unknown selector profile", + ))?; + subject.validate_value_claims(profile)?; + authorized_combinations.insert(( + grant.requirement.as_str(), + grant.purpose.as_str(), + subject.role.as_str(), + subject.selector_profile.as_str(), + )); + if source.request.selector_inputs.iter().any(|input| { + input.role == subject.role + && input + .alternatives + .iter() + .any(|alternative| alternative.profile == subject.selector_profile) + }) { + source_selector_set + .push((subject.role.clone(), subject.selector_profile.clone())); + } + } + if requirement + .subject_roles + .iter() + .any(|role| !seen_roles.contains(role.role.as_str())) + { + return invalid("authority grant omits a required subject role"); + } + if source_selector_set.is_empty() { + return invalid( + "authority path does not activate any declared source selector input", + ); + } + source_selector_set.sort(); + source_selector_sets + .entry(requirement.source.clone()) + .or_default() + .insert(source_selector_set); + } + } + + for requirement in &self.requirements { + for purpose in &requirement.purposes { + for role in &requirement.subject_roles { + for profile in &role.selector_profiles { + if !authorized_combinations.contains(&( + requirement.id.as_str(), + purpose.as_str(), + role.role.as_str(), + profile.as_str(), + )) { + return invalid( + "requirement role and selector profile lack an authority path", + ); + } + } + } + } + } + self.validate_source_selector_sets(&source_selector_sets)?; + Ok(()) + } + + fn validate_source_selector_sets( + &self, + allowed: &BTreeMap>, + ) -> Result<(), ConfigError> { + for (source_id, source) in self.sources.iter() { + let sets = allowed.get(source_id).ok_or(ConfigError::Invalid( + "configured source is unreachable from every authority grant", + ))?; + let reachable = sets + .iter() + .flatten() + .map(|(role, profile)| (role.as_str(), profile.as_str())) + .collect::>(); + if source.request.selector_inputs.iter().any(|input| { + input.alternatives.iter().any(|alternative| { + !reachable.contains(&(input.role.as_str(), alternative.profile.as_str())) + }) + }) { + return invalid( + "source selector input is unreachable from every complete authority path", + ); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceConfig { + pub provider_id: String, + pub trust_domain: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IssuerConfig { + pub id: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfig { + pub version: u8, + pub bundle_directory: String, + pub listener: ListenerConfig, + pub secret_providers: RuntimeSecretProviders, + pub audit_storage: AuditStorageConfig, + pub outbound_tls: OutboundTlsConfig, +} + +impl RuntimeConfig { + pub fn parse_yaml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge); + } + let text = std::str::from_utf8(bytes).map_err(|_| ConfigError::InvalidYaml)?; + let config: Self = serde_norway::from_str(text).map_err(|_| ConfigError::InvalidYaml)?; + config.validate()?; + Ok(config) + } + + pub fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return invalid("runtime version must equal 1"); + } + validate_absolute_path(&self.bundle_directory)?; + self.listener.validate()?; + self.secret_providers.validate()?; + self.audit_storage.validate()?; + self.outbound_tls.validate() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeSecretProviders { + pub file: FileSecretProvider, +} + +impl RuntimeSecretProviders { + fn validate(&self) -> Result<(), ConfigError> { + self.file.validate() + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FileSecretProvider { + pub root: String, +} + +impl FileSecretProvider { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.root) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditStorageConfig { + pub path: String, + pub maximum_file_bytes: u64, +} + +impl AuditStorageConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.path)?; + validate_range( + self.maximum_file_bytes, + 1_048_576, + 1_099_511_627_776, + "audit maximumFileBytes", + ) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutboundTlsConfig { + pub system_roots: bool, + pub trust_profiles: OrderedMap, +} + +impl OutboundTlsConfig { + fn validate(&self) -> Result<(), ConfigError> { + if !self.system_roots { + return invalid("outbound TLS system roots must remain enabled"); + } + validate_named_map(&self.trust_profiles, 0, 64, TrustProfileBinding::validate) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TrustProfileBinding { + pub ca_bundle_file: String, +} + +impl TrustProfileBinding { + fn validate(&self) -> Result<(), ConfigError> { + validate_absolute_path(&self.ca_bundle_file) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + pub bind_host: String, + pub port: u16, + pub tls_termination: TlsTermination, + pub trust_proxy_identity_headers: bool, + pub maximum_request_bytes: u64, + pub maximum_concurrent_requests: u32, + pub request_timeout_milliseconds: u64, + pub shutdown_grace_milliseconds: u64, +} + +impl ListenerConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.bind_host.len() < 2 || self.bind_host.len() > 64 { + return invalid("listener bindHost length is invalid"); + } + let ip: IpAddr = self + .bind_host + .parse() + .map_err(|_| ConfigError::Invalid("listener bindHost must be a private numeric IP"))?; + let private = match ip { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(), + IpAddr::V6(ip) => ip.is_loopback() || is_unique_local(ip), + }; + if !private || ip.is_unspecified() || ip.is_multicast() { + return invalid("listener bindHost must be loopback or private"); + } + if self.trust_proxy_identity_headers { + return invalid("proxy identity headers must not be trusted"); + } + validate_range( + self.maximum_request_bytes, + 1_024, + 1_048_576, + "maximumRequestBytes", + )?; + validate_range( + u64::from(self.maximum_concurrent_requests), + 1, + 4_096, + "maximumConcurrentRequests", + )?; + validate_range( + self.request_timeout_milliseconds, + 1, + 30_000, + "requestTimeoutMilliseconds", + )?; + validate_range( + self.shutdown_grace_milliseconds, + 1, + 120_000, + "shutdownGraceMilliseconds", + ) + } +} + +fn is_unique_local(ip: Ipv6Addr) -> bool { + ip.octets()[0] & 0xfe == 0xfc +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TlsTermination { + OperatorControlledUpstream, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthenticationConfig { + pub kind: AuthenticationKind, + pub issuer: String, + pub audiences: Vec, + pub token_types: Vec, + pub algorithms: Vec, + pub jwks_uri: String, + pub principal_claim: String, + pub requester_tags_claim: String, + pub evidence_audience_claim: String, + pub grant_id_claim: String, + pub grant_authority_claim: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_claim: Option, +} + +impl AuthenticationConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_https_issuer(&self.issuer)?; + validate_https_url(&self.jwks_uri, false)?; + validate_unique_strings(&self.audiences, 1, 16, 1, 512, "authentication audiences")?; + validate_unique(&self.token_types, 1, 4, "authentication tokenTypes")?; + validate_unique(&self.algorithms, 1, 3, "authentication algorithms")?; + for claim in [ + Some(&self.principal_claim), + Some(&self.requester_tags_claim), + Some(&self.evidence_audience_claim), + Some(&self.grant_id_claim), + Some(&self.grant_authority_claim), + self.actor_claim.as_ref(), + ] + .into_iter() + .flatten() + { + validate_claim_name(claim)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthenticationKind { + OidcAccessToken, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum AccessTokenType { + #[serde(rename = "at+jwt")] + AtJwt, + #[serde(rename = "application/at+jwt")] + ApplicationAtJwt, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum AccessTokenAlgorithm { + EdDSA, + ES256, + RS256, +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SecretProvider { + Environment, + File, +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct SecretRef(String); + +impl SecretRef { + pub fn parse(value: &str) -> Result { + if let Some(name) = value.strip_prefix("secret:file/") { + if valid_file_secret_name(name) { + return Ok(Self(value.to_owned())); + } + } + invalid("secret reference does not use an exact permitted grammar") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn provider(&self) -> SecretProvider { + SecretProvider::File + } +} + +impl fmt::Debug for SecretRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("SecretRef").field(&self.0).finish() + } +} + +impl<'de> Deserialize<'de> for SecretRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +impl Serialize for SecretRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn valid_file_secret_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditConfig { + pub format: AuditFormat, + pub hash_secret_ref: SecretRef, + pub hash_key_version: u32, + pub fail_closed: bool, +} + +impl AuditConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.hash_key_version == 0 || !self.fail_closed { + return invalid("audit must be versioned and fail closed"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuditFormat { + KeyedJsonl, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubjectBindingConfig { + pub secret_ref: SecretRef, + pub key_version: u32, +} + +impl SubjectBindingConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.key_version == 0 { + return invalid("subject binding keyVersion must be positive"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RateLimitConfig { + pub requests_per_principal_per_minute: u64, + pub burst_per_principal: u64, + pub failed_selector_attempts_per_principal_authority_per_minute: u64, +} + +impl RateLimitConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_range( + self.requests_per_principal_per_minute, + 1, + 1_000_000, + "request rate limit", + )?; + validate_range(self.burst_per_principal, 1, 100_000, "burst rate limit")?; + validate_range( + self.failed_selector_attempts_per_principal_authority_per_minute, + 1, + 100_000, + "failed-selector rate limit", + ) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SigningConfig { + pub format: SigningFormat, + pub algorithm: SigningAlgorithm, + pub active_key_id: String, + pub active_key_ref: SecretRef, + pub retired_public_jwk_files: Vec, + pub jwks_path: String, + pub maximum_assertion_validity_seconds: u64, + pub verifier_clock_skew_seconds: u64, +} + +impl SigningConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_string(&self.active_key_id, 1, 256, "active signing key id")?; + if self.active_key_id.chars().any(char::is_control) { + return invalid("active signing key id contains a control character"); + } + validate_unique( + &self.retired_public_jwk_files, + 0, + 32, + "retired public JWK paths", + )?; + if self.jwks_path != "/.well-known/evidence/jwks.json" { + return invalid("JWKS path is not the Version 1 discovery path"); + } + validate_range( + self.maximum_assertion_validity_seconds, + 1, + 31_536_000, + "maximum assertion validity", + )?; + validate_range( + self.verifier_clock_skew_seconds, + 0, + 600, + "verifier clock skew", + ) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SigningFormat { + FlattenedJwsJson, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum SigningAlgorithm { + EdDSA, +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct PublicJwkPath(String); + +impl PublicJwkPath { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PublicJwkPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PublicJwkPath") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for PublicJwkPath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + if is_public_jwk_path(&value) { + Ok(Self(value)) + } else { + Err(de::Error::custom("invalid public JWK path")) + } + } +} + +impl Serialize for PublicJwkPath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn is_public_jwk_path(value: &str) -> bool { + let Some(name) = value.strip_prefix("public-keys/") else { + return false; + }; + let Some(stem) = name.strip_suffix(".jwk.json") else { + return false; + }; + !stem.is_empty() + && stem + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectorProfile { + pub maximum_aggregate_bytes: u64, + pub fields: OrderedMap, +} + +impl SelectorProfile { + fn validate(&self) -> Result<(), ConfigError> { + validate_range( + self.maximum_aggregate_bytes, + 1, + 8_192, + "selector maximumAggregateBytes", + )?; + validate_len(self.fields.len(), 1, 16, "selector fields")?; + for (name, field) in self.fields.iter() { + if !valid_field_name(name) { + return invalid("selector field name is invalid"); + } + field.validate(self.maximum_aggregate_bytes)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SelectorField { + String { + #[serde(rename = "minimumBytes")] + minimum_bytes: u64, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, + Date, + Integer { + minimum: i64, + maximum: i64, + }, + Boolean, + ControlledCode { + codelist: ArtifactPath, + #[serde(rename = "codelistVersion")] + codelist_version: String, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, +} + +impl SelectorField { + fn validate(&self, aggregate_maximum: u64) -> Result<(), ConfigError> { + match self { + Self::String { + minimum_bytes, + maximum_bytes, + } => { + validate_range(*minimum_bytes, 1, 8_192, "selector string minimumBytes")?; + validate_range(*maximum_bytes, 1, 8_192, "selector string maximumBytes")?; + if minimum_bytes > maximum_bytes || maximum_bytes > &aggregate_maximum { + return invalid("selector string byte bounds are inconsistent"); + } + } + Self::Integer { minimum, maximum } => { + if minimum > maximum || *minimum < -MAX_SAFE_INTEGER || *maximum > MAX_SAFE_INTEGER + { + return invalid("selector integer bounds are inconsistent"); + } + } + Self::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + } => { + require_artifact_prefix(codelist, "codelists/")?; + validate_string(codelist_version, 1, 128, "selector codelist version")?; + validate_range(*maximum_bytes, 1, 8_192, "selector code maximumBytes")?; + if maximum_bytes > &aggregate_maximum { + return invalid("selector code exceeds aggregate byte bound"); + } + } + Self::Date | Self::Boolean => {} + } + Ok(()) + } +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Hash)] +pub struct ArtifactPath(String); + +impl ArtifactPath { + pub fn parse(value: &str) -> Result { + if !valid_artifact_path(value) { + return invalid("artifact path is invalid"); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ArtifactPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ArtifactPath") + .field(&self.0) + .finish() + } +} + +impl<'de> Deserialize<'de> for ArtifactPath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(de::Error::custom) + } +} + +impl Serialize for ArtifactPath { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +fn valid_artifact_path(value: &str) -> bool { + const ROOTS: [&str; 5] = [ + "adapters/", + "derivations/", + "schemas/", + "codelists/", + "fixtures/", + ]; + ROOTS.iter().any(|root| value.starts_with(root)) + && !value.starts_with('/') + && !value.contains('\\') + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) + && Path::new(value) + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceConfig { + pub transport: SourceTransport, + pub base_url: String, + pub posture: AcquisitionPosture, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_trust_profile: Option, + pub authentication: SourceAuthentication, + pub request: FixedRequest, + pub extract_script: ArtifactPath, + pub fact_schema: ArtifactPath, +} + +impl SourceConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_source_origin(&self.base_url)?; + if self + .tls_trust_profile + .as_deref() + .is_some_and(|profile| !valid_local_id(profile)) + { + return invalid("source TLS trust profile identifier is invalid"); + } + self.authentication.validate()?; + self.request.validate()?; + require_artifact_prefix(&self.extract_script, "adapters/")?; + if !self.extract_script.as_str().ends_with(".rhai") { + return invalid("source extraction script must be a Rhai file"); + } + let adapter_id = Path::new(self.extract_script.as_str()) + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| valid_local_id(value)) + .ok_or(ConfigError::Invalid( + "source adapter name must be a local identifier", + ))?; + debug_assert!(!adapter_id.is_empty()); + require_artifact_prefix(&self.fact_schema, "schemas/")?; + if self.fact_schema == self.request.adapter_parameters_schema { + return invalid("fact and adapter-parameter schemas must be distinct artifacts"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SourceTransport { + HttpJson, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AcquisitionPosture { + SourceDerived, + FieldProjected, + RecordTransformed, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum SourceAuthentication { + Basic { + #[serde(rename = "usernameRef")] + username_ref: SecretRef, + #[serde(rename = "passwordRef")] + password_ref: SecretRef, + }, + StaticBearer { + #[serde(rename = "tokenRef")] + token_ref: SecretRef, + }, + StaticApiKey { + #[serde(rename = "headerName")] + header_name: String, + #[serde(rename = "valueRef")] + value_ref: SecretRef, + }, + Oauth2ClientCredentials { + #[serde(rename = "tokenEndpoint")] + token_endpoint: String, + #[serde(rename = "clientIdRef")] + client_id_ref: SecretRef, + #[serde(rename = "clientSecretRef")] + client_secret_ref: SecretRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + scope: Option, + #[serde(rename = "credentialPlacement")] + credential_placement: CredentialPlacement, + #[serde(rename = "maximumCacheSeconds")] + maximum_cache_seconds: u64, + }, +} + +impl SourceAuthentication { + fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::Basic { + username_ref: _, + password_ref: _, + } + | Self::StaticBearer { token_ref: _ } => Ok(()), + Self::StaticApiKey { + header_name, + value_ref: _, + } => validate_configurable_header_name(header_name), + Self::Oauth2ClientCredentials { + token_endpoint, + scope, + maximum_cache_seconds, + .. + } => { + let token_endpoint = validate_source_url(token_endpoint, false)?; + if token_endpoint.query().is_some() { + return invalid("OAuth token endpoint must not contain a query"); + } + if let Some(scope) = scope { + validate_string(scope, 1, 512, "OAuth scope")?; + } + validate_range( + *maximum_cache_seconds, + 0, + 86_400, + "OAuth maximum cache lifetime", + ) + } + } + } + + pub fn secret_refs(&self) -> Vec<&SecretRef> { + match self { + Self::Basic { + username_ref, + password_ref, + } => vec![username_ref, password_ref], + Self::StaticBearer { token_ref } => vec![token_ref], + Self::StaticApiKey { value_ref, .. } => vec![value_ref], + Self::Oauth2ClientCredentials { + client_id_ref, + client_secret_ref, + .. + } => vec![client_id_ref, client_secret_ref], + } + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CredentialPlacement { + BasicHeader, + FormBody, + QueryString, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixedRequest { + pub method: HttpMethod, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_template: Option, + #[serde(default, skip_serializing_if = "OrderedMap::is_empty")] + pub path_bindings: OrderedMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fixed_headers: Vec, + pub selector_inputs: Vec, + pub prepare_script: ArtifactPath, + pub adapter_parameters: OrderedMap, + pub adapter_parameters_schema: ArtifactPath, + pub preparation_limits: PreparationLimits, + pub projection: Vec, + pub redirects: RedirectPolicy, + pub timeout_milliseconds: u64, + pub maximum_response_bytes: u64, + pub concurrency_limit: u16, +} + +impl FixedRequest { + fn validate(&self) -> Result<(), ConfigError> { + match (&self.path, &self.path_template) { + (Some(path), None) => { + validate_normalized_request_path(path)?; + if !self.path_bindings.is_empty() { + return invalid("fixed source path must not define pathBindings"); + } + } + (None, Some(template)) => validate_path_template(template, &self.path_bindings)?, + _ => return invalid("source request must define exactly one of path or pathTemplate"), + } + validate_fixed_headers(&self.fixed_headers)?; + validate_selector_inputs(&self.selector_inputs)?; + require_artifact_prefix(&self.prepare_script, "adapters/")?; + if !self.prepare_script.as_str().ends_with(".rhai") { + return invalid("source preparation script must be a Rhai file"); + } + validate_len(self.adapter_parameters.len(), 0, 64, "adapter parameters")?; + for (name, value) in self.adapter_parameters.iter() { + if !valid_parameter_key(name) { + return invalid("adapter parameter name is invalid"); + } + value.validate(0)?; + } + require_artifact_prefix(&self.adapter_parameters_schema, "schemas/")?; + self.preparation_limits.validate()?; + if self.method == HttpMethod::GET + && self.preparation_limits.json_body != PreparationChannelPolicy::Forbidden + { + return invalid("GET source requests must forbid the JSON body channel"); + } + validate_projection(&self.projection)?; + validate_range(self.timeout_milliseconds, 1, 30_000, "source timeout")?; + validate_range( + self.maximum_response_bytes, + 1, + 1_048_576, + "source response size", + )?; + validate_range( + u64::from(self.concurrency_limit), + 1, + 256, + "source concurrency", + )?; + + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +pub enum HttpMethod { + GET, + POST, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RedirectPolicy { + Deny, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FixedHeader { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SelectorInput { + pub role: String, + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SelectorInputAlternative { + pub profile: String, + pub fields: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PathBindingConfig { + pub role: String, + pub profile: String, + pub field: String, +} + +impl PathBindingConfig { + fn validate(&self) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) + || !valid_local_id(&self.profile) + || !valid_field_name(&self.field) + { + return invalid("source selector binding identifier is invalid"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum AdapterParameterValue { + Boolean(bool), + Integer(i64), + String(String), + Array(Vec), + Object(OrderedMap), +} + +impl AdapterParameterValue { + fn validate(&self, depth: usize) -> Result<(), ConfigError> { + if depth > 32 { + return invalid("adapter parameter nesting exceeds Version 1 bounds"); + } + match self { + Self::Boolean(_) | Self::Integer(_) => Ok(()), + Self::String(value) => validate_string(value, 0, 16_384, "adapter parameter string"), + Self::Array(values) => { + validate_len(values.len(), 0, 256, "adapter parameter array")?; + for value in values { + value.validate(depth + 1)?; + } + Ok(()) + } + Self::Object(values) => { + validate_len(values.len(), 0, 256, "adapter parameter object")?; + for (name, value) in values.iter() { + if !valid_parameter_key(name) { + return invalid("adapter parameter object key is invalid"); + } + value.validate(depth + 1)?; + } + Ok(()) + } + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PreparationLimits { + pub query: PreparationChannelPolicy, + pub json_body: PreparationChannelPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_pairs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_name_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_query_value_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_json_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_collection_items: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_string_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub maximum_normalized_bytes: Option, +} + +impl PreparationLimits { + fn validate(&self) -> Result<(), ConfigError> { + if self.query == PreparationChannelPolicy::Forbidden + && self.json_body == PreparationChannelPolicy::Forbidden + { + return invalid("at least one preparation output channel must be usable"); + } + validate_optional_range(self.maximum_query_pairs, 1, 64)?; + validate_optional_range(self.maximum_query_name_bytes, 1, 64)?; + validate_optional_range(self.maximum_query_value_bytes, 1, 4_096)?; + validate_optional_range(self.maximum_json_depth, 1, 32)?; + validate_optional_range(self.maximum_collection_items, 1, 256)?; + validate_optional_range(self.maximum_string_bytes, 1, 16_384)?; + validate_optional_range(self.maximum_normalized_bytes, 1, 65_536) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PreparationChannelPolicy { + Required, + Allowed, + Forbidden, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityProfile { + pub kind: AuthorityKind, + pub requester_tags: Vec, + pub grants: Vec, +} + +impl AuthorityProfile { + fn validate(&self) -> Result<(), ConfigError> { + validate_unique_strings(&self.requester_tags, 1, 32, 1, 128, "requester tags")?; + if self.requester_tags.iter().any(|tag| !valid_local_id(tag)) { + return invalid("requester tag is invalid"); + } + validate_len(self.grants.len(), 1, 128, "authority grants")?; + for grant in &self.grants { + grant.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityKind { + Statutory, + Organizational, + Consent, + Delegated, + ExplicitRequest, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityGrant { + pub requirement: String, + pub purpose: String, + pub audience_from: AudienceFrom, + pub subjects: Vec, +} + +impl AuthorityGrant { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.requirement)?; + validate_purpose(&self.purpose)?; + validate_len(self.subjects.len(), 1, 8, "authority grant subjects") + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AudienceFrom { + AuthenticatedRequester, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantedSubject { + pub role: String, + pub selector_profile: String, + pub value_origin: ValueOrigin, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_claims: Option>, +} + +impl GrantedSubject { + fn validate_value_claims(&self, profile: &SelectorProfile) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) || !valid_local_id(&self.selector_profile) { + return invalid("authority subject identifier is invalid"); + } + match self.value_origin { + ValueOrigin::Request => { + if self.value_claims.is_some() { + return invalid("request-derived subject must not define valueClaims"); + } + } + ValueOrigin::AuthenticatedContext | ValueOrigin::AuthenticatedGrant => { + let claims = self.value_claims.as_ref().ok_or(ConfigError::Invalid( + "context-derived subject requires valueClaims", + ))?; + if claims.len() != profile.fields.len() + || profile + .fields + .keys() + .any(|field| !claims.contains_key(field)) + || claims + .keys() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid("valueClaims must exactly equal selector profile fields"); + } + let mut targets = BTreeSet::new(); + for (_, claim) in claims.iter() { + validate_claim_path(claim)?; + if !targets.insert(claim.as_str()) { + return invalid("valueClaims targets must be unique"); + } + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ValueOrigin { + AuthenticatedContext, + AuthenticatedGrant, + Request, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RequirementConfig { + pub id: String, + pub kind: RequirementKind, + pub source: String, + pub purposes: Vec, + pub subject_roles: Vec, + pub reference_frameworks: Vec, + pub evidence_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observation_timezone: Option, + pub validity_seconds: u64, + pub derivation: DerivationConfig, + pub concepts: Vec, + pub fixtures: ArtifactPath, + pub disclosure_guard: DisclosureGuard, + pub existence_disclosure: ExistenceDisclosure, +} + +impl RequirementConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.id)?; + if !valid_local_id(&self.source) { + return invalid("requirement source identifier is invalid"); + } + validate_unique_strings(&self.purposes, 1, 32, 1, 128, "requirement purposes")?; + for purpose in &self.purposes { + validate_purpose(purpose)?; + } + validate_len(self.subject_roles.len(), 1, 8, "requirement subject roles")?; + let mut roles = BTreeSet::new(); + for role in &self.subject_roles { + role.validate()?; + if !roles.insert(role.role.as_str()) { + return invalid("requirement subject roles must be unique"); + } + } + validate_unique_strings( + &self.reference_frameworks, + 1, + 16, + 1, + 512, + "reference frameworks", + )?; + for reference in &self.reference_frameworks { + validate_uri(reference)?; + } + validate_uri(&self.evidence_type)?; + if let Some(timezone) = &self.observation_timezone { + validate_string(timezone, 1, 128, "observation timezone")?; + chrono_tz::Tz::from_str(timezone).map_err(|_| { + ConfigError::Invalid("observation timezone is not an IANA timezone") + })?; + } + validate_range(self.validity_seconds, 1, 31_536_000, "requirement validity")?; + self.derivation.validate()?; + validate_len(self.concepts.len(), 1, 16, "requirement concepts")?; + let mut concepts = BTreeSet::new(); + for concept in &self.concepts { + concept.validate()?; + if !concepts.insert(concept.id.as_str()) { + return invalid("requirement concepts must be unique"); + } + } + require_artifact_prefix(&self.fixtures, "fixtures/")?; + self.disclosure_guard.validate() + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementKind { + Criterion, + InformationRequirement, + Constraint, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SubjectRole { + pub role: String, + pub cardinality: SubjectCardinality, + pub selector_profiles: Vec, +} + +impl SubjectRole { + fn validate(&self) -> Result<(), ConfigError> { + if !valid_local_id(&self.role) { + return invalid("subject role identifier is invalid"); + } + validate_unique_strings( + &self.selector_profiles, + 1, + 16, + 1, + 128, + "role selector profiles", + )?; + if self + .selector_profiles + .iter() + .any(|profile| !valid_local_id(profile)) + { + return invalid("role selector profile identifier is invalid"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SubjectCardinality { + One, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DerivationConfig { + pub script: ArtifactPath, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_inputs: Vec, + pub parameters: OrderedMap, +} + +impl DerivationConfig { + fn validate(&self) -> Result<(), ConfigError> { + require_artifact_prefix(&self.script, "derivations/")?; + if !self.script.as_str().ends_with(".rhai") { + return invalid("derivation script must be a Rhai file"); + } + validate_derivation_input_shape(&self.selector_inputs)?; + validate_len(self.parameters.len(), 0, 32, "derivation parameters")?; + for (name, value) in self.parameters.iter() { + if !valid_field_name(name) { + return invalid("derivation parameter name is invalid"); + } + value.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ParameterValue { + String(String), + Integer(i64), + Boolean(bool), + Decimal(DecimalValue), + BucketBoundaries(Vec), +} + +impl ParameterValue { + fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::String(value) => validate_string(value, 0, 1_024, "derivation string parameter"), + Self::Integer(value) => { + if value.unsigned_abs() > MAX_SAFE_INTEGER as u64 { + invalid("derivation integer parameter exceeds safe bounds") + } else { + Ok(()) + } + } + Self::Boolean(_) => Ok(()), + Self::Decimal(value) => value.validate(), + Self::BucketBoundaries(boundaries) => validate_bucket_boundaries(boundaries), + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DecimalValue { + #[serde(rename = "type")] + pub value_type: DecimalMarker, + pub value: String, +} + +impl DecimalValue { + fn validate(&self) -> Result<(), ConfigError> { + validate_decimal(&self.value) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DecimalMarker { + Decimal, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BucketBoundary { + pub minimum_inclusive: DecimalValue, + pub maximum_exclusive: DecimalValue, + pub code: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConceptConfig { + pub id: String, + pub form: ConceptForm, + pub required: bool, + #[serde(default)] + pub constraints: OrderedMap, +} + +impl ConceptConfig { + fn validate(&self) -> Result<(), ConfigError> { + validate_uri(&self.id)?; + validate_len(self.constraints.len(), 0, 32, "concept constraints")?; + validate_concept_constraints(self) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConceptForm { + Boolean, + ControlledCode, + ControlledCategory, + BoundedInteger, + BoundedDecimal, + DateBucket, + TimeBucket, + AudienceScopedEntityReference, + ControlledCodeList, + EntityReferenceList, + ReviewedStructuredValue, +} + +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DisclosureGuard { + pub families: Vec, +} + +impl DisclosureGuard { + fn validate(&self) -> Result<(), ConfigError> { + validate_unique_strings(&self.families, 1, 16, 1, 512, "disclosure families")?; + for family in &self.families { + validate_uri(family)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExistenceDisclosure { + CollapseUnresolved, +} + +fn validate_named_map( + map: &OrderedMap, + minimum: usize, + maximum: usize, + validate: impl Fn(&T) -> Result<(), ConfigError>, +) -> Result<(), ConfigError> { + validate_len(map.len(), minimum, maximum, "named configuration map")?; + for (name, value) in map.iter() { + if !valid_local_id(name) { + return invalid("local identifier is invalid"); + } + validate(value)?; + } + Ok(()) +} + +fn require_artifact_prefix(path: &ArtifactPath, prefix: &'static str) -> Result<(), ConfigError> { + if path.as_str().starts_with(prefix) { + Ok(()) + } else { + invalid("artifact path has the wrong bundle directory") + } +} + +fn validate_selector_inputs(inputs: &[SelectorInput]) -> Result<(), ConfigError> { + validate_len(inputs.len(), 1, 8, "source selector inputs")?; + validate_derivation_input_shape(inputs) +} + +fn validate_derivation_input_shape(inputs: &[SelectorInput]) -> Result<(), ConfigError> { + validate_len(inputs.len(), 0, 8, "selector inputs")?; + let mut roles = BTreeSet::new(); + for input in inputs { + if !valid_local_id(&input.role) || !roles.insert(input.role.as_str()) { + return invalid("selector-input roles must be valid and unique"); + } + validate_len( + input.alternatives.len(), + 1, + 16, + "selector-input alternatives", + )?; + let mut profiles = BTreeSet::new(); + for alternative in &input.alternatives { + if !valid_local_id(&alternative.profile) + || !profiles.insert(alternative.profile.as_str()) + { + return invalid("selector-input profiles must be valid and unique per role"); + } + validate_unique_strings(&alternative.fields, 1, 16, 1, 64, "selector-input fields")?; + if alternative + .fields + .iter() + .any(|field| !valid_field_name(field)) + { + return invalid("selector-input field name is invalid"); + } + } + } + Ok(()) +} + +fn validate_derivation_selector_inputs( + requirement: &RequirementConfig, + profiles: &OrderedMap, +) -> Result<(), ConfigError> { + for input in &requirement.derivation.selector_inputs { + let role = requirement + .subject_roles + .iter() + .find(|role| role.role == input.role) + .ok_or(ConfigError::Invalid( + "derivation selector input references an unknown requirement role", + ))?; + for alternative in &input.alternatives { + if !role.selector_profiles.contains(&alternative.profile) { + return invalid( + "derivation selector input profile is not allowed for the requirement role", + ); + } + let profile = profiles + .get(&alternative.profile) + .ok_or(ConfigError::Invalid( + "derivation selector input references an unknown selector profile", + ))?; + if alternative + .fields + .iter() + .any(|field| !profile.fields.contains_key(field)) + { + return invalid("derivation selector input references an unknown selector field"); + } + } + } + Ok(()) +} + +fn validate_fixed_headers(headers: &[FixedHeader]) -> Result<(), ConfigError> { + validate_len(headers.len(), 0, 32, "fixed headers")?; + let mut names = BTreeSet::new(); + for header in headers { + validate_configurable_header_name(&header.name)?; + if !names.insert(header.name.to_ascii_lowercase()) { + return invalid("fixed header names must be unique ignoring ASCII case"); + } + validate_string(&header.value, 0, 4_096, "fixed header value")?; + if header.value.chars().any(char::is_control) { + return invalid("fixed header value contains a control character"); + } + } + Ok(()) +} + +fn validate_configurable_header_name(name: &str) -> Result<(), ConfigError> { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(is_http_token_byte) + || is_reserved_header_name(&name.to_ascii_lowercase()) + { + return invalid("configured header name is prohibited"); + } + Ok(()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn is_reserved_header_name(name: &str) -> bool { + matches!( + name, + "authorization" + | "proxy-authorization" + | "host" + | "cookie" + | "set-cookie" + | "content-length" + | "content-type" + | "transfer-encoding" + | "expect" + | "connection" + | "keep-alive" + | "te" + | "trailer" + | "upgrade" + | "proxy-connection" + | "forwarded" + | "via" + | "x-real-ip" + | "traceparent" + | "tracestate" + | "baggage" + | "x-request-id" + | "x-correlation-id" + | "x-amzn-trace-id" + | "x-original-url" + | "x-rewrite-url" + | "x-http-method-override" + | "x-original-method" + ) || name.starts_with("x-forwarded-") + || name.starts_with("proxy-") + || name.starts_with("x-b3-") +} + +fn validate_path_template( + template: &str, + bindings: &OrderedMap, +) -> Result<(), ConfigError> { + validate_string(template, 2, 2_048, "source path template")?; + if !template.starts_with('/') + || template.starts_with("//") + || template.contains(['?', '#', '\\']) + || !template.is_ascii() + { + return invalid("source path template is invalid"); + } + let mut placeholders = BTreeSet::new(); + let mut normalized = String::new(); + for segment in template.split('/').skip(1) { + if segment.is_empty() || matches!(segment, "." | "..") { + return invalid("source path template contains an empty or dot segment"); + } + normalized.push('/'); + if let Some(name) = segment + .strip_prefix('{') + .and_then(|segment| segment.strip_suffix('}')) + { + if !valid_field_name(name) || !placeholders.insert(name) { + return invalid("source path-template placeholders must be valid and unique"); + } + normalized.push('x'); + } else { + if segment.contains(['{', '}']) { + return invalid("source path-template placeholder must occupy a complete segment"); + } + normalized.push_str(segment); + } + } + validate_normalized_request_path(&normalized)?; + if placeholders.is_empty() || placeholders != bindings.keys().collect::>() { + return invalid("pathBindings must exactly match path-template placeholders"); + } + for (_, binding) in bindings.iter() { + binding.validate()?; + } + Ok(()) +} + +fn validate_projection(projection: &[String]) -> Result<(), ConfigError> { + validate_unique_strings(projection, 1, 64, 2, 256, "source projection")?; + let paths = projection + .iter() + .map(|path| parse_projection_pointer(path)) + .collect::, _>>()?; + for (index, left) in paths.iter().enumerate() { + for right in paths.iter().skip(index + 1) { + if projection_paths_overlap(left, right) { + return invalid("source projection paths must not duplicate or overlap"); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, Eq, PartialEq)] +enum ProjectionSegment { + Wildcard, + Key(String), +} + +fn parse_projection_pointer(pointer: &str) -> Result, ConfigError> { + if !pointer.starts_with('/') + || pointer.starts_with("//") + || pointer.chars().any(char::is_control) + { + return invalid("source projection is not an extended JSON Pointer"); + } + pointer[1..] + .split('/') + .map(|raw| { + if raw.is_empty() { + return invalid("source projection contains an empty segment"); + } + if raw == "*" { + return Ok(ProjectionSegment::Wildcard); + } + let mut decoded = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(character) = chars.next() { + if character == '~' { + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return invalid("source projection contains an invalid escape"), + } + } else { + decoded.push(character); + } + } + Ok(ProjectionSegment::Key(decoded)) + }) + .collect() +} + +fn projection_paths_overlap(left: &[ProjectionSegment], right: &[ProjectionSegment]) -> bool { + let common = left.len().min(right.len()); + left.iter().zip(right).take(common).all(|(left, right)| { + left == right + || matches!(left, ProjectionSegment::Wildcard) + || matches!(right, ProjectionSegment::Wildcard) + }) +} + +fn validate_optional_range( + value: Option, + minimum: u64, + maximum: u64, +) -> Result<(), ConfigError> { + value.map_or(Ok(()), |value| { + validate_range(value, minimum, maximum, "optional bound") + }) +} + +fn validate_source_origin(value: &str) -> Result<(), ConfigError> { + let url = validate_source_url(value, true)?; + if url.path() != "/" || url.query().is_some() { + return invalid("source baseUrl must contain only scheme, host, and optional port"); + } + Ok(()) +} + +fn validate_source_url(value: &str, origin_only: bool) -> Result { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("source URL is invalid"))?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return invalid("source URL contains prohibited authority or fragment data"); + } + if origin_only && url.query().is_some() { + return invalid("source origin must not contain a query"); + } + match url.scheme() { + "https" => {} + "http" => { + match url.host() { + Some(Host::Ipv4(ip)) if ip.is_loopback() => {} + Some(Host::Ipv6(ip)) if ip.is_loopback() => {} + _ => return invalid("insecure source URL must use a numeric loopback host"), + } + if !has_canonical_loopback_authority(value) { + return invalid("insecure source URL host syntax is ambiguous"); + } + } + _ => return invalid("source URL scheme is not permitted"), + } + Ok(url) +} + +fn has_canonical_loopback_authority(value: &str) -> bool { + let Some(rest) = value.strip_prefix("http://") else { + return false; + }; + let authority = rest.split('/').next().unwrap_or(rest); + if let Some(suffix) = authority.strip_prefix("[::1]") { + return suffix.is_empty() || valid_port_suffix(suffix); + } + let (host, port) = authority + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .map_or((authority, None), |(host, port)| (host, Some(port))); + if port.is_some_and(|port| !valid_port(port)) { + return false; + } + let octets = host.split('.').collect::>(); + octets.len() == 4 + && octets[0] == "127" + && octets.iter().all(|octet| { + !octet.is_empty() + && (octet == &"0" || !octet.starts_with('0')) + && octet.bytes().all(|byte| byte.is_ascii_digit()) + && octet.parse::().is_ok() + }) +} + +fn valid_port_suffix(value: &str) -> bool { + value.strip_prefix(':').is_some_and(valid_port) +} + +fn valid_port(value: &str) -> bool { + !value.starts_with('0') && value.parse::().is_ok_and(|port| port != 0) +} + +fn validate_https_url(value: &str, origin_only: bool) -> Result<(), ConfigError> { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("HTTPS URL is invalid"))?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || (origin_only && (url.path() != "/" || url.query().is_some())) + { + return invalid("HTTPS URL violates the strict origin contract"); + } + Ok(()) +} + +fn validate_https_issuer(value: &str) -> Result<(), ConfigError> { + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("HTTPS issuer is invalid"))?; + if url.scheme() != "https" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return invalid("HTTPS issuer violates the exact issuer contract"); + } + Ok(()) +} + +fn validate_normalized_request_path(value: &str) -> Result<(), ConfigError> { + if value.len() < 2 + || !value.starts_with('/') + || value.starts_with("//") + || value.contains(['?', '#', '\\']) + || !value.is_ascii() + { + return invalid("source request path is invalid"); + } + let mut index = 0; + let bytes = value.as_bytes(); + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + || bytes[index + 1].is_ascii_lowercase() + || bytes[index + 2].is_ascii_lowercase() + { + return invalid("source request path contains a non-canonical escape"); + } + let decoded = u8::from_str_radix(&value[index + 1..index + 3], 16) + .map_err(|_| ConfigError::Invalid("source request path escape is invalid"))?; + if decoded.is_ascii_alphanumeric() + || matches!(decoded, b'-' | b'.' | b'_' | b'~' | b'/' | b'\\') + { + return invalid("source request path contains an ambiguous escape"); + } + index += 3; + continue; + } + if !(byte.is_ascii_alphanumeric() + || matches!( + byte, + b'/' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + | b'-' + )) + { + return invalid("source request path contains a prohibited character"); + } + index += 1; + } + if value + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + return invalid("source request path contains a dot segment"); + } + Ok(()) +} + +fn validate_bucket_boundaries(boundaries: &[BucketBoundary]) -> Result<(), ConfigError> { + validate_len(boundaries.len(), 1, 64, "bucket boundaries")?; + let mut codes = BTreeSet::new(); + let mut previous_maximum: Option<&str> = None; + for boundary in boundaries { + boundary.minimum_inclusive.validate()?; + boundary.maximum_exclusive.validate()?; + if compare_decimal_text( + &boundary.minimum_inclusive.value, + &boundary.maximum_exclusive.value, + ) != std::cmp::Ordering::Less + { + return invalid("bucket interval must be non-empty"); + } + if previous_maximum.is_some_and(|previous| { + compare_decimal_text(previous, &boundary.minimum_inclusive.value) + != std::cmp::Ordering::Equal + }) { + return invalid("bucket intervals must be ordered and contiguous"); + } + if !valid_code(&boundary.code) || !codes.insert(boundary.code.as_str()) { + return invalid("bucket code is invalid or duplicated"); + } + previous_maximum = Some(&boundary.maximum_exclusive.value); + } + Ok(()) +} + +fn validate_concept_constraints(concept: &ConceptConfig) -> Result<(), ConfigError> { + let required: &[&str] = match concept.form { + ConceptForm::Boolean => &[], + ConceptForm::ControlledCode => &["codelist", "codelistVersion", "maximumBytes"], + ConceptForm::ControlledCategory => &[ + "categoryScheme", + "schemeVersion", + "maximumBytes", + "codelist", + ], + ConceptForm::BoundedInteger => &["minimum", "maximum"], + ConceptForm::BoundedDecimal => &["minimum", "maximum", "maximumScale"], + ConceptForm::DateBucket | ConceptForm::TimeBucket => &["bucketScheme", "schemeVersion"], + ConceptForm::AudienceScopedEntityReference => &["maximumBytes"], + ConceptForm::ControlledCodeList => &[ + "codelist", + "codelistVersion", + "minimumItems", + "maximumItems", + "unique", + ], + ConceptForm::EntityReferenceList => &["minimumItems", "maximumItems", "unique"], + ConceptForm::ReviewedStructuredValue => &["schema", "maximumSerializedBytes"], + }; + if concept.constraints.len() != required.len() + || required + .iter() + .any(|name| !concept.constraints.contains_key(name)) + { + return invalid("concept constraints do not exactly match the declared value form"); + } + + match concept.form { + ConceptForm::Boolean => {} + ConceptForm::ControlledCode => { + validate_codelist_constraints(&concept.constraints, "codelistVersion")?; + } + ConceptForm::ControlledCategory => { + validate_uri(yaml_string(&concept.constraints, "categoryScheme")?)?; + validate_string( + yaml_string(&concept.constraints, "schemeVersion")?, + 1, + 128, + "scheme version", + )?; + validate_codelist_path(yaml_string(&concept.constraints, "codelist")?)?; + validate_constraint_u64(&concept.constraints, "maximumBytes", 1, 8_192)?; + } + ConceptForm::BoundedInteger => { + let minimum = yaml_i64(&concept.constraints, "minimum")?; + let maximum = yaml_i64(&concept.constraints, "maximum")?; + if minimum > maximum || minimum < -MAX_SAFE_INTEGER || maximum > MAX_SAFE_INTEGER { + return invalid("bounded integer constraints are invalid"); + } + } + ConceptForm::BoundedDecimal => { + let minimum = yaml_string(&concept.constraints, "minimum")?; + let maximum = yaml_string(&concept.constraints, "maximum")?; + validate_decimal(minimum)?; + validate_decimal(maximum)?; + if compare_decimal_text(minimum, maximum) == std::cmp::Ordering::Greater { + return invalid("bounded decimal constraints are invalid"); + } + validate_constraint_u64(&concept.constraints, "maximumScale", 0, 9)?; + } + ConceptForm::DateBucket | ConceptForm::TimeBucket => { + validate_uri(yaml_string(&concept.constraints, "bucketScheme")?)?; + validate_string( + yaml_string(&concept.constraints, "schemeVersion")?, + 1, + 128, + "scheme version", + )?; + } + ConceptForm::AudienceScopedEntityReference => { + validate_constraint_u64(&concept.constraints, "maximumBytes", 1, 8_192)?; + } + ConceptForm::ControlledCodeList => { + validate_codelist_path(yaml_string(&concept.constraints, "codelist")?)?; + validate_string( + yaml_string(&concept.constraints, "codelistVersion")?, + 1, + 128, + "codelist version", + )?; + validate_collection_constraints(&concept.constraints)?; + } + ConceptForm::EntityReferenceList => validate_collection_constraints(&concept.constraints)?, + ConceptForm::ReviewedStructuredValue => { + validate_uri(yaml_string(&concept.constraints, "schema")?)?; + validate_constraint_u64(&concept.constraints, "maximumSerializedBytes", 1, 65_536)?; + } + } + Ok(()) +} + +fn validate_codelist_constraints( + constraints: &OrderedMap, + version_key: &str, +) -> Result<(), ConfigError> { + validate_codelist_path(yaml_string(constraints, "codelist")?)?; + validate_string( + yaml_string(constraints, version_key)?, + 1, + 128, + "codelist version", + )?; + validate_constraint_u64(constraints, "maximumBytes", 1, 8_192).map(|_| ()) +} + +fn validate_collection_constraints(constraints: &OrderedMap) -> Result<(), ConfigError> { + let minimum = validate_constraint_u64(constraints, "minimumItems", 1, 64)?; + let maximum = validate_constraint_u64(constraints, "maximumItems", 1, 64)?; + if minimum > maximum || !yaml_bool(constraints, "unique")? { + return invalid("collection constraints are invalid"); + } + Ok(()) +} + +fn validate_codelist_path(value: &str) -> Result<(), ConfigError> { + let path = ArtifactPath::parse(value)?; + require_artifact_prefix(&path, "codelists/") +} + +fn yaml_string<'a>(map: &'a OrderedMap, key: &str) -> Result<&'a str, ConfigError> { + map.get(key) + .and_then(YamlValue::as_str) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong scalar type", + )) +} + +fn yaml_i64(map: &OrderedMap, key: &str) -> Result { + map.get(key) + .and_then(YamlValue::as_i64) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong integer type", + )) +} + +fn yaml_bool(map: &OrderedMap, key: &str) -> Result { + map.get(key) + .and_then(YamlValue::as_bool) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong boolean type", + )) +} + +fn validate_constraint_u64( + map: &OrderedMap, + key: &str, + minimum: u64, + maximum: u64, +) -> Result { + let value = map + .get(key) + .and_then(YamlValue::as_u64) + .ok_or(ConfigError::Invalid( + "concept constraint has the wrong integer type", + ))?; + validate_range(value, minimum, maximum, "concept constraint")?; + Ok(value) +} + +fn validate_decimal(value: &str) -> Result<(), ConfigError> { + if value.is_empty() + || value.starts_with('+') + || value == "-0" + || value.starts_with("-0.") + || value.contains(['e', 'E']) + { + return invalid("decimal text is not canonical"); + } + let unsigned = value.strip_prefix('-').unwrap_or(value); + let mut parts = unsigned.split('.'); + let integer = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() + || integer.is_empty() + || !integer.bytes().all(|byte| byte.is_ascii_digit()) + || (integer.len() > 1 && integer.starts_with('0')) + || fraction.is_some_and(|fraction| { + fraction.is_empty() + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.ends_with('0') + }) + { + return invalid("decimal text is not canonical"); + } + let scale = fraction.map_or(0, str::len); + let precision = integer.len() + scale; + if precision > 28 || scale > 9 { + return invalid("decimal precision or scale exceeds Version 1 bounds"); + } + Ok(()) +} + +fn compare_decimal_text(left: &str, right: &str) -> std::cmp::Ordering { + fn parts(value: &str) -> (bool, &str, &str) { + let negative = value.starts_with('-'); + let unsigned = value.strip_prefix('-').unwrap_or(value); + let (integer, fraction) = unsigned.split_once('.').unwrap_or((unsigned, "")); + (negative, integer, fraction) + } + let (left_negative, left_integer, left_fraction) = parts(left); + let (right_negative, right_integer, right_fraction) = parts(right); + if left_negative != right_negative { + return if left_negative { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + }; + } + let magnitude = left_integer + .len() + .cmp(&right_integer.len()) + .then_with(|| left_integer.cmp(right_integer)) + .then_with(|| { + let width = left_fraction.len().max(right_fraction.len()); + left_fraction + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width) + .cmp( + right_fraction + .bytes() + .chain(std::iter::repeat(b'0')) + .take(width), + ) + }); + if left_negative { + magnitude.reverse() + } else { + magnitude + } +} + +fn validate_uri(value: &str) -> Result<(), ConfigError> { + validate_string(value, 1, 512, "URI")?; + let url = Url::parse(value).map_err(|_| ConfigError::Invalid("URI is invalid"))?; + if url.scheme().is_empty() || value.bytes().any(|byte| byte.is_ascii_whitespace()) { + return invalid("URI is invalid"); + } + Ok(()) +} + +fn validate_absolute_path(value: &str) -> Result<(), ConfigError> { + let path = Path::new(value); + if value.len() > 512 + || !value.starts_with('/') + || value.starts_with("//") + || value.contains('\\') + || !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) + { + return invalid("absolute operator path is invalid"); + } + Ok(()) +} + +fn validate_claim_name(value: &str) -> Result<(), ConfigError> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) + { + return invalid("claim name is invalid"); + } + Ok(()) +} + +fn validate_claim_path(value: &str) -> Result<(), ConfigError> { + if value.len() > 512 { + return invalid("claim path is too long"); + } + for segment in value.split('.') { + let bytes = segment.as_bytes(); + if bytes.is_empty() + || !matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + || !bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return invalid("claim path is invalid"); + } + } + Ok(()) +} + +fn validate_purpose(value: &str) -> Result<(), ConfigError> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !matches!(bytes.first(), Some(b'a'..=b'z')) + || !bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) + { + return invalid("purpose code is invalid"); + } + Ok(()) +} + +fn valid_local_id(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn valid_field_name(value: &str) -> bool { + value.len() <= 64 && valid_local_id(value) +} + +fn valid_parameter_key(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn valid_code(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && bytes[0].is_ascii_alphanumeric() + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn validate_len( + length: usize, + minimum: usize, + maximum: usize, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&length) { + Ok(()) + } else { + invalid("collection cardinality is outside Version 1 bounds") + } +} + +fn validate_range( + value: u64, + minimum: u64, + maximum: u64, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&value) { + Ok(()) + } else { + invalid("numeric value is outside Version 1 bounds") + } +} + +fn validate_string( + value: &str, + minimum: usize, + maximum: usize, + _field: &'static str, +) -> Result<(), ConfigError> { + if (minimum..=maximum).contains(&value.len()) && !value.contains('\0') { + Ok(()) + } else { + invalid("string length is outside Version 1 bounds") + } +} + +fn validate_unique( + values: &[T], + minimum: usize, + maximum: usize, + field: &'static str, +) -> Result<(), ConfigError> { + validate_len(values.len(), minimum, maximum, field)?; + if values.iter().collect::>().len() != values.len() { + return invalid("collection values must be unique"); + } + Ok(()) +} + +fn validate_unique_strings( + values: &[String], + minimum_items: usize, + maximum_items: usize, + minimum_bytes: usize, + maximum_bytes: usize, + field: &'static str, +) -> Result<(), ConfigError> { + validate_len(values.len(), minimum_items, maximum_items, field)?; + let mut seen = BTreeSet::new(); + for value in values { + validate_string(value, minimum_bytes, maximum_bytes, field)?; + if !seen.insert(value.as_str()) { + return invalid("collection values must be unique"); + } + } + Ok(()) +} + +fn invalid(reason: &'static str) -> Result { + Err(ConfigError::Invalid(reason)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle_contract_validator() -> jsonschema::JSONSchema { + let schema: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/bundle.schema.yaml" + )) + .expect("bundle contract is YAML"); + let schema = serde_json::to_value(schema).expect("bundle contract converts to JSON"); + jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("bundle contract compiles") + } + + fn runtime_contract_validator() -> jsonschema::JSONSchema { + let schema: serde_norway::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/contracts/runtime.schema.yaml" + )) + .expect("runtime contract is YAML"); + let schema = serde_json::to_value(schema).expect("runtime contract converts to JSON"); + jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("runtime contract compiles") + } + + fn bundle_contract_instance(yaml: &[u8]) -> serde_json::Value { + let value: serde_norway::Value = + serde_norway::from_slice(yaml).expect("bundle instance is YAML"); + serde_json::to_value(value).expect("bundle instance converts to JSON") + } + + #[test] + fn exact_secret_reference_grammars_are_closed() { + for valid in ["secret:file/a", "secret:file/source-token_v2.json"] { + assert!(SecretRef::parse(valid).is_ok(), "{valid}"); + } + for invalid in [ + "secret:env/", + "secret:env/SOURCE_2_PASSWORD", + "secret:env/lower", + "secret:env/A-B", + "secret:file/Upper", + "secret:file/../token", + "secret:file/.token", + "plain-value", + ] { + assert!(SecretRef::parse(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn decimal_comparison_is_exact() { + assert_eq!( + compare_decimal_text("-10.5", "-2"), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_decimal_text("1.2", "1.20"), + std::cmp::Ordering::Equal + ); + assert_eq!(compare_decimal_text("10", "2"), std::cmp::Ordering::Greater); + } + + #[test] + fn all_coequal_acceptance_definitions_use_the_same_typed_config() { + for yaml in [ + include_bytes!("../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/professional-licence/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml").as_slice(), + ] { + EvidenceConfig::parse_yaml(yaml).expect("acceptance definition must validate"); + } + } + + #[test] + fn bundle_contract_accepts_every_complete_version_one_bundle() { + let validator = bundle_contract_validator(); + for yaml in [ + include_bytes!("../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/professional-licence/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/conformance/selectors/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml").as_slice(), + ] { + assert!(validator.is_valid(&bundle_contract_instance(yaml))); + } + } + + #[test] + fn bundle_contract_closes_concept_constraints_by_form() { + let validator = bundle_contract_validator(); + let valid = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml" + )); + assert!(validator.is_valid(&valid)); + + let mut misspelled = valid.clone(); + let constraints = misspelled["requirements"][0]["concepts"][1]["constraints"] + .as_object_mut() + .expect("controlled-code constraints are an object"); + let version = constraints + .remove("codelistVersion") + .expect("canonical constraint exists"); + constraints.insert("codelist_version".to_owned(), version); + assert!(!validator.is_valid(&misspelled)); + + let mut unsupported = valid; + unsupported["requirements"][0]["concepts"][0]["constraints"]["maximumBytes"] = + serde_json::json!(32); + assert!(!validator.is_valid(&unsupported)); + } + + #[test] + fn typed_config_rejects_noncanonical_constraint_names() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let misspelled = valid.replacen("codelistVersion", "codelist_version", 1); + assert_ne!(misspelled, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(misspelled.as_bytes()).is_err()); + } + + #[test] + fn yaml_names_and_secret_references_are_strict() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!( + EvidenceConfig::parse_yaml(valid.replace("providerId", "provider_id").as_bytes()) + .is_err() + ); + let unexpected = valid.replacen( + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance}", + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance, unexpected: true}", + 1, + ); + assert_ne!(unexpected, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(unexpected.as_bytes()).is_err()); + let literal_secret = valid.replacen( + "activeKeyRef: secret:file/signing-key", + "activeKeyRef: literal-private-key", + 1, + ); + assert_ne!( + literal_secret, valid, + "fixture mutation must remain effective" + ); + assert!(EvidenceConfig::parse_yaml(literal_secret.as_bytes(),).is_err()); + assert!(EvidenceConfig::parse_yaml( + valid + .replace( + "activeKeyId: fixture-key-2026-01", + "activeKeyId: \"fixture-key\\u000A2026-01\"", + ) + .as_bytes(), + ) + .is_err()); + } + + #[test] + fn context_and_grant_claim_maps_are_exact_and_non_aliasing() { + let profile: SelectorProfile = serde_norway::from_str( + "maximumAggregateBytes: 32\nfields:\n alpha: {type: string, minimumBytes: 1, maximumBytes: 16}\n beta: {type: boolean}\n", + ) + .expect("selector profile parses"); + let exact: GrantedSubject = serde_norway::from_str( + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-context\nvalueClaims: {alpha: claims.alpha, beta: claims.beta}\n", + ) + .expect("subject parses"); + assert!(exact.validate_value_claims(&profile).is_ok()); + + for invalid_subject in [ + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-context\nvalueClaims: {alpha: claims.alpha}\n", + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: authenticated-grant\nvalueClaims: {alpha: claims.same, beta: claims.same}\n", + "role: subject\nselectorProfile: opaque-v1\nvalueOrigin: request\nvalueClaims: {alpha: claims.alpha, beta: claims.beta}\n", + ] { + let subject: GrantedSubject = + serde_norway::from_str(invalid_subject).expect("subject shape parses"); + assert!(subject.validate_value_claims(&profile).is_err()); + } + } + + #[test] + fn complete_authority_paths_cannot_be_unioned_across_partial_grants() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml" + )) + .expect("fixture validates"); + config.authority_profiles.0[0].1.grants[0].subjects.pop(); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "authority grant must bind the complete subject-role set" + )) + ); + } + + #[test] + fn active_source_role_sets_reject_unreachable_inputs_at_startup() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml" + )) + .expect("fixture validates"); + config.sources.0[0].1.request.selector_inputs[0] + .alternatives + .push(SelectorInputAlternative { + profile: "person-reference-v1".to_owned(), + fields: vec!["person_reference".to_owned()], + }); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "source selector input is unreachable from every complete authority path" + )) + ); + } + + #[test] + fn one_source_may_serve_mutually_exclusive_complete_role_sets() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("adult fixture validates"); + + let mut alternative = config.requirements[0].clone(); + alternative.id = "urn:example:fixture:requirement:adult-status-alternative:v1".to_owned(); + alternative.subject_roles[0].role = "alternate-subject".to_owned(); + alternative.evidence_type = + "urn:example:fixture:evidence-type:adult-status-alternative:v1".to_owned(); + alternative.derivation.script = + ArtifactPath::parse("derivations/adult-status-alternative.rhai") + .expect("alternative derivation path"); + alternative.concepts[0].id = + "urn:example:fixture:concept:adult-status-alternative".to_owned(); + alternative.disclosure_guard.families[0] = + "urn:example:fixture:disclosure-family:adult-status-alternative".to_owned(); + + let mut grant = config.authority_profiles.0[0].1.grants[0].clone(); + grant.requirement = alternative.id.clone(); + grant.subjects[0].role = "alternate-subject".to_owned(); + config.authority_profiles.0[0].1.grants.push(grant); + + let alternative_inputs = config.sources.0[0] + .1 + .request + .selector_inputs + .iter() + .cloned() + .map(|mut input| { + input.role = "alternate-subject".to_owned(); + input + }) + .collect::>(); + config.sources.0[0] + .1 + .request + .selector_inputs + .extend(alternative_inputs); + config.requirements.push(alternative); + + config + .validate() + .expect("mutually exclusive role sets may reuse fixed placements"); + assert_eq!( + config.source_selector_sets("source-a"), + vec![ + vec![( + "alternate-subject".to_owned(), + "person-demographics-v1".to_owned() + )], + vec![("subject".to_owned(), "person-demographics-v1".to_owned())] + ] + ); + } + + #[test] + fn one_trust_domain_and_native_token_identity_are_closed_configuration() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let invalid = valid.replacen( + "service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance}", + "service: {providerId: urn:example:fixture:provider:evidence, trustDomains: [urn:example:fixture:trust-domain:a, urn:example:fixture:trust-domain:b]}", + 1, + ); + assert_ne!(invalid, valid, "fixture mutation must remain effective"); + assert!(EvidenceConfig::parse_yaml(invalid.as_bytes()).is_err()); + } + + #[test] + fn source_urls_reject_insecure_aliases_and_ambiguous_numeric_hosts() { + for valid in [ + "https://source.invalid", + "http://127.0.0.1:18081", + "http://127.42.5.9", + "http://[::1]:18083", + ] { + assert!(validate_source_origin(valid).is_ok(), "{valid}"); + } + for invalid in [ + "http://localhost:18081", + "http://127.1:18081", + "http://127.00.0.1:18081", + "http://192.168.1.2", + "https://user@source.invalid", + "https://source.invalid/path", + "https://source.invalid#fragment", + ] { + assert!(validate_source_origin(invalid).is_err(), "{invalid}"); + } + } + + #[test] + fn source_adapter_name_is_audit_safe_and_oauth_endpoint_has_no_query() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + + let uppercase_adapter = valid.replace( + "extractScript: adapters/source-a.rhai", + "extractScript: adapters/Source-a.rhai", + ); + assert_eq!( + EvidenceConfig::parse_yaml(uppercase_adapter.as_bytes()), + Err(ConfigError::Invalid( + "source adapter name must be a local identifier" + )) + ); + + for query in [ + "?client_secret=plaintext", + "?client_id=duplicate", + "?fixed=true", + ] { + let mut oauth = + EvidenceConfig::parse_yaml(valid.as_bytes()).expect("fixture validates"); + oauth.sources.0[0].1.authentication = SourceAuthentication::Oauth2ClientCredentials { + token_endpoint: format!("https://source.invalid/token{query}"), + client_id_ref: SecretRef::parse("secret:file/oauth-client-id").expect("secret ref"), + client_secret_ref: SecretRef::parse("secret:file/oauth-client-secret") + .expect("secret ref"), + scope: None, + credential_placement: CredentialPlacement::FormBody, + maximum_cache_seconds: 60, + }; + assert_eq!( + oauth.validate(), + Err(ConfigError::Invalid( + "OAuth token endpoint must not contain a query" + )), + "{query}" + ); + } + } + + #[test] + fn get_sources_must_forbid_the_json_body_channel() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + config.sources.0[0].1.request.method = HttpMethod::GET; + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "GET source requests must forbid the JSON body channel" + )) + ); + + let validator = bundle_contract_validator(); + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["sources"]["source-a"]["request"]["method"] = serde_json::json!("GET"); + assert!(!validator.is_valid(&instance)); + } + + #[test] + fn a_shared_disclosure_family_rejects_the_complete_bundle() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + let mut duplicate = config.requirements[0].clone(); + duplicate.id = "urn:example:fixture:requirement:other:v1".to_owned(); + duplicate.evidence_type = "urn:example:fixture:evidence-type:other:v1".to_owned(); + duplicate.derivation.script = + ArtifactPath::parse("derivations/other.rhai").expect("artifact path"); + duplicate.concepts[0].id = "urn:example:fixture:concept:other".to_owned(); + config.requirements.push(duplicate); + assert_eq!( + config.validate(), + Err(ConfigError::Invalid( + "enabled requirements share a disclosure family" + )) + ); + } + + #[test] + fn runtime_document_is_closed_and_contains_no_governed_override_surface() { + let valid = br#" +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/secrets/registry-evidence} +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: + internal-pki: {caBundleFile: /etc/registry-evidence/ca/internal.pem} +"#; + RuntimeConfig::parse_yaml(valid).expect("closed runtime parses"); + let validator = runtime_contract_validator(); + assert!(validator.is_valid(&bundle_contract_instance(valid))); + for reference in [ + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml").as_slice(), + ] { + assert!(validator.is_valid(&bundle_contract_instance(reference))); + RuntimeConfig::parse_yaml(reference).expect("reference runtime matches Rust contract"); + } + for rejected_host in ["evidence.internal", "0.0.0.0", "8.8.8.8", "ff02::1"] { + let candidate = String::from_utf8(valid.to_vec()) + .expect("runtime fixture is UTF-8") + .replace("bindHost: 127.0.0.1", &format!("bindHost: {rejected_host}")); + assert!( + RuntimeConfig::parse_yaml(candidate.as_bytes()).is_err(), + "runtime accepted prohibited bindHost {rejected_host}" + ); + } + for governed_key in [ + "service", + "issuer", + "authentication", + "audit", + "subjectBinding", + "rateLimits", + "signing", + "selectorProfiles", + "sources", + "authorityProfiles", + "requirements", + ] { + let mut candidate = valid.to_vec(); + candidate.extend_from_slice(format!("{governed_key}: {{}}\n").as_bytes()); + assert_eq!( + RuntimeConfig::parse_yaml(&candidate), + Err(ConfigError::InvalidYaml), + "runtime accepted governed bundle key {governed_key}" + ); + assert!( + !validator.is_valid(&bundle_contract_instance(&candidate)), + "runtime schema accepted governed bundle key {governed_key}" + ); + } + } + + #[test] + fn path_templates_headers_and_projection_fail_closed() { + let bindings: OrderedMap = serde_norway::from_str( + "record_reference: {role: subject, profile: record-reference-v1, field: record_reference}\n", + ) + .expect("path binding parses"); + assert!(validate_path_template("/records/{record_reference}", &bindings).is_ok()); + for invalid_template in [ + "/records/{record_reference}/", + "/records/prefix-{record_reference}", + "/records/{missing}", + "/records/../{record_reference}", + ] { + assert!(validate_path_template(invalid_template, &bindings).is_err()); + } + + assert!(validate_configurable_header_name("X-API-Version").is_ok()); + for forbidden in [ + "Authorization", + "Host", + "Content-Length", + "Expect", + "Cookie", + "Proxy-Authorization", + "X-Forwarded-For", + "X-Original-URL", + "X-Rewrite-URL", + "X-HTTP-Method-Override", + "X-Original-Method", + "TraceParent", + ] { + assert!( + validate_configurable_header_name(forbidden).is_err(), + "{forbidden}" + ); + } + + assert!(validate_projection(&[ + "/total".to_owned(), + "/results/*/status".to_owned(), + "/declaration/mother.personReference".to_owned(), + ]) + .is_ok()); + for paths in [ + vec!["/results".to_owned(), "/results/*/status".to_owned()], + vec![ + "/results/*/status".to_owned(), + "/results/0/status".to_owned(), + ], + vec!["/bad/~2escape".to_owned()], + ] { + assert!(validate_projection(&paths).is_err()); + } + } + + #[test] + fn path_based_https_oidc_issuer_is_preserved_exactly() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture validates"); + config.authentication.issuer = "https://identity.example.test/realms/registry".to_owned(); + assert!(config.validate().is_ok()); + config.authentication.issuer.push_str("?tenant=wrong"); + assert!(config.validate().is_err()); + } + + #[test] + fn file_secret_references_are_the_only_governed_secret_form() { + assert!(SecretRef::parse("secret:file/source-token").is_ok()); + assert!(SecretRef::parse("secret:env/SOURCE_TOKEN").is_err()); + assert!(SecretRef::parse("literal-token").is_err()); + } +} diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs new file mode 100644 index 000000000..dc92569e7 --- /dev/null +++ b/crates/registry-evidence/src/contracts.rs @@ -0,0 +1,1019 @@ +//! Deterministic public-contract generation for Evidence Version 1. +//! +//! The generated files are release artifacts. This module is their only +//! source and deliberately has no dependency on a deployment bundle. + +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use jsonschema::{Draft, JSONSchema}; +use schemars::JsonSchema; +use serde_json::{json, Value}; +use thiserror::Error; + +use crate::model::{Evidence, EvidenceRequest, FlattenedJws, JwksDocument, ProblemBody}; + +pub const OPENAPI_FILE: &str = "registry-evidence.openapi.json"; +pub const REQUEST_SCHEMA_FILE: &str = "evidence-request-v1.schema.json"; +pub const EVIDENCE_SCHEMA_FILE: &str = "evidence-v1.schema.json"; +pub const JWS_SCHEMA_FILE: &str = "flattened-jws-v1.schema.json"; +pub const PROBLEM_SCHEMA_FILE: &str = "problem-v1.schema.json"; +pub const JWKS_SCHEMA_FILE: &str = "jwks-v1.schema.json"; + +const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; +const REQUEST_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/request-v1.json"; +const EVIDENCE_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json"; +const JWS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/flattened-jws-v1.json"; +const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; +const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; +const PROBLEM_VARIANTS: [(&str, u16, &str); 8] = [ + ("malformed_request", 400, "Request is not valid"), + ("invalid_selector", 400, "Request is not valid"), + ("authentication_failed", 401, "Authentication failed"), + ("not_authorized", 403, "Request is not authorized"), + ( + "evidence_not_available", + 422, + "Evidence could not be produced", + ), + ("rate_limited", 429, "Request rate exceeded"), + ( + "dependency_unavailable", + 503, + "Service temporarily unavailable", + ), + ( + "service_unavailable", + 503, + "Service temporarily unavailable", + ), +]; + +static REQUEST_VALIDATOR: OnceLock> = OnceLock::new(); +static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("built-in public contract schema failed to initialize")] +pub(crate) struct ContractValidationError; + +#[derive(Debug, Error)] +pub enum ContractGenerationError { + #[error("generated contract serialization failed")] + Serialization(#[from] serde_json::Error), + #[error("generated contract output failed at {path}")] + Output { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("generated contract no longer matches the public Rust wire type {type_name}")] + ModelDrift { type_name: &'static str }, +} + +/// Generate every committed public contract, keyed by its stable filename. +pub fn documents() -> Result, ContractGenerationError> { + let request = request_schema(); + let evidence = evidence_schema(); + let jws = jws_schema(); + let problem = problem_schema(); + let jwks = jwks_schema(); + assert_model_shape::("EvidenceRequest", &request, true)?; + assert_model_shape::("Evidence", &evidence, true)?; + assert_model_shape::("FlattenedJws", &jws, false)?; + assert_model_shape::("ProblemBody", &problem, false)?; + assert_model_shape::("JwksDocument", &jwks, false)?; + let openapi = openapi_document(&request, &evidence, &jws, &problem, &jwks); + + let values = [ + (REQUEST_SCHEMA_FILE, request), + (EVIDENCE_SCHEMA_FILE, evidence), + (JWS_SCHEMA_FILE, jws), + (PROBLEM_SCHEMA_FILE, problem), + (JWKS_SCHEMA_FILE, jwks), + (OPENAPI_FILE, openapi), + ]; + values + .into_iter() + .map(|(name, value)| Ok((name, pretty_json(&value)?))) + .collect() +} + +/// Write all generated contracts to an otherwise caller-owned directory. +pub fn write_documents(output: &Path) -> Result<(), ContractGenerationError> { + fs::create_dir_all(output).map_err(|source| ContractGenerationError::Output { + path: output.to_path_buf(), + source, + })?; + for (name, contents) in documents()? { + let path = output.join(name); + fs::write(&path, contents) + .map_err(|source| ContractGenerationError::Output { path, source })?; + } + Ok(()) +} + +/// Validate an inbound public request against the exact generated Version 1 schema. +pub(crate) fn request_contract_accepts(value: &Value) -> Result { + contract_validator(&REQUEST_VALIDATOR, request_schema) + .map(|validator| validator.is_valid(value)) +} + +/// Validate a verified JWS payload against the exact generated Version 1 schema. +pub(crate) fn evidence_contract_accepts(value: &Value) -> Result { + contract_validator(&EVIDENCE_VALIDATOR, evidence_schema) + .map(|validator| validator.is_valid(value)) +} + +fn contract_validator( + cell: &'static OnceLock>, + schema: fn() -> Value, +) -> Result<&'static JSONSchema, ContractValidationError> { + match cell.get_or_init(|| { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema()) + .map_err(|_| ContractValidationError) + }) { + Ok(validator) => Ok(validator), + Err(error) => Err(*error), + } +} + +fn assert_model_shape( + type_name: &'static str, + contract: &Value, + compare_nested_properties: bool, +) -> Result<(), ContractGenerationError> { + let derived = serde_json::to_value(schemars::schema_for!(T))?; + let matches = if compare_nested_properties { + property_groups(&derived) == property_groups(contract) + } else { + root_properties(&derived) == root_properties(contract) + }; + if matches { + Ok(()) + } else { + Err(ContractGenerationError::ModelDrift { type_name }) + } +} + +fn root_properties(value: &Value) -> Vec { + value + .get("properties") + .and_then(Value::as_object) + .map(|properties| properties.keys().cloned().collect()) + .unwrap_or_default() +} + +fn property_groups(value: &Value) -> Vec> { + fn visit(value: &Value, groups: &mut Vec>) { + match value { + Value::Array(values) => { + for value in values { + visit(value, groups); + } + } + Value::Object(object) => { + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + groups.push(properties.keys().cloned().collect()); + } + for value in object.values() { + visit(value, groups); + } + } + _ => {} + } + } + + let mut groups = Vec::new(); + visit(value, &mut groups); + groups.sort(); + groups +} + +fn pretty_json(value: &Value) -> Result { + let mut rendered = serde_json::to_string_pretty(value)?; + rendered.push('\n'); + Ok(rendered) +} + +fn request_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": REQUEST_SCHEMA_ID, + "title": "Evidence request Version 1", + "type": "object", + "additionalProperties": false, + "required": ["requirement", "purpose", "subjects"], + "properties": { + "requirement": {"type": "string", "format": "uri", "minLength": 1, "maxLength": 512}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, + "items": {"$ref": "#/$defs/subject"} + } + }, + "$defs": { + "subject": { + "type": "object", "additionalProperties": false, + "required": ["role", "selector"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "selector": {"$ref": "#/$defs/selector"} + } + }, + "selector": { + "type": "object", "additionalProperties": false, + "required": ["profile"], + "properties": { + "profile": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,127}$"}, + "values": { + "type": "object", "minProperties": 1, "maxProperties": 16, + "propertyNames": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "additionalProperties": {"$ref": "#/$defs/scalar-selector-value"} + } + } + }, + "scalar-selector-value": { + "oneOf": [ + {"type": "string", "minLength": 1, "maxLength": 512}, + {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + {"type": "boolean"} + ] + } + }, + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access." + }) +} + +fn evidence_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": EVIDENCE_SCHEMA_ID, + "title": "Evidence assertion payload Version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "id", "type", "supportsRequirement", "isConformantTo", + "issuedBy", "providedBy", "issuedAt", "observedAt", "validUntil", + "purpose", "audience", "configurationRevision", "subjects", "supportedValues" + ], + "properties": { + "schema": {"const": "registry.assertion-evidence/v1"}, + "id": {"type": "string", "format": "uri", "maxLength": 512}, + "type": {"const": "Evidence"}, + "supportsRequirement": {"type": "string", "format": "uri", "maxLength": 512}, + "isConformantTo": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "issuedAt": {"type": "string", "format": "date-time"}, + "observedAt": {"type": "string", "format": "date-time"}, + "validUntil": {"type": "string", "format": "date-time"}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "audience": {"type": "string", "format": "uri", "maxLength": 512}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, + "items": {"$ref": "#/$defs/subject-binding"} + }, + "supportedValues": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": {"$ref": "#/$defs/supported-value"} + } + }, + "$defs": { + "subject-binding": { + "type": "object", "additionalProperties": false, + "required": ["role", "binding"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "binding": {"type": "string", "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "supported-value": { + "type": "object", "additionalProperties": false, + "required": ["providesValueFor", "value"], + "properties": { + "providesValueFor": {"type": "string", "format": "uri", "maxLength": 512}, + "value": {"$ref": "#/$defs/value"} + } + }, + "value": { + "anyOf": [ + {"type": "boolean"}, + {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/bucket"}, + {"$ref": "#/$defs/entity-reference"}, + {"$ref": "#/$defs/structured"}, + { + "type": "array", "minItems": 1, "maxItems": 64, + "items": {"anyOf": [ + {"type": "string", "minLength": 1, "maxLength": 1024}, + {"$ref": "#/$defs/entity-reference"} + ]} + } + ] + }, + "bucket": { + "type": "object", "additionalProperties": false, + "required": ["form", "scheme", "bucket"], + "properties": { + "form": {"enum": ["date-bucket", "time-bucket"]}, + "scheme": {"type": "string", "format": "uri", "maxLength": 512}, + "bucket": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"} + } + }, + "entity-reference": { + "type": "object", "additionalProperties": false, + "required": ["form", "reference"], + "properties": { + "form": {"const": "audience-scoped-entity-reference"}, + "reference": {"type": "string", "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$"} + } + }, + "structured": { + "type": "object", "additionalProperties": false, + "required": ["form", "schema", "fields"], + "properties": { + "form": {"const": "reviewed-structured-value"}, + "schema": {"type": "string", "format": "uri", "maxLength": 512}, + "fields": {"type": "object", "minProperties": 1, "maxProperties": 16} + } + } + }, + "$comment": "The selected concept declaration further closes value form, schema, codelist, precision, cardinality, structured fields, and uniqueness. Selector profiles and selector values never appear in Evidence." + }) +} + +fn jws_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": JWS_SCHEMA_ID, + "title": "Evidence flattened JWS response Version 1", + "type": "object", + "additionalProperties": false, + "required": ["protected", "payload", "signature"], + "properties": { + "protected": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$"}, + "payload": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$"}, + "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"} + }, + "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=EdDSA, kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes." + }) +} + +fn problem_schema() -> Value { + let mut schema = json!({ + "$schema": SCHEMA_DIALECT, + "$id": PROBLEM_SCHEMA_ID, + "title": "Evidence public problem Version 1", + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "code", "operation"], + "properties": { + "type": { + "type": "string", + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request", + "https://registrystack.org/problems/evidence/invalid_selector", + "https://registrystack.org/problems/evidence/authentication_failed", + "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/evidence_not_available", + "https://registrystack.org/problems/evidence/rate_limited", + "https://registrystack.org/problems/evidence/dependency_unavailable", + "https://registrystack.org/problems/evidence/service_unavailable" + ] + }, + "title": {"type": "string", "enum": [ + "Request is not valid", "Authentication failed", "Request is not authorized", + "Evidence could not be produced", "Request rate exceeded", "Service temporarily unavailable" + ]}, + "status": {"type": "integer", "enum": [400, 401, 403, 422, 429, 503]}, + "code": {"type": "string", "enum": [ + "malformed_request", "invalid_selector", "authentication_failed", "not_authorized", + "evidence_not_available", "rate_limited", "dependency_unavailable", "service_unavailable" + ]}, + "operation": {"type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"} + }, + "$comment": "Problem members are a closed safe shape. No request, authority, source, script, supported-value, subject-binding, candidate, or credential detail is returned." + }); + schema["oneOf"] = Value::Array( + PROBLEM_VARIANTS + .into_iter() + .map(|(code, status, title)| { + json!({"properties": { + "type": {"const": format!("https://registrystack.org/problems/evidence/{code}")}, + "title": {"const": title}, + "status": {"const": status}, + "code": {"const": code} + }}) + }) + .collect(), + ); + schema +} + +fn jwks_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": JWKS_SCHEMA_ID, + "title": "Evidence public JWKS Version 1", + "type": "object", + "additionalProperties": false, + "required": ["keys"], + "properties": { + "keys": { + "type": "array", "minItems": 1, "maxItems": 33, "uniqueItems": true, + "items": {"$ref": "#/$defs/ed25519-public-jwk"} + } + }, + "$defs": { + "ed25519-public-jwk": { + "type": "object", "additionalProperties": false, + "required": ["kty", "kid", "alg", "crv", "x"], + "properties": { + "kty": {"const": "OKP"}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$"}, + "alg": {"const": "EdDSA"}, + "crv": {"const": "Ed25519"}, + "x": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"} + } + } + }, + "$comment": "Only the active and configured retired public keys are published. Key ids are unique and limited to 256 UTF-8 bytes by the runtime; JSON Schema maxLength is an additional code-point bound. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location." + }) +} + +fn insert_schema_family( + components: &mut serde_json::Map, + root_name: &str, + schema: &Value, + definition_names: &[(&str, &str)], +) { + let mut root = schema.clone(); + let definitions = root + .as_object_mut() + .and_then(|object| object.remove("$defs")) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + rewrite_openapi_schema(&mut root, definition_names); + components.insert(root_name.to_string(), root); + + for (definition_name, component_name) in definition_names { + let mut definition = definitions + .get(*definition_name) + .unwrap_or_else(|| panic!("missing generated schema definition {definition_name}")) + .clone(); + rewrite_openapi_schema(&mut definition, definition_names); + components.insert((*component_name).to_string(), definition); + } +} + +fn rewrite_openapi_schema(value: &mut Value, definition_names: &[(&str, &str)]) { + match value { + Value::Array(values) => { + for value in values { + rewrite_openapi_schema(value, definition_names); + } + } + Value::Object(object) => { + object.remove("$schema"); + object.remove("$id"); + object.remove("$comment"); + + if let Some(reference) = object + .get("$ref") + .and_then(Value::as_str) + .map(str::to_string) + { + if let Some(definition_name) = reference.strip_prefix("#/$defs/") { + let component_name = definition_names + .iter() + .find_map(|(definition, component)| { + (*definition == definition_name).then_some(*component) + }) + .unwrap_or_else(|| { + panic!("unmapped generated schema definition {definition_name}") + }); + object.insert( + "$ref".to_string(), + Value::String(format!("#/components/schemas/{component_name}")), + ); + } + } + + if let Some(constant) = object.remove("const") { + let schema_type = match &constant { + Value::String(_) => Some("string"), + Value::Bool(_) => Some("boolean"), + Value::Number(number) if number.is_i64() || number.is_u64() => Some("integer"), + Value::Number(_) => Some("number"), + Value::Array(_) => Some("array"), + Value::Object(_) => Some("object"), + Value::Null => None, + }; + if let Some(schema_type) = schema_type { + object + .entry("type".to_string()) + .or_insert_with(|| Value::String(schema_type.to_string())); + } + object.insert("enum".to_string(), Value::Array(vec![constant])); + } + if !object.contains_key("type") { + let enum_type = object + .get("enum") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .and_then(|value| match value { + Value::String(_) => Some("string"), + Value::Bool(_) => Some("boolean"), + Value::Number(number) if number.is_i64() || number.is_u64() => { + Some("integer") + } + Value::Number(_) => Some("number"), + _ => None, + }); + if let Some(enum_type) = enum_type { + object.insert("type".to_string(), Value::String(enum_type.to_string())); + } + } + + for value in object.values_mut() { + rewrite_openapi_schema(value, definition_names); + } + } + _ => {} + } +} + +fn problem_content(codes: &[&str]) -> Value { + let variants = codes + .iter() + .map(|code| { + let (_, status, title) = PROBLEM_VARIANTS + .iter() + .find(|(variant, _, _)| variant == code) + .unwrap_or_else(|| panic!("unknown public problem code {code}")); + json!({ + "allOf": [ + {"$ref": "#/components/schemas/Problem"}, + { + "type": "object", + "properties": { + "type": {"type": "string", "enum": [format!("https://registrystack.org/problems/evidence/{code}")]}, + "title": {"type": "string", "enum": [title]}, + "status": {"type": "integer", "enum": [status]}, + "code": {"type": "string", "enum": [code]} + } + } + ] + }) + }) + .collect::>(); + let schema = if variants.len() == 1 { + variants.into_iter().next().expect("one problem variant") + } else { + json!({"oneOf": variants}) + }; + json!({"application/problem+json": {"schema": schema}}) +} + +fn response_headers(extra: Option<(&str, Value)>) -> Value { + let mut headers = serde_json::Map::new(); + headers.insert( + "Cache-Control".to_string(), + json!({ + "description": "Evidence responses are never cacheable.", + "schema": {"type": "string", "enum": ["no-store"]} + }), + ); + if let Some((name, header)) = extra { + headers.insert(name.to_string(), header); + } + Value::Object(headers) +} + +fn openapi_document( + request: &Value, + evidence: &Value, + jws: &Value, + problem: &Value, + jwks: &Value, +) -> Value { + let mut schemas = serde_json::Map::new(); + insert_schema_family( + &mut schemas, + "EvidenceRequest", + request, + &[ + ("subject", "EvidenceRequestSubject"), + ("selector", "EvidenceRequestSelector"), + ("scalar-selector-value", "SelectorValue"), + ], + ); + insert_schema_family( + &mut schemas, + "Evidence", + evidence, + &[ + ("subject-binding", "SubjectBinding"), + ("supported-value", "SupportedValue"), + ("value", "PublicValue"), + ("bucket", "BucketValue"), + ("entity-reference", "EntityReferenceValue"), + ("structured", "StructuredValue"), + ], + ); + insert_schema_family(&mut schemas, "FlattenedJws", jws, &[]); + if let Some(properties) = schemas + .get_mut("FlattenedJws") + .and_then(Value::as_object_mut) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + { + properties["protected"]["x-decoded-schema"] = + json!({"$ref": "#/components/schemas/EvidenceProtectedHeader"}); + properties["payload"]["x-decoded-schema"] = + json!({"$ref": "#/components/schemas/Evidence"}); + } + schemas.insert( + "EvidenceProtectedHeader".to_string(), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["alg", "kid", "typ", "cty"], + "properties": { + "alg": {"type": "string", "enum": ["EdDSA"]}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^\\u0000-\\u001F\\u007F]+$"}, + "typ": {"type": "string", "enum": ["evidence+jws"]}, + "cty": {"type": "string", "enum": ["application/evidence+json"]} + } + }), + ); + insert_schema_family(&mut schemas, "Problem", problem, &[]); + insert_schema_family( + &mut schemas, + "JwksDocument", + jwks, + &[("ed25519-public-jwk", "Ed25519PublicJwk")], + ); + schemas.insert( + "HealthStatus".to_string(), + json!({ + "type": "object", "additionalProperties": false, + "required": ["status"], + "properties": {"status": {"type": "string", "enum": ["ok"]}} + }), + ); + schemas.insert( + "ReadyStatus".to_string(), + json!({ + "type": "object", "additionalProperties": false, + "required": ["status"], + "properties": {"status": {"type": "string", "enum": ["ready"]}} + }), + ); + + json!({ + "openapi": "3.1.0", + "info": { + "title": "Registry Evidence API", + "version": env!("CARGO_PKG_VERSION"), + "description": "Minimum-disclosure signed assertion service, Version 1." + }, + "paths": { + "/v1/evidence": { + "post": { + "operationId": "createEvidence", + "summary": "Produce signed evidence for one authorized fixed requirement", + "security": [{"bearerAuth": []}], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvidenceRequest"}}} + }, + "responses": { + "200": { + "description": "Signed Evidence as flattened JWS JSON Serialization", + "headers": response_headers(None), + "content": {"application/jose+json": {"schema": {"$ref": "#/components/schemas/FlattenedJws"}}} + }, + "400": { + "description": "Malformed request or invalid selector", + "headers": response_headers(None), + "content": problem_content(&["malformed_request", "invalid_selector"]) + }, + "401": { + "description": "Authentication failed", + "headers": response_headers(Some(("WWW-Authenticate", json!({ + "schema": {"type": "string", "enum": ["Bearer"]} + })))), + "content": problem_content(&["authentication_failed"]) + }, + "403": { + "description": "Request is not authorized", + "headers": response_headers(None), + "content": problem_content(&["not_authorized"]) + }, + "422": { + "description": "Evidence could not be produced", + "headers": response_headers(None), + "content": problem_content(&["evidence_not_available"]) + }, + "429": { + "description": "Request rate exceeded", + "headers": response_headers(Some(("Retry-After", json!({ + "schema": {"type": "string", "enum": ["1"]} + })))), + "content": problem_content(&["rate_limited"]) + }, + "503": { + "description": "Dependency or service temporarily unavailable", + "headers": response_headers(None), + "content": problem_content(&["dependency_unavailable", "service_unavailable"]) + }, + } + } + }, + "/health": { + "get": { + "operationId": "getHealth", + "summary": "Report process liveness without dependency access", + "responses": {"200": { + "description": "Process is live", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HealthStatus"}}} + }} + } + }, + "/ready": { + "get": { + "operationId": "getReadiness", + "summary": "Report fail-closed runtime readiness", + "responses": { + "200": { + "description": "Runtime is ready", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReadyStatus"}}} + }, + "503": { + "description": "Runtime is not ready", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, + "/.well-known/evidence/jwks.json": { + "get": { + "operationId": "getEvidenceJwks", + "summary": "Publish the active and retained public verification keys", + "responses": {"200": { + "description": "Evidence public verification keys", + "headers": response_headers(None), + "content": {"application/jwk-set+json": {"schema": {"$ref": "#/components/schemas/JwksDocument"}}} + }} + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Exactly one Authorization header containing one Bearer token is required." + } + }, + "schemas": Value::Object(schemas) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_json_schema_is_valid_draft_2020_12() { + for schema in [ + request_schema(), + evidence_schema(), + jws_schema(), + problem_schema(), + jwks_schema(), + ] { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("generated schema compiles"); + } + } + + #[test] + fn openapi_document_is_valid_utoipa_model() { + let document = openapi_document( + &request_schema(), + &evidence_schema(), + &jws_schema(), + &problem_schema(), + &jwks_schema(), + ); + for (name, schema) in document["components"]["schemas"] + .as_object() + .expect("component schemas are an object") + { + serde_json::from_value::>( + schema.clone(), + ) + .unwrap_or_else(|error| panic!("component schema {name} is invalid: {error}")); + } + serde_json::from_value::(document) + .expect("generated OpenAPI parses as OpenAPI 3.1"); + } + + #[test] + fn openapi_has_only_the_four_version_one_routes_and_exact_success_media() { + let document = openapi_document( + &request_schema(), + &evidence_schema(), + &jws_schema(), + &problem_schema(), + &jwks_schema(), + ); + let paths = document["paths"].as_object().expect("paths is an object"); + assert_eq!( + paths.keys().map(String::as_str).collect::>(), + [ + "/.well-known/evidence/jwks.json", + "/health", + "/ready", + "/v1/evidence" + ] + ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/jose+json"] + .is_object() + ); + assert!( + document["paths"]["/.well-known/evidence/jwks.json"]["get"]["responses"]["200"] + ["content"]["application/jwk-set+json"] + .is_object() + ); + assert_eq!( + document["components"]["schemas"]["FlattenedJws"]["properties"]["payload"] + ["x-decoded-schema"]["$ref"], + json!("#/components/schemas/Evidence") + ); + assert_eq!( + document["components"]["schemas"]["EvidenceProtectedHeader"]["properties"]["alg"] + ["enum"], + json!(["EdDSA"]) + ); + + for path in paths.values() { + let operation = path + .as_object() + .and_then(|operations| operations.values().next()) + .expect("each Version 1 path has one operation"); + for response in operation["responses"] + .as_object() + .expect("responses is an object") + .values() + { + assert_eq!( + response["headers"]["Cache-Control"]["schema"]["enum"], + json!(["no-store"]) + ); + } + } + + assert_eq!( + document["paths"]["/v1/evidence"]["post"]["responses"]["401"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["authentication_failed"]) + ); + assert_eq!( + document["paths"]["/ready"]["get"]["responses"]["503"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["service_unavailable"]) + ); + } + + #[test] + fn jws_and_problem_schemas_are_closed() { + let jws = jws_schema(); + assert_eq!(jws["additionalProperties"], json!(false)); + assert!(jws["properties"].get("header").is_none()); + + let problem = problem_schema(); + assert_eq!(problem["additionalProperties"], json!(false)); + assert_eq!( + problem["properties"].as_object().map(|value| value.len()), + Some(5) + ); + } + + #[test] + fn schemas_accept_the_exact_public_wire_shapes() { + let cases = [ + ( + request_schema(), + json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-v1", "values": {"opaque": "value"}} + }] + }), + ), + ( + evidence_schema(), + json!({ + "schema": "registry.assertion-evidence/v1", + "id": "urn:ulid:01K1EXAMPLE0000000000000000", + "type": "Evidence", + "supportsRequirement": "urn:example:requirement:v1", + "isConformantTo": "urn:example:evidence-type:v1", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "casework", + "audience": "urn:example:audience", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "subjects": [{ + "role": "subject", + "binding": format!("urn:evidence:subject:v1_{}", "a".repeat(43)) + }], + "supportedValues": [{ + "providesValueFor": "urn:example:concept", + "value": true + }] + }), + ), + ( + jws_schema(), + json!({ + "protected": "YWxn", + "payload": "ZXZpZGVuY2U", + "signature": "a".repeat(86) + }), + ), + ( + problem_schema(), + json!({ + "type": "https://registrystack.org/problems/evidence/evidence_not_available", + "title": "Evidence could not be produced", + "status": 422, + "code": "evidence_not_available", + "operation": "01ARZ3NDEKTSV4RRFFQ69G5FAV" + }), + ), + ( + jwks_schema(), + json!({"keys": [{ + "kty": "OKP", "kid": "evidence-key-1", "alg": "EdDSA", + "crv": "Ed25519", "x": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }]}), + ), + ]; + + for (schema, instance) in cases { + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("generated schema compiles"); + assert!(compiled.is_valid(&instance), "instance: {instance}"); + } + } + + #[test] + fn problem_schema_rejects_mismatched_code_status_and_title() { + let schema = problem_schema(); + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .expect("problem schema compiles"); + let mismatched = json!({ + "type": "https://registrystack.org/problems/evidence/dependency_unavailable", + "title": "Request is not valid", + "status": 400, + "code": "dependency_unavailable", + "operation": "01ARZ3NDEKTSV4RRFFQ69G5FAV" + }); + assert!(!compiled.is_valid(&mismatched)); + } +} diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs new file mode 100644 index 000000000..d5712c7d9 --- /dev/null +++ b/crates/registry-evidence/src/kernel.rs @@ -0,0 +1,2171 @@ +//! Generic offline Evidence evaluation and core-owned output projection. +//! +//! This module deliberately knows nothing about an acceptance case or source +//! product. It joins one captured bundle revision to the hardened Rhai runtime, +//! validates the complete declared Supported Value set, and constructs the +//! unsigned Evidence payload that the production release path later signs. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use chrono::{DateTime, Duration, SecondsFormat, Utc}; +use chrono_tz::Tz; +use jsonschema::{Draft, JSONSchema}; +use serde_json::{Map as JsonMap, Value}; +use thiserror::Error; + +use crate::binding::entity_reference; +use crate::bundle::{Bundle, Codelist}; +use crate::config::{ + ConceptConfig, ConceptForm, PreparationChannelPolicy, PreparationLimits, RequirementConfig, +}; +use crate::model::{ + BucketForm, BucketValue, EntityReferenceForm, EntityReferenceValue, Evidence, + EvidenceObjectType, LookupResult, PublicValue, ScalarOrEntityReference, StructuredValue, + StructuredValueForm, SubjectBinding, SupportedValue, +}; +use crate::rhai_runtime::{ + CalendarDate, CodelistHandle, CompiledDerivation, CompiledExtraction, CompiledPreparation, + DerivedConceptValue, DerivedValue, EvaluationContext, LegalLocalTime, RequestPartRequirement, + RequestParts, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, RhaiRuntimeError, + UtcInstant, MAXIMUM_RESULT_BYTES, +}; +use crate::values::Decimal; + +const MAXIMUM_PUBLIC_STRING_BYTES: usize = 1_024; +const MAXIMUM_BUCKET_CODE_BYTES: usize = 128; +const MAXIMUM_EVIDENCE_IDENTIFIER_BYTES: usize = 512; +const DEFAULT_MAXIMUM_QUERY_PAIRS: usize = 64; +const DEFAULT_MAXIMUM_QUERY_NAME_BYTES: usize = 64; +const DEFAULT_MAXIMUM_QUERY_VALUE_BYTES: usize = 4_096; +const DEFAULT_MAXIMUM_JSON_DEPTH: usize = 32; +const DEFAULT_MAXIMUM_COLLECTION_ITEMS: usize = 256; +const DEFAULT_MAXIMUM_STRING_BYTES: usize = 16_384; +const DEFAULT_MAXIMUM_NORMALIZED_BYTES: usize = 65_536; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum KernelError { + #[error("the Evidence bundle cannot initialize the offline kernel")] + Bundle, + #[error("the requested Evidence requirement is unavailable")] + Requirement, + #[error("the Evidence extraction failed")] + Extraction, + #[error("the Evidence request preparation failed")] + Preparation, + #[error("the source response violates its fixed protocol contract")] + SourceProtocol, + #[error("the Evidence script failed")] + Script, + #[error("the derived Evidence values violate the requirement contract")] + Output, + #[error("the Evidence payload metadata is invalid")] + Evidence, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum KernelOutcome { + Match(ValidatedValues), + NoMatch, + Ambiguous, +} + +/// Values that have passed the complete requirement output gate. +/// +/// The inner vector is intentionally not publicly constructible or mutable, so +/// Evidence construction cannot be invoked with an unchecked `PublicValue`. +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedValues(Vec); + +impl ValidatedValues { + pub fn as_slice(&self) -> &[SupportedValue] { + &self.0 + } +} + +impl std::fmt::Debug for ValidatedValues { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ValidatedValues") + .field( + "concept_identifiers", + &self + .0 + .iter() + .map(|value| value.provides_value_for.as_str()) + .collect::>(), + ) + .finish_non_exhaustive() + } +} + +/// Runtime-owned inputs needed to project protected derivation values. +pub struct ValueProjection<'a> { + pub audience: &'a str, + pub binding_key: &'a [u8], + pub binding_key_version: u32, +} + +/// Core-owned envelope inputs supplied by the authenticated release pipeline. +pub struct EvidenceConstruction<'a> { + pub evidence_id: &'a str, + pub purpose: &'a str, + pub audience: &'a str, + pub issued_at: DateTime, + pub observed_at: DateTime, + pub subjects: Vec, +} + +fn compile_request_parts_limits( + configured: &PreparationLimits, +) -> Result { + fn requirement(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn bounded(value: Option, default: usize) -> Result { + value + .map(usize::try_from) + .transpose() + .map_err(|_| KernelError::Bundle) + .map(|value| value.unwrap_or(default)) + } + + RequestPartsLimits::new( + requirement(configured.query), + requirement(configured.json_body), + RequestPartsBounds { + maximum_query_pairs: bounded( + configured.maximum_query_pairs, + DEFAULT_MAXIMUM_QUERY_PAIRS, + )?, + maximum_query_name_bytes: bounded( + configured.maximum_query_name_bytes, + DEFAULT_MAXIMUM_QUERY_NAME_BYTES, + )?, + maximum_query_value_bytes: bounded( + configured.maximum_query_value_bytes, + DEFAULT_MAXIMUM_QUERY_VALUE_BYTES, + )?, + maximum_json_depth: bounded(configured.maximum_json_depth, DEFAULT_MAXIMUM_JSON_DEPTH)?, + maximum_collection_items: bounded( + configured.maximum_collection_items, + DEFAULT_MAXIMUM_COLLECTION_ITEMS, + )?, + maximum_string_bytes: bounded( + configured.maximum_string_bytes, + DEFAULT_MAXIMUM_STRING_BYTES, + )?, + maximum_normalized_bytes: bounded( + configured.maximum_normalized_bytes, + DEFAULT_MAXIMUM_NORMALIZED_BYTES, + )?, + }, + ) + .map_err(|_| KernelError::Bundle) +} + +/// A kernel compiled entirely from the bytes captured in one immutable bundle. +pub struct OfflineKernel { + bundle: Arc, + runtime: RhaiRuntime, + preparations: BTreeMap, + extractions: BTreeMap, + request_parts_limits: BTreeMap, + derivations: BTreeMap, + fact_schemas: BTreeMap, + reviewed_schemas: BTreeMap, + codelist_handles: BTreeMap>, +} + +impl std::fmt::Debug for OfflineKernel { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OfflineKernel") + .field("configuration_revision", &self.bundle.revision()) + .field("source_count", &self.extractions.len()) + .field("requirement_count", &self.derivations.len()) + .finish() + } +} + +impl OfflineKernel { + pub fn compile(bundle: Arc) -> Result { + let runtime = RhaiRuntime::new(); + let mut preparations = BTreeMap::new(); + let mut extractions = BTreeMap::new(); + let mut request_parts_limits = BTreeMap::new(); + let mut fact_schemas = BTreeMap::new(); + for (source_id, source) in bundle.config.sources.iter() { + let preparation = bundle + .script(&source.request.prepare_script) + .ok_or(KernelError::Bundle)?; + let compiled_preparation = runtime + .compile_preparation(&preparation.source) + .map_err(|_| KernelError::Bundle)?; + preparations.insert(source_id.to_owned(), compiled_preparation); + + let extraction = bundle + .script(&source.extract_script) + .ok_or(KernelError::Bundle)?; + let compiled_extraction = runtime + .compile_extraction(&extraction.source) + .map_err(|_| KernelError::Bundle)?; + extractions.insert(source_id.to_owned(), compiled_extraction); + request_parts_limits.insert( + source_id.to_owned(), + compile_request_parts_limits(&source.request.preparation_limits)?, + ); + + let schema = bundle + .fact_schema(&source.fact_schema) + .ok_or(KernelError::Bundle)?; + let compiled_schema = compile_schema(schema)?; + fact_schemas.insert(source_id.to_owned(), compiled_schema); + } + + let mut derivations = BTreeMap::new(); + for requirement in &bundle.config.requirements { + let script = bundle + .script(&requirement.derivation.script) + .ok_or(KernelError::Bundle)?; + let compiled = runtime + .compile_derivation(&script.source) + .map_err(|_| KernelError::Bundle)?; + derivations.insert(requirement.id.clone(), compiled); + } + + let mut reviewed_schemas = BTreeMap::new(); + for schema in bundle.fact_schemas.values() { + if let Some(identifier) = schema.get("$id").and_then(Value::as_str) { + if reviewed_schemas + .insert(identifier.to_owned(), compile_schema(schema)?) + .is_some() + { + return Err(KernelError::Bundle); + } + } + } + + let codelist_handles = bundle + .config + .requirements + .iter() + .map(|requirement| { + build_codelist_handles(&bundle, requirement) + .map(|handles| (requirement.id.clone(), handles)) + }) + .collect::>()?; + Ok(Self { + bundle, + runtime, + preparations, + extractions, + request_parts_limits, + derivations, + fact_schemas, + reviewed_schemas, + codelist_handles, + }) + } + + /// Run the reviewed preparation script after authorization and access audit. + pub fn prepare( + &self, + requirement_id: &str, + selectors: &Value, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let source = self + .bundle + .config + .sources + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let script = self + .preparations + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let limits = self + .request_parts_limits + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| KernelError::Bundle)?; + self.runtime + .prepare(script, selectors, ¶meters, limits) + .map_err(|_| KernelError::Preparation) + } + + pub fn bundle(&self) -> &Bundle { + &self.bundle + } + + pub fn requirement(&self, requirement_id: &str) -> Option<&RequirementConfig> { + self.bundle + .config + .requirements + .iter() + .find(|requirement| requirement.id == requirement_id) + } + + /// Run only the closed extraction ABI over one already-bounded JSON response. + pub fn extract( + &self, + requirement_id: &str, + source_response: &Value, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let script = self + .extractions + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let schema = self + .fact_schemas + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let source = self + .bundle + .config + .sources + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| KernelError::Bundle)?; + self.runtime + .extract(script, source_response, ¶meters, schema) + .map_err(|error| match error { + RhaiRuntimeError::ExtractionResult | RhaiRuntimeError::FactSchema => { + KernelError::Extraction + } + RhaiRuntimeError::Unavailable => KernelError::Extraction, + RhaiRuntimeError::SourceProtocol => KernelError::SourceProtocol, + RhaiRuntimeError::Compilation + | RhaiRuntimeError::EntryPoint + | RhaiRuntimeError::Invocation + | RhaiRuntimeError::InputBound + | RhaiRuntimeError::AdapterInput + | RhaiRuntimeError::PreparationResult + | RhaiRuntimeError::DerivationResult + | RhaiRuntimeError::DerivationInput + | RhaiRuntimeError::EvaluationContext + | RhaiRuntimeError::Codelist => KernelError::Script, + }) + } + + /// Derive and gate the exact Supported Value set for one unique match. + pub fn derive_and_validate( + &self, + requirement_id: &str, + facts: &BTreeMap, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + self.derive_and_validate_with_selectors( + requirement_id, + facts, + &Value::Object(JsonMap::new()), + observed_at, + projection, + ) + } + + /// Derive with the exact requirement-declared selector subset. + pub fn derive_and_validate_with_selectors( + &self, + requirement_id: &str, + facts: &BTreeMap, + selectors: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + let script = self + .derivations + .get(requirement_id) + .ok_or(KernelError::Bundle)?; + let evaluation_context = self.evaluation_context(requirement, observed_at)?; + let derived = self + .runtime + .derive(script, facts, selectors, evaluation_context) + .map_err(|error| match error { + RhaiRuntimeError::Unavailable => KernelError::Extraction, + RhaiRuntimeError::SourceProtocol => KernelError::Script, + _ => KernelError::Script, + })?; + self.validate_values(requirement_id, derived, projection) + } + + /// Run the complete offline lookup and derivation path. + pub fn evaluate( + &self, + requirement_id: &str, + source_response: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + self.evaluate_with_selectors( + requirement_id, + source_response, + &Value::Object(JsonMap::new()), + observed_at, + projection, + ) + } + + /// Run lookup and derivation with the exact selector subset declared for derivation. + pub fn evaluate_with_selectors( + &self, + requirement_id: &str, + source_response: &Value, + selectors: &Value, + observed_at: DateTime, + projection: ValueProjection<'_>, + ) -> Result { + match self.extract(requirement_id, source_response)? { + LookupResult::Match(facts) => self + .derive_and_validate_with_selectors( + requirement_id, + &facts, + selectors, + observed_at, + projection, + ) + .map(KernelOutcome::Match), + LookupResult::NoMatch => Ok(KernelOutcome::NoMatch), + LookupResult::Ambiguous => Ok(KernelOutcome::Ambiguous), + } + } + + /// Apply the output gate to values produced by the hardened Rhai ABI. + pub fn validate_values( + &self, + requirement_id: &str, + derived: Vec, + projection: ValueProjection<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + gate_values( + requirement, + derived, + projection, + &self.bundle.codelists, + &self.reviewed_schemas, + ) + } + + /// Construct the exact unsigned payload after output validation. + pub fn construct_evidence( + &self, + requirement_id: &str, + values: ValidatedValues, + input: EvidenceConstruction<'_>, + ) -> Result { + let requirement = self + .requirement(requirement_id) + .ok_or(KernelError::Requirement)?; + validate_evidence_inputs(requirement, values.as_slice(), &input)?; + let valid_until = input + .issued_at + .checked_add_signed(Duration::seconds( + i64::try_from(requirement.validity_seconds).map_err(|_| KernelError::Evidence)?, + )) + .ok_or(KernelError::Evidence) + .map(format_utc)?; + + Ok(Evidence { + schema: crate::EVIDENCE_SCHEMA_V1.to_owned(), + id: input.evidence_id.to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: requirement.id.clone(), + is_conformant_to: requirement.evidence_type.clone(), + issued_by: self.bundle.config.issuer.id.clone(), + provided_by: self.bundle.config.service.provider_id.clone(), + issued_at: format_utc(input.issued_at), + observed_at: format_utc(input.observed_at), + valid_until, + purpose: input.purpose.to_owned(), + audience: input.audience.to_owned(), + configuration_revision: self.bundle.revision().to_owned(), + subjects: input.subjects, + supported_values: values.0, + }) + } + + fn evaluation_context( + &self, + requirement: &RequirementConfig, + observed_at: DateTime, + ) -> Result { + let timezone = requirement + .observation_timezone + .as_deref() + .map(Tz::from_str) + .transpose() + .map_err(|_| KernelError::Bundle)? + .unwrap_or(Tz::UTC); + let local = observed_at.with_timezone(&timezone); + let parameters = serde_json::to_value(&requirement.derivation.parameters) + .map_err(|_| KernelError::Bundle)?; + EvaluationContext::new( + UtcInstant::parse(&format_utc(observed_at)).map_err(map_context_error)?, + CalendarDate::parse(&local.format("%Y-%m-%d").to_string()) + .map_err(map_context_error)?, + LegalLocalTime::parse(&local.format("%H:%M:%S%:z").to_string()) + .map_err(map_context_error)?, + ¶meters, + self.codelist_handles + .get(&requirement.id) + .cloned() + .ok_or(KernelError::Bundle)?, + ) + .map_err(map_context_error) + } +} + +fn map_context_error(_: RhaiRuntimeError) -> KernelError { + KernelError::Bundle +} + +fn compile_schema(schema: &Value) -> Result { + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .map_err(|_| KernelError::Bundle) +} + +fn build_codelist_handles( + bundle: &Bundle, + requirement: &RequirementConfig, +) -> Result, KernelError> { + let mut paths = BTreeSet::new(); + for concept in &requirement.concepts { + if let Some(path) = concept + .constraints + .get("codelist") + .and_then(serde_norway::Value::as_str) + { + paths.insert(path); + } + if matches!( + concept.form, + ConceptForm::DateBucket | ConceptForm::TimeBucket + ) { + let scheme = constraint_str(concept, "bucketScheme")?; + let version = constraint_str(concept, "schemeVersion")?; + let mut matches = bundle + .codelists + .iter() + .filter(|(_, codelist)| codelist.id() == scheme && codelist.version() == version); + let (path, _) = matches.next().ok_or(KernelError::Bundle)?; + if matches.next().is_some() { + return Err(KernelError::Bundle); + } + paths.insert(path); + } + } + + let mut handles = BTreeMap::new(); + for path in paths { + let codelist = bundle.codelists.get(path).ok_or(KernelError::Bundle)?; + let name = Path::new(path) + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or(KernelError::Bundle)?; + let entries = match codelist { + Codelist::Codes { codes, .. } => codes + .iter() + .map(|code| (code.clone(), code.clone())) + .collect(), + Codelist::Mapping { entries, .. } => entries.clone(), + }; + let handle = CodelistHandle::new(entries).map_err(|_| KernelError::Bundle)?; + if handles.insert(name.to_owned(), handle).is_some() { + return Err(KernelError::Bundle); + } + } + Ok(handles) +} + +fn gate_values( + requirement: &RequirementConfig, + derived: Vec, + projection: ValueProjection<'_>, + codelists: &BTreeMap, + reviewed_schemas: &BTreeMap, +) -> Result { + if derived.is_empty() || derived.len() > 16 { + return Err(KernelError::Output); + } + let derived_count = derived.len(); + let by_identifier = derived + .into_iter() + .map(|entry| (entry.concept_id, entry.value)) + .collect::>(); + if by_identifier.len() != derived_count + || by_identifier.len() > requirement.concepts.len() + || requirement + .concepts + .iter() + .any(|concept| concept.required && !by_identifier.contains_key(&concept.id)) + || by_identifier.keys().any(|identifier| { + !requirement + .concepts + .iter() + .any(|concept| concept.id == *identifier) + }) + { + return Err(KernelError::Output); + } + + let mut result = Vec::with_capacity(by_identifier.len()); + let mut total_bytes = 0usize; + for concept in &requirement.concepts { + let Some(value) = by_identifier.get(&concept.id) else { + continue; + }; + let public = validate_value(concept, value, &projection, codelists, reviewed_schemas)?; + total_bytes = total_bytes + .checked_add( + serde_json::to_vec(&public) + .map_err(|_| KernelError::Output)? + .len(), + ) + .ok_or(KernelError::Output)?; + if total_bytes > MAXIMUM_RESULT_BYTES { + return Err(KernelError::Output); + } + result.push(SupportedValue { + provides_value_for: concept.id.clone(), + value: public, + }); + } + Ok(ValidatedValues(result)) +} + +fn validate_value( + concept: &ConceptConfig, + value: &DerivedValue, + projection: &ValueProjection<'_>, + codelists: &BTreeMap, + reviewed_schemas: &BTreeMap, +) -> Result { + match concept.form { + ConceptForm::Boolean => match value { + DerivedValue::Json(Value::Bool(value)) => Ok(PublicValue::Boolean(*value)), + _ => Err(KernelError::Output), + }, + ConceptForm::ControlledCode | ConceptForm::ControlledCategory => { + let text = derived_string(value)?; + let maximum = constraint_usize(concept, "maximumBytes")?; + validate_public_string(text, maximum)?; + let codelist = declared_codelist(concept, codelists)?; + if concept.form == ConceptForm::ControlledCategory + && codelist.id() != constraint_str(concept, "categoryScheme")? + { + return Err(KernelError::Bundle); + } + if !codelist.contains_output(text) { + return Err(KernelError::Output); + } + Ok(PublicValue::String(text.to_owned())) + } + ConceptForm::BoundedInteger => { + let integer = match value { + DerivedValue::Json(Value::Number(number)) => { + number.as_i64().ok_or(KernelError::Output)? + } + _ => return Err(KernelError::Output), + }; + let minimum = constraint_i64(concept, "minimum")?; + let maximum = constraint_i64(concept, "maximum")?; + if !(minimum..=maximum).contains(&integer) { + return Err(KernelError::Output); + } + Ok(PublicValue::Integer(integer)) + } + ConceptForm::BoundedDecimal => { + let decimal = match value { + DerivedValue::Decimal(decimal) => decimal, + _ => return Err(KernelError::Output), + }; + let minimum = Decimal::parse(constraint_str(concept, "minimum")?) + .map_err(|_| KernelError::Bundle)?; + let maximum = Decimal::parse(constraint_str(concept, "maximum")?) + .map_err(|_| KernelError::Bundle)?; + let maximum_scale = constraint_u64(concept, "maximumScale")?; + if u64::from(decimal.scale()) > maximum_scale + || decimal.compare(&minimum).is_lt() + || decimal.compare(&maximum).is_gt() + { + return Err(KernelError::Output); + } + Ok(PublicValue::String(decimal.canonical().to_owned())) + } + ConceptForm::DateBucket | ConceptForm::TimeBucket => { + validate_bucket(concept, value, codelists) + } + ConceptForm::AudienceScopedEntityReference => { + let seed = match value { + DerivedValue::EntityReferenceSeed(seed) => seed, + _ => return Err(KernelError::Output), + }; + let reference = project_entity(concept, seed, projection)?; + Ok(PublicValue::EntityReference(EntityReferenceValue { + form: EntityReferenceForm::AudienceScopedEntityReference, + reference, + })) + } + ConceptForm::ControlledCodeList => { + let values = match value { + DerivedValue::Json(Value::Array(values)) => values, + _ => return Err(KernelError::Output), + }; + validate_cardinality(concept, values.len())?; + let codelist = declared_codelist(concept, codelists)?; + let mut unique = BTreeSet::new(); + let mut public = Vec::with_capacity(values.len()); + for item in values { + let text = item.as_str().ok_or(KernelError::Output)?; + validate_public_string(text, MAXIMUM_PUBLIC_STRING_BYTES)?; + if !codelist.contains_output(text) || !unique.insert(text) { + return Err(KernelError::Output); + } + public.push(ScalarOrEntityReference::String(text.to_owned())); + } + Ok(PublicValue::List(public)) + } + ConceptForm::EntityReferenceList => { + let seeds = match value { + DerivedValue::EntityReferenceSeedList(seeds) => seeds, + _ => return Err(KernelError::Output), + }; + validate_cardinality(concept, seeds.len())?; + let mut unique = BTreeSet::new(); + let mut public = Vec::with_capacity(seeds.len()); + for seed in seeds { + let reference = project_entity(concept, seed, projection)?; + if !unique.insert(reference.clone()) { + return Err(KernelError::Output); + } + public.push(ScalarOrEntityReference::EntityReference( + EntityReferenceValue { + form: EntityReferenceForm::AudienceScopedEntityReference, + reference, + }, + )); + } + Ok(PublicValue::List(public)) + } + ConceptForm::ReviewedStructuredValue => { + validate_structured(concept, value, reviewed_schemas) + } + } +} + +fn validate_bucket( + concept: &ConceptConfig, + value: &DerivedValue, + codelists: &BTreeMap, +) -> Result { + let object = match value { + DerivedValue::Json(Value::Object(object)) => object, + _ => return Err(KernelError::Output), + }; + if !has_exact_json_keys(object, &["form", "scheme", "bucket"]) { + return Err(KernelError::Output); + } + let expected_form = match concept.form { + ConceptForm::DateBucket => "date-bucket", + ConceptForm::TimeBucket => "time-bucket", + _ => return Err(KernelError::Bundle), + }; + let form = object["form"].as_str().ok_or(KernelError::Output)?; + let scheme = object["scheme"].as_str().ok_or(KernelError::Output)?; + let bucket = object["bucket"].as_str().ok_or(KernelError::Output)?; + if form != expected_form + || scheme != constraint_str(concept, "bucketScheme")? + || !valid_code(bucket) + || bucket.len() > MAXIMUM_BUCKET_CODE_BYTES + { + return Err(KernelError::Output); + } + let scheme_version = constraint_str(concept, "schemeVersion")?; + let codelist = codelists + .values() + .filter(|candidate| candidate.id() == scheme && candidate.version() == scheme_version) + .exactly_one() + .ok_or(KernelError::Bundle)?; + if !codelist.contains_output(bucket) { + return Err(KernelError::Output); + } + Ok(PublicValue::Bucket(BucketValue { + form: if concept.form == ConceptForm::DateBucket { + BucketForm::DateBucket + } else { + BucketForm::TimeBucket + }, + scheme: scheme.to_owned(), + bucket: bucket.to_owned(), + })) +} + +fn validate_structured( + concept: &ConceptConfig, + value: &DerivedValue, + schemas: &BTreeMap, +) -> Result { + let object = match value { + DerivedValue::Json(Value::Object(object)) => object, + _ => return Err(KernelError::Output), + }; + if !has_exact_json_keys(object, &["form", "schema", "fields"]) + || object["form"].as_str() != Some("reviewed-structured-value") + { + return Err(KernelError::Output); + } + let schema_id = object["schema"].as_str().ok_or(KernelError::Output)?; + if schema_id != constraint_str(concept, "schema")? { + return Err(KernelError::Output); + } + let fields = object["fields"] + .as_object() + .filter(|fields| !fields.is_empty() && fields.len() <= 16) + .ok_or(KernelError::Output)?; + let maximum = constraint_usize(concept, "maximumSerializedBytes")?; + if serde_json::to_vec(value_as_json(value)?) + .map_err(|_| KernelError::Output)? + .len() + > maximum + { + return Err(KernelError::Output); + } + let schema = schemas.get(schema_id).ok_or(KernelError::Bundle)?; + let fields_value = Value::Object(fields.clone()); + if !schema.is_valid(&fields_value) { + return Err(KernelError::Output); + } + Ok(PublicValue::Structured(StructuredValue { + form: StructuredValueForm::ReviewedStructuredValue, + schema: schema_id.to_owned(), + fields: fields.clone().into_iter().collect(), + })) +} + +fn project_entity( + concept: &ConceptConfig, + seed: &crate::values::EntityReferenceSeed, + projection: &ValueProjection<'_>, +) -> Result { + let reference = entity_reference( + projection.binding_key, + projection.binding_key_version, + &concept.id, + projection.audience, + seed.expose_for_projection(), + ) + .map_err(|_| KernelError::Output)?; + let maximum = if concept.constraints.contains_key("maximumBytes") { + constraint_usize(concept, "maximumBytes")? + } else { + MAXIMUM_PUBLIC_STRING_BYTES + }; + if reference.len() > maximum { + return Err(KernelError::Output); + } + Ok(reference) +} + +fn validate_evidence_inputs( + requirement: &RequirementConfig, + values: &[SupportedValue], + input: &EvidenceConstruction<'_>, +) -> Result<(), KernelError> { + if input.evidence_id.is_empty() + || input.evidence_id.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES + || url::Url::parse(input.evidence_id).is_err() + || input.audience.is_empty() + || input.audience.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES + || url::Url::parse(input.audience).is_err() + || !requirement + .purposes + .iter() + .any(|purpose| purpose == input.purpose) + || input.issued_at < input.observed_at + || input.subjects.len() != requirement.subject_roles.len() + || values.is_empty() + { + return Err(KernelError::Evidence); + } + let mut roles = BTreeSet::new(); + for (configured, subject) in requirement.subject_roles.iter().zip(&input.subjects) { + if configured.role != subject.role + || !roles.insert(subject.role.as_str()) + || !valid_opaque_binding(&subject.binding) + { + return Err(KernelError::Evidence); + } + } + let expected = requirement + .concepts + .iter() + .filter(|concept| concept.required) + .map(|concept| concept.id.as_str()) + .collect::>(); + let actual = values + .iter() + .map(|value| value.provides_value_for.as_str()) + .collect::>(); + if actual.len() != values.len() + || !expected.is_subset(&actual) + || actual.iter().any(|identifier| { + !requirement + .concepts + .iter() + .any(|concept| concept.id == *identifier) + }) + { + return Err(KernelError::Evidence); + } + Ok(()) +} + +fn format_utc(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn valid_opaque_binding(value: &str) -> bool { + let Some(rest) = value.strip_prefix("urn:evidence:subject:v") else { + return false; + }; + let Some((version, encoded)) = rest.split_once('_') else { + return false; + }; + !version.is_empty() + && !version.starts_with('0') + && version.bytes().all(|byte| byte.is_ascii_digit()) + && encoded.len() == 43 + && encoded + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn derived_string(value: &DerivedValue) -> Result<&str, KernelError> { + match value { + DerivedValue::Json(Value::String(value)) => Ok(value), + _ => Err(KernelError::Output), + } +} + +fn value_as_json(value: &DerivedValue) -> Result<&Value, KernelError> { + match value { + DerivedValue::Json(value) => Ok(value), + _ => Err(KernelError::Output), + } +} + +fn validate_public_string(value: &str, declared_maximum: usize) -> Result<(), KernelError> { + if value.is_empty() + || value.len() > declared_maximum + || value.len() > MAXIMUM_PUBLIC_STRING_BYTES + { + return Err(KernelError::Output); + } + Ok(()) +} + +fn validate_cardinality(concept: &ConceptConfig, length: usize) -> Result<(), KernelError> { + let minimum = constraint_usize(concept, "minimumItems")?; + let maximum = constraint_usize(concept, "maximumItems")?; + if length < minimum || length > maximum || !constraint_bool(concept, "unique")? { + return Err(KernelError::Output); + } + Ok(()) +} + +fn declared_codelist<'a>( + concept: &ConceptConfig, + codelists: &'a BTreeMap, +) -> Result<&'a Codelist, KernelError> { + let path = constraint_str(concept, "codelist")?; + let expected_version_key = if concept.form == ConceptForm::ControlledCategory { + "schemeVersion" + } else { + "codelistVersion" + }; + let expected_version = constraint_str(concept, expected_version_key)?; + let codelist = codelists.get(path).ok_or(KernelError::Bundle)?; + if codelist.version() != expected_version { + return Err(KernelError::Bundle); + } + Ok(codelist) +} + +fn constraint_str<'a>(concept: &'a ConceptConfig, name: &str) -> Result<&'a str, KernelError> { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_str) + .ok_or(KernelError::Bundle) +} + +fn constraint_i64(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_i64) + .ok_or(KernelError::Bundle) +} + +fn constraint_u64(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_u64) + .ok_or(KernelError::Bundle) +} + +fn constraint_usize(concept: &ConceptConfig, name: &str) -> Result { + usize::try_from(constraint_u64(concept, name)?).map_err(|_| KernelError::Bundle) +} + +fn constraint_bool(concept: &ConceptConfig, name: &str) -> Result { + concept + .constraints + .get(name) + .and_then(serde_norway::Value::as_bool) + .ok_or(KernelError::Bundle) +} + +fn has_exact_json_keys(object: &JsonMap, keys: &[&str]) -> bool { + object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key)) +} + +fn valid_code(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +trait ExactlyOne: Iterator + Sized { + fn exactly_one(mut self) -> Option { + let item = self.next()?; + self.next().is_none().then_some(item) + } +} + +impl ExactlyOne for I {} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::Duration as StdDuration; + + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use serde_json::json; + use tempfile::TempDir; + + use crate::signing::{jwks_document, EvidenceSigner}; + use crate::source::project_fixture_response; + use crate::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; + + const KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; + const AUDIENCE: &str = "urn:example:fixture:audience"; + const SUPPORTED_VALUE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"supported-values-fixture-key"}"#; + + fn projection() -> ValueProjection<'static> { + ValueProjection { + audience: AUDIENCE, + binding_key: KEY, + binding_key_version: 1, + } + } + + #[test] + fn all_four_acceptance_bundles_use_the_same_kernel() { + let cases = [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ]; + for case in cases { + let copied = immutable_fixture(case); + let bundle = Arc::new(Bundle::load(copied.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let fixture: Value = serde_norway::from_slice( + bundle + .artifact(requirement.fixtures.as_str()) + .expect("fixture is captured"), + ) + .expect("fixture parses"); + let source_config = bundle + .config + .sources + .get(&requirement.source) + .expect("requirement source exists"); + for test_case in fixture["cases"].as_array().expect("cases is an array") { + if test_case.get("source_failure").is_some() + || test_case.get("injected_derivation").is_some() + || test_case.get("companion_bundle").is_some() + || test_case.get("subjects").is_some() + { + continue; + } + let Some(source) = test_case.get("source") else { + continue; + }; + let source = project_fixture_response(source_config, source) + .map_err(|_| KernelError::SourceProtocol); + let observed = observed_for_case(&fixture, test_case); + let selectors = test_case + .get("derivationSelectorInputs") + .or_else(|| { + fixture + .get("common") + .and_then(|common| common.get("derivationSelectorInputs")) + }) + .cloned() + .unwrap_or_else(|| Value::Object(JsonMap::new())); + let result = source.and_then(|source| { + kernel.evaluate_with_selectors( + &requirement.id, + &source, + &selectors, + observed, + projection(), + ) + }); + match test_case.get("expected_lookup").and_then(Value::as_str) { + Some("no_match") => assert!(matches!(result, Ok(KernelOutcome::NoMatch))), + Some("ambiguous") => assert!(matches!(result, Ok(KernelOutcome::Ambiguous))), + _ if test_case.get("expected_public_problem").is_some() => { + assert!(result.is_err(), "{case}: {}", test_case["id"]) + } + _ => { + let KernelOutcome::Match(values) = result.unwrap_or_else(|error| { + panic!("{case}: {} failed: {error:?}", test_case["id"]) + }) else { + panic!("{case}: expected match"); + }; + assert!(!values.as_slice().is_empty()); + if let Some(expected) = test_case.get("expected_value") { + assert_eq!( + serde_json::to_value(&values.as_slice()[0].value) + .expect("value serializes"), + *expected + ); + } + } + } + } + } + } + + #[test] + fn extraction_failures_and_invalid_outputs_fail_closed() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 0})), + Ok(LookupResult::NoMatch) + ); + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 2})), + Ok(LookupResult::Ambiguous) + ); + assert!(kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!("true")), + }], + projection(), + ) + .is_err()); + let validated = kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ) + .expect("valid output"); + let debug = format!("{validated:?}"); + assert!(!debug.contains("true")); + assert!(kernel + .validate_values( + &requirement.id, + vec![ + DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(true)), + }, + DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(false)), + }, + ], + projection(), + ) + .is_err()); + assert!(kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:extra".to_owned(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ) + .is_err()); + } + + #[test] + fn required_unmapped_source_fact_is_unavailable_not_a_script_failure() { + let copied = immutable_fixture("residence-region"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let observed = "2026-08-02T00:00:00Z".parse().expect("time"); + let LookupResult::Match(facts) = kernel + .extract( + &requirement.id, + &json!({"total": 1, "official_residence_code": "R-999"}), + ) + .expect("source extracts") + else { + panic!("source must uniquely match"); + }; + let raw = kernel.runtime.derive( + kernel + .derivations + .get(&requirement.id) + .expect("derivation exists"), + &facts, + &Value::Object(JsonMap::new()), + kernel + .evaluation_context(requirement, observed) + .expect("context builds"), + ); + assert!( + matches!(raw, Err(RhaiRuntimeError::Unavailable)), + "unexpected closed error class: {:?}", + raw.err() + ); + assert_eq!( + kernel.evaluate( + &requirement.id, + &json!({"total": 1, "official_residence_code": "R-999"}), + observed, + projection(), + ), + Err(KernelError::Extraction) + ); + } + + #[test] + fn scalar_decimal_and_collection_forms_are_exact() { + let code_list = Codelist::Codes { + id: "urn:example:codes".to_owned(), + version: "1".to_owned(), + codes: vec!["A".to_owned(), "B".to_owned()], + }; + let codelists = BTreeMap::from([("urn:example:codes".to_owned(), code_list)]); + let schemas = BTreeMap::new(); + + assert_eq!( + validate_value( + &concept("form: boolean\nrequired: true\nconstraints: {}"), + &DerivedValue::Json(json!(false)), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::Boolean(false)) + ); + assert_eq!( + validate_value( + &concept( + "form: bounded-integer\nrequired: true\nconstraints: {minimum: -2, maximum: 2}" + ), + &DerivedValue::Json(json!(2)), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::Integer(2)) + ); + assert_eq!( + validate_value( + &concept("form: controlled-code\nrequired: true\nconstraints: {codelist: 'urn:example:codes', codelistVersion: '1', maximumBytes: 8}"), + &DerivedValue::Json(json!("A")), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::String("A".to_owned())) + ); + assert_eq!( + validate_value( + &concept("form: bounded-decimal\nrequired: true\nconstraints: {minimum: '-1.5', maximum: '1.5', maximumScale: 2}"), + &DerivedValue::Decimal(Decimal::parse("0.25").expect("decimal")), + &projection(), + &codelists, + &schemas, + ), + Ok(PublicValue::String("0.25".to_owned())) + ); + assert!(validate_value( + &concept("form: bounded-decimal\nrequired: true\nconstraints: {minimum: '-1.5', maximum: '1.5', maximumScale: 2}"), + &DerivedValue::Json(json!(0.25)), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + + let controlled = concept( + "form: controlled-code-list\nrequired: true\nconstraints: {codelist: 'urn:example:codes', codelistVersion: '1', minimumItems: 1, maximumItems: 2, unique: true}", + ); + assert!(validate_value( + &controlled, + &DerivedValue::Json(json!(["A", "A"])), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + assert!(validate_value( + &controlled, + &DerivedValue::Json(json!(["UNKNOWN"])), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + + let category_list = Codelist::Codes { + id: "urn:example:category-scheme".to_owned(), + version: "7".to_owned(), + codes: vec!["category-a".to_owned()], + }; + let category_lists = + BTreeMap::from([("codelists/categories.yaml".to_owned(), category_list)]); + assert_eq!( + validate_value( + &concept("form: controlled-category\nrequired: true\nconstraints: {categoryScheme: 'urn:example:category-scheme', schemeVersion: '7', maximumBytes: 32, codelist: 'codelists/categories.yaml'}"), + &DerivedValue::Json(json!("category-a")), + &projection(), + &category_lists, + &schemas, + ), + Ok(PublicValue::String("category-a".to_owned())) + ); + } + + #[test] + fn bucket_entity_and_structured_forms_are_closed() { + let buckets = Codelist::Codes { + id: "urn:example:bucket-scheme".to_owned(), + version: "1".to_owned(), + codes: vec!["inside".to_owned()], + }; + let codelists = BTreeMap::from([("buckets".to_owned(), buckets)]); + let bucket = concept( + "form: time-bucket\nrequired: true\nconstraints: {bucketScheme: 'urn:example:bucket-scheme', schemeVersion: '1'}", + ); + let date_bucket = concept( + "form: date-bucket\nrequired: true\nconstraints: {bucketScheme: 'urn:example:bucket-scheme', schemeVersion: '1'}", + ); + assert!(matches!( + validate_value( + &date_bucket, + &DerivedValue::Json( + json!({"form":"date-bucket","scheme":"urn:example:bucket-scheme","bucket":"inside"}) + ), + &projection(), + &codelists, + &BTreeMap::new(), + ), + Ok(PublicValue::Bucket(BucketValue { + form: BucketForm::DateBucket, + .. + })) + )); + assert!(validate_value( + &bucket, + &DerivedValue::Json(json!({"form":"time-bucket","scheme":"urn:example:bucket-scheme","bucket":"unknown"})), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + + let entity = concept( + "form: audience-scoped-entity-reference\nrequired: true\nconstraints: {maximumBytes: 160}", + ); + let seed = crate::values::EntityReferenceSeed::new("protected-seed").expect("seed"); + let public = validate_value( + &entity, + &DerivedValue::EntityReferenceSeed(seed), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .expect("entity projects"); + assert!(matches!(public, PublicValue::EntityReference(_))); + assert!(validate_value( + &entity, + &DerivedValue::Json(json!("protected-seed")), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + + let entity_list = concept( + "form: entity-reference-list\nrequired: true\nconstraints: {minimumItems: 1, maximumItems: 2, unique: true}", + ); + let duplicate = crate::values::EntityReferenceSeed::new("same-seed").expect("seed"); + assert!(validate_value( + &entity_list, + &DerivedValue::EntityReferenceSeedList(vec![duplicate.clone(), duplicate]), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .is_err()); + let projected = validate_value( + &entity_list, + &DerivedValue::EntityReferenceSeedList(vec![ + crate::values::EntityReferenceSeed::new("seed-a").expect("seed"), + crate::values::EntityReferenceSeed::new("seed-b").expect("seed"), + ]), + &projection(), + &codelists, + &BTreeMap::new(), + ) + .expect("entity list projects"); + assert!(matches!(projected, PublicValue::List(_))); + + let schema_id = "urn:example:structured"; + let schema = compile_schema(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": schema_id, + "type": "object", + "additionalProperties": false, + "required": ["status", "effective_date", "observed_at"], + "properties": { + "status": {"type": "string", "enum": ["A"]}, + "effective_date": {"type": "string", "format": "date"}, + "observed_at": {"type": "string", "format": "date-time"} + } + })) + .expect("schema compiles"); + let schemas = BTreeMap::from([(schema_id.to_owned(), schema)]); + let structured = concept( + "form: reviewed-structured-value\nrequired: true\nconstraints: {schema: 'urn:example:structured', maximumSerializedBytes: 512}", + ); + assert!(validate_value( + &structured, + &DerivedValue::Json(json!({"form":"reviewed-structured-value","schema":schema_id,"fields":{"status":"A","effective_date":"2026-02-30","observed_at":"not-an-instant"}})), + &projection(), + &codelists, + &schemas, + ) + .is_err()); + assert!(validate_value( + &structured, + &DerivedValue::Json(json!({"form":"reviewed-structured-value","schema":schema_id,"fields":{"status":"A","effective_date":"2026-02-28","observed_at":"2026-02-28T12:00:00Z"}})), + &projection(), + &codelists, + &schemas, + ) + .is_ok()); + } + + #[test] + fn evidence_construction_is_deterministic_and_role_ordered() { + let copied = immutable_fixture("legal-parent-relationship"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let values = kernel + .validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(json!(false)), + }], + projection(), + ) + .expect("values validate"); + let construction = || EvidenceConstruction { + evidence_id: "urn:ulid:01K1EXAMPLE0000000000000000", + purpose: &requirement.purposes[0], + audience: AUDIENCE, + issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), + observed_at: "2026-08-02T00:00:00Z".parse().expect("time"), + subjects: vec![ + SubjectBinding { + role: "child".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }, + SubjectBinding { + role: "candidate-parent".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "B".repeat(43)), + }, + ], + }; + let first = kernel + .construct_evidence(&requirement.id, values.clone(), construction()) + .expect("constructs"); + let second = kernel + .construct_evidence(&requirement.id, values, construction()) + .expect("constructs"); + assert_eq!(first, second); + assert_eq!(first.supported_values[0].value, PublicValue::Boolean(false)); + assert_eq!(first.subjects[0].role, "child"); + assert_eq!(first.subjects[1].role, "candidate-parent"); + assert_eq!(first.valid_until, "2026-08-03T00:00:01Z"); + } + + #[tokio::test] + async fn supported_value_fixture_cases_use_the_real_gate_and_signed_round_trip() { + let fixture = supported_values_fixture(); + assert_eq!( + fixture["fixture"].as_str(), + Some("registry.evidence.supported-values/v1") + ); + assert_eq!(fixture["synthetic_only"].as_bool(), Some(true)); + + let copied = immutable_supported_values_bundle(); + let bundle = Arc::new(Bundle::load(copied.path()).expect("supported-value bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let forms = fixture["forms"].as_array().expect("forms are an array"); + assert_eq!(forms.len(), 11, "all Version 1 forms remain covered"); + assert_eq!(requirement.concepts.len(), forms.len()); + + let signer = supported_values_signer().await; + for form_fixture in forms { + let form = form_fixture["form"].as_str().expect("form name"); + let concept_id = format!("urn:example:fixture:concept:{form}"); + let concept = requirement + .concepts + .iter() + .find(|candidate| candidate.id == concept_id) + .unwrap_or_else(|| panic!("bundle declaration missing {form}")); + assert_fixture_declaration(form_fixture, concept); + + for category in ["positive", "boundary"] { + let cases = form_fixture[category] + .as_array() + .unwrap_or_else(|| panic!("{form} {category} cases")); + for (index, fixture_case) in cases.iter().enumerate() { + let derived = accepted_derived_value( + &kernel, + requirement, + form_fixture, + category, + index, + fixture_case, + ) + .unwrap_or_else(|error| { + panic!("{form} {category}[{index}] derivation failed: {error:?}") + }); + let values = gate_fixture_value(&kernel, requirement, form, derived) + .unwrap_or_else(|error| { + panic!("{form} {category}[{index}] gate failed: {error:?}") + }); + let public = values + .as_slice() + .iter() + .find(|value| value.provides_value_for == concept_id) + .unwrap_or_else(|| panic!("{form} value was not emitted")); + assert_fixture_public_shape(form, fixture_case, &public.value); + assert_signed_type_preserving_round_trip(&kernel, requirement, values, &signer) + .await; + } + } + + let negatives = form_fixture["negative"] + .as_array() + .unwrap_or_else(|| panic!("{form} negatives")); + for (index, fixture_case) in negatives.iter().enumerate() { + let derivation = + fixture_derived_value(&kernel, requirement, &concept_id, fixture_case); + if fixture_case.get("leak_surface").is_some() { + let derived = derivation.unwrap_or_else(|error| { + panic!("{form} privacy negative[{index}] must derive: {error:?}") + }); + let values = gate_fixture_value(&kernel, requirement, form, derived) + .expect("protected seed is projected by the output gate"); + let serialized = serde_json::to_string(values.as_slice()).expect("serializes"); + let debug = format!("{values:?}"); + assert!(!serialized.contains("source-seed-canary")); + assert!(!debug.contains("source-seed-canary")); + } else { + let rejected = match derivation { + Err(_) => true, + Ok(derived) => { + gate_fixture_value(&kernel, requirement, form, derived).is_err() + } + }; + assert!(rejected, "{form} negative[{index}] must fail closed"); + } + } + } + } + + #[test] + fn supported_value_fixture_global_negatives_are_enforced() { + let fixture = supported_values_fixture(); + let declared = fixture["global_negative"] + .as_array() + .expect("global negatives") + .iter() + .map(|value| value.as_str().expect("negative id")) + .collect::>(); + assert_eq!( + declared, + vec![ + "undeclared-concept", + "duplicate-concept", + "missing-required-concept", + "extra-value-metadata", + "per-value-size-plus-one", + "aggregate-result-size-plus-one", + ] + ); + + let copied = immutable_supported_values_bundle(); + let bundle = Arc::new(Bundle::load(copied.path()).expect("supported-value bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + let boolean = || DerivedConceptValue { + concept_id: "urn:example:fixture:concept:boolean".to_owned(), + value: DerivedValue::Json(json!(true)), + }; + + assert_eq!( + kernel.validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:undeclared".to_owned(), + value: DerivedValue::Json(json!(true)), + }], + projection(), + ), + Err(KernelError::Output), + "undeclared-concept" + ); + assert_eq!( + kernel.validate_values(&requirement.id, vec![boolean(), boolean()], projection(),), + Err(KernelError::Output), + "duplicate-concept" + ); + assert_eq!( + kernel.validate_values( + &requirement.id, + vec![DerivedConceptValue { + concept_id: "urn:example:fixture:concept:bounded-integer".to_owned(), + value: DerivedValue::Json(json!(0)), + }], + projection(), + ), + Err(KernelError::Output), + "missing-required-concept" + ); + + let extra_metadata = r#" + fn derive(facts, selectors, evaluation_context) { + [#{ + concept_id: "urn:example:fixture:concept:boolean", + value: true, + confidence: "not-allowed" + }] + } + "#; + let script = kernel + .runtime + .compile_derivation(extra_metadata) + .expect("negative script compiles"); + assert!( + kernel + .runtime + .derive( + &script, + &BTreeMap::new(), + &Value::Object(JsonMap::new()), + kernel + .evaluation_context( + requirement, + "2026-08-02T00:00:00Z".parse().expect("time") + ) + .expect("context"), + ) + .is_err(), + "extra-value-metadata" + ); + + let oversized = "A".repeat(MAXIMUM_PUBLIC_STRING_BYTES + 1); + let oversized_codelist = Codelist::Codes { + id: "urn:example:fixture:codelist:oversized".to_owned(), + version: "1".to_owned(), + codes: vec![oversized.clone()], + }; + assert_eq!( + validate_value( + &concept("form: controlled-code\nrequired: true\nconstraints: {codelist: oversized, codelistVersion: '1', maximumBytes: 8192}"), + &DerivedValue::Json(Value::String(oversized)), + &projection(), + &BTreeMap::from([("oversized".to_owned(), oversized_codelist)]), + &BTreeMap::new(), + ), + Err(KernelError::Output), + "per-value-size-plus-one" + ); + + let aggregate_schema_id = "urn:example:fixture:schema:aggregate:v1"; + let aggregate_schema = compile_schema(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": aggregate_schema_id, + "type": "object", + "additionalProperties": false, + "required": ["blob"], + "properties": {"blob": {"type": "string", "maxLength": 5000}} + })) + .expect("aggregate schema compiles"); + let aggregate_concepts = (0..16) + .map(|index| { + let mut candidate = concept(&format!( + "form: reviewed-structured-value\nrequired: false\nconstraints: {{schema: '{aggregate_schema_id}', maximumSerializedBytes: 8192}}" + )); + candidate.id = format!("urn:example:fixture:concept:aggregate-{index}"); + candidate + }) + .collect::>(); + let mut aggregate_requirement = requirement.clone(); + aggregate_requirement.concepts = aggregate_concepts; + let aggregate_values = (0..16) + .map(|index| DerivedConceptValue { + concept_id: format!("urn:example:fixture:concept:aggregate-{index}"), + value: DerivedValue::Json(json!({ + "form": "reviewed-structured-value", + "schema": aggregate_schema_id, + "fields": {"blob": "X".repeat(4200)} + })), + }) + .collect(); + assert_eq!( + gate_values( + &aggregate_requirement, + aggregate_values, + projection(), + &BTreeMap::new(), + &BTreeMap::from([(aggregate_schema_id.to_owned(), aggregate_schema)]), + ), + Err(KernelError::Output), + "aggregate-result-size-plus-one" + ); + } + + fn supported_values_fixture() -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance/supported-values.yaml"); + serde_norway::from_slice(&fs::read(path).expect("supported-value fixture reads")) + .expect("supported-value fixture parses") + } + + fn supported_values_bundle_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance/supported-values") + } + + fn immutable_supported_values_bundle() -> TempDir { + let temporary = tempfile::tempdir().expect("temporary directory"); + copy_tree(&supported_values_bundle_path(), temporary.path()); + make_read_only(temporary.path()); + temporary + } + + fn gate_fixture_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + form: &str, + derived: DerivedConceptValue, + ) -> Result { + let mut values = Vec::with_capacity(2); + if form != "boolean" { + values.push(DerivedConceptValue { + concept_id: "urn:example:fixture:concept:boolean".to_owned(), + value: DerivedValue::Json(json!(true)), + }); + } + values.push(derived); + kernel.validate_values(&requirement.id, values, projection()) + } + + fn assert_fixture_declaration(form_fixture: &Value, concept: &ConceptConfig) { + let form = form_fixture["form"].as_str().expect("form"); + let declaration = form_fixture["declaration"] + .as_object() + .expect("form declaration"); + assert_eq!(declaration["form"], form); + assert_eq!( + serde_json::to_value(concept.form).expect("form serializes"), + Value::String(form.to_owned()) + ); + assert_eq!( + concept.required, + declaration + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false) + ); + + let mut expected = JsonMap::new(); + for (name, value) in declaration { + let translated = match name.as_str() { + "form" | "required" | "fields" => continue, + "codelist" => "codelist", + "codelist_version" => "codelistVersion", + "category_scheme" => "categoryScheme", + "scheme_version" => "schemeVersion", + "maximum_bytes" => "maximumBytes", + "maximum_scale" => "maximumScale", + "bucket_scheme" => "bucketScheme", + "minimum_items" => "minimumItems", + "maximum_items" => "maximumItems", + "maximum_serialized_bytes" => "maximumSerializedBytes", + other => other, + }; + let value = if name == "codelist" && value.as_str() == Some("synthetic-codes") { + Value::String("codelists/synthetic-codes.yaml".to_owned()) + } else { + value.clone() + }; + expected.insert(translated.to_owned(), value); + } + if form == "controlled-category" { + expected.insert( + "codelist".to_owned(), + Value::String("codelists/categories.yaml".to_owned()), + ); + } + assert_eq!( + serde_json::to_value(&concept.constraints).expect("constraints serialize"), + Value::Object(expected), + "{form} fixture declaration must be the executable bundle declaration" + ); + } + + fn accepted_derived_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + form_fixture: &Value, + category: &str, + index: usize, + fixture_case: &Value, + ) -> Result { + let form = form_fixture["form"].as_str().expect("form"); + let concept_id = format!("urn:example:fixture:concept:{form}"); + if form == "audience-scoped-entity-reference" { + let expression = if category == "positive" { + form_fixture["derivation_positive"][index]["rhai"] + .as_str() + .expect("entity derivation") + .to_owned() + } else { + "entity_reference_seed(\"synthetic-boundary-seed\")".to_owned() + }; + return derive_expression(kernel, requirement, &concept_id, &expression); + } + if form == "entity-reference-list" { + let derivation_index = if category == "positive" { + index + } else { + index + 1 + }; + let expressions = form_fixture["derivation_positive"][derivation_index] + .as_array() + .expect("entity list derivation") + .iter() + .map(|value| value.as_str().expect("Rhai expression")) + .collect::>() + .join(", "); + return derive_expression( + kernel, + requirement, + &concept_id, + &format!("[{expressions}]"), + ); + } + fixture_derived_value(kernel, requirement, &concept_id, fixture_case) + } + + fn fixture_derived_value( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + concept_id: &str, + fixture_case: &Value, + ) -> Result { + if let Some(expression) = fixture_case.get("rhai") { + let expression = match expression { + Value::Array(expressions) => format!( + "[{}]", + expressions + .iter() + .map(rhai_fixture_expression) + .collect::>() + .join(", ") + ), + value => rhai_fixture_expression(value), + }; + derive_expression(kernel, requirement, concept_id, &expression) + } else { + Ok(DerivedConceptValue { + concept_id: concept_id.to_owned(), + value: DerivedValue::Json(fixture_case.clone()), + }) + } + } + + fn rhai_fixture_expression(value: &Value) -> String { + match value { + Value::String(value) + if value.starts_with("decimal(") + || value.starts_with("parse_decimal(") + || value.starts_with("entity_reference_seed(") => + { + value.clone() + } + Value::String(value) => serde_json::to_string(value).expect("string expression"), + Value::Number(value) => value.to_string(), + _ => panic!("unsupported fixture Rhai expression form"), + } + } + + fn derive_expression( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + concept_id: &str, + expression: &str, + ) -> Result { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"{concept_id}\", value: {expression} }}] }}" + ); + let script = kernel.runtime.compile_derivation(&source)?; + let values = kernel.runtime.derive( + &script, + &BTreeMap::new(), + &Value::Object(JsonMap::new()), + kernel + .evaluation_context(requirement, "2026-08-02T00:00:00Z".parse().expect("time")) + .expect("evaluation context"), + )?; + values + .into_iter() + .next() + .ok_or(RhaiRuntimeError::DerivationResult) + } + + fn assert_fixture_public_shape(form: &str, fixture_case: &Value, actual: &PublicValue) { + let actual = serde_json::to_value(actual).expect("public value serializes"); + if let Some(wire) = fixture_case.get("wire_json").and_then(Value::as_str) { + let expected: Value = serde_json::from_str(wire).expect("wire JSON parses"); + assert_eq!(actual, expected); + return; + } + match form { + "audience-scoped-entity-reference" => { + let _: PublicValue = serde_json::from_value(fixture_case.clone()) + .expect("public entity exemplar parses"); + assert_eq!(actual["form"], "audience-scoped-entity-reference"); + let reference = actual["reference"].as_str().expect("projected reference"); + assert!(reference.starts_with("urn:evidence:entity:v1_")); + } + "entity-reference-list" => { + let _: PublicValue = serde_json::from_value(fixture_case.clone()) + .expect("public entity-list exemplar parses"); + assert_eq!( + actual.as_array().map(Vec::len), + fixture_case.as_array().map(Vec::len) + ); + assert!(actual + .as_array() + .expect("public list") + .iter() + .all(|item| item["form"] == "audience-scoped-entity-reference")); + } + _ => assert_eq!(actual, *fixture_case), + } + } + + async fn supported_values_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(SUPPORTED_VALUE_PRIVATE_JWK).expect("fixture key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, "supported-values-fixture-key") + .await + .expect("fixture signer initializes") + } + + async fn assert_signed_type_preserving_round_trip( + kernel: &OfflineKernel, + requirement: &RequirementConfig, + values: ValidatedValues, + signer: &EvidenceSigner, + ) { + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01K1SUPPORTEDVALUES0000000000", + purpose: "conformance", + audience: AUDIENCE, + issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), + observed_at: "2026-08-02T00:00:00Z".parse().expect("time"), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("Evidence constructs"); + let expected_values = + serde_json::to_value(&evidence.supported_values).expect("values serialize"); + let jws = signer + .sign_json(&evidence) + .await + .expect("fixture Evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("fixture JWKS builds"); + let verified = verify_flattened_jws( + &serialized, + &jwks, + &EvidenceVerificationPolicy { + issued_by: evidence.issued_by.clone(), + provided_by: evidence.provided_by.clone(), + requirement: evidence.supports_requirement.clone(), + evidence_type: evidence.is_conformant_to.clone(), + purpose: evidence.purpose.clone(), + audience: evidence.audience.clone(), + configuration_revision: evidence.configuration_revision.clone(), + now: "2026-08-02T12:00:00Z".parse().expect("time"), + clock_skew: StdDuration::from_secs(30), + }, + ) + .expect("signed Evidence verifies"); + assert_eq!( + serde_json::to_value(verified.supported_values).expect("verified values serialize"), + expected_values, + "JSON value types must survive construction, signing, verification, and parsing" + ); + } + + fn concept(body: &str) -> ConceptConfig { + serde_norway::from_str(&format!("id: urn:example:concept\n{body}\n")) + .expect("concept parses") + } + + fn observed_for_case(fixture: &Value, test_case: &Value) -> DateTime { + let local_date = test_case + .get("legal_local_date") + .or_else(|| { + fixture + .get("common") + .and_then(|common| common.get("legal_local_date")) + }) + .and_then(Value::as_str) + .unwrap_or("2026-08-02"); + format!("{local_date}T00:00:00Z") + .parse() + .expect("observed time") + } + + fn immutable_fixture(name: &str) -> TempDir { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(name); + let temporary = tempfile::tempdir().expect("temporary directory"); + copy_tree(&source, temporary.path()); + make_read_only(temporary.path()); + temporary + } + + fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("reads fixture") { + let entry = entry.expect("directory entry"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("file type").is_dir() { + fs::create_dir(&destination).expect("creates directory"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copies fixture"); + } + } + } + + #[cfg(unix)] + fn make_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + for entry in fs::read_dir(path).expect("reads copied fixture") { + let entry = entry.expect("directory entry"); + let child = entry.path(); + if entry.file_type().expect("file type").is_dir() { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("locks directory"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)).expect("locks file"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("locks root"); + } + + #[cfg(not(unix))] + fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("reads copied fixture") { + let entry = entry.expect("directory entry"); + let child = entry.path(); + if entry.file_type().expect("file type").is_dir() { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child).expect("metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("locks file"); + } + } + } +} diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs new file mode 100644 index 000000000..a14746316 --- /dev/null +++ b/crates/registry-evidence/src/lib.rs @@ -0,0 +1,32 @@ +//! Evidence Version 1 minimum-disclosure assertion runtime. + +#[cfg(not(unix))] +compile_error!("registry-evidence Version 1 requires a Unix target for owner and file-identity security guarantees"); + +pub mod audit; +pub mod auth; +pub mod binding; +pub mod bundle; +pub mod config; +pub mod contracts; +pub mod kernel; +pub mod model; +pub mod problem; +pub mod rate_limit; +pub mod rhai_runtime; +pub mod runtime; +pub mod secrets; +pub mod selector; +pub mod server; +pub mod signing; +pub mod source; +pub mod values; +pub mod verifier; + +#[cfg(test)] +mod runtime_tests; + +pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; +pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; +pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; +pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs new file mode 100644 index 000000000..0719ed659 --- /dev/null +++ b/crates/registry-evidence/src/main.rs @@ -0,0 +1,2418 @@ +//! Evidence Version 1 operator CLI and serving process. + +use std::{ + collections::BTreeMap, + fmt, + path::{Component, Path, PathBuf}, + process::ExitCode, + str::FromStr, + sync::Arc, +}; + +use chrono::{DateTime, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use clap::{Parser, Subcommand}; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::{ + bundle::{Bundle, BundleError, DeploymentInputs, RuntimeDocument}, + config::{ConfigError, EvidenceConfig, OutboundTlsConfig, SelectorInput}, + kernel::{ + EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValidatedValues, + ValueProjection, + }, + model::{LookupResult, PublicValue, ScalarOrEntityReference, SelectorValue, SubjectBinding}, + problem::ProblemCode, + rhai_runtime::{DerivedConceptValue, DerivedValue, RequestParts}, + runtime::{source_failure_problem, EvidenceRuntime, RuntimeInitializationError}, + secrets::{SecretProvider, SecretResolver}, + selector::{ + resolve_offline_fixture_authorization, resolve_offline_fixture_subjects, + OfflineFixtureError, ResolvedAuthorization, ResolvedSelectorValue, + }, + server, + signing::{jwks_document, EvidenceSigner}, + source::{ + project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, + }, + verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, +}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use serde_json::{Map as JsonMap, Value}; +use zeroize::Zeroizing; + +const DEFAULT_RUNTIME_PATH: &str = "/etc/registry-evidence/runtime.yaml"; +const OFFLINE_AUDIENCE: &str = "urn:registry-evidence:offline-evaluation"; +const OFFLINE_BINDING_KEY: [u8; 32] = [0x45; 32]; +const ANTI_RECONSTRUCTION_FIXTURE: &[u8] = + include_bytes!("../../../products/evidence/fixtures/conformance/anti-reconstruction.yaml"); + +#[derive(Debug, Parser)] +#[command(name = "evidence", version, about = "Registry Evidence Version 1")] +struct Cli { + /// One closed operator runtime file that binds the governed bundle. + #[arg( + long, + global = true, + env = "REGISTRY_EVIDENCE_RUNTIME", + default_value = DEFAULT_RUNTIME_PATH + )] + runtime: PathBuf, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Validate and compile the complete immutable bundle. + Check, + /// Evaluate one bundle-owned fixture without source or credential access. + Evaluate { + /// Bundle-relative fixture path referenced by exactly one requirement. + #[arg(long)] + fixture: PathBuf, + }, + /// Start the native Evidence HTTP service. + Serve, +} + +#[derive(Debug, PartialEq, Eq)] +struct CliError(&'static str); + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for CliError {} + +#[derive(Debug, Default, PartialEq, Eq)] +struct FixtureSummary { + evaluated_cases: usize, +} + +#[tokio::main] +async fn main() -> ExitCode { + match run(Cli::parse()).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("evidence: {error}"); + ExitCode::FAILURE + } + } +} + +async fn run(cli: Cli) -> Result<(), CliError> { + match cli.command { + Command::Check => { + let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; + let runtime = deployment.runtime; + let bundle = Arc::new(deployment.bundle); + OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("bundle compilation failed"))?; + let _source_plans = compile_source_plans(&bundle.config, &runtime)?; + println!( + "Evidence deployment {} / {} passed check ({} requirements)", + bundle.revision(), + runtime.revision(), + bundle.config.requirements.len() + ); + Ok(()) + } + Command::Evaluate { fixture } => { + let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; + let runtime = deployment.runtime; + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| CliError("fixture bundle compilation failed"))?; + let source_plans = compile_source_plans(&bundle.config, &runtime)?; + let summary = evaluate_fixture(&bundle, &kernel, &source_plans, &fixture).await?; + println!( + "Evidence fixture passed ({} evaluated cases)", + summary.evaluated_cases + ); + Ok(()) + } + Command::Serve => { + let runtime = Arc::new( + EvidenceRuntime::initialize(&cli.runtime) + .await + .map_err(runtime_initialization_error)?, + ); + server::serve(runtime, shutdown_signal()) + .await + .map_err(|_| CliError("service failed")) + } + } +} + +fn deployment_load_error(error: BundleError) -> CliError { + match error { + BundleError::Unavailable => CliError("deployment input is unavailable"), + BundleError::NotImmutable => CliError("deployment input is not immutable"), + BundleError::UnsupportedEntry => CliError("deployment contains an unsupported entry"), + BundleError::InvalidPath => CliError("deployment contains an invalid path binding"), + BundleError::UnknownFile => CliError("deployment artifact closure is invalid"), + BundleError::TooLarge => CliError("deployment exceeds a Version 1 size bound"), + BundleError::Config(_) => CliError("deployment configuration is invalid"), + BundleError::InvalidArtifact(_) => CliError("deployment artifact is invalid"), + BundleError::InvalidScript => CliError("deployment script is invalid"), + } +} + +fn runtime_initialization_error(error: RuntimeInitializationError) -> CliError { + match error { + RuntimeInitializationError::Bundle => CliError("runtime bundle initialization failed"), + RuntimeInitializationError::Secrets => CliError("runtime secret initialization failed"), + RuntimeInitializationError::Audit => CliError("runtime audit initialization failed"), + RuntimeInitializationError::Signing => CliError("runtime signing initialization failed"), + RuntimeInitializationError::Source => CliError("runtime source initialization failed"), + RuntimeInitializationError::RateLimit => { + CliError("runtime rate-limit initialization failed") + } + } +} + +fn compile_source_plans( + config: &EvidenceConfig, + runtime: &RuntimeDocument, +) -> Result, CliError> { + compile_source_plans_with_runtime( + config, + &runtime.config.secret_providers.file.root, + &runtime.config.outbound_tls, + &runtime.ca_bundles, + ) +} + +fn compile_source_plans_with_runtime( + config: &EvidenceConfig, + secret_root: &str, + outbound_tls: &OutboundTlsConfig, + ca_bundles: &BTreeMap>, +) -> Result, CliError> { + let secrets = Arc::new( + SecretResolver::new([SecretProvider::File], secret_root) + .map_err(|_| CliError("source plan compilation failed"))?, + ); + let mut plans = BTreeMap::new(); + for (source_id, source) in config.sources.iter() { + let allowed_selector_sets = config.source_selector_sets(source_id); + let plan = SourceExecutor::new_with_selector_sets_and_tls( + source, + &allowed_selector_sets, + outbound_tls, + ca_bundles, + Arc::clone(&secrets), + ) + .map_err(|_| CliError("source plan compilation failed"))?; + plans.insert(source_id.to_owned(), plan); + } + Ok(plans) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +async fn evaluate_fixture( + bundle: &Arc, + kernel: &OfflineKernel, + source_plans: &BTreeMap, + fixture_path: &Path, +) -> Result { + let signer = offline_fixture_signer().await?; + let fixture_name = safe_fixture_name(fixture_path)?; + let referenced = bundle + .config + .requirements + .iter() + .filter(|requirement| requirement.fixtures.as_str() == fixture_name) + .collect::>(); + if referenced.len() != 1 { + return Err(CliError( + "fixture must be a captured artifact referenced by exactly one requirement", + )); + } + let requirement = referenced[0]; + let fixture = bundle + .fixtures + .get(fixture_name) + .ok_or(CliError("fixture artifact is not captured by the bundle"))?; + let fixture = serde_json::to_value(fixture) + .map_err(|_| CliError("fixture contract is not representable"))?; + let object = fixture + .as_object() + .ok_or(CliError("fixture contract must be an object"))?; + if object.get("synthetic_only") != Some(&Value::Bool(true)) { + return Err(CliError("fixture is not an approved synthetic definition")); + } + if object.get("coequal_acceptance_definition") != Some(&Value::Bool(true)) { + if object + .get("fixture") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("registry.evidence.reference.") && id.ends_with("/v1")) + { + return evaluate_reference_fixture( + bundle, + kernel, + source_plans, + &signer, + requirement, + object, + ) + .await; + } + return Err(CliError( + "fixture is not an approved synthetic acceptance definition", + )); + } + let common = object.get("common").and_then(Value::as_object); + let cases = object + .get("cases") + .and_then(Value::as_array) + .ok_or(CliError("fixture cases are unavailable"))?; + if cases.is_empty() || cases.len() > 256 { + return Err(CliError("fixture case count is invalid")); + } + + let mut summary = FixtureSummary::default(); + let mut successful_values = Vec::new(); + for case in cases { + let case = case + .as_object() + .ok_or(CliError("fixture case is not an object"))?; + let id = case + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 128) + .ok_or(CliError("fixture case identifier is invalid"))?; + + if case.get("subjects").is_some() { + require_expected(case, "pre-source-selector-rejection")?; + match resolve_offline_fixture_subjects( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) { + Ok(_) => return Err(CliError("fixture selector rejection did not occur")), + Err(OfflineFixtureError::Purpose) => { + return Err(CliError(FIXTURE_PURPOSE_FAILURE)); + } + Err(OfflineFixtureError::Authorization(_)) => {} + } + summary.evaluated_cases += 1; + continue; + } + if let Some(expected_roles) = case.get("expected_subject_roles") { + let expected_roles = expected_roles + .as_array() + .ok_or(CliError("fixture subject-role expectation is invalid"))? + .iter() + .map(|role| { + role.as_str() + .map(ToOwned::to_owned) + .ok_or(CliError("fixture subject-role expectation is invalid")) + }) + .collect::, _>>()?; + let actual_roles = resolve_offline_fixture_subjects( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "fixture subjects did not resolve"))?; + if actual_roles != expected_roles { + return Err(CliError("fixture subject roles did not match")); + } + } + let observed_at = + fixture_observed_at(case, common, requirement.observation_timezone.as_deref())?; + + if let Some(source) = case.get("source") { + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + common, + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "fixture subjects did not resolve"))?; + let derivation_selectors = + fixture_selector_value(&resolved, &requirement.derivation.selector_inputs)?; + if let Some(expected) = case + .get("derivationSelectorInputs") + .or_else(|| common.and_then(|common| common.get("derivationSelectorInputs"))) + { + if expected != &derivation_selectors { + return Err(CliError( + "fixture derivation selector projection did not match", + )); + } + } + let source_config = bundle + .config + .sources + .get(&requirement.source) + .ok_or(CliError("fixture source is unavailable"))?; + let outcome = match project_fixture_response(source_config, source) { + Ok(projected) => kernel.evaluate_with_selectors( + &requirement.id, + &projected, + &derivation_selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ), + Err(_) => Err(KernelError::SourceProtocol), + }; + if let Some(values) = validate_case_outcome(id, case, outcome)? { + successful_values.push( + sign_and_verify_fixture_evidence( + bundle, + kernel, + &signer, + requirement, + &resolved, + values, + observed_at, + ) + .await?, + ); + } + summary.evaluated_cases += 1; + continue; + } + + if let Some(injected) = case.get("injected_derivation") { + validate_injected_rejection(kernel, &requirement.id, injected)?; + require_expected(case, "output-gate-rejection")?; + summary.evaluated_cases += 1; + continue; + } + + if let Some(source_failure) = case.get("source_failure") { + validate_source_failure(case, source_failure)?; + summary.evaluated_cases += 1; + continue; + } + + if let Some(companion) = case.get("companion_bundle") { + validate_companion_rejection(bundle, requirement, case, companion)?; + summary.evaluated_cases += 1; + continue; + } + + return Err(CliError( + "fixture case has no closed Version 1 evaluation form", + )); + } + validate_privacy_expectation(object, requirement, &successful_values)?; + Ok(summary) +} + +async fn evaluate_reference_fixture( + bundle: &Arc, + kernel: &OfflineKernel, + source_plans: &BTreeMap, + signer: &EvidenceSigner, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &JsonMap, +) -> Result { + require_exact_keys( + fixture, + &[ + "fixture", + "synthetic_only", + "common", + "cases", + "privacyExpectation", + ], + )?; + let common = fixture + .get("common") + .and_then(Value::as_object) + .ok_or(CliError("reference fixture common block is invalid"))?; + require_allowed_keys( + common, + &[ + "observed_at", + "purpose", + "selectors", + "verified_token_claims", + "derivationSelectorInputs", + "expectedRequestParts", + "expectedTransport", + ], + )?; + for required in [ + "observed_at", + "selectors", + "expectedRequestParts", + "expectedTransport", + ] { + if !common.contains_key(required) { + return Err(CliError("reference fixture common block is incomplete")); + } + } + let cases = fixture + .get("cases") + .and_then(Value::as_array) + .filter(|cases| !cases.is_empty() && cases.len() <= 256) + .ok_or(CliError("reference fixture case count is invalid"))?; + let mut identifiers = std::collections::BTreeSet::new(); + let mut successful_values = Vec::new(); + let mut summary = FixtureSummary::default(); + + for case in cases { + let case = case + .as_object() + .ok_or(CliError("reference fixture case is not an object"))?; + require_allowed_keys( + case, + &[ + "id", + "purpose", + "response", + "sourceFailure", + "bundleMutation", + "requestMutation", + "derivationMutation", + "derivationParameterMutation", + "selectorOverrides", + "observed_at", + "expected", + ], + )?; + let id = case + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty() && id.len() <= 128 && identifiers.insert(*id)) + .ok_or(CliError("reference fixture case identifier is invalid"))?; + let expected = case + .get("expected") + .and_then(Value::as_object) + .ok_or(CliError("reference fixture expectation is invalid"))?; + require_allowed_keys( + expected, + &[ + "lookup", + "facts", + "value", + "entityReferenceCount", + "rawReferencesDisclosed", + "signed", + "publicProblem", + "error", + "derivationRuns", + "bundle", + "outputGate", + "rejectedBefore", + "sourceRequestCount", + "expectedTransport", + ], + )?; + let forms = [ + "response", + "sourceFailure", + "bundleMutation", + "requestMutation", + "derivationMutation", + "derivationParameterMutation", + "selectorOverrides", + ]; + let selected_forms = forms + .iter() + .filter(|name| case.contains_key(**name)) + .copied() + .collect::>(); + if selected_forms.len() != 1 { + return Err(CliError("reference fixture case form is not closed")); + } + validate_reference_expectation_keys(selected_forms[0], expected)?; + + if let Some(mutation) = case.get("bundleMutation").and_then(Value::as_str) { + if mutation != "duplicate-disclosure-family" + || expected.get("bundle").and_then(Value::as_str) != Some("rejected") + { + return Err(CliError("reference bundle mutation is invalid")); + } + validate_reference_bundle_mutation(bundle, requirement)?; + summary.evaluated_cases += 1; + continue; + } + if let Some(mutation) = case.get("requestMutation").and_then(Value::as_str) { + validate_reference_request_mutation( + bundle, + requirement, + common, + case, + expected, + mutation, + )?; + summary.evaluated_cases += 1; + continue; + } + + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + Some(common), + case, + OFFLINE_AUDIENCE, + ) + .map_err(|error| fixture_failure(error, "reference fixture subjects did not resolve"))?; + let source = bundle + .config + .sources + .get(&requirement.source) + .ok_or(CliError("reference fixture source is unavailable"))?; + let source_plan = source_plans + .get(&requirement.source) + .ok_or(CliError("reference fixture source plan is unavailable"))?; + let preparation_selectors = + fixture_selector_value(&resolved, &source.request.selector_inputs)?; + let prepared = match kernel.prepare(&requirement.id, &preparation_selectors) { + Ok(prepared) => prepared, + Err(error) if case.contains_key("selectorOverrides") => { + validate_reference_error(expected, error, false)?; + if expected.get("rejectedBefore").and_then(Value::as_str) != Some("credential") { + return Err(CliError( + "reference preparation rejection boundary did not match", + )); + } + require_reference_request_count(expected, 0)?; + summary.evaluated_cases += 1; + continue; + } + Err(_) => return Err(CliError("reference fixture request preparation failed")), + }; + if !case.contains_key("selectorOverrides") { + validate_reference_request_parts(common, &prepared)?; + } + let source_selectors = + reference_source_selectors(&resolved, &source.request.selector_inputs)?; + validate_reference_transport( + source, + source_plan, + &source_selectors, + &prepared, + common + .get("expectedTransport") + .and_then(Value::as_object) + .ok_or(CliError("reference transport expectation is invalid"))?, + )?; + if let Some(transport) = expected.get("expectedTransport").and_then(Value::as_object) { + validate_reference_transport( + source, + source_plan, + &source_selectors, + &prepared, + transport, + )?; + } + if case.contains_key("selectorOverrides") { + if expected.contains_key("error") + || expected.contains_key("publicProblem") + || expected.contains_key("rejectedBefore") + { + return Err(CliError( + "reference successful preparation contradicts its expectation", + )); + } + require_reference_request_count(expected, 1)?; + summary.evaluated_cases += 1; + continue; + } + let derivation_selectors = + fixture_selector_value(&resolved, &requirement.derivation.selector_inputs)?; + if let Some(expected_selectors) = common.get("derivationSelectorInputs") { + if expected_selectors != &derivation_selectors { + return Err(CliError("reference derivation selectors did not match")); + } + } else if derivation_selectors != Value::Object(JsonMap::new()) { + return Err(CliError( + "reference derivation selectors were not minimized", + )); + } + if let Some(failure) = case.get("sourceFailure").and_then(Value::as_str) { + validate_reference_source_failure(failure, expected)?; + summary.evaluated_cases += 1; + continue; + } + + let observed_at = fixture_observed_at( + case, + Some(common), + requirement.observation_timezone.as_deref(), + )?; + if let Some(mutation) = case.get("derivationMutation").and_then(Value::as_str) { + validate_reference_derivation_mutation(kernel, requirement, expected, mutation)?; + summary.evaluated_cases += 1; + continue; + } + if let Some(mutation) = case + .get("derivationParameterMutation") + .and_then(Value::as_object) + { + validate_reference_parameter_mutation( + bundle, + requirement, + cases, + mutation, + &derivation_selectors, + observed_at, + expected, + )?; + summary.evaluated_cases += 1; + continue; + } + + let response = case + .get("response") + .ok_or(CliError("reference fixture response is unavailable"))?; + let projected = project_fixture_response(source, response) + .map_err(|_| CliError("reference fixture source projection failed"))?; + if let Some(values) = validate_reference_response( + ReferenceResponseContext { + bundle, + kernel, + signer, + requirement, + resolved: &resolved, + }, + &projected, + &derivation_selectors, + observed_at, + expected, + ) + .await? + { + successful_values.push(values); + } + require_reference_request_count(expected, 1)?; + summary.evaluated_cases += 1; + let _ = id; + } + + validate_reference_privacy(fixture, requirement, &successful_values)?; + Ok(summary) +} + +struct ReferenceResponseContext<'a> { + bundle: &'a Bundle, + kernel: &'a OfflineKernel, + signer: &'a EvidenceSigner, + requirement: &'a registry_evidence::config::RequirementConfig, + resolved: &'a ResolvedAuthorization, +} + +async fn validate_reference_response( + context: ReferenceResponseContext<'_>, + response: &Value, + selectors: &Value, + observed_at: DateTime, + expected: &JsonMap, +) -> Result, CliError> { + let lookup = match context.kernel.extract(&context.requirement.id, response) { + Ok(lookup) => lookup, + Err(error) => { + validate_reference_error(expected, error, false)?; + return Ok(None); + } + }; + match lookup { + LookupResult::NoMatch => { + validate_reference_unresolved(expected, "no_match")?; + Ok(None) + } + LookupResult::Ambiguous => { + validate_reference_unresolved(expected, "ambiguous")?; + Ok(None) + } + LookupResult::Match(facts) => { + if expected.get("lookup").and_then(Value::as_str) != Some("match") { + return Err(CliError("reference lookup outcome did not match")); + } + if let Some(exact) = expected.get("facts") { + let actual = serde_json::to_value(&facts) + .map_err(|_| CliError("reference facts are not representable"))?; + if exact != &actual { + return Err(CliError("reference exact facts did not match")); + } + } + let values = match context.kernel.derive_and_validate_with_selectors( + &context.requirement.id, + &facts, + selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) { + Ok(values) => values, + Err(error) => { + validate_reference_error(expected, error, true)?; + return Ok(None); + } + }; + if expected.get("derivationRuns").and_then(Value::as_bool) != Some(true) { + return Err(CliError("reference derivation execution did not match")); + } + if let Some(exact) = expected.get("value") { + if values.as_slice().len() != 1 + || public_json(&values.as_slice()[0].value)? != *exact + { + return Err(CliError("reference scalar value did not match")); + } + } + if let Some(count) = expected.get("entityReferenceCount").and_then(Value::as_u64) { + let actual = match values.as_slice() { + [value] => match &value.value { + PublicValue::List(items) => items + .iter() + .filter(|item| { + matches!(item, ScalarOrEntityReference::EntityReference(_)) + }) + .count() as u64, + _ => 0, + }, + _ => 0, + }; + if actual != count { + return Err(CliError("reference entity-reference count did not match")); + } + } + if expected + .get("rawReferencesDisclosed") + .and_then(Value::as_bool) + == Some(false) + { + let encoded = serde_json::to_string(values.as_slice()) + .map_err(|_| CliError("reference values are not representable"))?; + let mut protected_source_strings = Vec::new(); + collect_strings(response, &mut protected_source_strings); + if protected_source_strings + .iter() + .filter(|value| value.len() >= 8) + .any(|value| encoded.contains(value)) + { + return Err(CliError("reference raw source reference was disclosed")); + } + } + if expected.get("signed").and_then(Value::as_bool) != Some(true) { + return Err(CliError("reference signing expectation did not match")); + } + sign_and_verify_fixture_evidence( + context.bundle, + context.kernel, + context.signer, + context.requirement, + context.resolved, + values, + observed_at, + ) + .await + .map(Some) + } + } +} + +async fn sign_and_verify_fixture_evidence( + bundle: &Bundle, + kernel: &OfflineKernel, + signer: &EvidenceSigner, + requirement: ®istry_evidence::config::RequirementConfig, + resolved: &ResolvedAuthorization, + values: ValidatedValues, + observed_at: DateTime, +) -> Result { + let subjects = resolved + .subjects + .iter() + .map(|subject| { + Ok(SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + &OFFLINE_BINDING_KEY, + 1, + &bundle.config.service.trust_domain, + OFFLINE_AUDIENCE, + &resolved.purpose, + ) + .map_err(|_| CliError("fixture subject binding failed"))?, + }) + }) + .collect::, CliError>>()?; + let issued_at = observed_at + chrono::Duration::seconds(1); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + purpose: &resolved.purpose, + audience: OFFLINE_AUDIENCE, + issued_at, + observed_at, + subjects, + }, + ) + .map_err(|_| CliError("fixture evidence construction failed"))?; + let signed = signer + .sign_json(&evidence) + .await + .map_err(|_| CliError("fixture evidence signing failed"))?; + let jwks = jwks_document(signer.public_jwk(), []) + .map_err(|_| CliError("fixture verification key construction failed"))?; + let verified = verify_flattened_jws( + &serde_json::to_vec(&signed) + .map_err(|_| CliError("fixture signed evidence is not representable"))?, + &jwks, + &EvidenceVerificationPolicy { + issued_by: bundle.config.issuer.id.clone(), + provided_by: bundle.config.service.provider_id.clone(), + requirement: requirement.id.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: resolved.purpose.clone(), + audience: OFFLINE_AUDIENCE.to_owned(), + configuration_revision: bundle.revision().to_owned(), + now: issued_at, + clock_skew: std::time::Duration::ZERO, + }, + ) + .map_err(|_| CliError("fixture signed evidence verification failed"))?; + serde_json::to_value(verified) + .map_err(|_| CliError("fixture verified evidence is not representable")) +} + +async fn offline_fixture_signer() -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + const KEY_ID: &str = "offline-fixture-signing-key"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private_jwk = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new( + LocalJwkSigner::new(private_jwk) + .map_err(|_| CliError("offline fixture signer initialization failed"))?, + ); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .map_err(|_| CliError("offline fixture signer self-test failed")) +} + +fn validate_reference_unresolved( + expected: &JsonMap, + lookup: &str, +) -> Result<(), CliError> { + if expected.get("lookup").and_then(Value::as_str) != Some(lookup) + || expected.get("derivationRuns").and_then(Value::as_bool) != Some(false) + || expected.get("signed").and_then(Value::as_bool) != Some(false) + || expected.get("publicProblem").and_then(Value::as_str) != Some("evidence_not_available") + { + return Err(CliError("reference unresolved outcome did not match")); + } + Ok(()) +} + +fn validate_reference_expectation_keys( + form: &str, + expected: &JsonMap, +) -> Result<(), CliError> { + let allowed: &[&str] = match form { + "response" => &[ + "lookup", + "facts", + "value", + "entityReferenceCount", + "rawReferencesDisclosed", + "signed", + "publicProblem", + "error", + "derivationRuns", + "sourceRequestCount", + ], + "sourceFailure" => &["publicProblem", "signed", "sourceRequestCount"], + "bundleMutation" => &["bundle"], + "requestMutation" => &["rejectedBefore", "signed", "sourceRequestCount"], + "derivationMutation" => &["outputGate", "signed"], + "derivationParameterMutation" => &["error", "publicProblem", "signed", "derivationRuns"], + "selectorOverrides" => &[ + "expectedTransport", + "error", + "publicProblem", + "signed", + "rejectedBefore", + "sourceRequestCount", + ], + _ => return Err(CliError("reference fixture case form is unknown")), + }; + require_allowed_keys(expected, allowed) +} + +fn validate_reference_error( + expected: &JsonMap, + error: KernelError, + derivation_ran: bool, +) -> Result<(), CliError> { + let (internal, public_problem) = match error { + KernelError::Preparation => ("adapter_input_error", "service_unavailable"), + KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), + KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), + _ => ("service_unavailable", "service_unavailable"), + }; + let expected_error = expected.get("error").and_then(Value::as_str); + let expected_problem = expected.get("publicProblem").and_then(Value::as_str); + if expected_error.is_none() && expected_problem.is_none() { + return Err(CliError("reference failing case has no exact expectation")); + } + if expected_error.is_some_and(|expected| expected != internal) { + return Err(CliError("reference internal error did not match")); + } + if expected_problem.is_some_and(|expected| expected != public_problem) { + return Err(CliError("reference public problem did not match")); + } + if expected.get("signed").and_then(Value::as_bool) != Some(false) { + return Err(CliError( + "reference failed case signing expectation did not match", + )); + } + if expected.get("derivationRuns").and_then(Value::as_bool) != Some(derivation_ran) { + return Err(CliError("reference derivation execution did not match")); + } + Ok(()) +} + +fn validate_reference_request_parts( + common: &JsonMap, + actual: &RequestParts, +) -> Result<(), CliError> { + let expected = common + .get("expectedRequestParts") + .and_then(Value::as_object) + .ok_or(CliError("reference request-parts expectation is invalid"))?; + require_exact_keys(expected, &["query", "body"])?; + let query = expected + .get("query") + .and_then(Value::as_array) + .ok_or(CliError("reference query expectation is invalid"))?; + if query.len() != actual.query.len() { + return Err(CliError("reference prepared query did not match")); + } + for (expected, actual) in query.iter().zip(&actual.query) { + let expected = expected + .as_object() + .ok_or(CliError("reference query-pair expectation is invalid"))?; + require_exact_keys(expected, &["name", "value"])?; + if expected.get("name").and_then(Value::as_str) != Some(&actual.name) + || expected.get("value").and_then(Value::as_str) != Some(&actual.value) + { + return Err(CliError("reference prepared query did not match")); + } + } + let expected_body = expected.get("body").filter(|body| !body.is_null()); + if expected_body != actual.body.as_ref() { + return Err(CliError("reference prepared body did not match")); + } + Ok(()) +} + +fn validate_reference_transport( + source: ®istry_evidence::config::SourceConfig, + source_plan: &SourceExecutor, + selectors: &[ResolvedSourceSelector], + parts: &RequestParts, + expected: &JsonMap, +) -> Result<(), CliError> { + require_allowed_keys(expected, &["path", "query", "body", "fixedHeaders"])?; + let materialized = source_plan + .materialize_request(selectors, parts) + .map_err(|_| CliError("reference transport materialization failed"))?; + if expected + .get("path") + .and_then(Value::as_str) + .is_some_and(|path| materialized.path() != path) + { + return Err(CliError("reference materialized path did not match")); + } + if let Some(query) = expected.get("query").and_then(Value::as_str) { + if materialized.query().unwrap_or_default() != query { + return Err(CliError("reference encoded query did not match")); + } + } + if let Some(body) = expected.get("body").filter(|body| !body.is_null()) { + if materialized.body() != Some(body) { + return Err(CliError("reference transport body did not match")); + } + } + if let Some(headers) = expected.get("fixedHeaders").and_then(Value::as_array) { + if headers.len() != source.request.fixed_headers.len() { + return Err(CliError("reference fixed headers did not match")); + } + for (expected, actual) in headers.iter().zip(&source.request.fixed_headers) { + let expected = expected + .as_object() + .ok_or(CliError("reference fixed-header expectation is invalid"))?; + require_exact_keys(expected, &["name", "value"])?; + if expected.get("name").and_then(Value::as_str) != Some(&actual.name) + || expected.get("value").and_then(Value::as_str) != Some(&actual.value) + { + return Err(CliError("reference fixed headers did not match")); + } + } + } + Ok(()) +} + +fn reference_source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result, CliError> { + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or(CliError("reference source selector role is unavailable"))?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or(CliError("reference source selector profile is invalid"))?; + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or(CliError("reference source selector field is unavailable"))?; + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + Ok((name.clone(), value)) + }) + .collect::, CliError>>()?; + Ok(ResolvedSourceSelector { + role: input.role.clone(), + profile: alternative.profile.clone(), + values, + }) + }) + .collect() +} + +fn validate_reference_source_failure( + failure: &str, + expected: &JsonMap, +) -> Result<(), CliError> { + let error = match failure { + "timeout" => SourceError::Timeout, + "connection-refused" => SourceError::Transport, + "invalid-media-type" => SourceError::WrongMediaType, + "oversized" => SourceError::ResponseTooLarge, + "malformed-json" => SourceError::InvalidJson, + _ => return Err(CliError("reference source-failure name is invalid")), + }; + if source_failure_problem(&error) != ProblemCode::DependencyUnavailable + || expected.get("publicProblem").and_then(Value::as_str) != Some("dependency_unavailable") + || expected.get("signed").and_then(Value::as_bool) != Some(false) + { + return Err(CliError("reference source-failure mapping is invalid")); + } + require_reference_request_count(expected, 1) +} + +fn validate_reference_bundle_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, +) -> Result<(), CliError> { + let mut mutated = bundle.config.clone(); + let mut companion = requirement.clone(); + companion.id.push_str(":fixture-companion"); + companion.evidence_type.push_str(":fixture-companion"); + for concept in &mut companion.concepts { + concept.id.push_str(":fixture-companion"); + } + mutated.requirements.push(companion); + if mutated.validate() + != Err(ConfigError::Invalid( + "enabled requirements share a disclosure family", + )) + { + return Err(CliError( + "reference unsafe bundle mutation was not rejected", + )); + } + Ok(()) +} + +fn validate_reference_request_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + common: &JsonMap, + case: &JsonMap, + expected: &JsonMap, + mutation: &str, +) -> Result<(), CliError> { + if expected.get("rejectedBefore").and_then(Value::as_str) != Some("source") { + return Err(CliError("reference request rejection boundary is invalid")); + } + let selectors = common + .get("selectors") + .and_then(Value::as_object) + .ok_or(CliError("reference selectors are invalid"))?; + let mut subjects = selectors + .iter() + .map(|(role, selector)| { + let mut selector = selector + .as_object() + .cloned() + .ok_or(CliError("reference selector is invalid"))?; + selector.insert("role".to_owned(), Value::String(role.clone())); + Ok(Value::Object(selector)) + }) + .collect::, CliError>>()?; + match mutation { + "swap-subject-roles" if subjects.len() == 2 => { + let first = subjects[0]["role"].clone(); + subjects[0]["role"] = subjects[1]["role"].clone(); + subjects[1]["role"] = first; + } + "supply-grant-derived-candidate" => {} + _ => return Err(CliError("reference request-mutation name is invalid")), + } + let mut mutated = case.clone(); + mutated.insert("subjects".to_owned(), Value::Array(subjects)); + match resolve_offline_fixture_authorization( + bundle, + requirement, + Some(common), + &mutated, + OFFLINE_AUDIENCE, + ) { + Ok(_) => return Err(CliError("reference request mutation was authorized")), + Err(OfflineFixtureError::Purpose) => return Err(CliError(FIXTURE_PURPOSE_FAILURE)), + Err(OfflineFixtureError::Authorization(_)) => {} + } + if expected.get("signed").and_then(Value::as_bool) != Some(false) { + return Err(CliError("reference rejected request requires signing")); + } + require_reference_request_count(expected, 0) +} + +fn validate_reference_derivation_mutation( + kernel: &OfflineKernel, + requirement: ®istry_evidence::config::RequirementConfig, + expected: &JsonMap, + mutation: &str, +) -> Result<(), CliError> { + if mutation != "return-raw-reference" + || expected.get("outputGate").and_then(Value::as_str) != Some("rejected") + || expected.get("signed").and_then(Value::as_bool) != Some(false) + { + return Err(CliError("reference derivation mutation is invalid")); + } + let injected = vec![DerivedConceptValue { + concept_id: requirement.concepts[0].id.clone(), + value: DerivedValue::Json(Value::String("PROTECTED-REFERENCE".to_owned())), + }]; + if kernel + .validate_values( + &requirement.id, + injected, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) + .is_ok() + { + return Err(CliError( + "reference derivation mutation crossed the output gate", + )); + } + Ok(()) +} + +fn validate_reference_parameter_mutation( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + cases: &[Value], + mutation: &JsonMap, + selectors: &Value, + observed_at: DateTime, + expected: &JsonMap, +) -> Result<(), CliError> { + let mut disposable = bundle.clone(); + let mut config = serde_json::to_value(&disposable.config) + .map_err(|_| CliError("reference configuration is not representable"))?; + let target = config["requirements"] + .as_array_mut() + .and_then(|requirements| { + requirements + .iter_mut() + .find(|candidate| candidate["id"].as_str() == Some(&requirement.id)) + }) + .ok_or(CliError("reference disposable requirement is unavailable"))?; + let parameters = target["derivation"]["parameters"] + .as_object_mut() + .ok_or(CliError("reference derivation parameters are invalid"))?; + for (name, value) in mutation { + if !parameters.contains_key(name) { + return Err(CliError("reference parameter mutation is unknown")); + } + parameters.insert(name.clone(), value.clone()); + } + disposable.config = serde_json::from_value(config) + .map_err(|_| CliError("reference parameter mutation is invalid"))?; + disposable + .config + .validate() + .map_err(|_| CliError("reference parameter mutation broke configuration"))?; + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .map_err(|_| CliError("reference disposable kernel did not compile"))?; + let positive = cases + .iter() + .find(|case| case.get("id").and_then(Value::as_str) == Some("positive")) + .and_then(|case| case.get("response")) + .ok_or(CliError("reference positive response is unavailable"))?; + let source = disposable + .config + .sources + .get(&requirement.source) + .ok_or(CliError("reference disposable source is unavailable"))?; + let projected = project_fixture_response(source, positive) + .map_err(|_| CliError("reference positive response projection failed"))?; + let outcome = kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ); + match outcome { + Err(error) => validate_reference_error(expected, error, true), + Ok(_) => Err(CliError("reference parameter mutation did not fail")), + } +} + +fn require_reference_request_count( + expected: &JsonMap, + actual: u64, +) -> Result<(), CliError> { + if expected + .get("sourceRequestCount") + .and_then(Value::as_u64) + .is_some_and(|count| count != actual) + || actual > 1 + { + return Err(CliError("reference source request count did not match")); + } + Ok(()) +} + +fn validate_reference_privacy( + fixture: &JsonMap, + requirement: ®istry_evidence::config::RequirementConfig, + successful_values: &[Value], +) -> Result<(), CliError> { + let source = fixture + .get("privacyExpectation") + .and_then(Value::as_object) + .ok_or(CliError("reference privacy expectation is invalid"))?; + require_exact_keys( + source, + &["evidenceContains", "evidenceExcludes", "diagnosticsExclude"], + )?; + let mut expectation = JsonMap::new(); + for (source_name, target_name) in [ + ("evidenceContains", "evidence_contains"), + ("evidenceExcludes", "evidence_excludes"), + ("diagnosticsExclude", "diagnostics_exclude"), + ] { + expectation.insert( + target_name.to_owned(), + source + .get(source_name) + .cloned() + .ok_or(CliError("reference privacy expectation is incomplete"))?, + ); + } + let projection = serde_json::json!({ + "supportsRequirement": requirement.id, + "isConformantTo": requirement.evidence_type, + "subjectRoles": requirement + .subject_roles + .iter() + .map(|role| role.role.as_str()) + .collect::>(), + "successfulValues": successful_values, + }); + validate_privacy_projection(&expectation, &projection) +} + +const FIXTURE_PURPOSE_FAILURE: &str = "fixture does not select one of the requirement's purposes"; + +/// Report an unselected fixture purpose as its own failure so a harness +/// omission is never presented as a rejected request. +fn fixture_failure(error: OfflineFixtureError, authorization: &'static str) -> CliError { + match error { + OfflineFixtureError::Purpose => CliError(FIXTURE_PURPOSE_FAILURE), + OfflineFixtureError::Authorization(_) => CliError(authorization), + } +} + +fn require_exact_keys(object: &JsonMap, expected: &[&str]) -> Result<(), CliError> { + require_allowed_keys(object, expected)?; + if expected.iter().any(|key| !object.contains_key(*key)) { + return Err(CliError("reference fixture required key is missing")); + } + Ok(()) +} + +fn require_allowed_keys(object: &JsonMap, allowed: &[&str]) -> Result<(), CliError> { + if object + .keys() + .any(|key| !allowed.iter().any(|allowed| key == allowed)) + { + return Err(CliError("reference fixture contains an unknown key")); + } + Ok(()) +} + +fn fixture_selector_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let mut selectors = JsonMap::new(); + for input in inputs { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or(CliError("fixture selector input role is unavailable"))?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or(CliError("fixture selector input profile is unavailable"))?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or(CliError("fixture selector input field is unavailable"))?; + values.insert(name.clone(), field.value.as_json()); + } + let mut selector = JsonMap::new(); + selector.insert( + "profile".to_owned(), + Value::String(alternative.profile.clone()), + ); + selector.insert("values".to_owned(), Value::Object(values)); + if selectors + .insert(input.role.clone(), Value::Object(selector)) + .is_some() + { + return Err(CliError("fixture selector input role is duplicated")); + } + } + Ok(Value::Object(selectors)) +} + +fn validate_case_outcome( + _case_id: &str, + case: &serde_json::Map, + outcome: Result, +) -> Result, CliError> { + let derivation_runs = optional_boolean(case, "derivation_runs")?; + let signed_success = optional_boolean(case, "signed_success")?; + let expected_problem = optional_string(case, "expected_public_problem")?; + + if let Some(expected_lookup @ ("no_match" | "ambiguous")) = + optional_string(case, "expected_lookup")? + { + let matches = matches!( + (expected_lookup, &outcome), + ("no_match", Ok(KernelOutcome::NoMatch)) | ("ambiguous", Ok(KernelOutcome::Ambiguous)) + ); + if !matches { + return Err(CliError( + "fixture lookup outcome did not match its contract", + )); + } + if signed_success != Some(false) || derivation_runs != Some(false) { + return Err(CliError( + "unresolved fixture must deny derivation and signed success", + )); + } + if expected_problem != Some("evidence_not_available") { + return Err(CliError("unresolved fixture public problem is not exact")); + } + return Ok(None); + } + if optional_string(case, "expected_lookup")?.is_some_and(|lookup| lookup != "match") { + return Err(CliError("fixture lookup expectation is invalid")); + } + + if let Some(problem) = expected_problem { + let exact = matches!( + (problem, &outcome), + ( + "evidence_not_available", + Err(registry_evidence::kernel::KernelError::Extraction) + ) | ( + "dependency_unavailable", + Err(registry_evidence::kernel::KernelError::SourceProtocol) + ) | ( + "service_unavailable", + Err(registry_evidence::kernel::KernelError::Script + | registry_evidence::kernel::KernelError::Output + | registry_evidence::kernel::KernelError::Bundle + | registry_evidence::kernel::KernelError::Requirement + | registry_evidence::kernel::KernelError::Evidence) + ) + ); + if !exact { + return Err(CliError( + "fixture kernel failure did not match its public problem", + )); + } + let derivation_ran = !matches!( + outcome, + Err(registry_evidence::kernel::KernelError::Extraction + | registry_evidence::kernel::KernelError::SourceProtocol) + ); + if signed_success != Some(false) || derivation_runs != Some(derivation_ran) { + return Err(CliError( + "failing fixture execution expectations did not match", + )); + } + return Ok(None); + } + + let KernelOutcome::Match(values) = + outcome.map_err(|_| CliError("fixture evaluation failed unexpectedly"))? + else { + return Err(CliError("fixture expected a unique match")); + }; + if optional_string(case, "expected_lookup")? != Some("match") { + return Err(CliError("matched fixture must require an exact match")); + } + if derivation_runs == Some(false) { + return Err(CliError("matched fixture cannot deny derivation execution")); + } + if signed_success == Some(false) { + return Err(CliError( + "matched fixture cannot deny signed-success eligibility", + )); + } + + let expected_value = case.get("expected_value"); + let expected_values = case.get("expected_values"); + if expected_value.is_some() == expected_values.is_some() { + return Err(CliError( + "matched fixture must declare exactly one value expectation", + )); + } + if let Some(expected) = expected_value { + if values.as_slice().len() != 1 || public_json(&values.as_slice()[0].value)? != *expected { + return Err(CliError("fixture value did not match its contract")); + } + } + if let Some(expected) = expected_values.and_then(Value::as_object) { + if values.as_slice().len() != expected.len() { + return Err(CliError("fixture value set did not match its contract")); + } + for (name, expected_value) in expected { + let actual = values + .as_slice() + .iter() + .filter(|candidate| { + candidate.provides_value_for == *name + || candidate + .provides_value_for + .strip_suffix(name) + .is_some_and(|prefix| prefix.ends_with(':')) + }) + .collect::>(); + if actual.len() != 1 || public_json(&actual[0].value)? != *expected_value { + return Err(CliError("fixture value set did not match its contract")); + } + } + } else if expected_values.is_some() { + return Err(CliError("fixture value-set expectation is invalid")); + } + if derivation_runs != Some(true) || signed_success != Some(true) { + return Err(CliError( + "matched fixture must require derivation and signed success", + )); + } + Ok(Some(values)) +} + +fn validate_source_failure( + case: &serde_json::Map, + source_failure: &Value, +) -> Result<(), CliError> { + let failure = match source_failure.as_str() { + Some("timeout") => SourceError::Timeout, + Some("redirect") => SourceError::Redirect, + Some("http-503") => SourceError::Status(SourceStatus::ServerError), + Some("wrong-media-type") => SourceError::WrongMediaType, + _ => return Err(CliError("fixture source-failure category is invalid")), + }; + if optional_string(case, "expected_public_problem")? != Some("dependency_unavailable") + || optional_boolean(case, "signed_success")? != Some(false) + || source_failure_problem(&failure) != ProblemCode::DependencyUnavailable + { + return Err(CliError("fixture source-failure mapping is invalid")); + } + Ok(()) +} + +fn validate_companion_rejection( + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + case: &serde_json::Map, + companion: &Value, +) -> Result<(), CliError> { + let label = companion + .as_str() + .filter(|value| { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) + .ok_or(CliError("fixture companion-bundle label is invalid"))?; + require_expected(case, "bundle-rejection")?; + let matrix: Value = serde_norway::from_slice(ANTI_RECONSTRUCTION_FIXTURE) + .map_err(|_| CliError("anti-reconstruction fixture is invalid"))?; + let rejected = matrix + .get("rejected_bundles") + .and_then(Value::as_array) + .ok_or(CliError("anti-reconstruction fixture is invalid"))?; + let declaration = rejected + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(label)) + .and_then(Value::as_object) + .ok_or(CliError("fixture companion-bundle label is unknown"))?; + let shared_family = declaration + .get("shared_disclosure_family") + .and_then(Value::as_str) + .ok_or(CliError("anti-reconstruction family is invalid"))?; + let definitions = declaration + .get("definitions") + .and_then(Value::as_array) + .filter(|definitions| definitions.len() >= 2) + .ok_or(CliError( + "anti-reconstruction combination must contain multiple definitions", + ))?; + let distinct_definitions = definitions + .iter() + .map(serde_json::to_string) + .collect::, _>>() + .map_err(|_| CliError("anti-reconstruction definition is invalid"))?; + if distinct_definitions.len() != definitions.len() + || !definitions.iter().all(Value::is_object) + || declaration + .get("threat") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || declaration + .get("expected") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + return Err(CliError( + "anti-reconstruction combination declaration is incomplete", + )); + } + + let mut unsafe_config = bundle.config.clone(); + let original = unsafe_config + .requirements + .iter_mut() + .find(|candidate| candidate.id == requirement.id) + .ok_or(CliError("fixture requirement is missing"))?; + original.disclosure_guard.families = vec![shared_family.to_owned()]; + for index in 1..definitions.len() { + let suffix = format!(":fixture-companion-{index}"); + let mut companion = requirement.clone(); + companion.id.push_str(&suffix); + companion.evidence_type.push_str(&suffix); + companion.disclosure_guard.families = vec![shared_family.to_owned()]; + companion.derivation.script = registry_evidence::config::ArtifactPath::parse(&format!( + "derivations/fixture-companion-{index}.rhai" + )) + .map_err(|_| CliError("fixture companion path is invalid"))?; + companion.fixtures = registry_evidence::config::ArtifactPath::parse(&format!( + "fixtures/fixture-companion-{index}.yaml" + )) + .map_err(|_| CliError("fixture companion path is invalid"))?; + for concept in &mut companion.concepts { + concept.id.push_str(&suffix); + } + unsafe_config.requirements.push(companion); + } + if unsafe_config.validate() + != Err(ConfigError::Invalid( + "enabled requirements share a disclosure family", + )) + { + return Err(CliError("unsafe companion bundle was not rejected")); + } + Ok(()) +} + +fn optional_boolean( + case: &serde_json::Map, + name: &str, +) -> Result, CliError> { + case.get(name) + .map(|value| { + value + .as_bool() + .ok_or(CliError("fixture boolean expectation is invalid")) + }) + .transpose() +} + +fn optional_string<'a>( + case: &'a serde_json::Map, + name: &str, +) -> Result, CliError> { + case.get(name) + .map(|value| { + value + .as_str() + .ok_or(CliError("fixture string expectation is invalid")) + }) + .transpose() +} + +fn validate_privacy_expectation( + fixture: &serde_json::Map, + requirement: ®istry_evidence::config::RequirementConfig, + successful_values: &[Value], +) -> Result<(), CliError> { + let expectation = fixture + .get("privacy_expectation") + .and_then(Value::as_object) + .ok_or(CliError("fixture privacy expectation is unavailable"))?; + let projection = serde_json::json!({ + "supportsRequirement": requirement.id, + "isConformantTo": requirement.evidence_type, + "subjectRoles": requirement + .subject_roles + .iter() + .map(|role| role.role.as_str()) + .collect::>(), + "successfulValues": successful_values, + }); + validate_privacy_projection(expectation, &projection) +} + +fn validate_privacy_projection( + expectation: &serde_json::Map, + projection: &Value, +) -> Result<(), CliError> { + let mut disclosed_strings = Vec::new(); + collect_strings(projection, &mut disclosed_strings); + + for expected in expectation_strings(expectation, "evidence_contains")? { + if !disclosed_strings.contains(&expected) { + return Err(CliError("fixture required disclosure is absent")); + } + } + for prohibited in expectation_strings(expectation, "evidence_excludes")? { + if disclosed_strings.contains(&prohibited) { + return Err(CliError("fixture prohibited disclosure is present")); + } + } + // CLI diagnostics are structurally static (`CliError(&'static str)`) and + // the success line contains counts only. Still exercise every declared + // diagnostic canary against the exact dynamic-free output templates so a + // future template change cannot silently weaken this fixture assertion. + for prohibited in expectation_strings(expectation, "diagnostics_exclude")? { + if [ + "Evidence fixture passed (0 evaluated cases)", + "evidence: fixture evaluation failed", + ] + .iter() + .any(|surface| surface.contains(prohibited)) + { + return Err(CliError("fixture prohibited diagnostic is present")); + } + } + Ok(()) +} + +fn expectation_strings<'a>( + expectation: &'a serde_json::Map, + name: &str, +) -> Result, CliError> { + expectation + .get(name) + .and_then(Value::as_array) + .ok_or(CliError("fixture privacy expectation is invalid"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or(CliError("fixture privacy expectation is invalid")) + }) + .collect() +} + +fn collect_strings<'a>(value: &'a Value, output: &mut Vec<&'a str>) { + match value { + Value::String(value) => output.push(value), + Value::Array(values) => { + for value in values { + collect_strings(value, output); + } + } + Value::Object(values) => { + for (key, value) in values { + output.push(key); + collect_strings(value, output); + } + } + _ => {} + } +} + +fn validate_injected_rejection( + kernel: &OfflineKernel, + requirement_id: &str, + injected: &Value, +) -> Result<(), CliError> { + let injected = injected + .as_array() + .ok_or(CliError("injected derivation fixture must be an array"))?; + let mut derived = Vec::with_capacity(injected.len()); + for value in injected { + let object = value + .as_object() + .ok_or(CliError("injected derivation member is invalid"))?; + if object.len() != 2 { + return Err(CliError("injected derivation member is not closed")); + } + let concept_id = object + .get("concept_id") + .and_then(Value::as_str) + .ok_or(CliError("injected derivation concept is invalid"))?; + let value = object + .get("value") + .cloned() + .ok_or(CliError("injected derivation value is missing"))?; + derived.push(DerivedConceptValue { + concept_id: concept_id.to_owned(), + value: DerivedValue::Json(value), + }); + } + if kernel + .validate_values( + requirement_id, + derived, + ValueProjection { + audience: OFFLINE_AUDIENCE, + binding_key: &OFFLINE_BINDING_KEY, + binding_key_version: 1, + }, + ) + .is_ok() + { + return Err(CliError("injected derivation was not rejected")); + } + Ok(()) +} + +fn public_json(value: &PublicValue) -> Result { + serde_json::to_value(value).map_err(|_| CliError("fixture value is not representable")) +} + +fn fixture_observed_at( + case: &serde_json::Map, + common: Option<&serde_json::Map>, + timezone: Option<&str>, +) -> Result, CliError> { + if let Some(observed) = case.get("observed_at").and_then(Value::as_str) { + return DateTime::parse_from_rfc3339(observed) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixture observation time is invalid")); + } + + if let Some(local_date) = case.get("legal_local_date").and_then(Value::as_str) { + return local_date_at_noon(local_date, timezone); + } + + if let Some(observed) = common + .and_then(|value| value.get("observed_at")) + .and_then(Value::as_str) + { + return DateTime::parse_from_rfc3339(observed) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixture observation time is invalid")); + } + + if let Some(local_date) = common + .and_then(|value| value.get("legal_local_date")) + .and_then(Value::as_str) + { + return local_date_at_noon(local_date, timezone); + } + + DateTime::parse_from_rfc3339("1970-01-01T12:00:00Z") + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| CliError("fixed fixture time is invalid")) +} + +fn local_date_at_noon(local_date: &str, timezone: Option<&str>) -> Result, CliError> { + let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d") + .map_err(|_| CliError("fixture legal local date is invalid"))?; + let local_noon = date + .and_hms_opt(12, 0, 0) + .ok_or(CliError("fixture legal local date is invalid"))?; + let timezone = timezone + .map(Tz::from_str) + .transpose() + .map_err(|_| CliError("fixture observation timezone is invalid"))? + .unwrap_or(Tz::UTC); + timezone + .from_local_datetime(&local_noon) + .single() + .map(|value| value.with_timezone(&Utc)) + .ok_or(CliError("fixture legal local date cannot be resolved")) +} + +fn require_expected(case: &serde_json::Map, expected: &str) -> Result<(), CliError> { + if case.get("expected").and_then(Value::as_str) == Some(expected) { + Ok(()) + } else { + Err(CliError("fixture boundary expectation is invalid")) + } +} + +fn safe_fixture_name(path: &Path) -> Result<&str, CliError> { + if path.is_absolute() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(CliError( + "fixture path must be bundle-relative and normalized", + )); + } + let name = path + .to_str() + .filter(|value| value.starts_with("fixtures/") && value.ends_with(".yaml")) + .ok_or(CliError("fixture path is invalid"))?; + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn fixture_paths_never_escape_the_captured_bundle() { + assert_eq!( + safe_fixture_name(Path::new("fixtures/cases.yaml")), + Ok("fixtures/cases.yaml") + ); + assert!(safe_fixture_name(Path::new("../fixtures/cases.yaml")).is_err()); + assert!(safe_fixture_name(Path::new("/tmp/cases.yaml")).is_err()); + } + + #[test] + fn offline_dates_are_fixed_and_never_use_ambient_time() { + let case = serde_json::json!({"legal_local_date": "2026-08-03"}); + let observed = fixture_observed_at(case.as_object().expect("object"), None, None) + .expect("date converts"); + assert_eq!(observed.to_rfc3339(), "2026-08-03T12:00:00+00:00"); + } + + #[test] + fn case_local_date_overrides_common_observation_time() { + let case = serde_json::json!({"legal_local_date": "2026-08-01"}); + let common = serde_json::json!({"observed_at": "2026-08-02T00:00:00Z"}); + let observed = fixture_observed_at( + case.as_object().expect("case object"), + common.as_object(), + Some("Asia/Bangkok"), + ) + .expect("case-local date converts"); + assert_eq!(observed.to_rfc3339(), "2026-08-01T05:00:00+00:00"); + } + + #[test] + fn symbolic_source_failures_use_the_production_public_mapper() { + for category in ["timeout", "redirect", "http-503", "wrong-media-type"] { + let case = serde_json::json!({ + "source_failure": category, + "expected_public_problem": "dependency_unavailable", + "signed_success": false + }); + let object = case.as_object().expect("object"); + assert_eq!( + validate_source_failure(object, &object["source_failure"]), + Ok(()) + ); + } + } + + #[test] + fn public_unavailability_requires_an_extraction_failure() { + let case = serde_json::json!({ + "expected_public_problem": "evidence_not_available", + "derivation_runs": false, + "signed_success": false + }); + let case = case.as_object().expect("object"); + assert!(validate_case_outcome("case", case, Ok(KernelOutcome::NoMatch)).is_err()); + assert!(validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Script), + ) + .is_err()); + assert_eq!( + validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Extraction), + ), + Ok(None) + ); + } + + #[test] + fn service_unavailability_requires_an_internal_kernel_failure() { + let case = serde_json::json!({ + "expected_public_problem": "service_unavailable", + "derivation_runs": true, + "signed_success": false + }); + let case = case.as_object().expect("object"); + assert_eq!( + validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Script), + ), + Ok(None) + ); + assert!(validate_case_outcome( + "case", + case, + Err(registry_evidence::kernel::KernelError::Extraction), + ) + .is_err()); + } + + #[test] + fn unresolved_lookup_rejects_derivation_or_signed_success_claims() { + for declaration in [ + serde_json::json!({ + "expected_lookup": "no_match", + "expected_public_problem": "evidence_not_available", + "derivation_runs": true + }), + serde_json::json!({ + "expected_lookup": "no_match", + "expected_public_problem": "evidence_not_available", + "signed_success": true + }), + ] { + assert!(validate_case_outcome( + "case", + declaration.as_object().expect("object"), + Ok(KernelOutcome::NoMatch), + ) + .is_err()); + } + } + + #[test] + fn reference_fixture_forms_reject_irrelevant_expectations() { + let bundle = serde_json::json!({"bundle": "rejected"}); + assert_eq!( + validate_reference_expectation_keys( + "bundleMutation", + bundle.as_object().expect("object"), + ), + Ok(()) + ); + + let irrelevant = serde_json::json!({"bundle": "rejected", "signed": false}); + assert!(validate_reference_expectation_keys( + "bundleMutation", + irrelevant.as_object().expect("object"), + ) + .is_err()); + + let transport = serde_json::json!({ + "expectedTransport": {"path": "/records"}, + "sourceRequestCount": 1 + }); + assert_eq!( + validate_reference_expectation_keys( + "selectorOverrides", + transport.as_object().expect("object"), + ), + Ok(()) + ); + assert!(validate_reference_expectation_keys( + "response", + transport.as_object().expect("object"), + ) + .is_err()); + } + + #[test] + fn reference_failures_require_exact_unsigned_stage_expectations() { + let exact = serde_json::json!({ + "error": "source_protocol_error", + "publicProblem": "dependency_unavailable", + "derivationRuns": false, + "signed": false + }); + assert_eq!( + validate_reference_error( + exact.as_object().expect("object"), + KernelError::SourceProtocol, + false, + ), + Ok(()) + ); + + let wrong_public = serde_json::json!({ + "error": "source_protocol_error", + "publicProblem": "service_unavailable", + "derivationRuns": false, + "signed": false + }); + assert!(validate_reference_error( + wrong_public.as_object().expect("object"), + KernelError::SourceProtocol, + false, + ) + .is_err()); + } + + #[test] + fn privacy_expectations_check_exact_projected_strings() { + let expectation = serde_json::json!({ + "evidence_contains": ["urn:example:concept", "subject"], + "evidence_excludes": ["raw-source-value"], + "diagnostics_exclude": ["selector-value"] + }); + let projection = serde_json::json!({ + "subjectRoles": ["subject"], + "successfulValues": [{"providesValueFor": "urn:example:concept", "value": true}] + }); + assert_eq!( + validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &projection, + ), + Ok(()) + ); + + let leaking = serde_json::json!({"value": "raw-source-value"}); + assert!(validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &leaking, + ) + .is_err()); + + let leaking_key = serde_json::json!({"raw-source-value": false}); + assert!(validate_privacy_projection( + expectation.as_object().expect("expectation object"), + &leaking_key, + ) + .is_err()); + } + + #[test] + fn check_compiles_source_plans_without_resolving_secrets() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + let valid_config = EvidenceConfig::parse_yaml(valid.as_bytes()).expect("config validates"); + let outbound_tls = OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }; + assert_eq!( + compile_source_plans_with_runtime( + &valid_config, + "/run/secrets/evidence", + &outbound_tls, + &Default::default(), + ) + .map(|plans| plans.len()), + Ok(valid_config.sources.len()) + ); + + let invalid = valid.replacen("timeoutMilliseconds: 3000", "timeoutMilliseconds: 0", 1); + assert_ne!(invalid, valid, "fixture mutation must remain effective"); + let invalid_config: EvidenceConfig = + serde_norway::from_str(&invalid).expect("closed typed shape deserializes"); + assert_eq!( + compile_source_plans_with_runtime( + &invalid_config, + "/run/secrets/evidence", + &outbound_tls, + &Default::default(), + ) + .map(|_| ()), + Err(CliError("source plan compilation failed")) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_every_coequal_acceptance_fixture() { + for definition in [ + "adult-status", + "residence-region", + "professional-licence", + "legal-parent-relationship", + ] { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(definition); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }, + &Default::default(), + ) + .expect("source plans compile"); + let fixture = Path::new(bundle.config.requirements[0].fixtures.as_str()); + let expected_cases = bundle.fixtures[fixture.to_str().expect("fixture path")] + .get("cases") + .and_then(serde_norway::Value::as_sequence) + .expect("cases") + .len(); + assert_eq!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + Ok(FixtureSummary { + evaluated_cases: expected_cases, + }), + "{definition}" + ); + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_the_combined_acceptance_bundle() { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/all-definitions"); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("acceptance bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }, + &Default::default(), + ) + .expect("source plans compile"); + for requirement in &bundle.config.requirements { + let fixture = Path::new(requirement.fixtures.as_str()); + assert!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture) + .await + .is_ok(), + "combined acceptance fixture failed" + ); + } + + set_tree_mode(directory.path(), 0o755, 0o444); + } + + #[cfg(unix)] + #[tokio::test] + async fn offline_cli_evaluates_every_reference_deployment_fixture() { + for project in ["dhis2-adult-status", "opencrvs-family-evidence"] { + let directory = tempfile::tempdir().expect("temporary bundle"); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects") + .join(project) + .join("bundle"); + copy_tree(&source, directory.path()); + set_tree_mode(directory.path(), 0o555, 0o444); + + let bundle = Arc::new(Bundle::load(directory.path()).expect("reference bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let outbound_tls: OutboundTlsConfig = if project == "dhis2-adult-status" { + serde_norway::from_str( + "systemRoots: true\ntrustProfiles:\n government-internal-pki:\n caBundleFile: /etc/registry-evidence/ca/government-internal.pem\n", + ) + .expect("private TLS profile parses") + } else { + OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + } + }; + let ca_bundles = if project == "dhis2-adult-status" { + let certificate = + rcgen::generate_simple_self_signed( + vec!["tracker.dhis2.gov.example".to_owned()], + ) + .expect("generate private TLS root"); + let certificate = test_certificate_pem(certificate.cert.der().as_ref()); + BTreeMap::from([("government-internal-pki".to_owned(), certificate)]) + } else { + BTreeMap::new() + }; + let source_plans = compile_source_plans_with_runtime( + &bundle.config, + "/run/secrets/evidence", + &outbound_tls, + &ca_bundles, + ) + .expect("source plans compile"); + for requirement in &bundle.config.requirements { + let fixture = Path::new(requirement.fixtures.as_str()); + let expected_cases = bundle.fixtures[requirement.fixtures.as_str()] + .get("cases") + .and_then(serde_norway::Value::as_sequence) + .expect("cases") + .len(); + assert_eq!( + evaluate_fixture(&bundle, &kernel, &source_plans, fixture).await, + Ok(FixtureSummary { + evaluated_cases: expected_cases, + }), + "{project}/{}", + requirement.id + ); + } + + set_tree_mode(directory.path(), 0o755, 0o444); + } + } + + #[cfg(unix)] + fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create destination"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy artifact"); + } + } + } + + #[cfg(unix)] + fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + let metadata = fs::symlink_metadata(path).expect("tree metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read tree") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set file mode"); + } + } + + #[cfg(unix)] + fn test_certificate_pem(der: &[u8]) -> Vec { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + let encoded = STANDARD.encode(der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for line in encoded.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(line).expect("base64 is UTF-8")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + pem.into_bytes() + } +} diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs new file mode 100644 index 000000000..b3c850ac8 --- /dev/null +++ b/crates/registry-evidence/src/model.rs @@ -0,0 +1,428 @@ +use std::{collections::BTreeMap, fmt}; + +use schemars::JsonSchema; +use serde::{de, Deserialize, Deserializer, Serialize}; +use serde_json::{Number, Value}; +use utoipa::ToSchema; + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceRequest { + pub requirement: String, + pub purpose: String, + /// Unordered role set encoded as an array. Roles are resolved by name and + /// canonicalized to requirement declaration order. + pub subjects: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RequestedSubject { + pub role: String, + pub selector: RequestedSelector, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RequestedSelector { + pub profile: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum SelectorValue { + String(String), + Integer(i64), + Boolean(bool), +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Evidence { + pub schema: String, + pub id: String, + #[serde(rename = "type")] + pub evidence_type_name: EvidenceObjectType, + pub supports_requirement: String, + pub is_conformant_to: String, + pub issued_by: String, + pub provided_by: String, + pub issued_at: String, + pub observed_at: String, + pub valid_until: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, + pub subjects: Vec, + pub supported_values: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum EvidenceObjectType { + Evidence, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjectBinding { + pub role: String, + pub binding: String, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SupportedValue { + pub provides_value_for: String, + pub value: PublicValue, +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum PublicValue { + Boolean(bool), + Integer(i64), + String(String), + Bucket(BucketValue), + EntityReference(EntityReferenceValue), + Structured(StructuredValue), + List(Vec), +} + +impl<'de> Deserialize<'de> for SelectorValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + match Value::deserialize(deserializer)? { + Value::String(value) => Ok(Self::String(value)), + Value::Number(value) => safe_json_integer(&value) + .map(Self::Integer) + .ok_or_else(|| de::Error::custom("selector number is not a safe JSON integer")), + Value::Bool(value) => Ok(Self::Boolean(value)), + _ => Err(de::Error::custom("selector value is not a scalar")), + } + } +} + +impl<'de> Deserialize<'de> for PublicValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + match value { + Value::Bool(value) => Ok(Self::Boolean(value)), + Value::Number(value) => safe_json_integer(&value) + .map(Self::Integer) + .ok_or_else(|| de::Error::custom("public number is not a safe JSON integer")), + Value::String(value) => Ok(Self::String(value)), + Value::Array(values) => serde_json::from_value(Value::Array(values)) + .map(Self::List) + .map_err(de::Error::custom), + Value::Object(object) => { + let form = object + .get("form") + .and_then(Value::as_str) + .map(str::to_owned); + let value = Value::Object(object); + match form.as_deref() { + Some("date-bucket" | "time-bucket") => serde_json::from_value(value) + .map(Self::Bucket) + .map_err(de::Error::custom), + Some("audience-scoped-entity-reference") => serde_json::from_value(value) + .map(Self::EntityReference) + .map_err(de::Error::custom), + Some("reviewed-structured-value") => serde_json::from_value(value) + .map(Self::Structured) + .map_err(de::Error::custom), + _ => Err(de::Error::custom("public object has an unsupported form")), + } + } + Value::Null => Err(de::Error::custom("public value cannot be null")), + } + } +} + +fn safe_json_integer(number: &Number) -> Option { + const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + + if let Some(value) = number.as_i64() { + return (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER) + .contains(&value) + .then_some(value); + } + if let Some(value) = number.as_u64() { + return (value <= MAX_SAFE_INTEGER as u64).then_some(value as i64); + } + let value = number.as_f64()?; + (value.is_finite() + && value.fract() == 0.0 + && value >= -(MAX_SAFE_INTEGER as f64) + && value <= MAX_SAFE_INTEGER as f64) + .then_some(value as i64) +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(untagged)] +pub enum ScalarOrEntityReference { + String(String), + EntityReference(EntityReferenceValue), +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct BucketValue { + pub form: BucketForm, + pub scheme: String, + pub bucket: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum BucketForm { + DateBucket, + TimeBucket, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EntityReferenceValue { + pub form: EntityReferenceForm, + pub reference: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum EntityReferenceForm { + AudienceScopedEntityReference, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct StructuredValue { + pub form: StructuredValueForm, + pub schema: String, + pub fields: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum StructuredValueForm { + ReviewedStructuredValue, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct FlattenedJws { + pub protected: String, + pub payload: String, + pub signature: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct JwksDocument { + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ProblemBody { + #[serde(rename = "type")] + pub type_uri: String, + pub title: String, + pub status: u16, + pub code: String, + pub operation: String, +} + +#[derive(Clone, PartialEq)] +pub enum LookupResult { + Match(BTreeMap), + NoMatch, + Ambiguous, +} + +macro_rules! redacted_debug { + ($($type_name:ty),+ $(,)?) => { + $( + impl fmt::Debug for $type_name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($type_name)) + .field("protected", &"") + .finish() + } + } + )+ + }; +} + +redacted_debug!( + EvidenceRequest, + RequestedSubject, + RequestedSelector, + SelectorValue, + Evidence, + SubjectBinding, + SupportedValue, + PublicValue, + ScalarOrEntityReference, + BucketValue, + EntityReferenceValue, + StructuredValue, + FlattenedJws, + LookupResult, +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_integer_lexical_forms_canonicalize_to_safe_i64() { + for input in ["1", "1.0", "1e0"] { + assert_eq!( + serde_json::from_str::(input).expect("selector integer parses"), + SelectorValue::Integer(1) + ); + let public = serde_json::from_str::(input).expect("public integer parses"); + assert_eq!(public, PublicValue::Integer(1)); + assert_eq!( + serde_json::to_string(&public).expect("integer serializes"), + "1" + ); + } + for input in ["1.5", "9007199254740992", "-9007199254740992"] { + assert!(serde_json::from_str::(input).is_err()); + assert!(serde_json::from_str::(input).is_err()); + } + } + + #[test] + fn request_rejects_query_material_and_unknown_fields() { + let input = serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }], + "threshold": 18 + }); + assert!(serde_json::from_value::(input).is_err()); + + let caller_grant_reference = serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "grantId": "caller-selected-grant", + "grantAuthority": "caller-selected-authority", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }] + }); + assert!(serde_json::from_value::(caller_grant_reference).is_err()); + } + + #[test] + fn evidence_has_no_selector_echo_field() { + let serialized = serde_json::to_value(Evidence { + schema: crate::EVIDENCE_SCHEMA_V1.to_string(), + id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_string(), + is_conformant_to: "urn:example:evidence-type:v1".to_string(), + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + issued_at: "2026-08-02T00:00:00Z".to_string(), + observed_at: "2026-08-02T00:00:00Z".to_string(), + valid_until: "2026-08-03T00:00:00Z".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "a".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept".to_string(), + value: PublicValue::Boolean(true), + }], + }) + .expect("evidence serializes"); + let text = serialized.to_string(); + assert!(!text.contains("selector")); + assert!(!text.contains("opaque")); + } + + #[test] + fn debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads() { + let request = EvidenceRequest { + requirement: "urn:example:protected-requirement-canary".to_owned(), + purpose: "protected-purpose-canary".to_owned(), + subjects: vec![RequestedSubject { + role: "subject".to_owned(), + selector: RequestedSelector { + profile: "protected-profile-canary".to_owned(), + values: Some(BTreeMap::from([( + "protected-field-canary".to_owned(), + SelectorValue::String("protected-selector-canary".to_owned()), + )])), + }, + }], + }; + let evidence = Evidence { + schema: "protected-schema-canary".to_owned(), + id: "protected-evidence-id-canary".to_owned(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "protected-requirement-canary".to_owned(), + is_conformant_to: "protected-evidence-type-canary".to_owned(), + issued_by: "protected-issuer-canary".to_owned(), + provided_by: "protected-provider-canary".to_owned(), + issued_at: "2026-08-02T00:00:00Z".to_owned(), + observed_at: "2026-08-02T00:00:00Z".to_owned(), + valid_until: "2026-08-03T00:00:00Z".to_owned(), + purpose: "protected-purpose-canary".to_owned(), + audience: "protected-audience-canary".to_owned(), + configuration_revision: "protected-revision-canary".to_owned(), + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: "protected-binding-canary".to_owned(), + }], + supported_values: vec![SupportedValue { + provides_value_for: "protected-concept-canary".to_owned(), + value: PublicValue::String("protected-supported-value-canary".to_owned()), + }], + }; + let lookup = LookupResult::Match(BTreeMap::from([( + "protected-fact-name-canary".to_owned(), + serde_json::json!("protected-fact-value-canary"), + )])); + let signed = FlattenedJws { + protected: "protected-header-canary".to_owned(), + payload: "protected-payload-canary".to_owned(), + signature: "protected-signature-canary".to_owned(), + }; + + for diagnostic in [ + format!("{request:?}"), + format!( + "{:?}", + SelectorValue::String("protected-selector-canary".to_owned()) + ), + format!("{evidence:?}"), + format!( + "{:?}", + PublicValue::String("protected-supported-value-canary".to_owned()) + ), + format!("{lookup:?}"), + format!("{signed:?}"), + ] { + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains("canary")); + } + } +} diff --git a/crates/registry-evidence/src/problem.rs b/crates/registry-evidence/src/problem.rs new file mode 100644 index 000000000..11f6c1761 --- /dev/null +++ b/crates/registry-evidence/src/problem.rs @@ -0,0 +1,126 @@ +//! Closed public problem details for the Evidence HTTP boundary. + +use http::StatusCode; + +use crate::model::ProblemBody; + +const PROBLEM_BASE: &str = "https://registrystack.org/problems/evidence/"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProblemCode { + MalformedRequest, + InvalidSelector, + AuthenticationFailed, + NotAuthorized, + EvidenceNotAvailable, + RateLimited, + DependencyUnavailable, + ServiceUnavailable, +} + +impl ProblemCode { + pub const fn code(self) -> &'static str { + match self { + Self::MalformedRequest => "malformed_request", + Self::InvalidSelector => "invalid_selector", + Self::AuthenticationFailed => "authentication_failed", + Self::NotAuthorized => "not_authorized", + Self::EvidenceNotAvailable => "evidence_not_available", + Self::RateLimited => "rate_limited", + Self::DependencyUnavailable => "dependency_unavailable", + Self::ServiceUnavailable => "service_unavailable", + } + } + + pub const fn status(self) -> StatusCode { + match self { + Self::MalformedRequest | Self::InvalidSelector => StatusCode::BAD_REQUEST, + Self::AuthenticationFailed => StatusCode::UNAUTHORIZED, + Self::NotAuthorized => StatusCode::FORBIDDEN, + Self::EvidenceNotAvailable => StatusCode::UNPROCESSABLE_ENTITY, + Self::RateLimited => StatusCode::TOO_MANY_REQUESTS, + Self::DependencyUnavailable | Self::ServiceUnavailable => { + StatusCode::SERVICE_UNAVAILABLE + } + } + } + + pub const fn title(self) -> &'static str { + match self { + Self::MalformedRequest | Self::InvalidSelector => "Request is not valid", + Self::AuthenticationFailed => "Authentication failed", + Self::NotAuthorized => "Request is not authorized", + Self::EvidenceNotAvailable => "Evidence could not be produced", + Self::RateLimited => "Request rate exceeded", + Self::DependencyUnavailable | Self::ServiceUnavailable => { + "Service temporarily unavailable" + } + } + } + + pub fn body(self, operation: &str) -> ProblemBody { + ProblemBody { + type_uri: format!("{PROBLEM_BASE}{}", self.code()), + title: self.title().to_string(), + status: self.status().as_u16(), + code: self.code().to_string(), + operation: operation.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unresolved_internal_classes_share_one_exact_public_shape() { + let no_match = ProblemCode::EvidenceNotAvailable.body("operation-0000000000000001"); + let ambiguous = ProblemCode::EvidenceNotAvailable.body("operation-0000000000000001"); + assert_eq!(no_match, ambiguous); + assert_eq!( + serde_json::to_value(no_match).expect("problem serializes"), + serde_json::json!({ + "type": "https://registrystack.org/problems/evidence/evidence_not_available", + "title": "Evidence could not be produced", + "status": 422, + "code": "evidence_not_available", + "operation": "operation-0000000000000001" + }) + ); + } + + #[test] + fn every_code_has_the_contract_status_and_title() { + let cases = [ + (ProblemCode::MalformedRequest, 400, "Request is not valid"), + (ProblemCode::InvalidSelector, 400, "Request is not valid"), + ( + ProblemCode::AuthenticationFailed, + 401, + "Authentication failed", + ), + (ProblemCode::NotAuthorized, 403, "Request is not authorized"), + ( + ProblemCode::EvidenceNotAvailable, + 422, + "Evidence could not be produced", + ), + (ProblemCode::RateLimited, 429, "Request rate exceeded"), + ( + ProblemCode::DependencyUnavailable, + 503, + "Service temporarily unavailable", + ), + ( + ProblemCode::ServiceUnavailable, + 503, + "Service temporarily unavailable", + ), + ]; + for (code, status, title) in cases { + assert_eq!(code.status().as_u16(), status); + assert_eq!(code.title(), title); + } + } +} diff --git a/crates/registry-evidence/src/rate_limit.rs b/crates/registry-evidence/src/rate_limit.rs new file mode 100644 index 000000000..6aca7b043 --- /dev/null +++ b/crates/registry-evidence/src/rate_limit.rs @@ -0,0 +1,273 @@ +//! In-memory pseudonym-keyed request and failed-selector rate limits. + +use std::{collections::HashMap, time::Duration}; + +use thiserror::Error; +use tokio::time::Instant; + +const MAX_TRACKED_KEYS: usize = 100_000; + +#[derive(Debug, Clone, Copy)] +pub struct RateLimitConfig { + pub requests_per_principal_per_minute: u32, + pub burst_per_principal: u32, + pub failed_selector_attempts_per_principal_authority_per_minute: u32, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitError { + #[error("rate-limit configuration is invalid")] + Configuration, + #[error("request rate exceeded")] + RequestExceeded, + #[error("failed-selector rate exceeded")] + FailedSelectorExceeded, + #[error("rate-limit capacity is unavailable")] + Capacity, +} + +#[derive(Debug)] +struct TokenBucket { + tokens: f64, + updated_at: Instant, +} + +#[derive(Debug)] +struct FixedWindow { + started_at: Instant, + count: u32, +} + +#[derive(Debug)] +pub struct EvidenceRateLimiter { + config: RateLimitConfig, + requests: tokio::sync::Mutex>, + selector_failures: tokio::sync::Mutex>, +} + +impl EvidenceRateLimiter { + pub fn new(config: RateLimitConfig) -> Result { + if config.requests_per_principal_per_minute == 0 + || config.burst_per_principal == 0 + || config.failed_selector_attempts_per_principal_authority_per_minute == 0 + { + return Err(RateLimitError::Configuration); + } + Ok(Self { + config, + requests: tokio::sync::Mutex::new(HashMap::new()), + selector_failures: tokio::sync::Mutex::new(HashMap::new()), + }) + } + + pub async fn check_request(&self, principal_pseudonym: &str) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_pseudonym)?; + let now = Instant::now(); + let mut buckets = self.requests.lock().await; + prune_buckets(&mut buckets, now); + if !buckets.contains_key(principal_pseudonym) && buckets.len() >= MAX_TRACKED_KEYS { + return Err(RateLimitError::Capacity); + } + let capacity = f64::from(self.config.burst_per_principal); + let refill_per_second = f64::from(self.config.requests_per_principal_per_minute) / 60.0; + let bucket = buckets + .entry(principal_pseudonym.to_owned()) + .or_insert(TokenBucket { + tokens: capacity, + updated_at: now, + }); + let elapsed = now.duration_since(bucket.updated_at).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * refill_per_second).min(capacity); + bucket.updated_at = now; + if bucket.tokens < 1.0 { + return Err(RateLimitError::RequestExceeded); + } + bucket.tokens -= 1.0; + Ok(()) + } + + /// Check the selector-failure budget before source access. Call + /// [`Self::record_selector_failure`] only when selector validation or + /// authorization actually fails. + pub async fn check_selector_failure_budget( + &self, + principal_authority_pseudonym: &str, + ) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_authority_pseudonym)?; + let now = Instant::now(); + let mut windows = self.selector_failures.lock().await; + prune_windows(&mut windows, now); + match windows.get(principal_authority_pseudonym) { + Some(window) + if now.duration_since(window.started_at) < Duration::from_secs(60) + && window.count + >= self + .config + .failed_selector_attempts_per_principal_authority_per_minute => + { + Err(RateLimitError::FailedSelectorExceeded) + } + _ => Ok(()), + } + } + + pub async fn record_selector_failure( + &self, + principal_authority_pseudonym: &str, + ) -> Result<(), RateLimitError> { + validate_pseudonym_key(principal_authority_pseudonym)?; + let now = Instant::now(); + let mut windows = self.selector_failures.lock().await; + prune_windows(&mut windows, now); + if !windows.contains_key(principal_authority_pseudonym) && windows.len() >= MAX_TRACKED_KEYS + { + return Err(RateLimitError::Capacity); + } + let window = windows + .entry(principal_authority_pseudonym.to_owned()) + .or_insert(FixedWindow { + started_at: now, + count: 0, + }); + if now.duration_since(window.started_at) >= Duration::from_secs(60) { + window.started_at = now; + window.count = 0; + } + window.count = window.count.saturating_add(1); + if window.count + > self + .config + .failed_selector_attempts_per_principal_authority_per_minute + { + return Err(RateLimitError::FailedSelectorExceeded); + } + Ok(()) + } +} + +fn validate_pseudonym_key(key: &str) -> Result<(), RateLimitError> { + if key.is_empty() || key.len() > 256 || key.chars().any(char::is_whitespace) { + return Err(RateLimitError::Configuration); + } + Ok(()) +} + +fn prune_buckets(buckets: &mut HashMap, now: Instant) { + if buckets.len() < MAX_TRACKED_KEYS / 2 { + return; + } + buckets.retain(|_, bucket| now.duration_since(bucket.updated_at) < Duration::from_secs(600)); +} + +fn prune_windows(windows: &mut HashMap, now: Instant) { + if windows.len() < MAX_TRACKED_KEYS / 2 { + return; + } + windows.retain(|_, window| now.duration_since(window.started_at) < Duration::from_secs(120)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limiter() -> EvidenceRateLimiter { + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds") + } + + #[tokio::test] + async fn request_burst_and_refill_are_enforced() { + let limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 6_000, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"); + limiter.check_request("pseudonym-a").await.expect("first"); + limiter.check_request("pseudonym-a").await.expect("second"); + assert_eq!( + limiter.check_request("pseudonym-a").await, + Err(RateLimitError::RequestExceeded) + ); + tokio::time::sleep(Duration::from_millis(11)).await; + limiter + .check_request("pseudonym-a") + .await + .expect("refilled"); + } + + #[tokio::test] + async fn request_budget_is_shared_by_every_use_of_a_principal_key() { + let limiter = limiter(); + let principal_key = "stable-principal-pseudonym"; + + for _request_context in [ + ("adult", "service-enrolment", "audience-a"), + ("residence", "benefit-eligibility", "audience-b"), + ] { + limiter + .check_request(principal_key) + .await + .expect("shared principal budget has capacity"); + } + + assert_eq!( + limiter.check_request(principal_key).await, + Err(RateLimitError::RequestExceeded) + ); + limiter + .check_request("other-principal-pseudonym") + .await + .expect("another principal has an independent budget"); + } + + #[tokio::test] + async fn selector_failure_budget_is_separate_and_authority_scoped() { + let limiter = limiter(); + limiter + .record_selector_failure("principal-authority-a") + .await + .expect("first failure"); + limiter + .record_selector_failure("principal-authority-a") + .await + .expect("second failure"); + assert_eq!( + limiter + .check_selector_failure_budget("principal-authority-a") + .await, + Err(RateLimitError::FailedSelectorExceeded) + ); + limiter + .check_selector_failure_budget("principal-authority-b") + .await + .expect("other authority remains available"); + } + + #[tokio::test] + async fn selector_failure_budget_is_shared_across_request_contexts() { + let limiter = limiter(); + let principal_authority_key = "stable-principal-authority-pseudonym"; + + for _request_context in [ + ("service-enrolment", "audience-a"), + ("benefit-eligibility", "audience-b"), + ] { + limiter + .record_selector_failure(principal_authority_key) + .await + .expect("shared selector-failure budget has capacity"); + } + + assert_eq!( + limiter + .check_selector_failure_budget(principal_authority_key) + .await, + Err(RateLimitError::FailedSelectorExceeded) + ); + } +} diff --git a/crates/registry-evidence/src/rhai_runtime.rs b/crates/registry-evidence/src/rhai_runtime.rs new file mode 100644 index 000000000..69bcb69d7 --- /dev/null +++ b/crates/registry-evidence/src/rhai_runtime.rs @@ -0,0 +1,2986 @@ +//! Hardened, deterministic Rhai execution for Evidence bundle scripts. + +use std::{ + cmp::Ordering, + collections::{BTreeMap, BTreeSet}, + fmt, + sync::Arc, +}; + +use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; +use rhai::{ + Array, CallFnOptions, Dynamic, Engine, EvalAltResult, ImmutableString, Map, Module, Scope, AST, + INT, +}; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + model::LookupResult, + values::{Decimal, EntityReferenceSeed}, +}; + +pub const MAXIMUM_OPERATIONS: u64 = 100_000; +pub const MAXIMUM_CALL_DEPTH: usize = 32; +pub const MAXIMUM_EXPRESSION_DEPTH: usize = 64; +pub const MAXIMUM_MODULES: usize = 0; +pub const MAXIMUM_STRING_BYTES: usize = 16_384; +pub const MAXIMUM_ARRAY_ITEMS: usize = 256; +pub const MAXIMUM_MAP_ENTRIES: usize = 256; +pub const MAXIMUM_FACT_ENTRIES: usize = 64; +pub const MAXIMUM_CONCEPT_VALUES: usize = 16; +pub const MAXIMUM_CODELIST_ENTRIES: usize = 4_096; +pub const MAXIMUM_SOURCE_INPUT_BYTES: usize = 1_048_576; +pub const MAXIMUM_RESULT_BYTES: usize = 65_536; +pub const MAXIMUM_PREPARATION_INPUT_BYTES: usize = 1_048_576; +pub const MAXIMUM_REQUEST_PARTS_BYTES: usize = 65_536; +pub const MAXIMUM_QUERY_PAIRS: usize = 64; +pub const MAXIMUM_QUERY_NAME_BYTES: usize = 64; +pub const MAXIMUM_QUERY_VALUE_BYTES: usize = 4_096; +pub const MAXIMUM_JSON_BODY_DEPTH: usize = 32; + +const MAXIMUM_BUCKETS: usize = 64; +const MAXIMUM_ENTITY_REFERENCE_ITEMS: usize = 64; +const MAXIMUM_REQUIRED_CODE_BYTES: usize = 64; + +#[derive(Clone, Copy, Debug)] +struct RequiredUnavailable; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum RhaiRuntimeError { + #[error("Evidence script compilation failed")] + Compilation, + #[error("Evidence script has an invalid entry point")] + EntryPoint, + #[error("Evidence script invocation failed")] + Invocation, + #[error("Evidence required input is unavailable")] + Unavailable, + #[error("Evidence source response violates its protocol contract")] + SourceProtocol, + #[error("Evidence script input exceeds its bound")] + InputBound, + #[error("Evidence request preparation input is invalid")] + AdapterInput, + #[error("Evidence request preparation result violates the closed ABI")] + PreparationResult, + #[error("Evidence extraction result violates the closed ABI")] + ExtractionResult, + #[error("Evidence extracted facts violate their schema")] + FactSchema, + #[error("Evidence derivation result violates the closed ABI")] + DerivationResult, + #[error("Evidence derivation input violates its reviewed contract")] + DerivationInput, + #[error("Evidence evaluation context is invalid")] + EvaluationContext, + #[error("Evidence codelist is invalid")] + Codelist, +} + +/// A schema hook kept separate from bundle configuration while that layer is loaded. +pub trait FactSchemaValidator { + fn is_valid(&self, facts: &Value) -> bool; +} + +impl FactSchemaValidator for jsonschema::JSONSchema { + fn is_valid(&self, facts: &Value) -> bool { + jsonschema::JSONSchema::is_valid(self, facts) + } +} + +impl FactSchemaValidator for F +where + F: Fn(&Value) -> bool, +{ + fn is_valid(&self, facts: &Value) -> bool { + self(facts) + } +} + +#[derive(Clone, Debug)] +pub struct CompiledExtraction { + ast: AST, +} + +#[derive(Clone, Debug)] +pub struct CompiledPreparation { + ast: AST, +} + +#[derive(Clone, Debug)] +pub struct CompiledDerivation { + ast: AST, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RequestPartRequirement { + Forbidden, + Optional, + Required, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestPartsLimits { + query: RequestPartRequirement, + body: RequestPartRequirement, + maximum_query_pairs: usize, + maximum_query_name_bytes: usize, + maximum_query_value_bytes: usize, + maximum_json_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, + maximum_normalized_bytes: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RequestPartsBounds { + pub maximum_query_pairs: usize, + pub maximum_query_name_bytes: usize, + pub maximum_query_value_bytes: usize, + pub maximum_json_depth: usize, + pub maximum_collection_items: usize, + pub maximum_string_bytes: usize, + pub maximum_normalized_bytes: usize, +} + +impl RequestPartsLimits { + pub fn new( + query: RequestPartRequirement, + body: RequestPartRequirement, + bounds: RequestPartsBounds, + ) -> Result { + let RequestPartsBounds { + maximum_query_pairs, + maximum_query_name_bytes, + maximum_query_value_bytes, + maximum_json_depth, + maximum_collection_items, + maximum_string_bytes, + maximum_normalized_bytes, + } = bounds; + if maximum_query_pairs == 0 + || maximum_query_pairs > MAXIMUM_QUERY_PAIRS + || maximum_query_name_bytes == 0 + || maximum_query_name_bytes > MAXIMUM_QUERY_NAME_BYTES + || maximum_query_value_bytes == 0 + || maximum_query_value_bytes > MAXIMUM_QUERY_VALUE_BYTES + || maximum_json_depth == 0 + || maximum_json_depth > MAXIMUM_JSON_BODY_DEPTH + || maximum_collection_items == 0 + || maximum_collection_items > MAXIMUM_ARRAY_ITEMS + || maximum_string_bytes == 0 + || maximum_string_bytes > MAXIMUM_STRING_BYTES + || maximum_normalized_bytes == 0 + || maximum_normalized_bytes > MAXIMUM_REQUEST_PARTS_BYTES + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(Self { + query, + body, + maximum_query_pairs, + maximum_query_name_bytes, + maximum_query_value_bytes, + maximum_json_depth, + maximum_collection_items, + maximum_string_bytes, + maximum_normalized_bytes, + }) + } +} + +#[derive(Clone, PartialEq)] +pub struct QueryPair { + pub name: String, + pub value: String, +} + +impl fmt::Debug for QueryPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("QueryPair") + .field("name", &"[redacted]") + .field("value", &"[redacted]") + .finish() + } +} + +#[derive(Clone, PartialEq)] +pub struct RequestParts { + pub query: Vec, + pub body: Option, +} + +impl fmt::Debug for RequestParts { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RequestParts") + .field("query_pairs", &self.query.len()) + .field("body_present", &self.body.is_some()) + .finish() + } +} + +/// Strict proleptic-Gregorian date exposed to Rhai as an opaque typed value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct CalendarDate(NaiveDate); + +impl CalendarDate { + pub fn parse(input: &str) -> Result { + if !is_canonical_date_text(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + NaiveDate::parse_from_str(input, "%Y-%m-%d") + .map(Self) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + + pub fn as_naive_date(self) -> NaiveDate { + self.0 + } +} + +/// Strict RFC 3339 instant normalized to UTC without ambient clock access. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct UtcInstant(DateTime); + +impl UtcInstant { + pub fn parse(input: &str) -> Result { + if !is_strict_rfc3339(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + DateTime::parse_from_rfc3339(input) + .map(|value| Self(value.with_timezone(&Utc))) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + + pub fn as_utc(self) -> DateTime { + self.0 + } +} + +/// A validated local clock time carrying its explicit UTC offset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegalLocalTime(ImmutableString); + +impl LegalLocalTime { + pub fn parse(input: &str) -> Result { + if !is_strict_local_time(input) { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok(Self(input.into())) + } +} + +/// Read-only, bounded mapping handle. Rhai can only pass it to `codelist_lookup`. +#[derive(Clone)] +pub struct CodelistHandle { + entries: Arc>, +} + +impl CodelistHandle { + pub fn new(entries: BTreeMap) -> Result { + if entries.len() > MAXIMUM_CODELIST_ENTRIES + || entries.iter().any(|(input, output)| { + input.is_empty() + || output.is_empty() + || input.len() > MAXIMUM_STRING_BYTES + || output.len() > MAXIMUM_STRING_BYTES + }) + { + return Err(RhaiRuntimeError::Codelist); + } + Ok(Self { + entries: Arc::new(entries), + }) + } +} + +impl fmt::Debug for CodelistHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CodelistHandle") + .field("entry_count", &self.entries.len()) + .finish() + } +} + +/// The only values derivation may pass to the lead output gate. +#[derive(Clone)] +pub enum DerivedValue { + Json(Value), + Decimal(Decimal), + EntityReferenceSeed(EntityReferenceSeed), + EntityReferenceSeedList(Vec), +} + +impl fmt::Debug for DerivedValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(value) => formatter + .debug_struct("DerivedValue::Json") + .field("form", &json_form(value)) + .finish(), + Self::Decimal(_) => formatter.write_str("DerivedValue::Decimal([REDACTED])"), + Self::EntityReferenceSeed(_) => { + formatter.write_str("DerivedValue::EntityReferenceSeed([REDACTED])") + } + Self::EntityReferenceSeedList(values) => formatter + .debug_struct("DerivedValue::EntityReferenceSeedList") + .field("count", &values.len()) + .finish(), + } + } +} + +fn json_form(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[derive(Clone)] +pub struct DerivedConceptValue { + pub concept_id: String, + pub value: DerivedValue, +} + +impl fmt::Debug for DerivedConceptValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DerivedConceptValue") + .field("concept_id", &self.concept_id) + .field("value", &self.value) + .finish() + } +} + +/// Exact deterministic input to `derive`. +#[derive(Clone, Debug)] +pub struct EvaluationContext { + observed_at: UtcInstant, + legal_local_date: CalendarDate, + legal_local_time: LegalLocalTime, + parameters: Map, + codelists: BTreeMap, +} + +impl EvaluationContext { + pub fn new( + observed_at: UtcInstant, + legal_local_date: CalendarDate, + legal_local_time: LegalLocalTime, + parameters: &Value, + codelists: BTreeMap, + ) -> Result { + if codelists.len() > MAXIMUM_MAP_ENTRIES + || codelists + .keys() + .any(|name| name.is_empty() || name.len() > MAXIMUM_STRING_BYTES) + { + return Err(RhaiRuntimeError::EvaluationContext); + } + validate_json_bound(parameters, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::EvaluationContext)?; + let parameters = parameters_to_map(parameters)?; + Ok(Self { + observed_at, + legal_local_date, + legal_local_time, + parameters, + codelists, + }) + } + + fn into_dynamic(self) -> Dynamic { + let mut codelists = Map::new(); + for (name, handle) in self.codelists { + codelists.insert(name.into(), Dynamic::from(handle)); + } + let mut context = Map::new(); + context.insert("observed_at".into(), Dynamic::from(self.observed_at)); + context.insert( + "legal_local_date".into(), + Dynamic::from(self.legal_local_date), + ); + context.insert( + "legal_local_time".into(), + Dynamic::from(self.legal_local_time), + ); + context.insert("parameters".into(), Dynamic::from(self.parameters)); + context.insert("codelists".into(), Dynamic::from(codelists)); + Dynamic::from(context) + } +} + +/// One immutable capability allowlist used by every bundle script. +pub struct RhaiRuntime { + engine: Engine, +} + +impl fmt::Debug for RhaiRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhaiRuntime()") + } +} + +impl Default for RhaiRuntime { + fn default() -> Self { + Self::new() + } +} + +impl RhaiRuntime { + pub fn new() -> Self { + let mut engine = Engine::new_raw(); + engine + .set_max_operations(MAXIMUM_OPERATIONS) + .set_max_call_levels(MAXIMUM_CALL_DEPTH) + .set_max_expr_depths(MAXIMUM_EXPRESSION_DEPTH, MAXIMUM_EXPRESSION_DEPTH) + .set_max_modules(MAXIMUM_MODULES) + .set_max_string_size(MAXIMUM_STRING_BYTES) + .set_max_array_size(MAXIMUM_ARRAY_ITEMS) + .set_max_map_size(MAXIMUM_MAP_ENTRIES) + .set_allow_anonymous_fn(false) + .disable_symbol("import") + .disable_symbol("export") + .disable_symbol("eval") + .disable_symbol("print") + .disable_symbol("debug") + .disable_symbol("while") + .disable_symbol("until") + .disable_symbol("loop") + .disable_symbol("do") + .disable_symbol("switch") + .disable_symbol("try") + .disable_symbol("catch") + .disable_symbol("..") + .disable_symbol("..=") + .disable_symbol("?.") + .disable_symbol("??"); + + let mut iterators = Module::new(); + iterators.set_iterable::(); + engine.register_global_module(iterators.into()); + + register_language_essentials(&mut engine); + register_evidence_primitives(&mut engine); + + Self { engine } + } + + pub fn compile_preparation( + &self, + source: &str, + ) -> Result { + self.compile_exact(source, "prepare", 2) + .map(|ast| CompiledPreparation { ast }) + } + + pub fn compile_extraction(&self, source: &str) -> Result { + self.compile_exact(source, "extract", 2) + .map(|ast| CompiledExtraction { ast }) + } + + pub fn compile_derivation(&self, source: &str) -> Result { + self.compile_exact(source, "derive", 3) + .map(|ast| CompiledDerivation { ast }) + } + + pub fn prepare( + &self, + script: &CompiledPreparation, + selectors: &Value, + parameters: &Value, + limits: &RequestPartsLimits, + ) -> Result { + validate_adapter_inputs(selectors, parameters)?; + let selectors = adapter_object_to_dynamic(selectors)?; + let parameters = adapter_object_to_dynamic(parameters)?; + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "prepare", + (selectors, parameters), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Preparation))?; + decode_request_parts(result, limits) + } + + pub fn extract( + &self, + script: &CompiledExtraction, + source_response: &Value, + parameters: &Value, + fact_schema: &V, + ) -> Result + where + V: FactSchemaValidator + ?Sized, + { + validate_json_bound(source_response, MAXIMUM_SOURCE_INPUT_BYTES)?; + if !json_numbers_are_supported(source_response) { + return Err(RhaiRuntimeError::InputBound); + } + validate_adapter_object(parameters)?; + let input = + rhai::serde::to_dynamic(source_response).map_err(|_| RhaiRuntimeError::InputBound)?; + let parameters = adapter_object_to_dynamic(parameters)?; + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "extract", + (input, parameters), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Extraction))?; + decode_lookup_result(result, fact_schema) + } + + pub fn derive( + &self, + script: &CompiledDerivation, + facts: &BTreeMap, + selectors: &Value, + evaluation_context: EvaluationContext, + ) -> Result, RhaiRuntimeError> { + if facts.len() > MAXIMUM_FACT_ENTRIES { + return Err(RhaiRuntimeError::InputBound); + } + let facts_value = Value::Object( + facts + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + ); + validate_json_bound(&facts_value, MAXIMUM_RESULT_BYTES)?; + let facts = + rhai::serde::to_dynamic(facts_value).map_err(|_| RhaiRuntimeError::InputBound)?; + validate_adapter_object(selectors)?; + let selectors = adapter_object_to_dynamic(selectors)?; + let context = evaluation_context.into_dynamic(); + let result = self + .engine + .call_fn_with_options::( + CallFnOptions::new().eval_ast(false), + &mut Scope::new(), + &script.ast, + "derive", + (facts, selectors, context), + ) + .map_err(|error| classify_invocation_error(error, ScriptStage::Derivation))?; + decode_derivation_result(result) + } + + fn compile_exact( + &self, + source: &str, + function_name: &str, + parameter_count: usize, + ) -> Result { + if source.len() > MAXIMUM_RESULT_BYTES { + return Err(RhaiRuntimeError::InputBound); + } + validate_script_source(source)?; + let ast = self + .engine + .compile(source) + .map_err(|_| RhaiRuntimeError::Compilation)?; + let mut names = BTreeSet::new(); + let mut entry_points = 0usize; + for function in ast.iter_functions() { + if !names.insert(function.name) { + return Err(RhaiRuntimeError::EntryPoint); + } + if function.name == function_name { + if function.params.len() != parameter_count + || function.access != rhai::FnAccess::Public + { + return Err(RhaiRuntimeError::EntryPoint); + } + entry_points += 1; + } + } + if entry_points != 1 { + return Err(RhaiRuntimeError::EntryPoint); + } + Ok(ast) + } + + #[cfg(test)] + fn engine(&self) -> &Engine { + &self.engine + } +} + +fn register_language_essentials(engine: &mut Engine) { + engine + .register_fn("==", |left: INT, right: INT| left == right) + .register_fn("!=", |left: INT, right: INT| left != right) + .register_fn("<", |left: INT, right: INT| left < right) + .register_fn("<=", |left: INT, right: INT| left <= right) + .register_fn(">", |left: INT, right: INT| left > right) + .register_fn(">=", |left: INT, right: INT| left >= right) + .register_fn("!", |value: bool| !value) + .register_fn("==", |left: bool, right: bool| left == right) + .register_fn("!=", |left: bool, right: bool| left != right) + .register_fn("==", |left: ImmutableString, right: ImmutableString| { + left == right + }) + .register_fn("!=", |left: ImmutableString, right: ImmutableString| { + left != right + }) + .register_get("len", |array: &mut Array| { + INT::try_from(array.len()).unwrap_or(INT::MAX) + }) + .register_fn("len", |map: Map| { + INT::try_from(map.len()).unwrap_or(INT::MAX) + }) + .register_fn("contains", |map: Map, name: ImmutableString| { + map.contains_key(name.as_str()) + }) + .register_fn("push", bounded_array_push) + .register_fn("replace", literal_string_replace) + .register_fn("parse_integer", parse_integer); +} + +fn register_evidence_primitives(engine: &mut Engine) { + engine + .register_type_with_name::("Date") + .register_type_with_name::("Instant") + .register_type_with_name::("LegalLocalTime") + .register_type_with_name::("Decimal") + .register_type_with_name::("EntityReferenceSeed") + .register_type_with_name::("CodelistHandle") + .register_fn("parse_date", parse_date) + .register_fn("parse_instant", parse_instant) + .register_fn("decimal", parse_decimal) + .register_fn("parse_decimal", parse_decimal) + .register_fn("integer_to_decimal", integer_to_decimal) + .register_fn("add_calendar_years", add_calendar_years) + .register_fn("add_calendar_months", add_calendar_months) + .register_fn("compare_dates", compare_dates) + .register_fn("compare_instants", compare_instants) + .register_fn("days_between", days_between) + .register_fn("compare_decimals", compare_decimals) + .register_fn("bucket_number", bucket_number) + .register_fn("entity_reference_seed", entity_reference_seed) + .register_fn("codelist_lookup", codelist_lookup) + .register_fn("list_contains", list_contains) + .register_fn("set_contains", set_contains) + .register_fn("required", required) + .register_fn("is_missing", is_missing); +} + +fn parse_date(input: &str) -> Result> { + CalendarDate::parse(input).map_err(|_| primitive_error("invalid_date")) +} + +fn parse_instant(input: &str) -> Result> { + UtcInstant::parse(input).map_err(|_| primitive_error("invalid_instant")) +} + +fn parse_decimal(input: &str) -> Result> { + Decimal::parse(input).map_err(|_| primitive_error("invalid_decimal")) +} + +fn integer_to_decimal(value: INT) -> Decimal { + Decimal::from_integer(value) +} + +fn add_calendar_years(date: CalendarDate, years: INT) -> Result> { + if !(-1_000..=1_000).contains(&years) { + return Err(primitive_error("calendar_years_out_of_bounds")); + } + let months = years + .checked_mul(12) + .ok_or_else(|| primitive_error("calendar_years_out_of_bounds"))?; + add_calendar_months(date, months) +} + +fn add_calendar_months( + date: CalendarDate, + months: INT, +) -> Result> { + if !(-12_000..=12_000).contains(&months) { + return Err(primitive_error("calendar_months_out_of_bounds")); + } + let month_index = i64::from(date.0.year()) + .checked_mul(12) + .and_then(|value| value.checked_add(i64::from(date.0.month0()))) + .and_then(|value| value.checked_add(months)) + .ok_or_else(|| primitive_error("invalid_calendar_result"))?; + let year = i32::try_from(month_index.div_euclid(12)) + .map_err(|_| primitive_error("invalid_calendar_result"))?; + let month = u32::try_from(month_index.rem_euclid(12) + 1) + .map_err(|_| primitive_error("invalid_calendar_result"))?; + let day = date.0.day().min(last_day_of_month(year, month)?); + NaiveDate::from_ymd_opt(year, month, day) + .map(CalendarDate) + .ok_or_else(|| primitive_error("invalid_calendar_result")) +} + +fn compare_dates(left: CalendarDate, right: CalendarDate) -> INT { + ordering_value(left.cmp(&right)) +} + +fn compare_instants(left: UtcInstant, right: UtcInstant) -> INT { + ordering_value(left.cmp(&right)) +} + +fn days_between(first: CalendarDate, second: CalendarDate) -> Result> { + let days = second.0.signed_duration_since(first.0).num_days(); + if !(-365_000..=365_000).contains(&days) { + return Err(primitive_error("calendar_days_out_of_bounds")); + } + Ok(days) +} + +fn compare_decimals(left: Decimal, right: Decimal) -> INT { + ordering_value(left.compare(&right)) +} + +fn bucket_number(value: Decimal, boundaries: Array) -> Result> { + if boundaries.is_empty() || boundaries.len() > MAXIMUM_BUCKETS { + return Err(primitive_error("invalid_numeric_buckets")); + } + + let mut parsed = Vec::with_capacity(boundaries.len()); + let mut codes = BTreeSet::new(); + for boundary in boundaries { + let map = boundary + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + if !has_exact_keys(&map, &["minimumInclusive", "maximumExclusive", "code"]) { + return Err(primitive_error("invalid_numeric_buckets")); + } + let minimum = map["minimumInclusive"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + let maximum = map["maximumExclusive"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + let code = map["code"] + .clone() + .try_cast::() + .ok_or_else(|| primitive_error("invalid_numeric_buckets"))?; + if minimum.compare(&maximum) != Ordering::Less + || code.is_empty() + || code.len() > MAXIMUM_STRING_BYTES + || !codes.insert(code.to_string()) + { + return Err(primitive_error("invalid_numeric_buckets")); + } + if parsed.last().is_some_and( + |(_, previous_maximum, _): &(Decimal, Decimal, ImmutableString)| { + previous_maximum.compare(&minimum) != Ordering::Equal + }, + ) { + return Err(primitive_error("invalid_numeric_buckets")); + } + parsed.push((minimum, maximum, code)); + } + + parsed + .into_iter() + .find(|(minimum, maximum, _)| { + value.compare(minimum) != Ordering::Less && value.compare(maximum) == Ordering::Less + }) + .map(|(_, _, code)| code) + .ok_or_else(|| primitive_error("number_outside_bucket_range")) +} + +fn entity_reference_seed(input: &str) -> Result> { + EntityReferenceSeed::new(input).map_err(|_| primitive_error("invalid_entity_reference_seed")) +} + +fn codelist_lookup(handle: CodelistHandle, code: &str) -> Dynamic { + handle + .entries + .get(code) + .cloned() + .map(Dynamic::from) + .unwrap_or(Dynamic::UNIT) +} + +fn list_contains(values: Array, needle: Dynamic) -> Result> { + if values.len() > MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; + for value in values { + let value = scalar_value(&value).ok_or_else(|| primitive_error("invalid_scalar"))?; + if value == needle { + return Ok(true); + } + } + Ok(false) +} + +fn set_contains(values: Array, needle: Dynamic) -> Result> { + if values.len() > MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; + let mut unique = BTreeSet::new(); + for value in values { + let value = scalar_value(&value).ok_or_else(|| primitive_error("invalid_scalar"))?; + if !unique.insert(value.clone()) { + return Err(primitive_error("set_contains_duplicate")); + } + } + Ok(unique.contains(&needle)) +} + +fn bounded_array_push(array: &mut Array, value: Dynamic) -> Result<(), Box> { + if array.len() >= MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + array.push(value); + Ok(()) +} + +fn literal_string_replace( + value: &mut ImmutableString, + from: &str, + to: &str, +) -> Result<(), Box> { + let occurrences = if from.is_empty() { + value.chars().count().saturating_add(1) + } else { + value.match_indices(from).count() + }; + let retained = if from.is_empty() { + value.len() + } else { + value + .len() + .checked_sub( + occurrences + .checked_mul(from.len()) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?, + ) + .ok_or_else(|| primitive_error("string_out_of_bounds"))? + }; + let output_len = retained + .checked_add( + occurrences + .checked_mul(to.len()) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?, + ) + .ok_or_else(|| primitive_error("string_out_of_bounds"))?; + if output_len > MAXIMUM_STRING_BYTES { + return Err(primitive_error("string_out_of_bounds")); + } + *value = value.replace(from, to).into(); + Ok(()) +} + +fn parse_integer(value: &str) -> Result> { + let digits = value.strip_prefix('-').unwrap_or(value); + if digits.is_empty() + || !digits.bytes().all(|byte| byte.is_ascii_digit()) + || value.starts_with('+') + { + return Err(primitive_error("invalid_integer")); + } + value + .parse::() + .map_err(|_| primitive_error("invalid_integer")) +} + +fn required(value: Dynamic, error_code: &str) -> Result> { + if !is_safe_error_code(error_code) { + return Err(primitive_error("invalid_required_error_code")); + } + if value.is_unit() { + return Err(EvalAltResult::ErrorRuntime( + Dynamic::from(RequiredUnavailable), + rhai::Position::NONE, + ) + .into()); + } + Ok(value) +} + +#[derive(Clone, Copy)] +enum ScriptStage { + Preparation, + Extraction, + Derivation, +} + +fn classify_invocation_error(error: Box, stage: ScriptStage) -> RhaiRuntimeError { + if contains_unavailable_signal(&error) { + RhaiRuntimeError::Unavailable + } else { + match stage { + ScriptStage::Preparation if contains_runtime_signal(&error, "adapter_input_error") => { + RhaiRuntimeError::AdapterInput + } + ScriptStage::Extraction if contains_runtime_signal(&error, "source_protocol_error") => { + RhaiRuntimeError::SourceProtocol + } + ScriptStage::Derivation + if contains_runtime_signal(&error, "derivation_input_error") => + { + RhaiRuntimeError::DerivationInput + } + _ => RhaiRuntimeError::Invocation, + } + } +} + +fn contains_unavailable_signal(error: &EvalAltResult) -> bool { + match error { + EvalAltResult::ErrorRuntime(value, _) => value.is::(), + EvalAltResult::ErrorInFunctionCall(_, _, inner, _) + | EvalAltResult::ErrorInModule(_, inner, _) => contains_unavailable_signal(inner), + _ => false, + } +} + +fn contains_runtime_signal(error: &EvalAltResult, expected: &str) -> bool { + match error { + EvalAltResult::ErrorRuntime(value, _) => value + .clone() + .try_cast::() + .is_some_and(|signal| signal.as_str() == expected), + EvalAltResult::ErrorInFunctionCall(_, _, inner, _) + | EvalAltResult::ErrorInModule(_, inner, _) => contains_runtime_signal(inner, expected), + _ => false, + } +} + +fn is_missing(value: Dynamic) -> bool { + value.is_unit() +} + +fn decode_lookup_result( + result: Dynamic, + fact_schema: &V, +) -> Result +where + V: FactSchemaValidator + ?Sized, +{ + let map = result + .try_cast::() + .ok_or(RhaiRuntimeError::ExtractionResult)?; + let outcome = map + .get("outcome") + .and_then(|value| value.clone().try_cast::()) + .ok_or(RhaiRuntimeError::ExtractionResult)?; + match outcome.as_str() { + "no_match" if has_exact_keys(&map, &["outcome"]) => Ok(LookupResult::NoMatch), + "ambiguous" if has_exact_keys(&map, &["outcome"]) => Ok(LookupResult::Ambiguous), + "match" if has_exact_keys(&map, &["outcome", "facts"]) => { + let facts_dynamic = map.get("facts").ok_or(RhaiRuntimeError::ExtractionResult)?; + if !dynamic_is_json(facts_dynamic) { + return Err(RhaiRuntimeError::ExtractionResult); + } + let facts: Value = rhai::serde::from_dynamic(facts_dynamic) + .map_err(|_| RhaiRuntimeError::ExtractionResult)?; + let object = facts + .as_object() + .ok_or(RhaiRuntimeError::ExtractionResult)?; + if object.len() > MAXIMUM_FACT_ENTRIES { + return Err(RhaiRuntimeError::ExtractionResult); + } + validate_json_bound(&facts, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::ExtractionResult)?; + if !fact_schema.is_valid(&facts) { + return Err(RhaiRuntimeError::FactSchema); + } + Ok(LookupResult::Match( + object + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(), + )) + } + _ => Err(RhaiRuntimeError::ExtractionResult), + } +} + +fn decode_derivation_result(result: Dynamic) -> Result, RhaiRuntimeError> { + let array = result + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)?; + if array.is_empty() || array.len() > MAXIMUM_CONCEPT_VALUES { + return Err(RhaiRuntimeError::DerivationResult); + } + let mut result = Vec::with_capacity(array.len()); + let mut identifiers = BTreeSet::new(); + let mut total_bytes = 2usize; + for item in array { + let map = item + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)?; + if !has_exact_keys(&map, &["concept_id", "value"]) { + return Err(RhaiRuntimeError::DerivationResult); + } + let concept_id = map["concept_id"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::DerivationResult)? + .to_string(); + if concept_id.is_empty() + || concept_id.len() > MAXIMUM_STRING_BYTES + || !identifiers.insert(concept_id.clone()) + { + return Err(RhaiRuntimeError::DerivationResult); + } + let value = decode_derived_value(map["value"].clone())?; + let encoded_identifier = serde_json::to_vec(&concept_id) + .map_err(|_| RhaiRuntimeError::DerivationResult)? + .len(); + total_bytes = total_bytes + .checked_add(encoded_identifier) + .and_then(|total| total.checked_add(derived_value_size(&value).ok()?)) + .and_then(|total| total.checked_add(29)) + .ok_or(RhaiRuntimeError::DerivationResult)?; + if total_bytes > MAXIMUM_RESULT_BYTES { + return Err(RhaiRuntimeError::DerivationResult); + } + result.push(DerivedConceptValue { concept_id, value }); + } + Ok(result) +} + +fn decode_derived_value(value: Dynamic) -> Result { + if value.is::() { + return Ok(DerivedValue::Decimal(value.cast::())); + } + if value.is::() { + return Ok(DerivedValue::EntityReferenceSeed( + value.cast::(), + )); + } + if value.is_array() { + let array = value.clone_cast::(); + if array.iter().any(Dynamic::is::) { + if array.is_empty() + || array.len() > MAXIMUM_ENTITY_REFERENCE_ITEMS + || !array.iter().all(Dynamic::is::) + { + return Err(RhaiRuntimeError::DerivationResult); + } + return Ok(DerivedValue::EntityReferenceSeedList( + array + .into_iter() + .map(Dynamic::cast::) + .collect(), + )); + } + } + if !dynamic_is_json(&value) { + return Err(RhaiRuntimeError::DerivationResult); + } + let json = rhai::serde::from_dynamic::(&value) + .map_err(|_| RhaiRuntimeError::DerivationResult)?; + validate_json_bound(&json, MAXIMUM_RESULT_BYTES) + .map_err(|_| RhaiRuntimeError::DerivationResult)?; + Ok(DerivedValue::Json(json)) +} + +fn validate_script_source(source: &str) -> Result<(), RhaiRuntimeError> { + let mut cursor = ScriptCursor::new(source); + while cursor.skip_trivia()? { + let _private = cursor.consume_word("private"); + cursor.skip_trivia()?; + if !cursor.consume_word("fn") { + return Err(RhaiRuntimeError::Compilation); + } + cursor.skip_trivia()?; + if !cursor.consume_identifier() { + return Err(RhaiRuntimeError::Compilation); + } + cursor.skip_trivia()?; + cursor.consume_balanced(b'(', b')')?; + cursor.skip_trivia()?; + cursor.consume_balanced(b'{', b'}')?; + } + + let mut cursor = ScriptCursor::new(source); + let mut previous_significant = None; + while cursor.index < cursor.bytes.len() { + if cursor.skip_noise()? { + continue; + } + let byte = cursor.bytes[cursor.index]; + if byte.is_ascii_whitespace() { + cursor.index += 1; + continue; + } + if is_identifier_start(byte) { + let start = cursor.index; + cursor.index += 1; + while cursor.index < cursor.bytes.len() + && is_identifier_continue(cursor.bytes[cursor.index]) + { + cursor.index += 1; + } + let word = &source[start..cursor.index]; + if word == "Fn" + || matches!(word, "call" | "curry") && previous_significant == Some(b'.') + { + return Err(RhaiRuntimeError::Compilation); + } + previous_significant = word.as_bytes().last().copied(); + continue; + } + if byte == b'[' { + let mut lookahead = ScriptCursor { + source, + bytes: cursor.bytes, + index: cursor.index + 1, + }; + lookahead.skip_trivia()?; + if lookahead.bytes.get(lookahead.index) == Some(&b'-') { + lookahead.index += 1; + lookahead.skip_trivia()?; + if lookahead + .bytes + .get(lookahead.index) + .is_some_and(u8::is_ascii_digit) + { + return Err(RhaiRuntimeError::Compilation); + } + } + } + previous_significant = Some(byte); + cursor.index += 1; + } + Ok(()) +} + +struct ScriptCursor<'a> { + source: &'a str, + bytes: &'a [u8], + index: usize, +} + +impl<'a> ScriptCursor<'a> { + fn new(source: &'a str) -> Self { + Self { + source, + bytes: source.as_bytes(), + index: 0, + } + } + + fn skip_trivia(&mut self) -> Result { + loop { + while self + .bytes + .get(self.index) + .is_some_and(u8::is_ascii_whitespace) + { + self.index += 1; + } + if !self.skip_comment()? { + return Ok(self.index < self.bytes.len()); + } + } + } + + fn skip_noise(&mut self) -> Result { + if self.skip_comment()? { + return Ok(true); + } + match self.bytes.get(self.index).copied() { + Some(b'"' | b'\'') => { + self.skip_quoted()?; + Ok(true) + } + Some(b'`') => Err(RhaiRuntimeError::Compilation), + _ => Ok(false), + } + } + + fn skip_comment(&mut self) -> Result { + if self.bytes.get(self.index..self.index + 2) == Some(b"//") { + self.index += 2; + while self.index < self.bytes.len() && self.bytes[self.index] != b'\n' { + self.index += 1; + } + return Ok(true); + } + if self.bytes.get(self.index..self.index + 2) == Some(b"/*") { + self.index += 2; + while self.index + 1 < self.bytes.len() + && self.bytes.get(self.index..self.index + 2) != Some(b"*/") + { + self.index += 1; + } + if self.bytes.get(self.index..self.index + 2) != Some(b"*/") { + return Err(RhaiRuntimeError::Compilation); + } + self.index += 2; + return Ok(true); + } + Ok(false) + } + + fn skip_quoted(&mut self) -> Result<(), RhaiRuntimeError> { + let quote = self.bytes[self.index]; + self.index += 1; + while self.index < self.bytes.len() { + match self.bytes[self.index] { + b'\\' => self.index = self.index.saturating_add(2), + byte if byte == quote => { + self.index += 1; + return Ok(()); + } + _ => self.index += 1, + } + } + Err(RhaiRuntimeError::Compilation) + } + + fn consume_word(&mut self, expected: &str) -> bool { + let remaining = &self.source[self.index..]; + if !remaining.starts_with(expected) { + return false; + } + let end = self.index + expected.len(); + if self + .bytes + .get(end) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + return false; + } + self.index = end; + true + } + + fn consume_identifier(&mut self) -> bool { + if !self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_start(*byte)) + { + return false; + } + self.index += 1; + while self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + self.index += 1; + } + true + } + + fn consume_balanced(&mut self, open: u8, close: u8) -> Result<(), RhaiRuntimeError> { + if self.bytes.get(self.index) != Some(&open) { + return Err(RhaiRuntimeError::Compilation); + } + let mut depth = 0usize; + while self.index < self.bytes.len() { + if self.skip_noise()? { + continue; + } + match self.bytes[self.index] { + byte if byte == open => depth += 1, + byte if byte == close => { + depth -= 1; + self.index += 1; + if depth == 0 { + return Ok(()); + } + continue; + } + _ => {} + } + self.index += 1; + } + Err(RhaiRuntimeError::Compilation) + } +} + +fn is_identifier_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' +} + +fn is_identifier_continue(byte: u8) -> bool { + is_identifier_start(byte) || byte.is_ascii_digit() +} + +fn validate_adapter_inputs(selectors: &Value, parameters: &Value) -> Result<(), RhaiRuntimeError> { + validate_adapter_object(selectors)?; + validate_adapter_object(parameters)?; + let combined = Value::Array(vec![selectors.clone(), parameters.clone()]); + let size = serde_json::to_vec(&combined) + .map_err(|_| RhaiRuntimeError::AdapterInput)? + .len(); + if size > MAXIMUM_PREPARATION_INPUT_BYTES { + return Err(RhaiRuntimeError::InputBound); + } + Ok(()) +} + +fn validate_adapter_object(value: &Value) -> Result<(), RhaiRuntimeError> { + if !value.is_object() || !adapter_value_is_supported(value) { + return Err(RhaiRuntimeError::AdapterInput); + } + Ok(()) +} + +fn adapter_value_is_supported(value: &Value) -> bool { + match value { + Value::Bool(_) => true, + Value::Number(value) => value.as_i64().is_some(), + Value::String(value) => value.len() <= MAXIMUM_STRING_BYTES, + Value::Array(values) => { + values.len() <= MAXIMUM_ARRAY_ITEMS && values.iter().all(adapter_value_is_supported) + } + Value::Object(values) => { + values.len() <= MAXIMUM_MAP_ENTRIES + && !is_typed_derivation_envelope(values) + && values.iter().all(|(name, value)| { + name.len() <= MAXIMUM_STRING_BYTES && adapter_value_is_supported(value) + }) + } + Value::Null => false, + } +} + +fn adapter_object_to_dynamic(value: &Value) -> Result { + let object = value.as_object().ok_or(RhaiRuntimeError::AdapterInput)?; + object + .iter() + .map(|(name, value)| Ok((name.as_str().into(), adapter_value_to_dynamic(value)?))) + .collect::>() + .map(Dynamic::from) +} + +fn adapter_value_to_dynamic(value: &Value) -> Result { + match value { + Value::Bool(value) => Ok(Dynamic::from(*value)), + Value::Number(value) => value + .as_i64() + .map(Dynamic::from) + .ok_or(RhaiRuntimeError::AdapterInput), + Value::String(value) if value.len() <= MAXIMUM_STRING_BYTES => { + Ok(Dynamic::from(value.clone())) + } + Value::Array(values) if values.len() <= MAXIMUM_ARRAY_ITEMS => values + .iter() + .map(adapter_value_to_dynamic) + .collect::>() + .map(Dynamic::from), + Value::Object(values) + if values.len() <= MAXIMUM_MAP_ENTRIES && !is_typed_derivation_envelope(values) => + { + values + .iter() + .map(|(name, value)| { + if name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::AdapterInput); + } + Ok((name.as_str().into(), adapter_value_to_dynamic(value)?)) + }) + .collect::>() + .map(Dynamic::from) + } + _ => Err(RhaiRuntimeError::AdapterInput), + } +} + +fn is_typed_derivation_envelope(values: &serde_json::Map) -> bool { + values.len() == 2 + && values.get("type") == Some(&Value::String("decimal".to_string())) + && values.get("value").is_some_and(Value::is_string) +} + +fn decode_request_parts( + result: Dynamic, + limits: &RequestPartsLimits, +) -> Result { + let map = result + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if !has_exact_keys(&map, &["query", "body"]) { + return Err(RhaiRuntimeError::PreparationResult); + } + + let query = map["query"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if query.len() > limits.maximum_query_pairs || query.len() > MAXIMUM_QUERY_PAIRS { + return Err(RhaiRuntimeError::PreparationResult); + } + let query = query + .into_iter() + .map(|pair| { + let pair = pair + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)?; + if !has_exact_keys(&pair, &["name", "value"]) { + return Err(RhaiRuntimeError::PreparationResult); + } + let name = pair["name"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)? + .to_string(); + let value = pair["value"] + .clone() + .try_cast::() + .ok_or(RhaiRuntimeError::PreparationResult)? + .to_string(); + if name.is_empty() + || name.len() > limits.maximum_query_name_bytes + || name.len() > MAXIMUM_QUERY_NAME_BYTES + || name.len() > limits.maximum_string_bytes + || value.len() > limits.maximum_query_value_bytes + || value.len() > MAXIMUM_QUERY_VALUE_BYTES + || value.len() > limits.maximum_string_bytes + || name.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + || value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(QueryPair { name, value }) + }) + .collect::, RhaiRuntimeError>>()?; + + validate_part_requirement(limits.query, !query.is_empty())?; + let body = if map["body"].is_unit() { + None + } else { + if !dynamic_is_json(&map["body"]) { + return Err(RhaiRuntimeError::PreparationResult); + } + let body = rhai::serde::from_dynamic::(&map["body"]) + .map_err(|_| RhaiRuntimeError::PreparationResult)?; + if !json_numbers_are_supported(&body) + || !json_value_within_limits( + &body, + limits.maximum_json_depth, + limits.maximum_collection_items, + limits.maximum_string_bytes, + ) + { + return Err(RhaiRuntimeError::PreparationResult); + } + Some(body) + }; + validate_part_requirement(limits.body, body.is_some())?; + + let normalized = Value::Object(serde_json::Map::from_iter([ + ("body".to_string(), body.clone().unwrap_or(Value::Null)), + ( + "query".to_string(), + Value::Array( + query + .iter() + .map(|pair| { + Value::Object(serde_json::Map::from_iter([ + ("name".to_string(), Value::String(pair.name.clone())), + ("value".to_string(), Value::String(pair.value.clone())), + ])) + }) + .collect(), + ), + ), + ])); + let normalized_size = serde_json::to_vec(&normalized) + .map_err(|_| RhaiRuntimeError::PreparationResult)? + .len(); + if normalized_size > limits.maximum_normalized_bytes + || normalized_size > MAXIMUM_REQUEST_PARTS_BYTES + { + return Err(RhaiRuntimeError::PreparationResult); + } + Ok(RequestParts { query, body }) +} + +fn validate_part_requirement( + requirement: RequestPartRequirement, + present: bool, +) -> Result<(), RhaiRuntimeError> { + match (requirement, present) { + (RequestPartRequirement::Forbidden, true) | (RequestPartRequirement::Required, false) => { + Err(RhaiRuntimeError::PreparationResult) + } + _ => Ok(()), + } +} + +fn json_value_within_limits( + value: &Value, + maximum_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, +) -> bool { + fn visit( + value: &Value, + container_depth: usize, + maximum_depth: usize, + maximum_collection_items: usize, + maximum_string_bytes: usize, + ) -> bool { + match value { + Value::String(value) => value.len() <= maximum_string_bytes, + Value::Array(values) => { + container_depth < maximum_depth + && values.len() <= maximum_collection_items + && values.iter().all(|value| { + visit( + value, + container_depth + 1, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) + }) + } + Value::Object(values) => { + container_depth < maximum_depth + && values.len() <= maximum_collection_items + && values.iter().all(|(name, value)| { + name.len() <= maximum_string_bytes + && visit( + value, + container_depth + 1, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) + }) + } + _ => true, + } + } + visit( + value, + 0, + maximum_depth, + maximum_collection_items, + maximum_string_bytes, + ) +} + +fn json_numbers_are_supported(value: &Value) -> bool { + match value { + Value::Number(value) => { + value.is_i64() || value.is_f64() && value.as_f64().is_some_and(f64::is_finite) + } + Value::Array(values) => values.iter().all(json_numbers_are_supported), + Value::Object(values) => values.values().all(json_numbers_are_supported), + _ => true, + } +} + +fn dynamic_is_json(value: &Dynamic) -> bool { + if value.is_unit() || value.is_bool() || value.is_int() || value.is_string() { + return true; + } + if value.is_float() { + return value.as_float().is_ok_and(f64::is_finite); + } + if value.is_array() { + return value.clone_cast::().iter().all(dynamic_is_json); + } + if value.is_map() { + return value.clone_cast::().values().all(dynamic_is_json); + } + false +} + +fn derived_value_size(value: &DerivedValue) -> Result { + match value { + DerivedValue::Json(value) => serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .map_err(|_| RhaiRuntimeError::DerivationResult), + DerivedValue::Decimal(value) => Ok(value.canonical().len()), + DerivedValue::EntityReferenceSeed(value) => Ok(value.expose_for_projection().len()), + DerivedValue::EntityReferenceSeedList(values) => { + values.iter().try_fold(0usize, |sum, value| { + sum.checked_add(value.expose_for_projection().len()) + .ok_or(RhaiRuntimeError::DerivationResult) + }) + } + } +} + +fn parameters_to_map(parameters: &Value) -> Result { + let object = parameters + .as_object() + .ok_or(RhaiRuntimeError::EvaluationContext)?; + if object.len() > MAXIMUM_MAP_ENTRIES { + return Err(RhaiRuntimeError::EvaluationContext); + } + object + .iter() + .map(|(name, value)| { + if name.is_empty() || name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok((name.as_str().into(), parameter_to_dynamic(value)?)) + }) + .collect() +} + +fn parameter_to_dynamic(value: &Value) -> Result { + match value { + Value::Bool(value) => Ok(Dynamic::from(*value)), + Value::Number(value) => value + .as_i64() + .map(Dynamic::from) + .ok_or(RhaiRuntimeError::EvaluationContext), + Value::String(value) if value.len() <= MAXIMUM_STRING_BYTES => { + Ok(Dynamic::from(value.clone())) + } + Value::Array(values) if values.len() <= MAXIMUM_ARRAY_ITEMS => values + .iter() + .map(parameter_to_dynamic) + .collect::>() + .map(Dynamic::from), + Value::Object(object) + if object.len() == 2 + && object.get("type") == Some(&Value::String("decimal".to_string())) + && object.get("value").is_some_and(Value::is_string) => + { + let text = object["value"] + .as_str() + .ok_or(RhaiRuntimeError::EvaluationContext)?; + Decimal::parse(text) + .map(Dynamic::from) + .map_err(|_| RhaiRuntimeError::EvaluationContext) + } + Value::Object(object) if object.len() <= MAXIMUM_MAP_ENTRIES => object + .iter() + .map(|(name, value)| { + if name.is_empty() || name.len() > MAXIMUM_STRING_BYTES { + return Err(RhaiRuntimeError::EvaluationContext); + } + Ok((name.as_str().into(), parameter_to_dynamic(value)?)) + }) + .collect::>() + .map(Dynamic::from), + _ => Err(RhaiRuntimeError::EvaluationContext), + } +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum ScalarValue { + Boolean(bool), + Integer(INT), + String(String), + Date(CalendarDate), + Instant(UtcInstant), + Decimal(String), +} + +impl fmt::Debug for ScalarValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let form = match self { + Self::Boolean(_) => "boolean", + Self::Integer(_) => "integer", + Self::String(_) => "string", + Self::Date(_) => "date", + Self::Instant(_) => "instant", + Self::Decimal(_) => "decimal", + }; + formatter + .debug_struct("ScalarValue") + .field("form", &form) + .field("value", &"[REDACTED]") + .finish() + } +} + +fn scalar_value(value: &Dynamic) -> Option { + if value.is_bool() { + return Some(ScalarValue::Boolean(value.as_bool().ok()?)); + } + if value.is_int() { + return Some(ScalarValue::Integer(value.as_int().ok()?)); + } + if value.is_string() { + return Some(ScalarValue::String( + value.clone_cast::().to_string(), + )); + } + if value.is::() { + return Some(ScalarValue::Date(value.clone_cast::())); + } + if value.is::() { + return Some(ScalarValue::Instant(value.clone_cast::())); + } + if value.is::() { + return Some(ScalarValue::Decimal( + value.clone_cast::().canonical().to_string(), + )); + } + None +} + +fn validate_json_bound( + value: &Value, + maximum_serialized_bytes: usize, +) -> Result<(), RhaiRuntimeError> { + let serialized = serde_json::to_vec(value).map_err(|_| RhaiRuntimeError::InputBound)?; + if serialized.len() > maximum_serialized_bytes || !json_collections_are_bounded(value) { + return Err(RhaiRuntimeError::InputBound); + } + Ok(()) +} + +fn json_collections_are_bounded(value: &Value) -> bool { + match value { + Value::String(value) => value.len() <= MAXIMUM_STRING_BYTES, + Value::Array(values) => { + values.len() <= MAXIMUM_ARRAY_ITEMS && values.iter().all(json_collections_are_bounded) + } + Value::Object(values) => { + values.len() <= MAXIMUM_MAP_ENTRIES + && values.iter().all(|(name, value)| { + name.len() <= MAXIMUM_STRING_BYTES && json_collections_are_bounded(value) + }) + } + _ => true, + } +} + +fn has_exact_keys(map: &Map, keys: &[&str]) -> bool { + map.len() == keys.len() && keys.iter().all(|key| map.contains_key(*key)) +} + +fn ordering_value(ordering: Ordering) -> INT { + match ordering { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + } +} + +fn last_day_of_month(year: i32, month: u32) -> Result> { + let (next_year, next_month) = if month == 12 { + (year.checked_add(1), 1) + } else { + (Some(year), month + 1) + }; + let next_year = next_year.ok_or_else(|| primitive_error("invalid_calendar_result"))?; + let first_next = NaiveDate::from_ymd_opt(next_year, next_month, 1) + .ok_or_else(|| primitive_error("invalid_calendar_result"))?; + Ok((first_next - Duration::days(1)).day()) +} + +fn is_canonical_date_text(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + +fn is_strict_rfc3339(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 20 + || !value.is_ascii() + || bytes.get(10) != Some(&b'T') + || !is_canonical_date_text(&value[..10]) + || bytes.get(13) != Some(&b':') + || bytes.get(16) != Some(&b':') + { + return false; + } + let hour = parse_two_digits(&bytes[11..13]); + let minute = parse_two_digits(&bytes[14..16]); + let second = parse_two_digits(&bytes[17..19]); + if !matches!(hour, Some(0..=23)) + || !matches!(minute, Some(0..=59)) + || !matches!(second, Some(0..=59)) + { + return false; + } + has_strict_fraction_and_offset(&value[19..]) +} + +fn is_strict_local_time(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 9 + || !value.is_ascii() + || bytes.get(2) != Some(&b':') + || bytes.get(5) != Some(&b':') + { + return false; + } + let hour = parse_two_digits(&bytes[0..2]); + let minute = parse_two_digits(&bytes[3..5]); + let second = parse_two_digits(&bytes[6..8]); + matches!(hour, Some(0..=23)) + && matches!(minute, Some(0..=59)) + && matches!(second, Some(0..=59)) + && has_strict_fraction_and_offset(&value[8..]) +} + +fn has_strict_fraction_and_offset(suffix: &str) -> bool { + let (fraction, offset) = if let Some(rest) = suffix.strip_prefix('.') { + let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digit_count == 0 || digit_count > 9 { + return false; + } + (&rest[..digit_count], &rest[digit_count..]) + } else { + ("", suffix) + }; + if !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + if offset == "Z" { + return true; + } + let bytes = offset.as_bytes(); + if bytes.len() != 6 || !matches!(bytes[0], b'+' | b'-') || bytes[3] != b':' { + return false; + } + matches!(parse_two_digits(&bytes[1..3]), Some(0..=23)) + && matches!(parse_two_digits(&bytes[4..6]), Some(0..=59)) +} + +fn parse_two_digits(value: &[u8]) -> Option { + if value.len() != 2 || !value.iter().all(u8::is_ascii_digit) { + return None; + } + Some((value[0] - b'0') * 10 + value[1] - b'0') +} + +fn is_safe_error_code(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAXIMUM_REQUIRED_CODE_BYTES + && bytes[0].is_ascii_lowercase() + && bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn primitive_error(code: &str) -> Box { + EvalAltResult::ErrorRuntime(code.into(), rhai::Position::NONE).into() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + const ADULT_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai" + )); + const ADULT_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai" + )); + const RESIDENCE_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai" + )); + const RESIDENCE_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai" + )); + const LICENCE_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai" + )); + const LICENCE_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai" + )); + const RELATIONSHIP_EXTRACTION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai" + )); + const RELATIONSHIP_DERIVATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai" + )); + + fn runtime() -> RhaiRuntime { + RhaiRuntime::new() + } + + fn context( + parameters: Value, + codelists: BTreeMap, + ) -> EvaluationContext { + EvaluationContext::new( + UtcInstant::parse("2026-08-02T00:00:00Z").expect("instant"), + CalendarDate::parse("2026-08-02").expect("date"), + LegalLocalTime::parse("07:00:00+07:00").expect("time"), + ¶meters, + codelists, + ) + .expect("context") + } + + fn request_limits( + query: RequestPartRequirement, + body: RequestPartRequirement, + ) -> RequestPartsLimits { + RequestPartsLimits::new( + query, + body, + RequestPartsBounds { + maximum_query_pairs: MAXIMUM_QUERY_PAIRS, + maximum_query_name_bytes: MAXIMUM_QUERY_NAME_BYTES, + maximum_query_value_bytes: MAXIMUM_QUERY_VALUE_BYTES, + maximum_json_depth: MAXIMUM_JSON_BODY_DEPTH, + maximum_collection_items: MAXIMUM_ARRAY_ITEMS, + maximum_string_bytes: MAXIMUM_STRING_BYTES, + maximum_normalized_bytes: MAXIMUM_REQUEST_PARTS_BYTES, + }, + ) + .expect("request limits") + } + + #[test] + fn engine_has_exact_normative_limits_and_disabled_module_syntax() { + let runtime = runtime(); + let engine = runtime.engine(); + assert_eq!(engine.max_operations(), MAXIMUM_OPERATIONS); + assert_eq!(engine.max_call_levels(), MAXIMUM_CALL_DEPTH); + assert_eq!(engine.max_expr_depth(), MAXIMUM_EXPRESSION_DEPTH); + assert_eq!(engine.max_function_expr_depth(), MAXIMUM_EXPRESSION_DEPTH); + assert_eq!(engine.max_modules(), MAXIMUM_MODULES); + assert_eq!(engine.max_string_size(), MAXIMUM_STRING_BYTES); + assert_eq!(engine.max_array_size(), MAXIMUM_ARRAY_ITEMS); + assert_eq!(engine.max_map_size(), MAXIMUM_MAP_ENTRIES); + assert!(engine.is_symbol_disabled("import")); + assert!(engine.is_symbol_disabled("export")); + assert!(engine.is_symbol_disabled("eval")); + assert!(engine.is_symbol_disabled("print")); + assert!(engine.is_symbol_disabled("debug")); + for symbol in [ + "while", "until", "loop", "do", "switch", "try", "catch", "..", "..=", "?.", "??", + ] { + assert!(engine.is_symbol_disabled(symbol), "{symbol}"); + } + assert!(!engine.allow_anonymous_fn()); + } + + #[test] + fn preparation_runs_named_helpers_with_fresh_isolated_inputs() { + let runtime = runtime(); + let script = runtime + .compile_preparation( + r#" + fn query_pair(name, value) { #{ name: name, value: value } } + fn prepare(selectors, parameters) { + let query = []; + query.push(query_pair("filter", selectors.subject.values.reference)); + query.push(query_pair("filter", parameters.status)); + let provider_name = parameters.provider_name; + provider_name.replace("_", "-"); + selectors.subject.values.reference = "mutated-only-locally"; + #{ + query: query, + body: #{ + provider: provider_name, + limit: parse_integer(parameters.limit) + } + } + } + "#, + ) + .expect("preparation compiles"); + let selectors = json!({ + "subject": {"profile": "reference-v1", "values": {"reference": "person-1"}} + }); + let parameters = json!({"status": "ACTIVE", "provider_name": "source_a", "limit": "02"}); + let limits = request_limits( + RequestPartRequirement::Required, + RequestPartRequirement::Required, + ); + for _ in 0..2 { + assert_eq!( + runtime + .prepare(&script, &selectors, ¶meters, &limits) + .expect("prepares"), + RequestParts { + query: vec![ + QueryPair { + name: "filter".to_string(), + value: "person-1".to_string(), + }, + QueryPair { + name: "filter".to_string(), + value: "ACTIVE".to_string(), + }, + ], + body: Some(json!({"limit": 2, "provider": "source-a"})), + } + ); + } + assert_eq!(selectors["subject"]["values"]["reference"], "person-1"); + assert_eq!(parameters["provider_name"], "source_a"); + } + + #[test] + fn preparation_inputs_and_request_parts_are_closed_and_bounded() { + let runtime = runtime(); + let passthrough = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: parameters.query, body: () } }", + ) + .expect("compiles"); + let limits = request_limits( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + ); + + for invalid in [ + json!({"value": null}), + json!({"value": 1.25}), + json!({"value": {"type": "decimal", "value": "1.25"}}), + ] { + assert_eq!( + runtime.prepare(&passthrough, &json!({}), &invalid, &limits), + Err(RhaiRuntimeError::AdapterInput) + ); + } + + for parameters in [ + json!({"query": [{"name": "", "value": "x"}]}), + json!({"query": [{"name": "x\r", "value": "y"}]}), + json!({"query": [{"name": "x", "value": "y\n"}]}), + json!({"query": [{"name": "x", "value": 1}]}), + ] { + assert!(matches!( + runtime.prepare(&passthrough, &json!({}), ¶meters, &limits), + Err(RhaiRuntimeError::AdapterInput | RhaiRuntimeError::PreparationResult) + )); + } + + let unknown = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: (), extra: true } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&unknown, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + for body in ["parse_date(\"2026-08-02\")", "'x'"] { + let source = + format!("fn prepare(selectors, parameters) {{ #{{ query: [], body: {body} }} }}"); + let script = runtime.compile_preparation(&source).expect("compiles"); + assert_eq!( + runtime.prepare(&script, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + } + let forbidden_query = request_limits( + RequestPartRequirement::Forbidden, + RequestPartRequirement::Optional, + ); + let query = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [#{name: \"x\", value: \"y\"}], body: () } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&query, &json!({}), &json!({}), &forbidden_query), + Err(RhaiRuntimeError::PreparationResult) + ); + + let strict = RequestPartsLimits::new( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + RequestPartsBounds { + maximum_query_pairs: 1, + maximum_query_name_bytes: 1, + maximum_query_value_bytes: 1, + maximum_json_depth: 1, + maximum_collection_items: 1, + maximum_string_bytes: 1, + maximum_normalized_bytes: 32, + }, + ) + .expect("strict limits"); + assert_eq!( + runtime.prepare(&query, &json!({}), &json!({}), &strict), + Err(RhaiRuntimeError::PreparationResult) + ); + } + + #[test] + fn compiler_rejects_every_forbidden_candidate_construct() { + let runtime = runtime(); + let bodies = [ + "while true {}", + "until true {}", + "loop {}", + "do {} while false", + "switch 1 { 1 => true, _ => false }", + "try { throw \"x\"; } catch (error) {}", + "let pointer = Fn(\"helper\");", + "let closure = |value| value;", + "let closure = || 1;", + "let text = `value=${selectors}`;", + "let value = [1, 2, 3][0..1];", + "let value = [1, 2, 3][-1];", + "let value = parameters?.value;", + "let value = parameters.value ?? 0;", + "let pointer = parameters.pointer; pointer.call();", + "let pointer = parameters.pointer; pointer.curry(1);", + ]; + for body in bodies { + let source = format!( + "fn prepare(selectors, parameters) {{ {body} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_err(), + "construct compiled: {body}" + ); + } + } + + #[test] + fn compiler_allows_unique_helpers_but_rejects_entry_overloads() { + let runtime = runtime(); + assert!(runtime + .compile_preparation( + "fn helper(value) { value } fn prepare(selectors, parameters) { #{query: [], body: helper(())} }" + ) + .is_ok()); + for source in [ + "fn prepare(a, b) { #{query: [], body: ()} } fn prepare(a) { a }", + "fn helper(a) { a } fn helper(a, b) { b } fn prepare(a, b) { #{query: [], body: ()} }", + "private fn prepare(a, b) { #{query: [], body: ()} }", + ] { + assert_eq!( + runtime.compile_preparation(source).unwrap_err(), + RhaiRuntimeError::EntryPoint + ); + } + } + + #[test] + fn strict_integer_parser_and_mutating_helpers_enforce_bounds() { + for (input, expected) in [("0", 0), ("-0", 0), ("0012", 12), ("-42", -42)] { + assert_eq!(parse_integer(input).expect("integer"), expected); + } + for invalid in [ + "", + "+1", + " 1", + "1 ", + "1.0", + "1e2", + "١", + "9223372036854775808", + "-9223372036854775809", + ] { + assert!(parse_integer(invalid).is_err(), "{invalid}"); + } + let mut full = vec![Dynamic::UNIT; MAXIMUM_ARRAY_ITEMS]; + assert!(bounded_array_push(&mut full, Dynamic::UNIT).is_err()); + let mut value: ImmutableString = "a".repeat(MAXIMUM_STRING_BYTES).into(); + assert!(literal_string_replace(&mut value, "", "x").is_err()); + } + + #[test] + fn compile_requires_one_exact_entry_point_and_rejects_top_level_statements() { + let runtime = runtime(); + assert_eq!( + runtime + .compile_extraction("fn derive(x) { x }") + .unwrap_err(), + RhaiRuntimeError::EntryPoint + ); + assert!(runtime + .compile_extraction("fn extract(x, parameters) { x } fn helper() {}") + .is_ok()); + + assert_eq!( + runtime + .compile_extraction( + r#" + throw("top_level_must_not_run"); + fn extract(source_response, parameters) { #{ outcome: "no_match" } } + "#, + ) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + } + + #[test] + fn ambient_and_diagnostic_capabilities_are_unavailable() { + let runtime = runtime(); + for forbidden in [ + "print(\"x\")", + "debug(\"x\")", + "eval(\"40 + 2\")", + "get_env(\"HOME\")", + "timestamp()", + ] { + let source = format!( + "fn extract(source_response, parameters) {{ {forbidden}; #{{ outcome: \"no_match\" }} }}" + ); + if let Ok(script) = runtime.compile_extraction(&source) { + assert_eq!( + runtime.extract(&script, &json!({}), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Invocation), + "{forbidden} must be unavailable" + ); + } + } + assert!(matches!( + runtime.compile_extraction( + "import \"outside\" as outside; fn extract(x, parameters) { #{ outcome: \"no_match\" } }" + ), + Err(RhaiRuntimeError::Compilation) + )); + } + + #[test] + fn extraction_decodes_only_the_closed_union_and_validates_facts() { + let runtime = runtime(); + let valid = runtime + .compile_extraction( + r#" + fn extract(source_response, parameters) { + #{ outcome: "match", facts: #{ code: source_response.code } } + } + "#, + ) + .expect("compiles"); + let schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["code"], + "properties": {"code": {"type": "string"}} + })) + .expect("schema"); + assert_eq!( + runtime.extract(&valid, &json!({"code": "A"}), &json!({}), &schema), + Ok(LookupResult::Match(BTreeMap::from([( + "code".to_string(), + json!("A") + )]))) + ); + assert_eq!( + runtime.extract(&valid, &json!({"code": 1}), &json!({}), &schema), + Err(RhaiRuntimeError::FactSchema) + ); + + for body in [ + "#{ outcome: \"no_match\", facts: #{} }", + "#{ outcome: \"ambiguous\", count: 2 }", + "#{ outcome: \"match\", facts: #{}, candidates: [] }", + "#{ outcome: \"unknown\" }", + ] { + let source = format!("fn extract(source_response, parameters) {{ {body} }}"); + let script = runtime.compile_extraction(&source).expect("compiles"); + assert_eq!( + runtime.extract(&script, &json!({}), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::ExtractionResult) + ); + } + } + + #[test] + fn extraction_source_input_uses_the_configured_one_mebibyte_boundary() { + fn sized_json_array(serialized_bytes: usize, items: usize) -> Value { + let payload_bytes = serialized_bytes + .checked_sub(1 + 3 * items) + .expect("serialized target fits array framing"); + let base = payload_bytes / items; + let remainder = payload_bytes % items; + let values = (0..items) + .map(|index| Value::String("x".repeat(base + usize::from(index < remainder)))) + .collect::>(); + let value = Value::Array(values); + assert_eq!( + serde_json::to_vec(&value).expect("serializes").len(), + serialized_bytes + ); + value + } + + let runtime = runtime(); + let script = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"no_match\" } }", + ) + .expect("compiles"); + for size in [MAXIMUM_RESULT_BYTES + 1, MAXIMUM_SOURCE_INPUT_BYTES] { + assert_eq!( + runtime.extract( + &script, + &sized_json_array(size, 64), + &json!({}), + &|_: &Value| true, + ), + Ok(LookupResult::NoMatch), + "source input of {size} bytes remains valid" + ); + } + assert_eq!( + runtime.extract( + &script, + &sized_json_array(MAXIMUM_SOURCE_INPUT_BYTES + 1, 64), + &json!({}), + &|_: &Value| true, + ), + Err(RhaiRuntimeError::InputBound) + ); + } + + #[test] + fn dates_instants_and_calendar_arithmetic_are_strict_and_bounded() { + assert!(CalendarDate::parse("2024-02-29").is_ok()); + for invalid in ["2023-02-29", "2026-8-02", "+2026-08-02", "2026-08-02Z"] { + assert!(CalendarDate::parse(invalid).is_err(), "{invalid}"); + } + assert!(UtcInstant::parse("2026-08-02T07:00:00+07:00").is_ok()); + for invalid in [ + "2026-08-02 00:00:00Z", + "2026-08-02T00:00:60Z", + "2026-08-02T00:00:00", + "2026-08-02T00:00:00.1234567890Z", + ] { + assert!(UtcInstant::parse(invalid).is_err(), "{invalid}"); + } + + let leap = CalendarDate::parse("2008-02-29").expect("date"); + assert_eq!( + add_calendar_years(leap, 18).expect("adds").as_naive_date(), + NaiveDate::from_ymd_opt(2026, 2, 28).expect("date") + ); + let month_end = CalendarDate::parse("2025-01-31").expect("date"); + assert_eq!( + add_calendar_months(month_end, 1) + .expect("adds") + .as_naive_date(), + NaiveDate::from_ymd_opt(2025, 2, 28).expect("date") + ); + assert!(add_calendar_years(leap, 1_001).is_err()); + assert!(days_between( + CalendarDate::parse("0001-01-01").expect("date"), + CalendarDate::parse("2001-01-01").expect("date") + ) + .is_err()); + } + + #[test] + fn candidate_numeric_string_and_opaque_type_surface_is_pinned() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let text = "source_adapter"; + text.replace("_", "-"); + [#{ + concept_id: "surface", + value: [ + 1.5 + 2.0, 1 + 2.5, 2.5 + 1, + 5.0 % 2.0, 2.0 ** 3.0, + 1.5 < 2.0, 1 < 2.0, 2.0 >= 1, + "ab" + "cd", "abc" - "b", text, + type_of(parse_date("2026-08-02")), + type_of(parse_instant("2026-08-02T00:00:00Z")), + type_of(evaluation_context.legal_local_time), + type_of(decimal("1.25")), + type_of(entity_reference_seed("reference")), + type_of(evaluation_context.codelists["codes"]) + ] + }] + } + "#, + ) + .expect("compiles"); + let codelist = CodelistHandle::new(BTreeMap::from([("A".to_string(), "B".to_string())])) + .expect("codelist"); + let values = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::from([("codes".to_string(), codelist)])), + ) + .expect("derives"); + assert!(matches!( + &values[0].value, + DerivedValue::Json(value) + if value == &json!([ + 3.5, 3.5, 3.5, 1.0, 8.0, + true, true, true, + "abcd", "ac", "source-adapter", + "Date", "Instant", "LegalLocalTime", "Decimal", + "EntityReferenceSeed", "CodelistHandle" + ]) + )); + } + + #[test] + fn exact_decimals_and_validated_contiguous_buckets_work() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let exact = decimal("1.25"); + let integer = integer_to_decimal(1); + [ + #{ concept_id: "decimal", value: exact }, + #{ concept_id: "comparison", value: compare_decimals(exact, integer) }, + #{ + concept_id: "bucket", + value: bucket_number(exact, evaluation_context.parameters.buckets) + } + ] + } + "#, + ) + .expect("compiles"); + let parameters = json!({ + "buckets": [ + { + "minimumInclusive": {"type": "decimal", "value": "0"}, + "maximumExclusive": {"type": "decimal", "value": "1"}, + "code": "low" + }, + { + "minimumInclusive": {"type": "decimal", "value": "1"}, + "maximumExclusive": {"type": "decimal", "value": "2"}, + "code": "high" + } + ] + }); + let values = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context(parameters, BTreeMap::new()), + ) + .expect("derives"); + assert!(matches!( + &values[0].value, + DerivedValue::Decimal(value) if value.canonical() == "1.25" + )); + assert!(matches!(&values[1].value, DerivedValue::Json(value) if value == &json!(1))); + assert!(matches!(&values[2].value, DerivedValue::Json(value) if value == &json!("high"))); + + let invalid = vec![boundary("0", "1", "a"), boundary("2", "3", "b")]; + assert!(bucket_number(Decimal::parse("1").expect("decimal"), invalid).is_err()); + } + + #[test] + fn derived_value_debug_redacts_every_value_carrier() { + let seed = + EntityReferenceSeed::new("entity-reference-debug-canary").expect("seed is valid"); + let values = [ + DerivedConceptValue { + concept_id: "urn:example:concept:string".to_owned(), + value: DerivedValue::Json(json!("json-debug-canary")), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:decimal".to_owned(), + value: DerivedValue::Decimal(Decimal::parse("8192.125").expect("decimal")), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:reference".to_owned(), + value: DerivedValue::EntityReferenceSeed(seed.clone()), + }, + DerivedConceptValue { + concept_id: "urn:example:concept:references".to_owned(), + value: DerivedValue::EntityReferenceSeedList(vec![seed]), + }, + ]; + let diagnostic = format!("{values:?}"); + for canary in [ + "json-debug-canary", + "8192.125", + "entity-reference-debug-canary", + ] { + assert!(!diagnostic.contains(canary), "protected value leaked"); + } + assert!(diagnostic.contains("urn:example:concept:string")); + assert!(diagnostic.contains("form: \"string\"")); + assert!(diagnostic.contains("count: 1")); + + let scalars = [ + ScalarValue::String("scalar-debug-canary".to_owned()), + ScalarValue::Integer(8_192_125), + ScalarValue::Decimal("8192.125".to_owned()), + ]; + let diagnostic = format!("{scalars:?}"); + for canary in ["scalar-debug-canary", "8192125", "8192.125"] { + assert!(!diagnostic.contains(canary), "protected value leaked"); + } + } + + #[test] + fn request_parts_debug_redacts_query_and_body_values() { + let parts = RequestParts { + query: vec![QueryPair { + name: "query-name-debug-canary".to_owned(), + value: "query-value-debug-canary".to_owned(), + }], + body: Some(serde_json::json!({ + "body-name-debug-canary": "body-value-debug-canary" + })), + }; + + let diagnostic = format!("{parts:?}"); + for protected in [ + "query-name-debug-canary", + "query-value-debug-canary", + "body-name-debug-canary", + "body-value-debug-canary", + ] { + assert!( + !diagnostic.contains(protected), + "request parts debug leaked protected material" + ); + } + assert!(diagnostic.contains("query_pairs: 1")); + assert!(diagnostic.contains("body_present: true")); + } + + #[test] + fn codelist_required_missing_and_exact_collections_work() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup(evaluation_context.codelists["regions"], facts.code); + [ + #{ concept_id: "mapped", value: required(mapped, "unknown_code") }, + #{ concept_id: "missing", value: is_missing(facts.absent) }, + #{ concept_id: "list", value: list_contains([1, "1", true], "1") }, + #{ concept_id: "set", value: set_contains(["A", "B"], "B") } + ] + } + "#, + ) + .expect("compiles"); + let handle = + CodelistHandle::new(BTreeMap::from([("R-101".to_string(), "NORTH".to_string())])) + .expect("codelist"); + let values = runtime + .derive( + &script, + &BTreeMap::from([("code".to_string(), json!("R-101"))]), + &json!({}), + context(json!({}), BTreeMap::from([("regions".to_string(), handle)])), + ) + .expect("derives"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!("NORTH"))); + assert!(matches!(&values[1].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(matches!(&values[2].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(matches!(&values[3].value, DerivedValue::Json(value) if value == &json!(true))); + assert!(set_contains( + vec![Dynamic::from("A"), Dynamic::from("A")], + Dynamic::from("A") + ) + .is_err()); + assert!(required(Dynamic::UNIT, "protected value").is_err()); + assert!(!is_missing(Dynamic::from(false))); + } + + #[test] + fn derivation_decode_is_closed_and_retains_protected_types() { + let runtime = runtime(); + let protected = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + [ + #{ concept_id: "one", value: entity_reference_seed(facts.seed) }, + #{ + concept_id: "many", + value: [entity_reference_seed("a"), entity_reference_seed("b")] + } + ] + } + "#, + ) + .expect("compiles"); + let values = runtime + .derive( + &protected, + &BTreeMap::from([("seed".to_string(), json!("protected-canary"))]), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("derives"); + assert!(matches!( + values[0].value, + DerivedValue::EntityReferenceSeed(_) + )); + assert!(matches!( + &values[1].value, + DerivedValue::EntityReferenceSeedList(values) if values.len() == 2 + )); + assert!(!format!("{:?}", values).contains("protected-canary")); + + for result in [ + "[#{ concept_id: \"x\", value: true, extra: false }]", + "[#{ concept_id: \"x\", value: true }, #{ concept_id: \"x\", value: false }]", + "#{ concept_id: \"x\", value: true }", + ] { + let source = format!("fn derive(facts, selectors, evaluation_context) {{ {result} }}"); + let script = runtime.compile_derivation(&source).expect("compiles"); + assert!(matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::DerivationResult) + )); + } + } + + #[test] + fn operation_limit_and_fresh_scope_fail_closed() { + let runtime = runtime(); + let runaway = runtime + .compile_derivation("fn derive(facts, selectors, evaluation_context) { while true {} }") + .unwrap_err(); + assert_eq!(runaway, RhaiRuntimeError::Compilation); + + let local_only = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let invocation_local = 1; + [#{ concept_id: "value", value: invocation_local }] + } + "#, + ) + .expect("compiles"); + for _ in 0..2 { + assert!(runtime + .derive( + &local_only, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ) + .is_ok()); + } + } + + #[test] + fn required_primitive_reports_closed_unavailability() { + let runtime = runtime(); + let script = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + [#{ concept_id: "urn:example:concept", value: required(facts.absent, "required_fact_missing") }] + }"#, + ) + .expect("script compiles"); + assert!(matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Unavailable) + )); + + let forged = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + throw "registry_evidence_required_unavailable"; + }"#, + ) + .expect("script compiles"); + assert!(matches!( + runtime.derive( + &forged, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )); + assert!(runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + try { required(facts.absent, "missing"); } catch (error) {} + [#{concept_id: "x", value: true}] + }"#, + ) + .is_err()); + } + + #[test] + fn all_four_acceptance_script_pairs_run_through_one_runtime() { + let runtime = runtime(); + + let adult_facts = matched_facts( + &runtime, + ADULT_EXTRACTION, + json!({"total": 1, "date_of_birth": "2008-08-02"}), + ); + let adult = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(ADULT_DERIVATION)) + .expect("adult derivation compiles"), + &adult_facts, + &json!({}), + context(json!({"minimum_age_years": 18}), BTreeMap::new()), + ) + .expect("adult derives"); + assert!(matches!(&adult[0].value, DerivedValue::Json(value) if value == &json!(true))); + + let residence_facts = matched_facts( + &runtime, + RESIDENCE_EXTRACTION, + json!({"total": 1, "official_residence_code": "R-101"}), + ); + let region_map = CodelistHandle::new(BTreeMap::from([ + ("R-101".to_string(), "REGION-NORTH".to_string()), + ("R-201".to_string(), "REGION-SOUTH".to_string()), + ])) + .expect("codelist"); + let residence = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(RESIDENCE_DERIVATION)) + .expect("residence derivation compiles"), + &residence_facts, + &json!({}), + context( + json!({}), + BTreeMap::from([("region-map".to_string(), region_map)]), + ), + ) + .expect("residence derives"); + assert!( + matches!(&residence[0].value, DerivedValue::Json(value) if value == &json!("REGION-NORTH")) + ); + + let licence_facts = matched_facts( + &runtime, + LICENCE_EXTRACTION, + json!({ + "total": 1, + "records": [{ + "licence_state": "CURRENT", + "valid_from": "2025-01-01", + "valid_until": "2026-08-20", + "historical_states": ["PENDING"] + }] + }), + ); + let licence = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(LICENCE_DERIVATION)) + .expect("licence derivation compiles"), + &licence_facts, + &json!({}), + context( + json!({ + "active_state": "CURRENT", + "expiry_buckets": [ + bucket_parameter("-365000", "0", "expired"), + bucket_parameter("0", "31", "within-30-days"), + bucket_parameter("31", "91", "within-90-days"), + bucket_parameter("91", "365001", "later") + ] + }), + BTreeMap::new(), + ), + ) + .expect("licence derives"); + assert!(matches!(&licence[0].value, DerivedValue::Json(value) if value == &json!(true))); + assert!( + matches!(&licence[1].value, DerivedValue::Json(value) if value == &json!("within-30-days")) + ); + + let relationship_facts = matched_facts( + &runtime, + RELATIONSHIP_EXTRACTION, + json!({ + "total": 1, + "records": [{ + "returned_child_reference": "synthetic-child-record-001", + "parent_references": ["synthetic-parent-reference-001"], + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }] + }), + ); + let relationship = runtime + .derive( + &runtime + .compile_derivation(&candidate_derivation(RELATIONSHIP_DERIVATION)) + .expect("relationship derivation compiles"), + &relationship_facts, + &json!({ + "child": { + "profile": "civil-record-reference-v1", + "values": {"record_reference": "synthetic-child-record-001"} + }, + "candidate-parent": { + "profile": "person-reference-v1", + "values": {"person_reference": "synthetic-parent-reference-001"} + } + }), + context( + json!({ + "matching_policy": "exact-opaque-reference-membership-v1", + "candidate_reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "legal_authority_attestation": "urn:example:fixture:governance:legal-parent-register:v1" + }), + BTreeMap::new(), + ), + ) + .expect("relationship derives"); + assert!( + matches!(&relationship[0].value, DerivedValue::Json(value) if value == &json!(true)) + ); + } + + #[test] + fn protected_seed_has_no_comparison_or_string_conversion_capability() { + let runtime = runtime(); + for expression in [ + "{ let seed = entity_reference_seed(facts.seed); seed == seed }", + "{ let seed = entity_reference_seed(facts.seed); seed.to_string() }", + "{ let seed = entity_reference_seed(facts.seed); debug(seed) }", + ] { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: {expression} }}] }}" + ); + match runtime.compile_derivation(&source) { + Ok(script) => assert!(matches!( + runtime.derive( + &script, + &BTreeMap::from([("seed".to_string(), json!("protected-canary"))]), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )), + Err(RhaiRuntimeError::Compilation) => {} + Err(error) => panic!("unexpected compilation result: {error}"), + } + } + } + + fn boundary(minimum: &str, maximum: &str, code: &str) -> Dynamic { + let mut map = Map::new(); + map.insert( + "minimumInclusive".into(), + Dynamic::from(Decimal::parse(minimum).expect("decimal")), + ); + map.insert( + "maximumExclusive".into(), + Dynamic::from(Decimal::parse(maximum).expect("decimal")), + ); + map.insert("code".into(), Dynamic::from(code.to_string())); + Dynamic::from(map) + } + + fn bucket_parameter(minimum: &str, maximum: &str, code: &str) -> Value { + json!({ + "minimumInclusive": {"type": "decimal", "value": minimum}, + "maximumExclusive": {"type": "decimal", "value": maximum}, + "code": code + }) + } + + fn matched_facts( + runtime: &RhaiRuntime, + source: &str, + response: Value, + ) -> BTreeMap { + let label = if source == ADULT_EXTRACTION { + "adult" + } else if source == RESIDENCE_EXTRACTION { + "residence" + } else if source == LICENCE_EXTRACTION { + "licence" + } else { + "relationship" + }; + let script = runtime + .compile_extraction(&candidate_extraction(source)) + .expect("extraction compiles"); + let parameters = match label { + "licence" => { + json!({"requestedFields": "licence_state,valid_from,valid_until", "resultLimit": "2"}) + } + "relationship" => json!({ + "requestedFields": [ + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete" + ], + "resultLimit": 2, + "referenceNamespace": "urn:example:fixture:person-reference", + "relationshipSetContract": "urn:example:fixture:legal-parent-set:v1", + "relationshipSetComplete": true + }), + _ => json!({}), + }; + match runtime + .extract(&script, &response, ¶meters, &|_: &Value| true) + .unwrap_or_else(|error| panic!("{label} extraction failed: {error}")) + { + LookupResult::Match(facts) => facts, + other => panic!("expected match, got {other:?}"), + } + } + + fn candidate_extraction(source: &str) -> String { + source.replacen( + "fn extract(source_response)", + "fn extract(source_response, parameters)", + 1, + ) + } + + fn candidate_derivation(source: &str) -> String { + source.replacen( + "fn derive(facts, evaluation_context)", + "fn derive(facts, selectors, evaluation_context)", + 1, + ) + } +} diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs new file mode 100644 index 000000000..0514c47b1 --- /dev/null +++ b/crates/registry-evidence/src/runtime.rs @@ -0,0 +1,1068 @@ +//! Complete authenticated Evidence evaluation and fail-closed release pipeline. + +use std::{collections::BTreeMap, path::Path, str, sync::Arc, time::Instant}; + +use chrono::Utc; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; +use serde_json::{Map as JsonMap, Value}; +use thiserror::Error; + +use crate::{ + audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind as AuditAuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, + }, + auth::{AuthenticatedContext, Authenticator}, + bundle::{Bundle, DeploymentInputs}, + config::{AuthorityKind, RuntimeConfig, SelectorInput}, + kernel::{EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection}, + model::{EvidenceRequest, FlattenedJws, JwksDocument, SelectorValue, SubjectBinding}, + problem::ProblemCode, + rate_limit::{EvidenceRateLimiter, RateLimitConfig, RateLimitError}, + secrets::{ProtectedSecret, SecretProvider, SecretResolver}, + selector::{ + match_entitlement, resolve_selectors, validate_subject_binding_key, AuthorizationError, + ResolvedAuthorization, ResolvedSelectorValue, + }, + signing::{jwks_document, EvidenceSigner}, + source::{ResolvedSourceSelector, SourceError, SourceExecutor}, +}; + +const MAX_OPERATION_BYTES: usize = 128; + +#[derive(Debug, Error)] +pub enum RuntimeInitializationError { + #[error("the immutable Evidence bundle could not be loaded")] + Bundle, + #[error("the Evidence secret resolver could not initialize")] + Secrets, + #[error("the Evidence audit boundary could not initialize")] + Audit, + #[error("the Evidence signing boundary could not initialize")] + Signing, + #[error("an Evidence source plan could not initialize")] + Source, + #[error("the Evidence rate limiter could not initialize")] + RateLimit, +} + +/// One safe failure classification for the public HTTP boundary. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RuntimeFailure { + problem: ProblemCode, + category: &'static str, +} + +impl RuntimeFailure { + pub fn problem(self) -> ProblemCode { + self.problem + } + + pub fn category(self) -> &'static str { + self.category + } +} + +impl std::fmt::Debug for RuntimeFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RuntimeFailure") + .field("problem", &self.problem) + .field("category", &self.category) + .finish() + } +} + +impl std::fmt::Display for RuntimeFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the Evidence operation did not complete") + } +} + +impl std::error::Error for RuntimeFailure {} + +/// All runtime state is derived from one captured immutable bundle revision. +pub struct EvidenceRuntime { + kernel: OfflineKernel, + runtime_config: RuntimeConfig, + runtime_revision: String, + authenticator: Authenticator, + sources: BTreeMap, + audit: EvidenceAuditLog, + signer: EvidenceSigner, + jwks: JwksDocument, + subject_binding_secret: ProtectedSecret, + rate_limiter: EvidenceRateLimiter, +} + +impl std::fmt::Debug for EvidenceRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceRuntime") + .field("configuration_revision", &self.kernel.bundle().revision()) + .field("source_count", &self.sources.len()) + .field("signing_key_id", &self.signer.key_id()) + .finish_non_exhaustive() + } +} + +impl EvidenceRuntime { + /// Capture and initialize the complete Version 1 deployment at one revision. + pub async fn initialize(runtime_path: &Path) -> Result { + Self::initialize_internal(runtime_path, None).await + } + + #[cfg(test)] + pub(crate) async fn initialize_with_authenticator( + runtime_path: &Path, + authenticator: Authenticator, + ) -> Result { + Self::initialize_internal(runtime_path, Some(authenticator)).await + } + + async fn initialize_internal( + runtime_path: &Path, + authenticator_override: Option, + ) -> Result { + let deployment = + DeploymentInputs::load(runtime_path).map_err(|_| RuntimeInitializationError::Bundle)?; + let runtime_document = deployment.runtime; + let runtime_config = runtime_document.config.clone(); + let runtime_revision = runtime_document.revision().to_owned(); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .map_err(|_| RuntimeInitializationError::Bundle)?; + + let secrets = Arc::new( + SecretResolver::new( + [SecretProvider::File], + &runtime_config.secret_providers.file.root, + ) + .map_err(|_| RuntimeInitializationError::Secrets)?, + ); + + let audit_secret = secrets + .resolve(bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Audit)?; + let audit = EvidenceAuditLog::initialize( + &runtime_config.audit_storage.path, + runtime_config.audit_storage.maximum_file_bytes, + audit_secret.expose_secret().to_vec(), + bundle.config.audit.hash_key_version, + ) + .await + .map_err(|_| RuntimeInitializationError::Audit)?; + + let subject_binding_secret = secrets + .resolve(bundle.config.subject_binding.secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Secrets)?; + validate_subject_binding_key( + subject_binding_secret.expose_secret(), + bundle.config.subject_binding.key_version, + &bundle.config.service.trust_domain, + ) + .map_err(|_| RuntimeInitializationError::Secrets)?; + + let signing_secret = secrets + .resolve(bundle.config.signing.active_key_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let signing_json = str::from_utf8(signing_secret.expose_secret()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let private_jwk = + PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; + let provider = Arc::new( + LocalJwkSigner::new(private_jwk).map_err(|_| RuntimeInitializationError::Signing)?, + ); + let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) + .await + .map_err(|_| RuntimeInitializationError::Signing)?; + let retired = bundle + .retired_public_jwks + .values() + .map(|value| { + serde_json::to_string(value) + .map_err(|_| RuntimeInitializationError::Signing) + .and_then(|json| { + PublicJwk::parse(&json).map_err(|_| RuntimeInitializationError::Signing) + }) + }) + .collect::, _>>()?; + let jwks = jwks_document(signer.public_jwk(), retired) + .map_err(|_| RuntimeInitializationError::Signing)?; + + let mut sources = BTreeMap::new(); + for (source_id, source) in bundle.config.sources.iter() { + let allowed_selector_sets = bundle.config.source_selector_sets(source_id); + let executor = SourceExecutor::new_with_selector_sets_and_tls( + source, + &allowed_selector_sets, + &runtime_config.outbound_tls, + &runtime_document.ca_bundles, + Arc::clone(&secrets), + ) + .map_err(|_| RuntimeInitializationError::Source)?; + sources.insert(source_id.to_owned(), executor); + } + + let configured_limits = &bundle.config.rate_limits; + let rate_limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: u32::try_from( + configured_limits.requests_per_principal_per_minute, + ) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + burst_per_principal: u32::try_from(configured_limits.burst_per_principal) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + failed_selector_attempts_per_principal_authority_per_minute: u32::try_from( + configured_limits.failed_selector_attempts_per_principal_authority_per_minute, + ) + .map_err(|_| RuntimeInitializationError::RateLimit)?, + }) + .map_err(|_| RuntimeInitializationError::RateLimit)?; + + Ok(Self { + kernel, + runtime_config, + runtime_revision, + authenticator: authenticator_override + .unwrap_or_else(|| Authenticator::from_config(&bundle.config.authentication)), + sources, + audit, + signer, + jwks, + subject_binding_secret, + rate_limiter, + }) + } + + pub fn bundle(&self) -> &Bundle { + self.kernel.bundle() + } + + pub fn runtime_config(&self) -> &RuntimeConfig { + &self.runtime_config + } + + pub fn runtime_revision(&self) -> &str { + &self.runtime_revision + } + + pub fn jwks(&self) -> &JwksDocument { + &self.jwks + } + + #[cfg(test)] + pub(crate) fn replace_signer_for_test(&mut self, signer: EvidenceSigner) { + self.signer = signer; + } + + /// Readiness proves all locally required material and source credentials. + /// It never performs an evidence-data request. + pub async fn ready(&self) -> bool { + if validate_subject_binding_key( + self.subject_binding_secret.expose_secret(), + self.bundle().config.subject_binding.key_version, + &self.bundle().config.service.trust_domain, + ) + .is_err() + || !self.signer.ready() + || !self.audit.ready().await + { + return false; + } + for source in self.sources.values() { + if source.credentials_ready().await.is_err() { + return false; + } + } + true + } + + /// Run the fixed authenticated path through signing and durable release audit. + pub async fn evaluate( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + ) -> Result { + self.evaluate_at(operation, access_token, request, None) + .await + } + + #[cfg(test)] + pub(crate) async fn evaluate_at_for_test( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + evaluation_time: chrono::DateTime, + ) -> Result { + self.evaluate_at(operation, access_token, request, Some(evaluation_time)) + .await + } + + async fn evaluate_at( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + evaluation_time: Option>, + ) -> Result { + if operation.len() < 16 + || operation.len() > MAX_OPERATION_BYTES + || operation.bytes().any(|byte| byte.is_ascii_whitespace()) + { + return Err(failure(ProblemCode::ServiceUnavailable, "operation-id")); + } + let started = Instant::now(); + let context = self + .authenticator + .authenticate(access_token) + .await + .map_err(|_| failure(ProblemCode::AuthenticationFailed, "authentication"))?; + let rate_scope = rate_limit_scope(&self.bundle().config.service.trust_domain); + let request_limit_key = self + .audit + .pseudonym("request-rate", &rate_scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_request(&request_limit_key) + .await + .map_err(map_request_limit)?; + + let scope = audit_scope( + &self.bundle().config.service.trust_domain, + &request.purpose, + context.evidence_audience(), + ); + let requester_pseudonym = self + .audit + .pseudonym("requester", &scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + + let matched = match_entitlement(self.bundle(), request, &context).map_err(map_authority)?; + let selector_limit_input = canonical_pair( + context.principal().as_bytes(), + matched.authority_profile().as_bytes(), + ) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "selector-rate-key"))?; + let selector_limit_key = self + .audit + .pseudonym("selector-failure-rate", &rate_scope, &selector_limit_input) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_selector_failure_budget(&selector_limit_key) + .await + .map_err(map_selector_limit)?; + let resolved = match resolve_selectors(self.bundle(), request, &context, &matched) { + Ok(resolved) => resolved, + Err(error) => { + if error == AuthorizationError::Selector { + self.rate_limiter + .record_selector_failure(&selector_limit_key) + .await + .map_err(map_selector_limit)?; + } + return Err(map_authority(error)); + } + }; + + let material = self.audit_material(&scope, requester_pseudonym, &context, &resolved)?; + let (source_id, adapter_id) = self.source_identity(&request.requirement)?; + let mut access_event = material.event( + operation, + AuditPhase::AccessAttempt, + AuditDecision::Authorized, + elapsed_millis(started), + ); + access_event.source_id = Some(source_id.clone()); + access_event.adapter_id = Some(adapter_id.clone()); + self.audit + .append(access_event) + .await + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "access-audit"))?; + + let executor = self + .sources + .get(&source_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let requirement = self + .kernel + .requirement(&request.requirement) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "requirement"))?; + let source = self + .bundle() + .config + .sources + .get(&source_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let preparation_selector_value = + source_selector_input_value(&resolved, &source.request.selector_inputs)?; + let selectors = source_selectors(&resolved, &source.request.selector_inputs)?; + let request_parts = match self + .kernel + .prepare(&request.requirement, &preparation_selector_value) + { + Ok(parts) => parts, + Err(error) => { + let category = kernel_failure_category(error); + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(kernel_failure_problem(error), category)); + } + }; + let source_response = match executor.execute(&selectors, &request_parts).await { + Ok(response) => response, + Err(error) => { + let category = source_failure_category(&error); + self.append_failure( + &material, + operation, + AuditDecision::DependencyFailure, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(source_failure_problem(&error), category)); + } + }; + let observed_at = evaluation_time.unwrap_or_else(Utc::now); + let derivation_selectors = + selector_input_value(&resolved, &requirement.derivation.selector_inputs)?; + let values = match self.kernel.evaluate_with_selectors( + &request.requirement, + &source_response, + &derivation_selectors, + observed_at, + ValueProjection { + audience: context.evidence_audience(), + binding_key: self.subject_binding_secret.expose_secret(), + binding_key_version: self.bundle().config.subject_binding.key_version, + }, + ) { + Ok(KernelOutcome::Match(values)) => values, + Ok(KernelOutcome::NoMatch) => { + self.append_failure( + &material, + operation, + AuditDecision::NoMatch, + "no-match", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(evidence_unavailable_failure()); + } + Ok(KernelOutcome::Ambiguous) => { + self.append_failure( + &material, + operation, + AuditDecision::Ambiguous, + "ambiguous", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(evidence_unavailable_failure()); + } + Err(error) => { + let category = kernel_failure_category(error); + let problem = kernel_failure_problem(error); + let decision = match error { + KernelError::Extraction => AuditDecision::FactMissing, + KernelError::SourceProtocol => AuditDecision::DependencyFailure, + _ => AuditDecision::EvaluationFailure, + }; + self.append_failure( + &material, + operation, + decision, + category, + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(problem, category)); + } + }; + + let subjects = match self.subject_bindings(&resolved) { + Ok(subjects) => subjects, + Err(error) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "subject-binding", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let issued_at = evaluation_time.unwrap_or_else(Utc::now); + let evidence = match self.kernel.construct_evidence( + &request.requirement, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + purpose: &request.purpose, + audience: context.evidence_audience(), + issued_at, + observed_at, + subjects, + }, + ) { + Ok(evidence) => evidence, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "evidence-construction", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure( + ProblemCode::ServiceUnavailable, + "evidence-construction", + )); + } + }; + let disclosed_concepts = evidence + .supported_values + .iter() + .map(|value| value.provides_value_for.clone()) + .collect::>(); + let signed = match self.signer.sign_json(&evidence).await { + Ok(signed) => signed, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + }; + + let mut release = material.event( + operation, + AuditPhase::DisclosureRelease, + AuditDecision::Released, + elapsed_millis(started), + ); + release.source_id = Some(source_id); + release.adapter_id = Some(adapter_id); + release.disclosed_concepts = Some(disclosed_concepts); + release.evidence_id = Some(evidence_id); + release.signing_key_id = Some(self.signer.key_id().to_owned()); + self.audit + .append(release) + .await + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-audit"))?; + Ok(signed) + } + + fn source_identity(&self, requirement_id: &str) -> Result<(String, String), RuntimeFailure> { + let requirement = self + .kernel + .requirement(requirement_id) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "requirement"))?; + let source = self + .bundle() + .config + .sources + .get(&requirement.source) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "source-plan"))?; + let adapter_id = Path::new(source.extract_script.as_str()) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty() && name.len() <= 128) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "adapter-id"))?; + Ok((requirement.source.clone(), adapter_id.to_owned())) + } + + fn audit_material( + &self, + scope: &str, + requester_pseudonym: String, + context: &AuthenticatedContext, + resolved: &ResolvedAuthorization, + ) -> Result { + let actor_pseudonym = context + .actor() + .map(|actor| self.audit.pseudonym("actor", scope, actor.as_bytes())) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let grant_pseudonym = resolved + .grant_id + .as_deref() + .map(|grant| self.audit.pseudonym("grant", scope, grant.as_bytes())) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let subjects = resolved + .subjects + .iter() + .map(|subject| { + let protected = subject + .audit_pseudonym_input(&resolved.audience, &resolved.purpose) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "subject-pseudonym"))?; + let pseudonym = self + .audit + .pseudonym("subject-selector-bundle", scope, &protected) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + Ok(AuditSubject { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_bundle_pseudonym: Some(pseudonym), + }) + }) + .collect::, RuntimeFailure>>()?; + Ok(AuditMaterial { + requirement: resolved.requirement.clone(), + bundle_revision: self.bundle().revision().to_owned(), + purpose: resolved.purpose.clone(), + requester_pseudonym, + actor_pseudonym, + authority: AuditAuthority { + kind: map_authority_kind(resolved.authority_kind), + grant_pseudonym, + }, + subjects, + }) + } + + fn subject_bindings( + &self, + resolved: &ResolvedAuthorization, + ) -> Result, RuntimeFailure> { + resolved + .subjects + .iter() + .map(|subject| { + subject + .binding( + self.subject_binding_secret.expose_secret(), + self.bundle().config.subject_binding.key_version, + &self.bundle().config.service.trust_domain, + &resolved.audience, + &resolved.purpose, + ) + .map(|binding| SubjectBinding { + role: subject.role.clone(), + binding, + }) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "subject-binding")) + }) + .collect() + } + + #[allow(clippy::too_many_arguments)] + async fn append_failure( + &self, + material: &AuditMaterial, + operation: &str, + decision: AuditDecision, + category: &'static str, + source_id: &str, + adapter_id: &str, + started: Instant, + ) -> Result<(), RuntimeFailure> { + let phase = match decision { + AuditDecision::NoMatch | AuditDecision::Ambiguous | AuditDecision::FactMissing => { + AuditPhase::Denial + } + _ => AuditPhase::TransientFailure, + }; + let mut event = material.event(operation, phase, decision, elapsed_millis(started)); + event.source_id = Some(source_id.to_owned()); + event.adapter_id = Some(adapter_id.to_owned()); + event.safe_error_category = Some(category.to_owned()); + self.audit + .append(event) + .await + .map(|_| ()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "failure-audit")) + } +} + +struct AuditMaterial { + requirement: String, + bundle_revision: String, + purpose: String, + requester_pseudonym: String, + actor_pseudonym: Option, + authority: AuditAuthority, + subjects: Vec, +} + +impl AuditMaterial { + fn event( + &self, + operation: &str, + phase: AuditPhase, + decision: AuditDecision, + duration_milliseconds: u64, + ) -> EvidenceAuditEvent { + let mut event = EvidenceAuditEvent::new( + operation.to_owned(), + phase, + self.requirement.clone(), + self.bundle_revision.clone(), + self.purpose.clone(), + self.requester_pseudonym.clone(), + self.authority.clone(), + self.subjects.clone(), + decision, + duration_milliseconds, + ); + event.actor_pseudonym = self.actor_pseudonym.clone(); + event + } +} + +fn source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result, RuntimeFailure> { + inputs + .iter() + .map(|input| { + let Some(subject) = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + else { + return Ok(None); + }; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or_else(selector_contract_failure)?; + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or_else(selector_contract_failure)?; + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + Ok((name.clone(), value)) + }) + .collect::, RuntimeFailure>>()?; + Ok(Some(ResolvedSourceSelector { + role: input.role.clone(), + profile: alternative.profile.clone(), + values, + })) + }) + .collect::, RuntimeFailure>>() + .map(|selectors| selectors.into_iter().flatten().collect()) +} + +fn source_selector_input_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let active = inputs + .iter() + .filter(|input| { + resolved + .subjects + .iter() + .any(|subject| subject.role == input.role) + }) + .cloned() + .collect::>(); + if active.is_empty() { + return Err(selector_contract_failure()); + } + selector_input_value(resolved, &active) +} + +fn selector_input_value( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Result { + let mut selectors = JsonMap::new(); + for input in inputs { + let (subject, alternative) = selector_input_subject(resolved, input)?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .ok_or_else(selector_contract_failure)?; + values.insert(name.clone(), field.value.as_json()); + } + let mut selector = JsonMap::new(); + selector.insert( + "profile".to_owned(), + Value::String(alternative.profile.clone()), + ); + selector.insert("values".to_owned(), Value::Object(values)); + if selectors + .insert(input.role.clone(), Value::Object(selector)) + .is_some() + { + return Err(selector_contract_failure()); + } + } + Ok(Value::Object(selectors)) +} + +fn selector_input_subject<'a>( + resolved: &'a ResolvedAuthorization, + input: &'a SelectorInput, +) -> Result< + ( + &'a crate::selector::ResolvedSubject, + &'a crate::config::SelectorInputAlternative, + ), + RuntimeFailure, +> { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .ok_or_else(selector_contract_failure)?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .ok_or_else(selector_contract_failure)?; + Ok((subject, alternative)) +} + +fn selector_contract_failure() -> RuntimeFailure { + failure(ProblemCode::ServiceUnavailable, "selector-contract") +} + +fn failure(problem: ProblemCode, category: &'static str) -> RuntimeFailure { + RuntimeFailure { problem, category } +} + +fn evidence_unavailable_failure() -> RuntimeFailure { + failure(ProblemCode::EvidenceNotAvailable, "evidence-unavailable") +} + +fn map_authority(error: AuthorizationError) -> RuntimeFailure { + match error { + AuthorizationError::Selector => failure(ProblemCode::InvalidSelector, "selector"), + AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority => { + failure(ProblemCode::NotAuthorized, "authorization") + } + AuthorizationError::Binding => failure(ProblemCode::ServiceUnavailable, "subject-binding"), + } +} + +fn map_request_limit(error: RateLimitError) -> RuntimeFailure { + match error { + RateLimitError::RequestExceeded => failure(ProblemCode::RateLimited, "request-rate"), + _ => failure(ProblemCode::ServiceUnavailable, "request-rate"), + } +} + +fn map_selector_limit(error: RateLimitError) -> RuntimeFailure { + match error { + RateLimitError::FailedSelectorExceeded => { + failure(ProblemCode::RateLimited, "selector-rate") + } + _ => failure(ProblemCode::ServiceUnavailable, "selector-rate"), + } +} + +fn source_failure_category(error: &SourceError) -> &'static str { + match error { + SourceError::Credential => "source-credential", + SourceError::Concurrency => "source-concurrency", + SourceError::Timeout => "source-timeout", + SourceError::Redirect => "source-redirect", + SourceError::Status(_) => "source-status", + SourceError::WrongMediaType => "source-media-type", + SourceError::ResponseTooLarge => "source-response-size", + SourceError::InvalidJson + | SourceError::ErrorEnvelope + | SourceError::ProjectionViolation => "source-protocol", + SourceError::InvalidPlan | SourceError::InvalidSelectors | SourceError::Transport => { + "source-unavailable" + } + } +} + +/// Map a closed source-boundary failure to its public problem class. +/// +/// The offline fixture command uses this same function, so its symbolic +/// failure cases cannot drift from the production release pipeline. +pub fn source_failure_problem(_error: &SourceError) -> ProblemCode { + ProblemCode::DependencyUnavailable +} + +fn kernel_failure_category(error: KernelError) -> &'static str { + match error { + KernelError::Preparation => "request-preparation", + KernelError::Extraction => "fact-unavailable", + KernelError::SourceProtocol => "source-protocol", + KernelError::Script => "script-failure", + KernelError::Output => "output-gate", + KernelError::Bundle | KernelError::Requirement | KernelError::Evidence => "kernel", + } +} + +fn kernel_failure_problem(error: KernelError) -> ProblemCode { + match error { + KernelError::Preparation => ProblemCode::ServiceUnavailable, + KernelError::Extraction => ProblemCode::EvidenceNotAvailable, + KernelError::SourceProtocol => ProblemCode::DependencyUnavailable, + KernelError::Script + | KernelError::Output + | KernelError::Bundle + | KernelError::Requirement + | KernelError::Evidence => ProblemCode::ServiceUnavailable, + } +} + +fn map_authority_kind(kind: AuthorityKind) -> AuditAuthorityKind { + match kind { + AuthorityKind::Statutory => AuditAuthorityKind::Statutory, + AuthorityKind::Organizational => AuditAuthorityKind::Organizational, + AuthorityKind::Consent => AuditAuthorityKind::Consent, + AuthorityKind::Delegated => AuditAuthorityKind::Delegated, + AuthorityKind::ExplicitRequest => AuditAuthorityKind::ExplicitRequest, + } +} + +fn audit_scope(trust_domain: &str, purpose: &str, audience: &str) -> String { + format!( + "v1:{}:{trust_domain}:{}:{purpose}:{}:{audience}", + trust_domain.len(), + purpose.len(), + audience.len() + ) +} + +/// Rate-limit pseudonyms deliberately omit request-controlled dimensions so a +/// principal cannot multiply its budget by varying purpose, audience, or +/// requirement. The pseudonym class and protected input distinguish request +/// and selector-failure budgets within this deployment scope. +fn rate_limit_scope(trust_domain: &str) -> String { + format!("v1-rate:{}:{trust_domain}", trust_domain.len()) +} + +fn canonical_pair(first: &[u8], second: &[u8]) -> Option> { + let first_length = u32::try_from(first.len()).ok()?; + let second_length = u32::try_from(second.len()).ok()?; + let mut output = Vec::with_capacity(8 + first.len() + second.len()); + output.extend_from_slice(&first_length.to_be_bytes()); + output.extend_from_slice(first); + output.extend_from_slice(&second_length.to_be_bytes()); + output.extend_from_slice(second); + Some(output) +} + +fn elapsed_millis(started: Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()) + .unwrap_or(u64::MAX) + .min(86_400_000) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_failures_and_rate_keys_are_value_free() { + let failure = failure(ProblemCode::InvalidSelector, "selector"); + let rendered = format!("{failure:?} {failure}"); + assert!(!rendered.contains("protected-principal")); + + let first = canonical_pair(b"a", b"bc").expect("pair"); + let second = canonical_pair(b"ab", b"c").expect("pair"); + assert_ne!(first, second); + } + + #[test] + fn audit_scope_is_unambiguous_for_component_boundaries() { + assert_ne!( + audit_scope("urn:a", "bc", "https://d.invalid"), + audit_scope("urn:ab", "c", "https://d.invalid") + ); + } + + #[test] + fn rate_limit_scope_cannot_be_fragmented_by_request_dimensions() { + let trust_domain = "urn:example:evidence"; + let shared = rate_limit_scope(trust_domain); + + for (requirement, purpose, audience) in [ + ("adult", "service-enrolment", "https://one.invalid"), + ("residence", "benefit-eligibility", "https://two.invalid"), + ("professional", "licence-check", "https://three.invalid"), + ] { + assert_eq!(rate_limit_scope(trust_domain), shared); + assert_ne!(audit_scope(trust_domain, purpose, audience), shared); + assert!(!shared.contains(requirement)); + assert!(!shared.contains(purpose)); + assert!(!shared.contains(audience)); + } + } + + #[test] + fn public_unavailability_does_not_distinguish_no_match_from_ambiguity() { + let no_match = evidence_unavailable_failure(); + let ambiguous = evidence_unavailable_failure(); + + assert_eq!(no_match.problem(), ambiguous.problem()); + assert_eq!(no_match.category(), ambiguous.category()); + assert_eq!(format!("{no_match:?}"), format!("{ambiguous:?}")); + } + + #[test] + fn fact_absence_and_trusted_script_failures_have_distinct_public_classes() { + assert_eq!( + kernel_failure_problem(KernelError::Extraction), + ProblemCode::EvidenceNotAvailable + ); + for failure in [KernelError::Script, KernelError::Output] { + assert_eq!( + kernel_failure_problem(failure), + ProblemCode::ServiceUnavailable + ); + } + } +} diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs new file mode 100644 index 000000000..440d9493a --- /dev/null +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -0,0 +1,2332 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::Write as _, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use async_trait::async_trait; +use axum::{body::Body, http::Request as HttpRequest}; +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_platform_crypto::{ + sign, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, + SigningProvider, +}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tower::ServiceExt as _; +use wiremock::{ + matchers::{body_json, header, method, path, query_param}, + Mock, MockServer, ResponseTemplate, +}; + +use crate::{ + auth::{AuthenticationClaimsConfig, Authenticator}, + model::{ + Evidence, EvidenceRequest, FlattenedJws, PublicValue, RequestedSelector, RequestedSubject, + SelectorValue, + }, + problem::ProblemCode, + runtime::{EvidenceRuntime, RuntimeInitializationError}, + server::{build_app, build_app_at_for_test, serve_listener_for_test}, + signing::EvidenceSigner, + verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, +}; + +const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-auth-key"}"#; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-evidence-key"}"#; +const TOKEN_ISSUER: &str = "https://identity.invalid"; +const TOKEN_AUDIENCE: &str = "evidence-fixture"; +const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; +const AUTHORITY: &str = "statutory-caseworker-v1"; +const BEARER: &str = "source-bearer-canary"; +const BASIC_USER: &str = "source-user-canary"; +const BASIC_PASSWORD: &str = "source-password-canary"; +const PARENT_REFERENCE: &str = "synthetic-parent-reference-001"; +const NON_PARENT_REFERENCE: &str = "synthetic-non-parent-reference-003"; + +struct AcceptanceRuntime { + _temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + runtime: Arc, + server: MockServer, + audit_path: PathBuf, +} + +struct PreparedAcceptance { + temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + server: MockServer, + audit_path: PathBuf, +} + +struct FailAfterSelfTestSigner { + delegate: LocalJwkSigner, + calls: AtomicUsize, +} + +struct UnavailableReadinessSigner { + delegate: LocalJwkSigner, +} + +/// Start the real Evidence HTTP router over TCP for one operator-driven curl, +/// then verify the returned JWS and durable audit before completing. +/// +/// This intentionally uses the deterministic acceptance source and a static +/// test JWKS. Live provider credentials are neither needed nor read here. +#[tokio::test] +#[ignore = "operator-driven local curl checkpoint"] +async fn first_curl_exercises_and_verifies_the_evidence_server() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, None).await; + + let request = adult_request(); + let token = access_token(None); + let state_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/evidence/.first-curl"); + fs::create_dir_all(&state_root).expect("first-curl state directory is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&state_root, fs::Permissions::from_mode(0o700)) + .expect("first-curl state directory is owner-only"); + } + let response_path = state_root.join("response.json"); + match fs::remove_file(&response_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("stale first-curl response could not be removed: {error}"), + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:18080") + .await + .expect("first-curl listener binds on 127.0.0.1:18080"); + let address = listener + .local_addr() + .expect("listener address is available"); + write_secret( + &state_root, + "request.json", + &serde_json::to_string_pretty(&request).expect("request serializes"), + ); + write_secret( + &state_root, + "session.env", + &format!("EVIDENCE_ACCESS_TOKEN={token}\n"), + ); + + println!( + "Evidence first-curl server is ready at http://{address}. The ignored session.env contains only the short-lived synthetic bearer token. Use the plain curl command in products/evidence/FIRST-CURL-TEST.md." + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let runtime = Arc::clone(&fixture.runtime); + let server = tokio::spawn(async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + + let serialized = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&response_path) { + if serde_json::from_slice::(&bytes).is_ok() { + break bytes; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("curl response arrives within three minutes"); + + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request), + ) + .expect("curl response JWS verifies against the running Evidence JWKS"); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); + assert_minimized_payload(&serialized); + + shutdown_tx + .send(()) + .expect("first-curl server is still running"); + server + .await + .expect("first-curl server task joins") + .expect("first-curl server stops cleanly"); + let audit = fs::read_to_string(&fixture.audit_path).expect("first-curl audit is readable"); + assert_eq!(audit.lines().count(), 2); + println!( + "PASS: Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." + ); +} + +#[async_trait] +impl SigningProvider for UnavailableReadinessSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.delegate.algorithm() + } + + fn key_id(&self) -> &str { + self.delegate.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.delegate.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + KeyReadiness::NotReady + } + + async fn sign(&self, payload: &[u8]) -> Result, SigningError> { + self.delegate.sign(payload).await + } +} + +#[async_trait] +impl SigningProvider for FailAfterSelfTestSigner { + fn algorithm(&self) -> SigningAlgorithm { + self.delegate.algorithm() + } + + fn key_id(&self) -> &str { + self.delegate.key_id() + } + + fn public_jwk(&self) -> PublicJwk { + self.delegate.public_jwk() + } + + fn readiness(&self) -> KeyReadiness { + KeyReadiness::Ready + } + + async fn sign(&self, payload: &[u8]) -> Result, SigningError> { + if self.calls.fetch_add(1, Ordering::AcqRel) == 0 { + self.delegate.sign(payload).await + } else { + Err(SigningError::external("synthetic unavailable signer")) + } + } +} + +#[tokio::test] +async fn real_router_serves_all_definitions_concurrently_without_crossing_boundaries() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let health = http.get("/health").await; + health.assert_status_ok(); + assert_eq!(health.json::(), json!({"status": "ok"})); + let ready = http.get("/ready").await; + ready.assert_status_ok(); + assert_eq!(ready.json::(), json!({"status": "ready"})); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let jwks = http.get("/.well-known/evidence/jwks.json").await; + jwks.assert_status_ok(); + assert_eq!(jwks.header("content-type"), "application/jwk-set+json"); + assert_eq!( + jwks.json::(), + *fixture.runtime.jwks() + ); + + mount_success_sources(&fixture.server, false).await; + let standard_token = access_token(None); + let parent_token = access_token(Some(parent_grant_claims())); + let adult_request = adult_request(); + let residence_request = residence_request(); + let licence_request = licence_request(); + let parent_request = parent_request(); + + let adult = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&adult_request); + let residence = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&residence_request); + let licence = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {standard_token}")) + .json(&licence_request); + let parent = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {parent_token}")) + .json(&parent_request); + let (adult, residence, licence, parent) = tokio::join!(adult, residence, licence, parent); + + for (response, request, expected_concept, expected_value, expected_roles) in [ + ( + adult, + &adult_request, + "urn:example:fixture:concept:adult-status", + PublicValue::Boolean(true), + vec!["subject"], + ), + ( + residence, + &residence_request, + "urn:example:fixture:concept:residence-region", + PublicValue::String("REGION-NORTH".to_owned()), + vec!["subject"], + ), + ( + licence, + &licence_request, + "urn:example:fixture:concept:licence-active", + PublicValue::Boolean(true), + vec!["subject"], + ), + ( + parent, + &parent_request, + "urn:example:fixture:concept:legal-parent-relationship-confirmed", + PublicValue::Boolean(false), + vec!["child", "candidate-parent"], + ), + ] { + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jose+json"); + let jws = response.json::(); + let serialized = serde_json::to_vec(&jws).expect("router JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, request), + ) + .expect("router response verifies"); + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + expected_roles + ); + assert!(evidence.supported_values.iter().any(|value| { + value.provides_value_for == expected_concept && value.value == expected_value + })); + assert_minimized_payload(&serialized); + } + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 4); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 4); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture() { + let fixture = acceptance_runtime().await; + let captured_revision = fixture.runtime.bundle().revision().to_owned(); + let captured_runtime_revision = fixture.runtime.runtime_revision().to_owned(); + let captured_config = fixture + .runtime + .bundle() + .artifact("evidence.yaml") + .expect("captured configuration exists") + .to_vec(); + let adapter_path = "adapters/adult-status-source.rhai"; + let captured_adapter = fixture + .runtime + .bundle() + .artifact(adapter_path) + .expect("captured adapter exists") + .to_vec(); + + make_writable(&fixture.bundle_root); + make_file_writable(&fixture.runtime_path); + fs::write( + &fixture.runtime_path, + b"this: is-not-the-captured-runtime\n", + ) + .expect("replace on-disk runtime configuration"); + fs::write( + fixture.bundle_root.join("evidence.yaml"), + b"this: is-not-the-captured-revision\n", + ) + .expect("replace on-disk configuration"); + fs::remove_file(fixture.bundle_root.join(adapter_path)).expect("remove on-disk adapter"); + fs::write( + fixture.bundle_root.join("adapters/fallback.rhai"), + b"fn extract(_) { no_match() }\n", + ) + .expect("add an unreferenced fallback-like artifact"); + + assert_eq!(fixture.runtime.bundle().revision(), captured_revision); + assert_eq!( + fixture.runtime.runtime_revision(), + captured_runtime_revision + ); + assert_eq!( + fixture.runtime.bundle().artifact("evidence.yaml"), + Some(captured_config.as_slice()) + ); + assert_eq!( + fixture.runtime.bundle().artifact(adapter_path), + Some(captured_adapter.as_slice()) + ); + assert!(fixture + .runtime + .bundle() + .artifact("adapters/fallback.rhai") + .is_none()); + + mount_adult_source(&fixture.server, None).await; + let request = adult_request(); + let jws = fixture + .runtime + .evaluate( + "operation-captured-bundle-revision", + &access_token(None), + &request, + ) + .await + .expect("captured runtime still evaluates with captured artifacts"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request), + ) + .expect("captured-revision assertion verifies"); + assert_eq!(evidence.configuration_revision, captured_revision); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(true) + ); +} + +#[tokio::test] +async fn admitted_evaluation_survives_client_disconnect_and_keeps_audit_chain_usable() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(750))).await; + + let request = adult_request(); + let client_token = access_token(None); + let body = serde_json::to_vec(&request).expect("request serializes"); + let app = build_app(Arc::clone(&fixture.runtime)); + let client_bound = tokio::spawn(async move { + app.oneshot( + HttpRequest::builder() + .method("POST") + .uri("/v1/evidence") + .header("authorization", format!("Bearer {client_token}")) + .header("content-type", "application/json") + .body(Body::from(body)) + .expect("HTTP request builds"), + ) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + client_bound.abort(); + assert!( + client_bound + .await + .expect_err("client-bound handler is cancelled") + .is_cancelled(), + "the regression must exercise handler cancellation" + ); + + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); + wait_for_runtime_ready(&fixture.runtime).await; + + fixture.server.reset().await; + mount_adult_source(&fixture.server, None).await; + fixture + .runtime + .evaluate( + "operation-after-client-disconnect", + &access_token(None), + &adult_request(), + ) + .await + .expect("a later evaluation can append to the same audit chain"); + let audit = wait_for_audit_counts(&fixture.audit_path, 2, 2).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 2); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 2); + assert!(fixture.runtime.ready().await); +} + +#[tokio::test] +async fn graceful_shutdown_waits_for_admitted_evaluation_and_terminal_audit() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(750))).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener + .local_addr() + .expect("listener address is available"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let runtime = Arc::clone(&fixture.runtime); + let serving = tokio::spawn(async move { + crate::server::serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + tokio::task::yield_now().await; + + let client = reqwest::Client::builder() + .no_proxy() + .build() + .expect("test client builds"); + let request = adult_request(); + let token = access_token(None); + let response = tokio::spawn(async move { + client + .post(format!("http://{address}/v1/evidence")) + .bearer_auth(token) + .json(&request) + .send() + .await + .expect("request completes") + }); + wait_for_source_request_count(&fixture.server, 1).await; + shutdown_tx + .send(()) + .expect("server still observes shutdown signal"); + assert!( + !serving.is_finished(), + "shutdown cannot finish while protected evaluation is active" + ); + + let response = response.await.expect("request task completes"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + tokio::time::timeout(Duration::from_secs(3), serving) + .await + .expect("server drains before timeout") + .expect("server task does not panic") + .expect("server exits cleanly"); + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); +} + +#[tokio::test] +async fn weak_subject_binding_key_fails_initialization_before_source_access() { + let prepared = prepare_acceptance("0123456789012345678901234567890").await; + let result = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await; + let error = match result { + Ok(_) => panic!("a 31-byte subject-binding key must be rejected"), + Err(error) => error, + }; + assert!( + matches!(error, RuntimeInitializationError::Secrets), + "weak binding key failed at the wrong boundary: {error:?}" + ); + assert!( + prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "initialization cannot contact an evidence source" + ); +} + +#[tokio::test] +async fn readiness_fails_for_missing_credentials_tampered_audit_and_unready_signing() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + fs::remove_file(prepared.temporary.path().join("secrets/source-a-token")) + .expect("source credential is removed"); + assert!( + !runtime.ready().await, + "missing source credentials deny readiness" + ); + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + fs::write(&prepared.audit_path, b"{}\n").expect("audit is tampered"); + assert!( + !runtime.ready().await, + "invalid audit chain denies readiness" + ); + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let provider: Arc = Arc::new(UnavailableReadinessSigner { + delegate: LocalJwkSigner::new(private).expect("test signer builds"), + }); + let signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("provider self-test succeeds independently of readiness posture"); + runtime.replace_signer_for_test(signer); + assert!( + !runtime.ready().await, + "unready signing provider denies readiness" + ); +} + +#[tokio::test] +async fn access_audit_failure_blocks_credentials_and_source_access() { + let fixture = acceptance_runtime().await; + fs::write(&fixture.audit_path, b"{}\n").expect("audit tamper writes"); + + let error = fixture + .runtime + .evaluate( + "operation-access-audit-failure", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("an unverifiable access audit must fail closed"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "source credentials and data requests remain untouched" + ); +} + +#[tokio::test] +async fn missing_principal_never_falls_back_to_client_id_or_azp() { + let fixture = acceptance_runtime().await; + let now = Utc::now().timestamp(); + let token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "client_id": "fallback-client-canary", + "azp": "fallback-authorized-party-canary", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let error = fixture + .runtime + .evaluate("operation-missing-principal", &token, &adult_request()) + .await + .expect_err("a token without the configured principal claim is denied"); + assert_eq!(error.problem(), ProblemCode::AuthenticationFailed); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "authentication failure cannot acquire source credentials" + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("fallback-client-canary")); + assert!(!audit.contains("fallback-authorized-party-canary")); +} + +#[tokio::test] +async fn disclosure_audit_failure_prevents_signed_response_release() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(500))).await; + let runtime = Arc::clone(&fixture.runtime); + let token = access_token(None); + let request = adult_request(); + let evaluation = tokio::spawn(async move { + runtime + .evaluate("operation-disclosure-audit-failure", &token, &request) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + fs::OpenOptions::new() + .append(true) + .open(&fixture.audit_path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("audit tamper writes after access acceptance"); + let error = evaluation + .await + .expect("evaluation task completes") + .expect_err("release audit failure cannot return a signed response"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + assert!( + !audit.contains("date_of_birth") && !audit.contains("2000-01-01"), + "audit failure diagnostics cannot retain source or disclosed values" + ); +} + +#[tokio::test] +async fn signing_failure_is_transient_audited_and_never_releases_unsigned_evidence() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let error = runtime + .evaluate( + "operation-signing-failure", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("signing failure cannot produce any success representation"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"decision\":\"signing-failure\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn multi_role_request_order_is_not_semantic_and_output_uses_declaration_order() { + let fixture = acceptance_runtime().await; + mount_parent_source( + &fixture.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + let mut request = parent_request(); + request.subjects.reverse(); + + let response = fixture + .runtime + .evaluate( + "operation-reversed-subject-order", + &access_token(Some(parent_grant_claims())), + &request, + ) + .await + .expect("roles resolve independently of request array order"); + let payload = URL_SAFE_NO_PAD + .decode(response.payload) + .expect("Evidence payload is base64url"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("Evidence payload is JSON"); + assert_eq!(evidence.subjects[0].role, "child"); + assert_eq!(evidence.subjects[1].role, "candidate-parent"); + assert_eq!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .len(), + 1 + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 1); +} + +#[tokio::test] +async fn security_contract_rejects_unknown_and_unauthorized_requests_before_source_access() { + let fixture = acceptance_runtime().await; + let now = Utc::now().timestamp(); + let unentitled_token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "unentitled-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["unentitled-tag"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + + let mut unknown_requirement = adult_request(); + unknown_requirement.requirement = "urn:example:fixture:requirement:unknown:v1".to_owned(); + let mut unauthorized_purpose = adult_request(); + unauthorized_purpose.purpose = "caller-selected-purpose".to_owned(); + let mut unauthorized_profile = adult_request(); + unauthorized_profile.subjects[0].selector.profile = "caller-selected-profile-v1".to_owned(); + + for (operation, token, request) in [ + ( + "operation-unknown-requirement", + access_token(None), + unknown_requirement, + ), + ( + "operation-unauthorized-purpose", + access_token(None), + unauthorized_purpose, + ), + ( + "operation-unauthorized-profile", + access_token(None), + unauthorized_profile, + ), + ( + "operation-selector-possession-without-authority", + unentitled_token, + adult_request(), + ), + ] { + let error = fixture + .runtime + .evaluate(operation, &token, &request) + .await + .expect_err("caller material cannot create authority"); + assert_eq!(error.problem(), ProblemCode::NotAuthorized); + } + + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("\"phase\":\"access-attempt\"")); + for protected in [ + "unentitled-principal", + "caller-selected-purpose", + "caller-selected-profile-v1", + ] { + assert!(!audit.contains(protected)); + } +} + +#[tokio::test] +async fn failed_selector_budget_is_enforced_by_the_runtime_and_scoped_to_authority() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + "rateLimits: {requestsPerPrincipalPerMinute: 120, burstPerPrincipal: 20, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 2}", + 1, + ); + replace_exact( + &mut configuration, + "authorityProfiles:\n statutory-caseworker-v1:", + r#"authorityProfiles: + alternate-caseworker-v1: + kind: statutory + requesterTags: [alternate-fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} + statutory-caseworker-v1:"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with bounded selector budget initializes"); + + let mut invalid = adult_request(); + invalid.subjects[0] + .selector + .values + .as_mut() + .expect("adult selector has values") + .remove("birth_date"); + let primary_token = access_token_for("shared-selector-principal", None); + let mut retained_failures = String::new(); + for attempt in 0..2 { + let error = runtime + .evaluate( + &format!("operation-selector-failure-primary-{attempt}"), + &primary_token, + &invalid, + ) + .await + .expect_err("invalid selectors consume the configured budget"); + assert_eq!(error.problem(), ProblemCode::InvalidSelector); + retained_failures.push_str(&format!("{error:?} {error}\n")); + } + let exhausted = runtime + .evaluate( + "operation-selector-failure-primary-exhausted", + &primary_token, + &invalid, + ) + .await + .expect_err("the next request is rejected before selector resolution"); + assert_eq!(exhausted.problem(), ProblemCode::RateLimited); + retained_failures.push_str(&format!("{exhausted:?} {exhausted}\n")); + + let now = Utc::now().timestamp(); + let alternate_token = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "shared-selector-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["alternate-fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let alternate = runtime + .evaluate( + "operation-selector-failure-alternate-authority", + &alternate_token, + &invalid, + ) + .await + .expect_err("a different matched authority has an independent selector budget"); + assert_eq!(alternate.problem(), ProblemCode::InvalidSelector); + retained_failures.push_str(&format!("{alternate:?} {alternate}\n")); + + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert!( + audit.is_empty(), + "pre-material selector failures are not audited" + ); + retained_failures.push_str(&audit); + for protected in [ + "shared-selector-principal", + "alternate-fixture-agency", + "Amina", + "Diallo", + "2000-01-01", + ] { + assert!(!audit.contains(protected)); + assert!(!retained_failures.contains(protected)); + } + for canary in privacy_canaries() { + assert!( + !retained_failures.contains(canary), + "public selector failures and audit remain value-free" + ); + } +} + +#[tokio::test] +async fn one_runtime_proves_all_definitions_and_collapses_unresolved_relationships() { + let fixture = acceptance_runtime().await; + assert!( + fixture.runtime.ready().await, + "readiness accepts local credentials and performs no evidence-data lookup" + ); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + mount_success_sources(&fixture.server, true).await; + let standard_token = access_token(None); + let parent_token = access_token(Some(parent_grant_claims())); + + let cases = [ + ( + "adult", + adult_request(), + standard_token.as_str(), + "urn:example:fixture:concept:adult-status", + PublicValue::Boolean(true), + 1, + ), + ( + "residence", + residence_request(), + standard_token.as_str(), + "urn:example:fixture:concept:residence-region", + PublicValue::String("REGION-NORTH".to_owned()), + 1, + ), + ( + "licence", + licence_request(), + standard_token.as_str(), + "urn:example:fixture:concept:licence-active", + PublicValue::Boolean(true), + 1, + ), + ( + "parent-true", + parent_request(), + parent_token.as_str(), + "urn:example:fixture:concept:legal-parent-relationship-confirmed", + PublicValue::Boolean(true), + 2, + ), + ]; + + for (operation_suffix, request, token, concept, expected, role_count) in cases { + let jws = fixture + .runtime + .evaluate( + &format!("operation-acceptance-{operation_suffix}"), + token, + &request, + ) + .await + .expect("full Evidence path signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let evidence = verify_flattened_jws( + &serialized, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request), + ) + .expect("released JWS verifies under the exact relying-procedure policy"); + assert_eq!(evidence.subjects.len(), role_count); + if role_count == 2 { + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + ["child", "candidate-parent"] + ); + } + assert!(evidence + .supported_values + .iter() + .any(|value| value.provides_value_for == concept && value.value == expected)); + assert_minimized_payload(&serialized); + } + + fixture.server.reset().await; + let non_parent = non_parent_candidate(); + mount_parent_source( + &fixture.server, + parent_source_response(vec!["synthetic-parent-reference-002"]), + ) + .await; + let non_parent_token = access_token(Some(parent_grant_claims_for(non_parent))); + let false_jws = fixture + .runtime + .evaluate( + "operation-acceptance-parent-false", + &non_parent_token, + &parent_request(), + ) + .await + .expect("exact non-membership in the complete governed parent set is signed"); + let false_evidence = verify_flattened_jws( + &serde_json::to_vec(&false_jws).expect("JWS serializes"), + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &parent_request()), + ) + .expect("negative Evidence verifies"); + assert_eq!( + false_evidence.supported_values[0].value, + PublicValue::Boolean(false) + ); + + for (suffix, response) in [ + ("none", json!({"total": 0, "records": []})), + ("ambiguous", json!({"total": 2, "records": [{}, {}]})), + ] { + fixture.server.reset().await; + mount_parent_source(&fixture.server, response).await; + let error = fixture + .runtime + .evaluate( + &format!("operation-acceptance-parent-{suffix}"), + &parent_token, + &parent_request(), + ) + .await + .expect_err("unresolved pairs never produce signed Evidence"); + assert_eq!(error.problem(), ProblemCode::EvidenceNotAvailable); + } + + fixture.server.reset().await; + let swapped_roles = request( + "urn:example:fixture:requirement:legal-parent-relationship:v1", + "fixture-enrolment", + vec![ + requested_subject( + "candidate-parent", + "civil-record-reference-v1", + Some([("record_reference", "synthetic-child-record-001")]), + ), + requested_subject::<[(&str, &str); 0]>("child", "person-reference-v1", None), + ], + ); + let swapped_error = fixture + .runtime + .evaluate( + "operation-acceptance-swapped-parent-roles", + &parent_token, + &swapped_roles, + ) + .await + .expect_err("role/profile substitution is rejected before source access"); + assert_eq!(swapped_error.problem(), ProblemCode::NotAuthorized); + + let substituted = parent_request_with_candidate_values(); + let error = fixture + .runtime + .evaluate( + "operation-acceptance-substitution", + &parent_token, + &substituted, + ) + .await + .expect_err("caller candidate substitution is rejected before source access"); + assert_eq!(error.problem(), ProblemCode::InvalidSelector); + let unauthorized = fixture + .runtime + .evaluate( + "operation-acceptance-unauthorized", + &access_token(Some(json!({ + "evidence_grant_id": "grant-canary", + "evidence_authority": "different-authority", + "grant": {"candidate_parent": parent_candidate()} + }))), + &parent_request(), + ) + .await + .expect_err("authority substitution is rejected before source access"); + assert_eq!(unauthorized.problem(), ProblemCode::NotAuthorized); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 7); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 5); + assert_eq!(audit.matches("\"phase\":\"denial\"").count(), 2); + let retained_failures = format!( + "{swapped_error:?} {swapped_error}\n{error:?} {error}\n{unauthorized:?} {unauthorized}\n{audit}" + ); + for canary in privacy_canaries() { + assert!( + !retained_failures.contains(canary), + "public failures and audit must not retain protected selector, grant, source, or secret material" + ); + } +} + +#[tokio::test] +async fn runtime_output_gate_rejects_every_fixture_injected_derivation_without_release() { + let cases = [ + ( + "adult", + "derivations/adult-status.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:adult-status", value: "true"}] }"#, + ), + ( + "residence", + "derivations/residence-region.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:residence-region", value: "R-101"}] }"#, + ), + ( + "licence", + "derivations/professional-licence.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:licence-active", value: true}, #{concept_id: "urn:example:fixture:concept:licence-expiry-category", value: "2026-08-20"}] }"#, + ), + ( + "relationship", + "derivations/legal-parent-relationship.rhai", + r#"fn derive(facts, selectors, evaluation_context) { [#{concept_id: "urn:example:fixture:concept:legal-parent-relationship-confirmed", value: true}, #{concept_id: "urn:example:fixture:concept:related-subject-name", value: "PrivacyCanary"}] }"#, + ), + ]; + + for (definition, script_path, script) in cases { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + fs::write(prepared.bundle_root.join(script_path), script) + .expect("test derivation replacement succeeds"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with a syntactically valid injected derivation initializes"); + let (request, token) = match definition { + "adult" => { + mount_adult_source(&prepared.server, None).await; + (adult_request(), access_token(None)) + } + "residence" => { + mount_residence_source(&prepared.server).await; + (residence_request(), access_token(None)) + } + "licence" => { + mount_licence_source(&prepared.server).await; + (licence_request(), access_token(None)) + } + "relationship" => { + mount_parent_source( + &prepared.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + (parent_request(), access_token(Some(parent_grant_claims()))) + } + _ => unreachable!("closed acceptance definitions"), + }; + + let error = runtime + .evaluate( + &format!("operation-output-gate-{definition}"), + &token, + &request, + ) + .await + .expect_err("invalid derived output cannot reach signing or release"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!( + audit + .matches("\"safeErrorCategory\":\"output-gate\"") + .count(), + 1 + ); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + let requests = prepared + .server + .received_requests() + .await + .expect("request journal is available"); + assert_eq!(requests.len(), 1); + let retained = format!( + "{error:?} {error}\n{audit}\n{} {}", + requests[0].method, + requests[0].url.path() + ); + for canary in privacy_canaries() { + assert!( + !retained.contains(canary), + "public failure, audit, and non-sensitive request metadata remain minimized" + ); + } + } +} + +#[tokio::test] +async fn runtime_rejects_an_extra_extracted_fact_before_derivation_or_release() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let adapter_path = prepared + .bundle_root + .join("adapters/adult-status-source.rhai"); + let mut adapter = fs::read_to_string(&adapter_path).expect("adapter is readable"); + replace_exact( + &mut adapter, + "facts: #{date_of_birth: source_response[\"date_of_birth\"]}", + "facts: #{date_of_birth: source_response[\"date_of_birth\"], unexpected_private_fact: \"PrivacyCanary\"}", + 1, + ); + fs::write(adapter_path, adapter).expect("test adapter replacement succeeds"); + make_read_only(&prepared.bundle_root); + let runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime with a syntactically valid adapter initializes"); + mount_adult_source(&prepared.server, None).await; + + let error = runtime + .evaluate( + "operation-extra-extracted-fact-rejection", + &access_token(None), + &adult_request(), + ) + .await + .expect_err("a fact outside the closed fact schema cannot reach derivation"); + assert_eq!(error.problem(), ProblemCode::EvidenceNotAvailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 1); + assert_eq!( + audit + .matches("\"safeErrorCategory\":\"fact-unavailable\"") + .count(), + 1 + ); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); + let requests = prepared + .server + .received_requests() + .await + .expect("request journal is available"); + assert_eq!(requests.len(), 1); + let retained = format!( + "{error:?} {error}\n{audit}\n{} {}", + requests[0].method, + requests[0].url.path() + ); + for canary in privacy_canaries() { + assert!( + !retained.contains(canary), + "public failure, audit, and non-sensitive request metadata remain minimized" + ); + } +} + +#[tokio::test] +async fn every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification() { + let fixture = acceptance_runtime().await; + let definitions = [ + ( + "adult", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml" + ) + .as_slice(), + ), + ( + "residence", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml" + ) + .as_slice(), + ), + ( + "licence", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml" + ) + .as_slice(), + ), + ( + "relationship", + include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml" + ) + .as_slice(), + ), + ]; + let mut executed = BTreeMap::new(); + let mut runtime_equivalents = BTreeSet::new(); + let mut startup_only = BTreeSet::new(); + + for (definition, bytes) in definitions { + let matrix: Value = serde_norway::from_slice(bytes).expect("acceptance matrix parses"); + let common = matrix["common"].as_object().expect("common is an object"); + for (index, case) in matrix["cases"] + .as_array() + .expect("cases are an array") + .iter() + .enumerate() + { + let case = case.as_object().expect("case is an object"); + let case_id = case["id"].as_str().expect("case id is text"); + if case.contains_key("injected_derivation") { + runtime_equivalents.insert(format!("{definition}/{case_id}:output-gate")); + continue; + } + if case.contains_key("companion_bundle") { + startup_only.insert(format!("{definition}/{case_id}:bundle-rejection")); + continue; + } + if case.get("expected").and_then(Value::as_str) == Some("pre-source-selector-rejection") + { + runtime_equivalents.insert(format!("{definition}/{case_id}:pre-source-rejection")); + continue; + } + + fixture.server.reset().await; + let request = match definition { + "adult" => adult_request(), + "residence" => residence_request(), + "licence" => licence_request(), + "relationship" => parent_request(), + _ => unreachable!("closed definition set"), + }; + let source_path = match definition { + "adult" | "residence" => "/v1/facts", + "licence" => "/v1/records", + "relationship" => "/v1/child-relationships", + _ => unreachable!("closed definition set"), + }; + let source_method = if definition == "licence" { + "GET" + } else { + "POST" + }; + let is_relationship = definition == "relationship"; + let response = if let Some(source) = case.get("source") { + ResponseTemplate::new(200).set_body_json(source) + } else { + match case.get("source_failure").and_then(Value::as_str) { + Some("timeout") => ResponseTemplate::new(200) + .set_body_json(json!({"total": 1})) + .set_delay(Duration::from_millis(3_050)), + Some("http-503") => ResponseTemplate::new(503), + Some("wrong-media-type") => { + ResponseTemplate::new(200).set_body_raw("{}", "text/plain") + } + Some("redirect") => { + ResponseTemplate::new(302).insert_header("location", "/prohibited-redirect") + } + _ => panic!("case must have source or closed source failure"), + } + }; + let mut source_mock = Mock::given(method(source_method)) + .and(path(source_path)) + .and(header("accept", "application/json")); + if is_relationship { + source_mock = source_mock + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(parent_source_request())); + } + source_mock + .respond_with(response) + .expect(1) + .mount(&fixture.server) + .await; + + let principal = format!("principal-{definition}-{index}"); + let token_claims = case + .get("verified_token_claims") + .cloned() + .or_else(|| is_relationship.then(|| parent_grant_claims_for(parent_candidate()))); + let token = access_token_for(&principal, token_claims); + let evaluation_time = acceptance_case_time(case, common); + let audit_before = fs::read_to_string(&fixture.audit_path) + .expect("audit is readable before router request") + .lines() + .count(); + let http = TestServer::new(build_app_at_for_test( + Arc::clone(&fixture.runtime), + evaluation_time, + )); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&request) + .await; + + if case.contains_key("expected_value") || case.contains_key("expected_values") { + response.assert_status_ok(); + let jws = response.json::(); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let mut policy = verification_policy(&fixture.runtime, &request); + policy.now = evaluation_time; + let evidence = verify_flattened_jws(&serialized, fixture.runtime.jwks(), &policy) + .expect("matrix JWS verifies"); + if let Some(expected) = case.get("expected_value") { + assert_eq!( + serde_json::to_value(&evidence.supported_values[0].value) + .expect("value serializes"), + *expected, + "{definition}/{case_id}" + ); + } else { + let expected = case["expected_values"] + .as_object() + .expect("expected values are an object"); + for value in &evidence.supported_values { + let short = value + .provides_value_for + .rsplit(':') + .next() + .expect("concept has a suffix"); + assert_eq!( + serde_json::to_value(&value.value).expect("value serializes"), + expected[short], + "{definition}/{case_id}/{short}" + ); + } + } + assert_minimized_payload(&serialized); + } else { + let expected = match case + .get("expected_public_problem") + .and_then(Value::as_str) + .expect("failed case names its public problem") + { + "evidence_not_available" => ProblemCode::EvidenceNotAvailable, + "dependency_unavailable" => ProblemCode::DependencyUnavailable, + "service_unavailable" => ProblemCode::ServiceUnavailable, + _ => panic!("unknown public problem"), + }; + response.assert_status(expected.status()); + assert_eq!( + response.json::()["code"], + expected.code(), + "{definition}/{case_id}" + ); + } + + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + let new_events = audit.lines().skip(audit_before).collect::>(); + assert_eq!(new_events.len(), 2, "{definition}/{case_id}"); + let first: Value = serde_json::from_str(new_events[0]).expect("access audit parses"); + let second: Value = serde_json::from_str(new_events[1]).expect("terminal audit parses"); + assert_eq!( + first["operation"], second["operation"], + "{definition}/{case_id}" + ); + for protected in [ + principal.as_str(), + "2000-01-01", + "2008-08-02", + "R-101", + "R-102", + "R-201", + "R-999", + "CURRENT", + "SUSPENDED", + "PENDING", + "UNIQUE", + "CONFIRMED", + "NOT_CONFIRMED", + "Amadou", + "Keita", + "1974-02-11", + "related_subject_name", + "synthetic-other-child-record", + "synthetic-parent-reference-001", + "synthetic-parent-reference-002", + "synthetic-non-parent-reference-003", + "synthetic-substitute-reference", + "PrivacyCanary", + ] { + assert!( + !audit.contains(protected), + "audit leaked protected material" + ); + } + assert_eq!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .iter() + .filter(|request| request.url.path() == source_path) + .count(), + 1, + "{definition}/{case_id} must perform exactly one evidence-data request" + ); + *executed.entry(definition).or_insert(0_usize) += 1; + } + } + + assert_eq!( + executed, + BTreeMap::from([ + ("adult", 11), + ("residence", 9), + ("licence", 10), + ("relationship", 20) + ]) + ); + assert_eq!( + runtime_equivalents, + BTreeSet::from([ + "adult/negative-wrong-derived-type:output-gate".to_owned(), + "licence/negative-exact-date-leak:output-gate".to_owned(), + "relationship/negative-caller-candidate-substitution:pre-source-rejection".to_owned(), + "relationship/negative-extra-family-fact:output-gate".to_owned(), + "relationship/negative-swapped-roles:pre-source-rejection".to_owned(), + "residence/negative-overly-precise-output:output-gate".to_owned(), + ]), + "every runtime-applicable non-source fixture has a named executable equivalent in this module" + ); + assert_eq!( + startup_only, + BTreeSet::from([ + "adult/anti-reconstruction:bundle-rejection".to_owned(), + "licence/anti-reconstruction:bundle-rejection".to_owned(), + "relationship/anti-reconstruction:bundle-rejection".to_owned(), + "residence/anti-reconstruction:bundle-rejection".to_owned(), + ]), + "only companion-bundle conflicts remain startup-only because an invalid bundle cannot initialize an EvidenceRuntime" + ); +} + +fn acceptance_case_time( + case: &serde_json::Map, + common: &serde_json::Map, +) -> chrono::DateTime { + if let Some(date) = case + .get("legal_local_date") + .or_else(|| common.get("legal_local_date")) + .and_then(Value::as_str) + { + return format!("{date}T05:00:00Z") + .parse() + .expect("fixed legal-local fixture date converts"); + } + case.get("observed_at") + .or_else(|| common.get("observed_at")) + .and_then(Value::as_str) + .unwrap_or("2026-08-02T00:00:00Z") + .parse() + .expect("fixed observation time parses") +} + +async fn acceptance_runtime() -> AcceptanceRuntime { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("one immutable acceptance runtime initializes"), + ); + AcceptanceRuntime { + _temporary: prepared.temporary, + bundle_root: prepared.bundle_root, + runtime_path: prepared.runtime_path, + runtime, + server: prepared.server, + audit_path: prepared.audit_path, + } +} + +async fn prepare_acceptance(binding_secret: &str) -> PreparedAcceptance { + let temporary = tempfile::tempdir().expect("temporary acceptance root"); + let bundle_root = temporary.path().join("bundle"); + let runtime_path = temporary.path().join("runtime.yaml"); + let secret_root = temporary.path().join("secrets"); + let audit_path = temporary.path().join("audit.jsonl"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + fs::create_dir(&secret_root).expect("secret root is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("secret root is owner-only"); + } + copy_tree(&fixture_root(), &bundle_root); + + let server = MockServer::start().await; + rewrite_deployment_values(&bundle_root, &server.uri()); + write_secret( + &secret_root, + "audit-hash-key", + "audit-hash-secret-canary-32-bytes-minimum", + ); + write_secret(&secret_root, "subject-binding-key", binding_secret); + write_secret(&secret_root, "signing-key", EVIDENCE_PRIVATE_JWK); + write_secret(&secret_root, "source-a-token", BEARER); + write_secret(&secret_root, "source-b-token", BEARER); + write_secret(&secret_root, "source-c-username", BASIC_USER); + write_secret(&secret_root, "source-c-password", BASIC_PASSWORD); + write_secret(&secret_root, "source-d-token", BEARER); + write_runtime_config(&runtime_path, &bundle_root, &secret_root, &audit_path); + make_file_read_only(&runtime_path); + make_read_only(&bundle_root); + + PreparedAcceptance { + temporary, + bundle_root, + runtime_path, + server, + audit_path, + } +} + +fn authenticator() -> Authenticator { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) + .expect("static auth JWKS parses"); + let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + fetcher, + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + +fn access_token(extra: Option) -> String { + access_token_for("requester-principal-canary", extra) +} + +fn access_token_for(principal: &str, extra: Option) -> String { + let now = Utc::now().timestamp(); + let mut claims = json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": principal, + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["fixture-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + }); + if let Some(Value::Object(extra)) = extra { + claims + .as_object_mut() + .expect("claims are an object") + .extend(extra); + } + signed_access_token(claims) +} + +fn signed_access_token(claims: Value) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "kid": "acceptance-auth-key", + "typ": "at+jwt" + })) + .expect("JWT header serializes"), + ); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let signing_input = format!("{header}.{payload}"); + let key = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth key parses"); + let signature = + URL_SAFE_NO_PAD.encode(sign(signing_input.as_bytes(), &key).expect("JWT signs")); + format!("{signing_input}.{signature}") +} + +fn parent_grant_claims() -> Value { + parent_grant_claims_for(parent_candidate()) +} + +fn parent_grant_claims_for(candidate: Value) -> Value { + json!({ + "evidence_grant_id": "synthetic-parentage-grant-001", + "evidence_authority": AUTHORITY, + "grant": {"candidate_parent": candidate} + }) +} + +fn parent_candidate() -> Value { + json!({"person_reference": PARENT_REFERENCE}) +} + +fn non_parent_candidate() -> Value { + json!({"person_reference": NON_PARENT_REFERENCE}) +} + +async fn mount_success_sources(server: &MockServer, candidate_is_parent: bool) { + mount_adult_source(server, None).await; + mount_residence_source(server).await; + mount_licence_source(server).await; + let parents = if candidate_is_parent { + vec![PARENT_REFERENCE] + } else { + vec!["synthetic-parent-reference-002"] + }; + mount_parent_source(server, parent_source_response(parents)).await; +} + +async fn mount_residence_source(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": {"record_reference": "synthetic-residence-record-001"}, + "fields": ["official_residence_code"], + "limit": 2 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "official_residence_code": "R-101" + }))) + .expect(1) + .mount(server) + .await; +} + +async fn mount_licence_source(server: &MockServer) { + let basic = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{BASIC_USER}:{BASIC_PASSWORD}")) + ); + Mock::given(method("GET")) + .and(path("/v1/records")) + .and(header("accept", "application/json")) + .and(header("authorization", basic.as_str())) + .and(query_param("limit", "2")) + .and(query_param("licence_reference", "synthetic-licence-001")) + .and(query_param("registry_region", "RR-A")) + .and(query_param( + "fields", + "licence_state,valid_from,valid_until", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "records": [{ + "licence_state": "CURRENT", + "valid_from": "2000-01-01", + "valid_until": "2099-12-31", + "historical_states": ["PENDING"] + }] + }))) + .expect(1) + .mount(server) + .await; +} + +async fn mount_adult_source(server: &MockServer, delay: Option) { + let response = ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + })); + let response = delay.map_or(response.clone(), |delay| response.set_delay(delay)); + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }))) + .respond_with(response) + .expect(1) + .mount(server) + .await; +} + +async fn mount_parent_source(server: &MockServer, response: Value) { + Mock::given(method("POST")) + .and(path("/v1/child-relationships")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(parent_source_request())) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(server) + .await; +} + +fn parent_source_request() -> Value { + json!({ + "lookup": {"record_reference": "synthetic-child-record-001"}, + "fields": [ + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete" + ], + "limit": 2 + }) +} + +fn parent_source_response(parent_references: Vec<&str>) -> Value { + json!({ + "total": 1, + "records": [{ + "returned_child_reference": "synthetic-child-record-001", + "parent_references": parent_references, + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }] + }) +} + +async fn wait_for_source_request_count(server: &MockServer, expected: usize) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let count = server + .received_requests() + .await + .expect("request journal is available") + .len(); + if count >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("source request arrives before the test deadline"); +} + +async fn wait_for_audit_counts(path: &Path, access: usize, release: usize) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let contents = fs::read_to_string(path).expect("audit is readable"); + if contents.matches("\"phase\":\"access-attempt\"").count() >= access + && contents.matches("\"phase\":\"disclosure-release\"").count() >= release + { + return contents; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("terminal audit records arrive before the test deadline") +} + +async fn wait_for_runtime_ready(runtime: &EvidenceRuntime) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if runtime.ready().await { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("runtime returns to ready after detached evaluation completes"); +} + +fn adult_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:adult-status:v1", + "fixture-eligibility", + vec![requested_subject( + "subject", + "person-demographics-v1", + Some([ + ("given_name", "Amina"), + ("family_name", "Diallo"), + ("birth_date", "2000-01-01"), + ]), + )], + ) +} + +fn residence_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:residence-region:v1", + "fixture-routing", + vec![requested_subject( + "subject", + "residence-record-v1", + Some([("record_reference", "synthetic-residence-record-001")]), + )], + ) +} + +fn licence_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:professional-licence-status:v1", + "fixture-registration", + vec![requested_subject( + "subject", + "licence-register-v1", + Some([ + ("licence_reference", "synthetic-licence-001"), + ("registry_region", "RR-A"), + ]), + )], + ) +} + +fn parent_request() -> EvidenceRequest { + request( + "urn:example:fixture:requirement:legal-parent-relationship:v1", + "fixture-enrolment", + vec![ + requested_subject( + "child", + "civil-record-reference-v1", + Some([("record_reference", "synthetic-child-record-001")]), + ), + requested_subject::<[(&str, &str); 0]>("candidate-parent", "person-reference-v1", None), + ], + ) +} + +fn parent_request_with_candidate_values() -> EvidenceRequest { + let mut request = parent_request(); + request.subjects[1].selector.values = Some(BTreeMap::from([( + "person_reference".to_owned(), + SelectorValue::String("synthetic-substitute-reference".to_owned()), + )])); + request +} + +fn request(requirement: &str, purpose: &str, subjects: Vec) -> EvidenceRequest { + EvidenceRequest { + requirement: requirement.to_owned(), + purpose: purpose.to_owned(), + subjects, + } +} + +fn requested_subject<'a, I>(role: &str, profile: &str, values: Option) -> RequestedSubject +where + I: IntoIterator, +{ + RequestedSubject { + role: role.to_owned(), + selector: RequestedSelector { + profile: profile.to_owned(), + values: values.map(|entries| { + entries + .into_iter() + .map(|(name, value)| (name.to_owned(), SelectorValue::String(value.to_owned()))) + .collect() + }), + }, + } +} + +fn verification_policy( + runtime: &EvidenceRuntime, + request: &EvidenceRequest, +) -> EvidenceVerificationPolicy { + let requirement = runtime + .bundle() + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is loaded"); + EvidenceVerificationPolicy { + issued_by: runtime.bundle().config.issuer.id.clone(), + provided_by: runtime.bundle().config.service.provider_id.clone(), + requirement: request.requirement.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + audience: EVIDENCE_AUDIENCE.to_owned(), + configuration_revision: runtime.bundle().revision().to_owned(), + now: Utc::now(), + clock_skew: Duration::from_secs(30), + } +} + +fn assert_minimized_payload(serialized_jws: &[u8]) { + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("flattened JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("flattened JWS payload is base64url"); + let text = String::from_utf8(payload).expect("Evidence payload is UTF-8 JSON"); + for canary in privacy_canaries() { + assert!( + !text.contains(canary), + "signed JWS retained protected material" + ); + } + for forbidden_field in [ + "record_reference", + "given_name", + "family_name", + "birth_date", + "licence_reference", + "registry_region", + "role_resolution", + "relationship_status", + "date_of_birth", + "official_residence_code", + "historical_states", + "returned_child_reference", + "parent_references", + "reference_namespace", + "relationship_set_contract", + "relationship_set_complete", + "person_reference", + ] { + assert!(!text.contains(forbidden_field)); + } +} + +fn privacy_canaries() -> &'static [&'static str] { + &[ + "Amina", + "Binta", + "Diallo", + "Other", + "Subject", + "Amadou", + "Keita", + "2000-01-01", + "1970-06-15", + "1971-01-01", + "1974-02-11", + "synthetic-child-record-001", + "synthetic-other-child-record", + "synthetic-residence-record-001", + "synthetic-licence-001", + "synthetic-parentage-grant-001", + "synthetic-parent-reference-001", + "synthetic-parent-reference-002", + "synthetic-non-parent-reference-003", + "synthetic-substitute-reference", + "PrivacyCanary", + "source-bearer-canary", + "source-user-canary", + "source-password-canary", + "audit-hash-secret-canary-32-bytes-minimum", + "subject-binding-secret-canary-32-bytes-minimum", + ] +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/all-definitions") +} + +fn rewrite_deployment_values(bundle_root: &Path, source_origin: &str) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied configuration is readable"); + replace_exact(&mut text, "https://source.invalid", source_origin, 4); + replace_exact( + &mut text, + "fixture-key-2026-01", + "acceptance-evidence-key", + 1, + ); + fs::write(path, text).expect("deployment-only fixture rewrite succeeds"); +} + +fn write_runtime_config( + runtime_path: &Path, + bundle_root: &Path, + secret_root: &Path, + audit_path: &Path, +) { + let document = format!( + r#"version: 1 +bundleDirectory: {} +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: {} +auditStorage: + path: {} + maximumFileBytes: 10485760 +outboundTls: + systemRoots: true + trustProfiles: {{}} +"#, + bundle_root.display(), + secret_root.display(), + audit_path.display(), + ); + fs::write(runtime_path, document).expect("immutable runtime configuration is written"); +} + +fn replace_exact(text: &mut String, from: &str, to: &str, expected: usize) { + assert_eq!( + text.matches(from).count(), + expected, + "fixture drift for {from}" + ); + *text = text.replace(from, to); +} + +fn write_secret(root: &Path, name: &str, value: &str) { + let path = root.join(name); + fs::write(&path, value.as_bytes()).expect("synthetic secret is written"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("synthetic secret is owner-only"); + } +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("acceptance fixture is readable") { + let entry = entry.expect("fixture directory entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture file type is readable") + .is_dir() + { + fs::create_dir(&destination).expect("fixture directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_file_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o444)) + .expect("runtime configuration is immutable"); +} + +#[cfg(unix)] +fn make_file_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o644)) + .expect("runtime configuration becomes writable for mutation test"); +} + +#[cfg(not(unix))] +fn make_file_read_only(path: &Path) { + let mut permissions = fs::metadata(path).expect("runtime metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(path, permissions).expect("runtime configuration is immutable"); +} + +#[cfg(not(unix))] +fn make_file_writable(path: &Path) { + let mut permissions = fs::metadata(path).expect("runtime metadata").permissions(); + permissions.set_readonly(false); + fs::set_permissions(path, permissions) + .expect("runtime configuration becomes writable for mutation test"); +} + +#[cfg(unix)] +fn make_read_only(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle directory entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("bundle file type is readable") + .is_dir() + { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("bundle root is immutable"); +} + +#[cfg(unix)] +fn make_writable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("bundle directory becomes writable for mutation test"); + for entry in fs::read_dir(path).expect("captured bundle remains readable") { + let entry = entry.expect("captured bundle entry remains readable"); + let child = entry.path(); + if entry + .file_type() + .expect("captured bundle file type remains readable") + .is_dir() + { + make_writable(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o644)) + .expect("bundle file becomes writable for mutation test"); + } + } +} + +#[cfg(not(unix))] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle directory entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("bundle file type is readable") + .is_dir() + { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child).expect("bundle metadata").permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("bundle file is immutable"); + } + } +} + +#[cfg(not(unix))] +fn make_writable(path: &Path) { + for entry in fs::read_dir(path).expect("captured bundle remains readable") { + let entry = entry.expect("captured bundle entry remains readable"); + let child = entry.path(); + if entry + .file_type() + .expect("captured bundle file type remains readable") + .is_dir() + { + make_writable(&child); + } else { + let mut permissions = fs::metadata(&child) + .expect("captured bundle metadata remains readable") + .permissions(); + permissions.set_readonly(false); + fs::set_permissions(child, permissions) + .expect("bundle file becomes writable for mutation test"); + } + } +} diff --git a/crates/registry-evidence/src/secrets.rs b/crates/registry-evidence/src/secrets.rs new file mode 100644 index 000000000..14a567748 --- /dev/null +++ b/crates/registry-evidence/src/secrets.rs @@ -0,0 +1,384 @@ +//! Bounded resolution of runtime secret references. + +use std::{ + collections::BTreeSet, + env, fmt, + fs::File, + io::Read, + path::{Path, PathBuf}, +}; + +use thiserror::Error; +use zeroize::Zeroizing; + +/// The maximum size of a resolved secret value. +pub const MAX_SECRET_BYTES: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretProvider { + Environment, + File, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SecretError { + #[error("the secret reference is invalid")] + InvalidReference, + #[error("the secret reference uses a disabled provider")] + ProviderDisabled, + #[error("the secret provider configuration is invalid")] + InvalidProviderConfiguration, + #[error("the referenced secret is unavailable")] + Unavailable, + #[error("the referenced secret file is unsafe")] + UnsafeFile, + #[error("the referenced secret could not be read")] + Read, + #[error("the referenced secret value is invalid")] + InvalidValue, +} + +/// Secret bytes that are erased when dropped and never exposed by `Debug`. +pub struct ProtectedSecret(Zeroizing>); + +impl ProtectedSecret { + /// Explicitly borrow the secret for the smallest possible consumer scope. + pub fn expose_secret(&self) -> &[u8] { + self.0.as_slice() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for ProtectedSecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedSecret([REDACTED])") + } +} + +/// Resolves only the providers explicitly enabled by runtime configuration. +#[derive(Debug)] +pub struct SecretResolver { + providers: BTreeSet, + file_root: PathBuf, +} + +impl SecretResolver { + pub fn new( + providers: impl IntoIterator, + file_root: impl Into, + ) -> Result { + let providers = providers.into_iter().collect::>(); + let file_root = file_root.into(); + if providers.is_empty() + || (providers.contains(&SecretProvider::File) && !file_root.is_absolute()) + { + return Err(SecretError::InvalidProviderConfiguration); + } + Ok(Self { + providers, + file_root, + }) + } + + pub fn resolve(&self, reference: &str) -> Result { + let (provider, name) = parse_reference(reference)?; + if !self.providers.contains(&provider) { + return Err(SecretError::ProviderDisabled); + } + + let bytes = match provider { + SecretProvider::Environment => read_environment(name)?, + SecretProvider::File => read_secret_file(&self.file_root, name)?, + }; + validate_secret(bytes) + } +} + +fn parse_reference(reference: &str) -> Result<(SecretProvider, &str), SecretError> { + if let Some(name) = reference.strip_prefix("secret:env/") { + if valid_environment_name(name) { + return Ok((SecretProvider::Environment, name)); + } + } else if let Some(name) = reference.strip_prefix("secret:file/") { + if valid_file_name(name) { + return Ok((SecretProvider::File, name)); + } + } + Err(SecretError::InvalidReference) +} + +fn valid_environment_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'A'..=b'Z')) + && bytes.len() <= 128 + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'_') +} + +fn valid_file_name(name: &str) -> bool { + let bytes = name.as_bytes(); + matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes.len() <= 128 + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +fn read_environment(name: &str) -> Result>, SecretError> { + let value = env::var_os(name).ok_or(SecretError::Unavailable)?; + #[cfg(unix)] + let bytes = { + use std::os::unix::ffi::OsStringExt as _; + value.into_vec() + }; + #[cfg(not(unix))] + let bytes = value + .into_string() + .map_err(|_| SecretError::InvalidValue)? + .into_bytes(); + Ok(Zeroizing::new(bytes)) +} + +#[cfg(unix)] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + use rustix::fs::{Mode, OFlags}; + + let root = rustix::fs::open( + root, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::DIRECTORY, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let secret = rustix::fs::openat( + &root, + name, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| SecretError::Unavailable)?; + let file = File::from(secret); + validate_file_metadata(&file)?; + read_bounded(file) +} + +#[cfg(unix)] +fn validate_file_metadata(file: &File) -> Result<(), SecretError> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let metadata = file.metadata().map_err(|_| SecretError::Read)?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.permissions().mode() & 0o7777 != 0o600 + || metadata.nlink() != 1 + { + return Err(SecretError::UnsafeFile); + } + Ok(()) +} + +#[cfg(not(unix))] +fn read_secret_file(root: &Path, name: &str) -> Result>, SecretError> { + let path = root.join(name); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| SecretError::Unavailable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(SecretError::UnsafeFile); + } + let file = File::open(path).map_err(|_| SecretError::Unavailable)?; + read_bounded(file) +} + +fn read_bounded(file: File) -> Result>, SecretError> { + let mut bytes = Zeroizing::new(Vec::new()); + file.take((MAX_SECRET_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| SecretError::Read)?; + Ok(bytes) +} + +fn validate_secret(bytes: Zeroizing>) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SECRET_BYTES || bytes.contains(&0) { + return Err(SecretError::InvalidValue); + } + Ok(ProtectedSecret(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().expect("lock") + } + + #[test] + fn references_use_only_the_two_exact_contract_grammars() { + for valid in [ + "secret:env/A", + "secret:env/SOURCE_2_PASSWORD", + "secret:file/a", + "secret:file/source-token_v2.json", + ] { + assert!(parse_reference(valid).is_ok(), "{valid}"); + } + for invalid in [ + "secret:env/", + "secret:env/lower", + "secret:env/A-B", + "secret:environment/A", + "secret:file/Upper", + "secret:file/../token", + "secret:file/nested/token", + "secret:file/.token", + "secret:file/token\0suffix", + "plain-value", + ] { + assert_eq!(parse_reference(invalid), Err(SecretError::InvalidReference)); + } + assert!(parse_reference(&format!("secret:env/A{}", "B".repeat(127))).is_ok()); + assert_eq!( + parse_reference(&format!("secret:env/A{}", "B".repeat(128))), + Err(SecretError::InvalidReference) + ); + } + + #[test] + fn provider_allowlist_is_enforced_before_lookup() { + let resolver = + SecretResolver::new([SecretProvider::File], "/safe-root").expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:env/DEFINITELY_NOT_PRESENT"), + Err(SecretError::ProviderDisabled) + )); + } + + #[test] + fn environment_secret_is_bounded_and_debug_is_redacted() { + let _guard = environment_lock(); + const NAME: &str = "REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST"; + env::set_var(NAME, "environment-canary"); + let resolver = + SecretResolver::new([SecretProvider::Environment], "").expect("resolver builds"); + let secret = resolver + .resolve("secret:env/REGISTRY_EVIDENCE_SECRET_RESOLVER_TEST") + .expect("secret resolves"); + env::remove_var(NAME); + + assert!( + secret.expose_secret() == b"environment-canary", + "resolved environment secret bytes differ" + ); + assert_eq!(format!("{secret:?}"), "ProtectedSecret([REDACTED])"); + assert!(!format!("{secret:?}").contains("environment-canary")); + } + + #[test] + fn empty_nul_and_oversized_values_are_rejected_without_echo() { + for value in [ + Vec::new(), + b"canary\0value".to_vec(), + vec![b'x'; MAX_SECRET_BYTES + 1], + ] { + let error = validate_secret(Zeroizing::new(value)).expect_err("invalid secret"); + assert_eq!(error, SecretError::InvalidValue); + assert_eq!(error.to_string(), "the referenced secret value is invalid"); + } + } + + #[cfg(unix)] + mod unix { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt as _}; + + fn write_secret(root: &Path, name: &str, value: &[u8], mode: u32) { + let path = root.join(name); + fs::write(&path, value).expect("write secret"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set mode"); + } + + #[test] + fn file_secret_uses_open_file_owner_and_exact_mode_checks() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "source-token", b"file-canary", 0o600); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + let secret = resolver + .resolve("secret:file/source-token") + .expect("safe file resolves"); + assert!( + secret.expose_secret() == b"file-canary", + "resolved file secret bytes differ" + ); + + write_secret(root.path(), "unsafe-token", b"unsafe-canary", 0o640); + assert!(matches!( + resolver.resolve("secret:file/unsafe-token"), + Err(SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_symlinks_and_non_regular_files() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "target", b"symlink-canary", 0o600); + symlink(root.path().join("target"), root.path().join("link")).expect("create symlink"); + fs::create_dir(root.path().join("directory")).expect("create directory"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + assert!(matches!( + resolver.resolve("secret:file/link"), + Err(SecretError::Unavailable) + )); + assert!(matches!( + resolver.resolve("secret:file/directory"), + Err(SecretError::Unavailable | SecretError::UnsafeFile) + )); + } + + #[test] + fn file_secret_rejects_every_name_for_a_hard_link() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret(root.path(), "first", b"hard-link-canary", 0o600); + fs::hard_link(root.path().join("first"), root.path().join("second")) + .expect("create hard link"); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + + for name in ["first", "second"] { + assert!(matches!( + resolver.resolve(&format!("secret:file/{name}")), + Err(SecretError::UnsafeFile) + )); + } + } + + #[test] + fn file_secret_read_is_bounded() { + let root = tempfile::tempdir().expect("temporary root"); + write_secret( + root.path(), + "oversized", + &vec![b'x'; MAX_SECRET_BYTES + 1], + 0o600, + ); + let resolver = + SecretResolver::new([SecretProvider::File], root.path()).expect("resolver builds"); + assert!(matches!( + resolver.resolve("secret:file/oversized"), + Err(SecretError::InvalidValue) + )); + } + } +} diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs new file mode 100644 index 000000000..6b8ab69cc --- /dev/null +++ b/crates/registry-evidence/src/selector.rs @@ -0,0 +1,1040 @@ +//! One fail-closed authorization and selector-resolution decision. + +use std::{collections::BTreeSet, fmt}; + +use chrono::NaiveDate; +use serde_json::Value; +use thiserror::Error; + +use crate::{ + auth::AuthenticatedContext, + binding::{ + subject_binding, BindingError, SelectorField as BindingField, + SelectorScalar as BindingScalar, SubjectBindingInput, + }, + bundle::{Bundle, Codelist}, + config::{ + AuthorityKind, GrantedSubject, SelectorField as ConfiguredField, SelectorProfile, + ValueOrigin, MAX_SAFE_INTEGER, + }, + model::{EvidenceRequest, RequestedSubject, SelectorValue}, +}; + +const MAX_CANONICAL_BYTES: usize = 64 * 1024; + +/// Validate configured subject-binding key material through the same primitive +/// used for released subject bindings. This keeps readiness from duplicating +/// the crypto boundary's key-length and key-version invariants. +pub(crate) fn validate_subject_binding_key( + key: &[u8], + key_version: u32, + trust_domain: &str, +) -> Result<(), AuthorizationError> { + let fields = [BindingField { + name: "readiness", + value: BindingScalar::Boolean(true), + }]; + subject_binding( + key, + key_version, + SubjectBindingInput { + trust_domain, + audience: "urn:registry-evidence:readiness", + purpose: "readiness", + role: "readiness", + profile: "readiness-v1", + fields: &fields, + }, + ) + .map(|_| ()) + .map_err(|_: BindingError| AuthorizationError::Binding) +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationError { + #[error("the Evidence request is not authorized")] + Unauthorized, + #[error("the Evidence request selector is invalid")] + Selector, + #[error("the Evidence authorization decision is ambiguous")] + AmbiguousAuthority, + #[error("the Evidence subject binding could not be constructed")] + Binding, +} + +/// Failure of the offline fixture harness, which resolves a captured fixture +/// case through the ordinary authorization boundary. +/// +/// A fixture that does not state which purpose it exercises is a fixture +/// contract failure, not an authorization denial. Keeping the two apart stops +/// a harness omission from reading as a rejected request. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum OfflineFixtureError { + #[error("the offline fixture does not select one of the requirement's purposes")] + Purpose, + #[error(transparent)] + Authorization(#[from] AuthorizationError), +} + +#[derive(Clone, PartialEq, Eq)] +pub enum ResolvedSelectorValue { + String(String), + Date(String), + Integer(i64), + Boolean(bool), + ControlledCode(String), +} + +impl fmt::Debug for ResolvedSelectorValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("") + } +} + +impl ResolvedSelectorValue { + pub fn as_json(&self) -> Value { + match self { + Self::String(value) | Self::Date(value) | Self::ControlledCode(value) => { + Value::String(value.clone()) + } + Self::Integer(value) => Value::from(*value), + Self::Boolean(value) => Value::from(*value), + } + } + + fn binding_scalar(&self) -> BindingScalar<'_> { + match self { + Self::String(value) => BindingScalar::String(value), + Self::Date(value) => BindingScalar::Date(value), + Self::Integer(value) => BindingScalar::Integer(*value), + Self::Boolean(value) => BindingScalar::Boolean(*value), + Self::ControlledCode(value) => BindingScalar::ControlledCode(value), + } + } + + fn canonical_bytes(&self) -> &[u8] { + match self { + Self::String(value) | Self::Date(value) | Self::ControlledCode(value) => { + value.as_bytes() + } + Self::Integer(_) | Self::Boolean(_) => &[], + } + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedSelectorField { + pub name: String, + pub value: ResolvedSelectorValue, +} + +impl fmt::Debug for ResolvedSelectorField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedSelectorField") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ResolvedSubject { + pub role: String, + pub selector_profile: String, + pub value_origin: ValueOrigin, + pub fields: Vec, +} + +impl fmt::Debug for ResolvedSubject { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedSubject") + .field("role", &self.role) + .field("selector_profile", &self.selector_profile) + .field("value_origin", &self.value_origin) + .field( + "field_names", + &self + .fields + .iter() + .map(|field| &field.name) + .collect::>(), + ) + .finish() + } +} + +impl ResolvedSubject { + pub fn value(&self, field_name: &str) -> Option<&ResolvedSelectorValue> { + self.fields + .iter() + .find(|field| field.name == field_name) + .map(|field| &field.value) + } + + pub fn binding( + &self, + key: &[u8], + key_version: u32, + trust_domain: &str, + audience: &str, + purpose: &str, + ) -> Result { + let fields = self + .fields + .iter() + .map(|field| BindingField { + name: &field.name, + value: field.value.binding_scalar(), + }) + .collect::>(); + subject_binding( + key, + key_version, + SubjectBindingInput { + trust_domain, + audience, + purpose, + role: &self.role, + profile: &self.selector_profile, + fields: &fields, + }, + ) + .map_err(|_: BindingError| AuthorizationError::Binding) + } + + /// Canonical protected input for the one permitted per-subject audit pseudonym. + pub fn audit_pseudonym_input( + &self, + audience: &str, + purpose: &str, + ) -> Result, AuthorizationError> { + let mut output = Vec::new(); + output.push(0x01); + push_component(&mut output, audience.as_bytes())?; + push_component(&mut output, purpose.as_bytes())?; + push_component(&mut output, self.role.as_bytes())?; + push_component(&mut output, self.selector_profile.as_bytes())?; + push_count(&mut output, self.fields.len())?; + for field in &self.fields { + push_component(&mut output, field.name.as_bytes())?; + match &field.value { + ResolvedSelectorValue::String(value) => { + output.push(0x01); + push_component(&mut output, value.as_bytes())?; + } + ResolvedSelectorValue::Date(value) => { + output.push(0x02); + push_component(&mut output, value.as_bytes())?; + } + ResolvedSelectorValue::Integer(value) => { + output.push(0x03); + push_component(&mut output, value.to_string().as_bytes())?; + } + ResolvedSelectorValue::Boolean(value) => { + output.push(0x04); + push_component(&mut output, &[u8::from(*value)])?; + } + ResolvedSelectorValue::ControlledCode(value) => { + output.push(0x05); + push_component(&mut output, value.as_bytes())?; + } + } + } + Ok(output) + } +} + +#[derive(Clone)] +pub struct ResolvedAuthorization { + pub authority_profile: String, + pub authority_kind: AuthorityKind, + pub grant_id: Option, + pub grant_authority: Option, + pub requirement: String, + pub purpose: String, + pub audience: String, + pub subjects: Vec, +} + +impl fmt::Debug for ResolvedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedAuthorization") + .field("authority_profile", &"") + .field("authority_kind", &self.authority_kind) + .field("grant_id", &self.grant_id.as_ref().map(|_| "")) + .field( + "grant_authority", + &self.grant_authority.as_ref().map(|_| ""), + ) + .field("requirement", &self.requirement) + .field("purpose", &self.purpose) + .field("audience", &self.audience) + .field("subjects", &self.subjects) + .finish() + } +} + +#[derive(Clone)] +pub struct MatchedEntitlement { + authority_profile: String, + authority_kind: AuthorityKind, + subjects: Vec, +} + +impl MatchedEntitlement { + pub fn authority_profile(&self) -> &str { + &self.authority_profile + } + + pub fn authority_kind(&self) -> AuthorityKind { + self.authority_kind + } +} + +impl fmt::Debug for MatchedEntitlement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MatchedEntitlement") + .field("authority_profile", &"") + .field("authority_kind", &self.authority_kind) + .field("subject_count", &self.subjects.len()) + .finish() + } +} + +/// Match exactly one complete entitlement without inspecting selector values. +/// +/// Keeping this decision separate lets the service charge invalid selector +/// attempts to the matched authority profile before resolving any protected +/// values. It performs no credential resolution and no source access. +pub fn match_entitlement( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, +) -> Result { + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .ok_or(AuthorizationError::Unauthorized)?; + if !requirement + .purposes + .iter() + .any(|purpose| purpose == &request.purpose) + { + return Err(AuthorizationError::Unauthorized); + } + validate_request_subject_shape(requirement, &request.subjects)?; + + let mut matched = Vec::new(); + for (authority_profile, authority) in bundle.config.authority_profiles.iter() { + if !authority.requester_tags.iter().all(|required| { + context + .requester_tags() + .iter() + .any(|actual| actual == required) + }) { + continue; + } + if context.actor().is_some() && authority.kind != AuthorityKind::Delegated { + continue; + } + for grant in &authority.grants { + if grant.requirement != request.requirement + || grant.purpose != request.purpose + || !same_subject_tuples(&grant.subjects, &request.subjects) + { + continue; + } + let uses_authenticated_grant = grant + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant && context.grant_authority() != Some(authority_profile) { + continue; + } + matched.push(MatchedEntitlement { + authority_profile: authority_profile.to_owned(), + authority_kind: authority.kind, + subjects: grant.subjects.clone(), + }); + } + } + + match matched.len() { + 1 => Ok(matched.remove(0)), + 0 => Err(AuthorizationError::Unauthorized), + _ => Err(AuthorizationError::AmbiguousAuthority), + } +} + +/// Resolve the complete role-bound selector set for one matched entitlement. +/// +/// This is the first operation that inspects caller or authenticated-grant +/// selector values. It still performs no credential resolution or source +/// access. +pub fn resolve_selectors( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, + matched: &MatchedEntitlement, +) -> Result { + let subjects = resolve_grant_subjects(bundle, &matched.subjects, &request.subjects, context)?; + let uses_authenticated_grant = matched + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + Ok(ResolvedAuthorization { + authority_profile: matched.authority_profile.clone(), + authority_kind: matched.authority_kind, + grant_id: uses_authenticated_grant + .then(|| context.grant_id().map(ToOwned::to_owned)) + .flatten(), + grant_authority: uses_authenticated_grant + .then(|| context.grant_authority().map(ToOwned::to_owned)) + .flatten(), + requirement: request.requirement.clone(), + purpose: request.purpose.clone(), + audience: context.evidence_audience().to_owned(), + subjects, + }) +} + +/// Resolve exactly one complete entitlement and its complete selector set. +/// +/// Service code that applies the failed-selector budget uses +/// [`match_entitlement`] and [`resolve_selectors`] separately. Offline callers +/// may use this convenience wrapper. +pub fn authorize_and_resolve( + bundle: &Bundle, + request: &EvidenceRequest, + context: &AuthenticatedContext, +) -> Result { + let matched = match_entitlement(bundle, request, context)?; + resolve_selectors(bundle, request, context, &matched) +} + +/// Exercise the normal authorization and selector boundary for one captured +/// offline fixture subject set without token, credential, or source access. +/// +/// Fixture JSON uses the compact `{role, profile, values}` representation from +/// the reviewed product bundles. No selector value is included in an error. +pub fn resolve_offline_fixture_authorization( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + common: Option<&serde_json::Map>, + case: &serde_json::Map, + audience: &str, +) -> Result { + let purpose = fixture_purpose(&requirement.purposes, common, case)?; + let subjects = if let Some(subjects) = case + .get("subjects") + .or_else(|| common.and_then(|value| value.get("subjects"))) + .and_then(Value::as_array) + { + subjects + .iter() + .map(parse_fixture_subject) + .collect::, _>>()? + } else { + fixture_subjects_from_selectors(bundle, requirement, purpose, common, case)? + }; + let request = EvidenceRequest { + requirement: requirement.id.clone(), + purpose: purpose.to_owned(), + subjects, + }; + let (authority_name, authority) = bundle + .config + .authority_profiles + .iter() + .find(|(_, authority)| { + authority.grants.iter().any(|grant| { + grant.requirement == request.requirement + && grant.purpose == request.purpose + && same_subject_tuples(&grant.subjects, &request.subjects) + }) + }) + .ok_or(AuthorizationError::Unauthorized)?; + let claims = case + .get("verified_token_claims") + .or_else(|| common.and_then(|value| value.get("verified_token_claims"))) + .cloned() + .unwrap_or_else(|| Value::Object(Default::default())); + let grant_id = claims + .get("evidence_grant_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let grant_authority = claims + .get("evidence_authority") + .and_then(Value::as_str) + .unwrap_or(authority_name) + .to_owned(); + let context = AuthenticatedContext::offline_fixture_context( + authority.requester_tags.clone(), + audience, + grant_id.as_deref(), + Some(&grant_authority), + claims, + ); + Ok(authorize_and_resolve(bundle, &request, &context)?) +} + +/// Resolve the purpose one fixture case exercises. +/// +/// A requirement declaring more than one purpose must say which one the case +/// covers, in the case or in the inherited common block. The harness never +/// chooses on the fixture's behalf, so offline evaluation reaches every +/// authorized purpose and no purpose is silently skipped. +fn fixture_purpose<'a>( + purposes: &'a [String], + common: Option<&serde_json::Map>, + case: &serde_json::Map, +) -> Result<&'a str, OfflineFixtureError> { + match case + .get("purpose") + .or_else(|| common.and_then(|common| common.get("purpose"))) + { + Some(declared) => { + let declared = declared.as_str().ok_or(OfflineFixtureError::Purpose)?; + purposes + .iter() + .find(|purpose| purpose.as_str() == declared) + .map(String::as_str) + .ok_or(OfflineFixtureError::Authorization( + AuthorizationError::Unauthorized, + )) + } + None => match purposes { + [only] => Ok(only.as_str()), + _ => Err(OfflineFixtureError::Purpose), + }, + } +} + +fn fixture_subjects_from_selectors( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + purpose: &str, + common: Option<&serde_json::Map>, + case: &serde_json::Map, +) -> Result, AuthorizationError> { + let mut selectors = case + .get("selectors") + .or_else(|| common.and_then(|value| value.get("selectors"))) + .and_then(Value::as_object) + .cloned() + .ok_or(AuthorizationError::Selector)?; + if let Some(overrides) = case.get("selectorOverrides") { + let overrides = overrides.as_object().ok_or(AuthorizationError::Selector)?; + for (role, replacement) in overrides { + let replacement = replacement + .as_object() + .filter(|object| object.keys().all(|key| key == "profile" || key == "values")) + .ok_or(AuthorizationError::Selector)?; + let selector = selectors + .get_mut(role) + .and_then(Value::as_object_mut) + .ok_or(AuthorizationError::Selector)?; + for (name, value) in replacement { + selector.insert(name.clone(), value.clone()); + } + } + } + + let mut requested = Vec::with_capacity(requirement.subject_roles.len()); + for configured_role in &requirement.subject_roles { + let mut selector = selectors + .remove(&configured_role.role) + .and_then(|value| value.as_object().cloned()) + .ok_or(AuthorizationError::Selector)?; + selector.insert( + "role".to_owned(), + Value::String(configured_role.role.clone()), + ); + requested.push(parse_fixture_subject(&Value::Object(selector))?); + } + if !selectors.is_empty() { + return Err(AuthorizationError::Selector); + } + + let grant = bundle + .config + .authority_profiles + .iter() + .flat_map(|(_, authority)| authority.grants.iter()) + .find(|grant| { + grant.requirement == requirement.id + && grant.purpose == purpose + && same_subject_tuples(&grant.subjects, &requested) + }) + .ok_or(AuthorizationError::Unauthorized)?; + for subject in &mut requested { + let granted = grant + .subjects + .iter() + .find(|granted| granted.role == subject.role) + .ok_or(AuthorizationError::Unauthorized)?; + if granted.value_origin != ValueOrigin::Request { + subject.selector.values = None; + } + } + Ok(requested) +} + +pub fn resolve_offline_fixture_subjects( + bundle: &Bundle, + requirement: &crate::config::RequirementConfig, + common: Option<&serde_json::Map>, + case: &serde_json::Map, + audience: &str, +) -> Result, OfflineFixtureError> { + resolve_offline_fixture_authorization(bundle, requirement, common, case, audience).map( + |resolved| { + resolved + .subjects + .into_iter() + .map(|subject| subject.role) + .collect() + }, + ) +} + +fn parse_fixture_subject(value: &Value) -> Result { + let object = value.as_object().ok_or(AuthorizationError::Selector)?; + if object + .keys() + .any(|key| !matches!(key.as_str(), "role" | "profile" | "values")) + { + return Err(AuthorizationError::Selector); + } + let role = object + .get("role") + .and_then(Value::as_str) + .ok_or(AuthorizationError::Selector)?; + let profile = object + .get("profile") + .and_then(Value::as_str) + .ok_or(AuthorizationError::Selector)?; + let values = object + .get("values") + .map(|values| { + values + .as_object() + .ok_or(AuthorizationError::Selector)? + .iter() + .map(|(name, value)| { + let value = match value { + Value::String(value) => SelectorValue::String(value.clone()), + Value::Number(value) => value + .as_i64() + .map(SelectorValue::Integer) + .ok_or(AuthorizationError::Selector)?, + Value::Bool(value) => SelectorValue::Boolean(*value), + _ => return Err(AuthorizationError::Selector), + }; + Ok((name.clone(), value)) + }) + .collect::>() + }) + .transpose()?; + Ok(RequestedSubject { + role: role.to_owned(), + selector: crate::model::RequestedSelector { + profile: profile.to_owned(), + values, + }, + }) +} + +fn validate_request_subject_shape( + requirement: &crate::config::RequirementConfig, + requested: &[RequestedSubject], +) -> Result<(), AuthorizationError> { + if requested.len() != requirement.subject_roles.len() { + return Err(AuthorizationError::Unauthorized); + } + let mut roles = BTreeSet::new(); + for subject in requested { + if !roles.insert(subject.role.as_str()) { + return Err(AuthorizationError::Unauthorized); + } + let configured = requirement + .subject_roles + .iter() + .find(|configured| configured.role == subject.role) + .ok_or(AuthorizationError::Unauthorized)?; + if !configured + .selector_profiles + .iter() + .any(|profile| profile == &subject.selector.profile) + { + return Err(AuthorizationError::Unauthorized); + } + } + Ok(()) +} + +fn same_subject_tuples(granted: &[GrantedSubject], requested: &[RequestedSubject]) -> bool { + granted.len() == requested.len() + && granted.iter().all(|grant| { + requested.iter().any(|subject| { + subject.role == grant.role && subject.selector.profile == grant.selector_profile + }) + }) +} + +fn resolve_grant_subjects( + bundle: &Bundle, + granted: &[GrantedSubject], + requested: &[RequestedSubject], + context: &AuthenticatedContext, +) -> Result, AuthorizationError> { + let uses_authenticated_grant = granted + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant + && (context.grant_id().is_none() || context.grant_authority().is_none()) + { + return Err(AuthorizationError::Unauthorized); + } + + granted + .iter() + .map(|grant| { + let subject = requested + .iter() + .find(|subject| subject.role == grant.role) + .ok_or(AuthorizationError::Unauthorized)?; + let profile = bundle + .config + .selector_profiles + .get(&grant.selector_profile) + .ok_or(AuthorizationError::Unauthorized)?; + let input = match grant.value_origin { + ValueOrigin::Request => subject + .selector + .values + .as_ref() + .ok_or(AuthorizationError::Selector)? + .clone(), + ValueOrigin::AuthenticatedContext | ValueOrigin::AuthenticatedGrant => { + if subject.selector.values.is_some() { + return Err(AuthorizationError::Selector); + } + let claims = grant + .value_claims + .as_ref() + .ok_or(AuthorizationError::Unauthorized)?; + claims + .iter() + .map(|(field, path)| { + let value = context + .claim_path(path) + .and_then(selector_value_from_claim) + .ok_or(AuthorizationError::Selector)?; + Ok((field.to_owned(), value)) + }) + .collect::>()? + } + }; + let fields = validate_values(bundle, profile, &input)?; + Ok(ResolvedSubject { + role: grant.role.clone(), + selector_profile: subject.selector.profile.clone(), + value_origin: grant.value_origin, + fields, + }) + }) + .collect() +} + +fn selector_value_from_claim(value: &Value) -> Option { + match value { + Value::String(value) => Some(SelectorValue::String(value.clone())), + Value::Number(value) => value.as_i64().map(SelectorValue::Integer), + Value::Bool(value) => Some(SelectorValue::Boolean(*value)), + _ => None, + } +} + +fn validate_values( + bundle: &Bundle, + profile: &SelectorProfile, + values: &std::collections::BTreeMap, +) -> Result, AuthorizationError> { + if values.len() != profile.fields.len() + || profile + .fields + .keys() + .any(|field| !values.contains_key(field)) + || values + .keys() + .any(|field| !profile.fields.contains_key(field)) + { + return Err(AuthorizationError::Selector); + } + + let mut aggregate_bytes = 0_u64; + let mut output = Vec::with_capacity(values.len()); + for (name, configured) in profile.fields.iter() { + let supplied = values.get(name).ok_or(AuthorizationError::Selector)?; + let resolved = validate_value(bundle, configured, supplied)?; + let value_bytes = match &resolved { + ResolvedSelectorValue::Integer(value) => value.to_string().len(), + ResolvedSelectorValue::Boolean(_) => 1, + _ => resolved.canonical_bytes().len(), + }; + aggregate_bytes = aggregate_bytes + .checked_add(u64::try_from(value_bytes).map_err(|_| AuthorizationError::Selector)?) + .ok_or(AuthorizationError::Selector)?; + output.push(ResolvedSelectorField { + name: name.to_owned(), + value: resolved, + }); + } + if aggregate_bytes > profile.maximum_aggregate_bytes { + return Err(AuthorizationError::Selector); + } + Ok(output) +} + +fn validate_value( + bundle: &Bundle, + configured: &ConfiguredField, + supplied: &SelectorValue, +) -> Result { + match (configured, supplied) { + ( + ConfiguredField::String { + minimum_bytes, + maximum_bytes, + }, + SelectorValue::String(value), + ) if bounded(value.len(), *minimum_bytes, *maximum_bytes) => { + Ok(ResolvedSelectorValue::String(value.clone())) + } + (ConfiguredField::Date, SelectorValue::String(value)) + if canonical_date(value).is_some() => + { + Ok(ResolvedSelectorValue::Date(value.clone())) + } + (ConfiguredField::Integer { minimum, maximum }, SelectorValue::Integer(value)) + if value >= minimum + && value <= maximum + && value.unsigned_abs() <= MAX_SAFE_INTEGER as u64 => + { + Ok(ResolvedSelectorValue::Integer(*value)) + } + (ConfiguredField::Boolean, SelectorValue::Boolean(value)) => { + Ok(ResolvedSelectorValue::Boolean(*value)) + } + ( + ConfiguredField::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + }, + SelectorValue::String(value), + ) if bounded(value.len(), 1, *maximum_bytes) => { + let list = bundle + .codelist(codelist) + .ok_or(AuthorizationError::Selector)?; + if list.version() != codelist_version || !codelist_contains_selector_value(list, value) + { + return Err(AuthorizationError::Selector); + } + Ok(ResolvedSelectorValue::ControlledCode(value.clone())) + } + _ => Err(AuthorizationError::Selector), + } +} + +fn codelist_contains_selector_value(codelist: &Codelist, value: &str) -> bool { + match codelist { + Codelist::Codes { codes, .. } => codes.iter().any(|code| code == value), + Codelist::Mapping { entries, .. } => entries.contains_key(value), + } +} + +fn canonical_date(value: &str) -> Option { + if value.len() != 10 { + return None; + } + NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .filter(|date| date.format("%Y-%m-%d").to_string() == value) +} + +fn bounded(actual: usize, minimum: u64, maximum: u64) -> bool { + u64::try_from(actual).is_ok_and(|actual| (minimum..=maximum).contains(&actual)) +} + +fn push_component(output: &mut Vec, component: &[u8]) -> Result<(), AuthorizationError> { + if component.is_empty() || output.len().saturating_add(component.len()) > MAX_CANONICAL_BYTES { + return Err(AuthorizationError::Selector); + } + let length = u32::try_from(component.len()).map_err(|_| AuthorizationError::Selector)?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(component); + Ok(()) +} + +fn push_count(output: &mut Vec, count: usize) -> Result<(), AuthorizationError> { + let count = u32::try_from(count).map_err(|_| AuthorizationError::Selector)?; + output.extend_from_slice(&count.to_be_bytes()); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_selector_debug_never_exposes_values() { + let subject = ResolvedSubject { + role: "subject".to_owned(), + selector_profile: "opaque-v1".to_owned(), + value_origin: ValueOrigin::Request, + fields: vec![ResolvedSelectorField { + name: "opaque".to_owned(), + value: ResolvedSelectorValue::String("selector-canary".to_owned()), + }], + }; + let debug = format!("{subject:?}"); + assert!(!debug.contains("selector-canary")); + assert!(debug.contains("opaque")); + } + + #[test] + fn canonical_selector_input_is_order_and_audience_sensitive() { + let subject = ResolvedSubject { + role: "subject".to_owned(), + selector_profile: "opaque-v1".to_owned(), + value_origin: ValueOrigin::Request, + fields: vec![ResolvedSelectorField { + name: "field".to_owned(), + value: ResolvedSelectorValue::String("value".to_owned()), + }], + }; + let first = subject + .audit_pseudonym_input("urn:audience:a", "purpose") + .expect("canonicalizes"); + let second = subject + .audit_pseudonym_input("urn:audience:b", "purpose") + .expect("canonicalizes"); + assert_ne!(first, second); + } + + #[test] + fn selector_claim_values_are_scalar_only() { + assert!(selector_value_from_claim(&serde_json::json!({"value": "x"})).is_none()); + assert!(selector_value_from_claim(&serde_json::json!(["x"])).is_none()); + assert_eq!( + selector_value_from_claim(&serde_json::json!(false)), + Some(SelectorValue::Boolean(false)) + ); + } + + fn fixture_object(value: Value) -> serde_json::Map { + value + .as_object() + .expect("fixture block is an object") + .clone() + } + + #[test] + fn a_single_purpose_requirement_needs_no_declared_fixture_purpose() { + let purposes = vec!["only-purpose".to_owned()]; + assert_eq!( + fixture_purpose(&purposes, None, &fixture_object(serde_json::json!({}))), + Ok("only-purpose") + ); + } + + #[test] + fn a_fixture_selects_one_of_several_declared_purposes() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": "second"})) + ), + Ok("second") + ); + } + + #[test] + fn a_common_fixture_purpose_applies_to_every_case() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let common = fixture_object(serde_json::json!({"purpose": "second"})); + assert_eq!( + fixture_purpose( + &purposes, + Some(&common), + &fixture_object(serde_json::json!({})) + ), + Ok("second") + ); + } + + #[test] + fn a_case_fixture_purpose_overrides_the_common_purpose() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let common = fixture_object(serde_json::json!({"purpose": "first"})); + assert_eq!( + fixture_purpose( + &purposes, + Some(&common), + &fixture_object(serde_json::json!({"purpose": "second"})) + ), + Ok("second") + ); + } + + #[test] + fn a_multi_purpose_requirement_without_a_declared_purpose_is_a_fixture_error() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + let error = fixture_purpose(&purposes, None, &fixture_object(serde_json::json!({}))) + .expect_err("an unselected purpose is rejected"); + assert_eq!(error, OfflineFixtureError::Purpose); + assert_ne!( + error, + OfflineFixtureError::Authorization(AuthorizationError::Unauthorized), + "an unselected fixture purpose must not be reported as an authorization denial" + ); + } + + #[test] + fn a_non_string_fixture_purpose_is_a_fixture_error() { + let purposes = vec!["first".to_owned(), "second".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": 1})) + ), + Err(OfflineFixtureError::Purpose) + ); + } + + #[test] + fn a_fixture_purpose_outside_the_requirement_is_unauthorized() { + let purposes = vec!["first".to_owned()]; + assert_eq!( + fixture_purpose( + &purposes, + None, + &fixture_object(serde_json::json!({"purpose": "other"})) + ), + Err(OfflineFixtureError::Authorization( + AuthorizationError::Unauthorized + )) + ); + } +} diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs new file mode 100644 index 000000000..593e8525a --- /dev/null +++ b/crates/registry-evidence/src/server.rs @@ -0,0 +1,671 @@ +//! Native Evidence Version 1 HTTP boundary. +//! +//! Request admission, body collection, and concurrency queueing observe the +//! configured request timeout. Once [`EvidenceRuntime::evaluate`] starts, this +//! boundary deliberately does not wrap it in a cancelling timeout: evaluation +//! contains the durable access-audit, signing, and durable release-audit +//! critical section. + +use std::{ + future::{Future, IntoFuture}, + io, + net::{IpAddr, SocketAddr}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use axum::{ + body::{to_bytes, Body}, + extract::State, + http::{ + header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, RETRY_AFTER}, + HeaderMap, HeaderValue, Request, StatusCode, + }, + middleware::{from_fn, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use registry_platform_crypto::parse_json_strict; +use registry_platform_httpsec::CspBuilder; +use serde::Serialize; +use tokio::{net::TcpListener, sync::Semaphore}; +use ulid::Ulid; + +use crate::{ + config::ListenerConfig, + contracts::request_contract_accepts, + model::EvidenceRequest, + problem::ProblemCode, + runtime::{EvidenceRuntime, RuntimeFailure}, + EVIDENCE_JWS_MEDIA_TYPE, +}; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; +const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; +const RETRY_AFTER_SECONDS: &str = "1"; + +#[derive(Clone)] +struct ServerState { + runtime: Arc, + maximum_request_bytes: usize, + request_timeout: Duration, + request_slots: Arc, + evaluations: EvaluationTracker, + #[cfg(test)] + evaluation_time: Option>, +} + +/// Build the four-route Version 1 application from one immutable runtime. +#[cfg(test)] +pub(crate) fn build_app(runtime: Arc) -> Router { + build_app_with_tracker(runtime).0 +} + +#[cfg(test)] +pub(crate) fn build_app_at_for_test( + runtime: Arc, + evaluation_time: chrono::DateTime, +) -> Router { + build_app_with_tracker_at(runtime, Some(evaluation_time)).0 +} + +fn build_app_with_tracker(runtime: Arc) -> (Router, EvaluationTracker) { + build_app_with_tracker_at(runtime, None) +} + +fn build_app_with_tracker_at( + runtime: Arc, + evaluation_time: Option>, +) -> (Router, EvaluationTracker) { + #[cfg(not(test))] + let _ = evaluation_time; + let listener = &runtime.runtime_config().listener; + let maximum_request_bytes = listener.maximum_request_bytes as usize; + let request_timeout = Duration::from_millis(listener.request_timeout_milliseconds); + let maximum_concurrent_requests = listener.maximum_concurrent_requests as usize; + let evaluations = EvaluationTracker::default(); + let state = Arc::new(ServerState { + runtime, + maximum_request_bytes, + request_timeout, + request_slots: Arc::new(Semaphore::new(maximum_concurrent_requests)), + evaluations: evaluations.clone(), + #[cfg(test)] + evaluation_time, + }); + + let routes = Router::new() + .route("/v1/evidence", post(create_evidence)) + .route("/health", get(health)) + .route("/ready", get(ready)) + .route("/.well-known/evidence/jwks.json", get(jwks)) + .fallback(unknown_route) + .method_not_allowed_fallback(unknown_route) + .with_state(state); + (response_layers(routes), evaluations) +} + +#[derive(Clone, Default)] +struct EvaluationTracker { + inner: Arc, +} + +#[derive(Default)] +struct EvaluationTrackerInner { + active: AtomicUsize, + idle: tokio::sync::Notify, +} + +impl EvaluationTracker { + fn spawn(&self, future: F) -> tokio::task::JoinHandle + where + F: Future + Send + 'static, + T: Send + 'static, + { + self.inner.active.fetch_add(1, Ordering::AcqRel); + let guard = ActiveEvaluation { + tracker: self.clone(), + }; + tokio::spawn(async move { + let _guard = guard; + future.await + }) + } + + async fn wait_idle(&self) { + loop { + let idle = self.inner.idle.notified(); + if self.inner.active.load(Ordering::Acquire) == 0 { + return; + } + idle.await; + } + } +} + +struct ActiveEvaluation { + tracker: EvaluationTracker, +} + +impl Drop for ActiveEvaluation { + fn drop(&mut self) { + if self.tracker.inner.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.tracker.inner.idle.notify_one(); + } + } +} + +fn response_layers(routes: Router) -> Router { + routes + .layer(from_fn(add_no_store)) + .layer(registry_platform_httpsec::corp_conditional()) + .layer( + registry_platform_httpsec::security_headers(CspBuilder::restrictive()).without_hsts(), + ) +} + +/// Bind the configured private listener and serve until graceful shutdown. +pub async fn serve(runtime: Arc, shutdown: F) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let listener_config = runtime.runtime_config().listener.clone(); + let bind_ip = listener_config + .bind_host + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let address = SocketAddr::new(bind_ip, listener_config.port); + let listener = TcpListener::bind(address).await?; + let (app, evaluations) = build_app_with_tracker(runtime); + let result = serve_listener(listener, app, &listener_config, shutdown).await; + + // A disconnected client can cause axum to drop its handler future. The + // admitted evaluation itself is owned by a detached task, so the server + // must explicitly drain those tasks before production shutdown returns. + evaluations.wait_idle().await; + result +} + +/// Serve a pre-bound listener and drain client-bound handlers on shutdown. +/// +/// The grace duration is an operational target, not a cancellation boundary. +/// A request already inside runtime evaluation is allowed to complete even if +/// it exceeds that target, preserving the audit and signing invariants. The +/// production [`serve`] entry point additionally drains detached evaluations. +async fn serve_listener( + listener: TcpListener, + app: Router, + config: &ListenerConfig, + shutdown: F, +) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let grace = Duration::from_millis(config.shutdown_grace_milliseconds); + let (shutdown_started_tx, shutdown_started_rx) = tokio::sync::oneshot::channel(); + let graceful = async move { + shutdown.await; + let _ = shutdown_started_tx.send(()); + }; + let server = axum::serve(listener, app) + .with_graceful_shutdown(graceful) + .into_future(); + tokio::pin!(server); + + let grace_watch = async move { + if shutdown_started_rx.await.is_ok() { + tokio::time::sleep(grace).await; + tracing::warn!( + target: "registry_evidence::server", + "graceful shutdown target elapsed; waiting for protected operations to finish" + ); + } + std::future::pending::<()>().await; + }; + tokio::pin!(grace_watch); + + tokio::select! { + result = &mut server => result, + () = &mut grace_watch => unreachable!("shutdown grace watcher never completes"), + } +} + +#[cfg(test)] +pub(crate) async fn serve_listener_for_test( + runtime: Arc, + listener: TcpListener, + shutdown: F, +) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let listener_config = runtime.runtime_config().listener.clone(); + let (app, evaluations) = build_app_with_tracker(runtime); + let result = serve_listener(listener, app, &listener_config, shutdown).await; + evaluations.wait_idle().await; + result +} + +async fn create_evidence( + State(state): State>, + request: Request, +) -> Response { + let operation = operation_id(); + let started = Instant::now(); + + let access_token = match bearer_token(request.headers()) { + Ok(token) => token.to_owned(), + Err(code) => return problem_response(code, &operation), + }; + if !has_exact_content_type(request.headers(), JSON_MEDIA_TYPE) + || content_length_exceeds(request.headers(), state.maximum_request_bytes) + { + return problem_response(ProblemCode::MalformedRequest, &operation); + } + + let admission_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let request_slot = match tokio::time::timeout( + admission_budget, + Arc::clone(&state.request_slots).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) | Err(_) => { + return problem_response(ProblemCode::ServiceUnavailable, &operation) + } + }; + + let body_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let body = match tokio::time::timeout( + body_budget, + to_bytes(request.into_body(), state.maximum_request_bytes), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(_)) => return problem_response(ProblemCode::MalformedRequest, &operation), + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let evidence_request = match parse_evidence_request(&body) { + Ok(request) => request, + Err(code) => return problem_response(code, &operation), + }; + + // The tracker-owned task, rather than this client-bound handler, owns the + // admitted concurrency permit and complete fail-closed evaluation. If the + // client disconnects while the handler awaits the join handle, dropping + // that handle detaches the task instead of cancelling its audit writes. + let evaluation_operation = operation.clone(); + let runtime = Arc::clone(&state.runtime); + let evaluations = state.evaluations.clone(); + #[cfg(test)] + let evaluation_time = state.evaluation_time; + let evaluation = evaluations.spawn(async move { + let _request_slot = request_slot; + #[cfg(test)] + if let Some(evaluation_time) = evaluation_time { + return runtime + .evaluate_at_for_test( + &evaluation_operation, + &access_token, + &evidence_request, + evaluation_time, + ) + .await; + } + runtime + .evaluate(&evaluation_operation, &access_token, &evidence_request) + .await + }); + let result = match evaluation.await { + Ok(result) => result, + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + + match result { + Ok(jws) => match serialize_response(StatusCode::OK, EVIDENCE_JWS_MEDIA_TYPE, &jws) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + }, + Err(failure) => runtime_failure_response(failure, &operation), + } +} + +async fn health() -> Response { + static_json_response(StatusCode::OK, r#"{"status":"ok"}"#) +} + +async fn ready(State(state): State>) -> Response { + let operation = operation_id(); + match tokio::time::timeout(state.request_timeout, state.runtime.ready()).await { + Ok(true) => static_json_response(StatusCode::OK, r#"{"status":"ready"}"#), + Ok(false) | Err(_) => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +async fn jwks(State(state): State>) -> Response { + let operation = operation_id(); + match serialize_response(StatusCode::OK, JWKS_MEDIA_TYPE, state.runtime.jwks()) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +async fn unknown_route() -> Response { + problem_response(ProblemCode::MalformedRequest, &operation_id()) +} + +async fn add_no_store(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn parse_evidence_request(bytes: &[u8]) -> Result { + let value = parse_json_strict(bytes).map_err(|_| ProblemCode::MalformedRequest)?; + match request_contract_accepts(&value) { + Ok(true) => {} + Ok(false) => return Err(ProblemCode::MalformedRequest), + Err(_) => return Err(ProblemCode::ServiceUnavailable), + } + serde_json::from_value(value).map_err(|_| ProblemCode::MalformedRequest) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, ProblemCode> { + let mut values = headers.get_all(AUTHORIZATION).iter(); + let value = values.next().ok_or(ProblemCode::AuthenticationFailed)?; + if values.next().is_some() { + return Err(ProblemCode::AuthenticationFailed); + } + let value = value + .to_str() + .map_err(|_| ProblemCode::AuthenticationFailed)?; + let token = value + .strip_prefix("Bearer ") + .ok_or(ProblemCode::AuthenticationFailed)?; + if token.is_empty() + || token + .bytes() + .any(|byte| byte.is_ascii_whitespace() || byte == b',') + { + return Err(ProblemCode::AuthenticationFailed); + } + Ok(token) +} + +fn has_exact_content_type(headers: &HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + let Some(value) = values.next() else { + return false; + }; + values.next().is_none() && value.as_bytes() == expected.as_bytes() +} + +fn content_length_exceeds(headers: &HeaderMap, maximum: usize) -> bool { + let mut values = headers.get_all(CONTENT_LENGTH).iter(); + let Some(value) = values.next() else { + return false; + }; + if values.next().is_some() { + return true; + } + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .is_none_or(|length| length > maximum as u64) +} + +fn remaining(limit: Duration, started: Instant) -> Option { + limit.checked_sub(started.elapsed()) +} + +fn runtime_failure_response(failure: RuntimeFailure, operation: &str) -> Response { + problem_response(failure.problem(), operation) +} + +fn problem_response(code: ProblemCode, operation: &str) -> Response { + let body = code.body(operation); + let mut response = serialize_response(code.status(), PROBLEM_MEDIA_TYPE, &body) + .unwrap_or_else(|| empty_response(StatusCode::INTERNAL_SERVER_ERROR)); + if code == ProblemCode::AuthenticationFailed { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer"), + ); + } + if code == ProblemCode::RateLimited { + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static(RETRY_AFTER_SECONDS)); + } + response +} + +fn serialize_response( + status: StatusCode, + media_type: &'static str, + value: &T, +) -> Option { + let bytes = serde_json::to_vec(value).ok()?; + Some(bytes_response(status, media_type, bytes)) +} + +fn static_json_response(status: StatusCode, body: &'static str) -> Response { + bytes_response(status, JSON_MEDIA_TYPE, body.as_bytes().to_vec()) +} + +fn bytes_response(status: StatusCode, media_type: &'static str, bytes: Vec) -> Response { + let mut response = (status, Body::from(bytes)).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(media_type)); + response +} + +fn empty_response(status: StatusCode) -> Response { + (status, Body::empty()).into_response() +} + +fn operation_id() -> String { + Ulid::new().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + + #[test] + fn authorization_requires_one_unambiguous_bearer_value() { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_static("Bearer three.parts.value"), + ); + assert_eq!( + bearer_token(&headers).expect("single bearer accepted"), + "three.parts.value" + ); + + for invalid in [ + "bearer three.parts.value", + "Bearer", + "Bearer three.parts.value", + "Bearer three.parts.value ", + "Bearer three.parts.value,other", + ] { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(invalid).expect("test header is valid"), + ); + assert_eq!( + bearer_token(&headers), + Err(ProblemCode::AuthenticationFailed) + ); + } + + headers.clear(); + headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer first")); + headers.append(AUTHORIZATION, HeaderValue::from_static("Bearer second")); + assert_eq!( + bearer_token(&headers), + Err(ProblemCode::AuthenticationFailed) + ); + } + + #[test] + fn request_json_is_strict_and_closed() { + let valid = br#"{ + "requirement":"urn:example:requirement:v1", + "purpose":"review", + "subjects":[{ + "role":"subject", + "selector":{"profile":"opaque-v1","values":{"opaque":"value"}} + }] + }"#; + assert!(parse_evidence_request(valid).is_ok()); + for number in ["1.0", "1e0"] { + let request = r#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":NUMBER}}}]}"# + .replace("NUMBER", number); + let parsed = parse_evidence_request(request.as_bytes()) + .expect("schema-valid integral JSON number is accepted"); + assert_eq!( + parsed.subjects[0] + .selector + .values + .as_ref() + .and_then(|values| values.get("opaque")), + Some(&crate::model::SelectorValue::Integer(1)) + ); + } + assert_eq!( + parse_evidence_request( + br#"{"requirement":"a","requirement":"b","purpose":"p","subjects":[]}"# + ), + Err(ProblemCode::MalformedRequest) + ); + assert_eq!( + parse_evidence_request( + br#"{"requirement":"a","purpose":"p","subjects":[],"query":"hidden"}"# + ), + Err(ProblemCode::MalformedRequest) + ); + + for invalid in [ + br#"{"requirement":"not a URI","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requirement":"urn:example:requirement:v1","purpose":"Uppercase","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[]}"#.as_slice(), + br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"Uppercase","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{}}}]}"#.as_slice(), + br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":9007199254740992}}}]}"#.as_slice(), + ] { + assert_eq!( + parse_evidence_request(invalid), + Err(ProblemCode::MalformedRequest) + ); + } + } + + #[tokio::test] + async fn problem_responses_have_closed_media_and_challenge_headers() { + let authentication = problem_response( + ProblemCode::AuthenticationFailed, + "01K1EVIDENCEOPERATION0000000", + ); + assert_eq!(authentication.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + authentication.headers().get(CONTENT_TYPE), + Some(&HeaderValue::from_static(PROBLEM_MEDIA_TYPE)) + ); + assert_eq!( + authentication + .headers() + .get(axum::http::header::WWW_AUTHENTICATE), + Some(&HeaderValue::from_static("Bearer")) + ); + + let rate = problem_response(ProblemCode::RateLimited, "01K1EVIDENCEOPERATION0000000"); + assert_eq!(rate.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + rate.headers().get(RETRY_AFTER), + Some(&HeaderValue::from_static(RETRY_AFTER_SECONDS)) + ); + } + + #[test] + fn operation_ids_meet_the_audit_contract() { + let operation = operation_id(); + assert!((16..=128).contains(&operation.len())); + assert!(!operation.bytes().any(|byte| byte.is_ascii_whitespace())); + } + + #[tokio::test] + async fn response_layers_apply_no_store_and_security_headers_without_hsts() { + let app = response_layers( + Router::new().route("/probe", get(|| async { StatusCode::NO_CONTENT })), + ); + let response = app + .oneshot( + Request::builder() + .uri("/probe") + .body(Body::empty()) + .expect("test request builds"), + ) + .await + .expect("infallible router responds"); + + assert_eq!( + response.headers().get(CACHE_CONTROL), + Some(&HeaderValue::from_static("no-store")) + ); + assert_eq!( + response.headers().get("x-content-type-options"), + Some(&HeaderValue::from_static("nosniff")) + ); + assert_eq!( + response.headers().get("x-frame-options"), + Some(&HeaderValue::from_static("DENY")) + ); + assert!(response + .headers() + .get("strict-transport-security") + .is_none()); + } + + #[tokio::test] + async fn evaluation_tracker_drains_a_detached_task_without_a_missed_wakeup() { + let tracker = EvaluationTracker::default(); + let (release, blocked) = tokio::sync::oneshot::channel(); + let detached = tracker.spawn(async move { + let _ = blocked.await; + }); + drop(detached); + + let waiter = tokio::spawn({ + let tracker = tracker.clone(); + async move { tracker.wait_idle().await } + }); + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + release.send(()).expect("detached task is still running"); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("detached task drains") + .expect("drain waiter does not panic"); + } +} diff --git a/crates/registry-evidence/src/signing.rs b/crates/registry-evidence/src/signing.rs new file mode 100644 index 000000000..173dc1edc --- /dev/null +++ b/crates/registry-evidence/src/signing.rs @@ -0,0 +1,329 @@ +//! Evidence-owned flattened JWS construction and key publication. + +use std::{collections::BTreeSet, sync::Arc}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{ + verify, KeyReadiness, PublicJwk, SigningAlgorithm, SigningError as ProviderSigningError, + SigningProvider, +}; +use serde::Serialize; +use thiserror::Error; + +use crate::{ + model::{FlattenedJws, JwksDocument}, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, +}; + +const MAX_KEY_ID_BYTES: usize = 256; +const MAX_PUBLISHED_KEYS: usize = 33; + +#[derive(Debug, Error)] +pub enum EvidenceSigningError { + #[error("the configured signing algorithm is not allowed")] + Algorithm, + #[error("the configured signing key identifier is invalid")] + KeyId, + #[error("the signing key identifier does not match the configured active key")] + ActiveKeyId, + #[error("the signing provider is unavailable")] + Provider(#[from] ProviderSigningError), + #[error("the signing provider failed its startup self-test")] + SelfTest, + #[error("the protected header could not be serialized")] + HeaderSerialization(#[source] serde_json::Error), + #[error("the evidence payload could not be serialized")] + PayloadSerialization(#[source] serde_json::Error), + #[error("the published key set contains an invalid or duplicate key identifier")] + PublishedKey, + #[error("the published key set could not be serialized")] + KeySerialization(#[source] serde_json::Error), +} + +#[derive(Debug, Serialize)] +struct ProtectedHeader<'a> { + alg: &'static str, + kid: &'a str, + typ: &'static str, + cty: &'static str, +} + +/// Evidence's single active Ed25519 signer. +pub struct EvidenceSigner { + provider: Arc, +} + +impl std::fmt::Debug for EvidenceSigner { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EvidenceSigner") + .field("algorithm", &self.provider.algorithm()) + .field("key_id", &self.provider.key_id()) + .finish_non_exhaustive() + } +} + +impl EvidenceSigner { + pub async fn initialize( + provider: Arc, + configured_active_key_id: &str, + ) -> Result { + validate_provider(provider.as_ref(), configured_active_key_id)?; + + let self_test_message = b"registry-evidence-signing-readiness-v1"; + let signature = provider.sign(self_test_message).await?; + verify(self_test_message, &signature, &provider.public_jwk()) + .map_err(|_| EvidenceSigningError::SelfTest)?; + + Ok(Self { provider }) + } + + pub fn key_id(&self) -> &str { + self.provider.key_id() + } + + pub fn public_jwk(&self) -> PublicJwk { + self.provider.public_jwk() + } + + /// Report the current signing-provider posture without exposing key data. + pub fn ready(&self) -> bool { + self.provider.readiness() == KeyReadiness::Ready + } + + /// Serialize and sign the exact JSON representation of a validated Evidence value. + pub async fn sign_json( + &self, + evidence: &T, + ) -> Result { + let payload = + serde_json::to_vec(evidence).map_err(EvidenceSigningError::PayloadSerialization)?; + self.sign_bytes(&payload).await + } + + /// Sign exact UTF-8 Evidence JSON bytes as a flattened JWS JSON value. + pub async fn sign_bytes( + &self, + evidence_json: &[u8], + ) -> Result { + let protected = serde_json::to_vec(&ProtectedHeader { + alg: "EdDSA", + kid: self.provider.key_id(), + typ: EVIDENCE_JWS_TYP, + cty: EVIDENCE_JWS_CTY, + }) + .map_err(EvidenceSigningError::HeaderSerialization)?; + + let protected = URL_SAFE_NO_PAD.encode(protected); + let payload = URL_SAFE_NO_PAD.encode(evidence_json); + let signing_input = [protected.as_bytes(), b".", payload.as_bytes()].concat(); + let signature = self.provider.sign(&signing_input).await?; + + Ok(FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + }) + } +} + +pub fn jwks_document( + active: PublicJwk, + retired: impl IntoIterator, +) -> Result { + let mut seen = BTreeSet::new(); + let mut keys = Vec::new(); + for key in std::iter::once(active).chain(retired) { + if keys.len() == MAX_PUBLISHED_KEYS { + return Err(EvidenceSigningError::PublishedKey); + } + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(EvidenceSigningError::Algorithm); + } + let key_id = key + .kid + .as_deref() + .ok_or(EvidenceSigningError::PublishedKey)?; + validate_key_id(key_id)?; + if !seen.insert(key_id.to_owned()) { + return Err(EvidenceSigningError::PublishedKey); + } + keys.push(serde_json::to_value(key).map_err(EvidenceSigningError::KeySerialization)?); + } + Ok(JwksDocument { keys }) +} + +fn validate_provider( + provider: &dyn SigningProvider, + configured_active_key_id: &str, +) -> Result<(), EvidenceSigningError> { + if provider.algorithm() != SigningAlgorithm::EdDsa { + return Err(EvidenceSigningError::Algorithm); + } + validate_key_id(provider.key_id())?; + if provider.key_id() != configured_active_key_id { + return Err(EvidenceSigningError::ActiveKeyId); + } + let public = provider.public_jwk(); + if public.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + || public.kid.as_deref() != Some(provider.key_id()) + { + return Err(EvidenceSigningError::Algorithm); + } + Ok(()) +} + +fn validate_key_id(key_id: &str) -> Result<(), EvidenceSigningError> { + if key_id.is_empty() || key_id.len() > MAX_KEY_ID_BYTES || key_id.chars().any(char::is_control) + { + return Err(EvidenceSigningError::KeyId); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; + + const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + const FIXTURE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"fixture-key-2026-01"}"#; + const SAME_KID_DIFFERENT_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"evidence-key-1"}"#; + + async fn signer() -> EvidenceSigner { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes") + } + + async fn fixture_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(FIXTURE_PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + EvidenceSigner::initialize(provider, "fixture-key-2026-01") + .await + .expect("signer initializes") + } + + #[tokio::test] + async fn flattened_jws_has_exact_protected_header_and_valid_signature() { + let signer = signer().await; + let evidence = serde_json::json!({"schema": crate::EVIDENCE_SCHEMA_V1}); + let jws = signer.sign_json(&evidence).await.expect("evidence signs"); + + let protected_bytes = URL_SAFE_NO_PAD + .decode(&jws.protected) + .expect("protected header decodes"); + let protected: serde_json::Value = + serde_json::from_slice(&protected_bytes).expect("protected header parses"); + assert_eq!( + protected, + serde_json::json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": "evidence+jws", + "cty": "application/evidence+json" + }) + ); + + let signing_input = format!("{}.{}", jws.protected, jws.payload); + let signature = URL_SAFE_NO_PAD + .decode(&jws.signature) + .expect("signature decodes"); + verify(signing_input.as_bytes(), &signature, &signer.public_jwk()) + .expect("signature verifies"); + } + + #[tokio::test] + async fn configured_key_id_must_match_provider() { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signer builds")); + let error = EvidenceSigner::initialize(provider, "different-key") + .await + .expect_err("mismatched key id is rejected"); + assert!(matches!(error, EvidenceSigningError::ActiveKeyId)); + } + + #[tokio::test] + async fn jwks_contains_public_material_only() { + let signer = signer().await; + let document = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let json = serde_json::to_value(document).expect("JWKS serializes"); + assert_eq!(json["keys"].as_array().map(Vec::len), Some(1)); + assert!(json["keys"][0].get("d").is_none()); + + let duplicate_private = + PrivateJwk::parse(SAME_KID_DIFFERENT_PRIVATE_JWK).expect("rotated test key parses"); + let duplicate = LocalJwkSigner::new(duplicate_private) + .expect("rotated signer builds") + .public_jwk(); + assert!(matches!( + jwks_document(signer.public_jwk(), [duplicate]), + Err(EvidenceSigningError::PublishedKey) + )); + + let retired = (0..32).map(|index| { + let mut key = signer.public_jwk(); + key.kid = Some(format!("retired-evidence-key-{index:02}")); + key + }); + let boundary = jwks_document(signer.public_jwk(), retired).expect("33 keys are allowed"); + assert_eq!(boundary.keys.len(), 33); + + let too_many = (0..33).map(|index| { + let mut key = signer.public_jwk(); + key.kid = Some(format!("excess-evidence-key-{index:02}")); + key + }); + assert!(matches!( + jwks_document(signer.public_jwk(), too_many), + Err(EvidenceSigningError::PublishedKey) + )); + } + + #[tokio::test] + async fn jws_fixture_payload_bytes_and_protected_header_are_exact() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/jws-cases.yaml" + )) + .expect("JWS fixture parses"); + let expected_header = fixture["protected_header"]["exact_json"] + .as_str() + .expect("fixture header is text"); + let signer = fixture_signer().await; + for case in fixture["cases"] + .as_array() + .expect("fixture cases are an array") + { + let relative = case["payload"].as_str().expect("payload path is text"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance") + .join(relative); + let payload = std::fs::read(path).expect("golden payload reads"); + assert_eq!(payload.last(), Some(&b'\n')); + let jws = signer.sign_bytes(&payload).await.expect("payload signs"); + assert_eq!( + URL_SAFE_NO_PAD + .decode(&jws.protected) + .expect("header decodes"), + expected_header.as_bytes() + ); + assert_eq!( + URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"), + payload + ); + let signing_input = format!("{}.{}", jws.protected, jws.payload); + let signature = URL_SAFE_NO_PAD + .decode(&jws.signature) + .expect("signature decodes"); + verify(signing_input.as_bytes(), &signature, &signer.public_jwk()) + .expect("fixture signature verifies"); + } + } +} diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs new file mode 100644 index 000000000..b169ffede --- /dev/null +++ b/crates/registry-evidence/src/source.rs @@ -0,0 +1,1605 @@ +//! Exact, bounded HTTP/JSON source execution for Evidence Version 1. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use http::{HeaderMap, HeaderName, HeaderValue}; +use registry_platform_httputil::{read_bounded, BoundedReadError}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue}; +use thiserror::Error; +use tokio::sync::{Mutex, Semaphore}; +use url::{Host, Url}; +use zeroize::Zeroizing; + +use crate::config::{ + AcquisitionPosture, CredentialPlacement, FixedRequest, HttpMethod, OutboundTlsConfig, + PathBindingConfig, PreparationChannelPolicy, SecretRef, SourceAuthentication, SourceConfig, + SourceSelectorSet, +}; +use crate::model::SelectorValue; +use crate::rhai_runtime::RequestParts; +use crate::secrets::{ProtectedSecret, SecretResolver}; + +const TOKEN_RESPONSE_MAXIMUM_BYTES: u64 = 8 * 1024; +const PRIVATE_CA_MAXIMUM_BYTES: u64 = 1024 * 1024; +const PROJECTED_RESPONSE_MAXIMUM_BYTES: usize = 65_536; +const JSON_MEDIA_TYPE: &str = "application/json"; +const GRAPHQL_JSON_MEDIA_TYPE: &str = "application/graphql-response+json"; + +/// A role-bound selector that has already passed authentication, +/// authorization, exact-field, type, and size validation. +/// +/// This type intentionally has no `Debug` implementation because its values +/// are protected request material. +pub struct ResolvedSourceSelector { + pub role: String, + pub profile: String, + pub values: BTreeMap, +} + +/// A safe status category that does not retain a response or request URL. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceStatus { + Unauthorized, + Forbidden, + RateLimited, + ServerError, + Other, +} + +/// Closed source failures. No variant retains request, response, selector, or +/// credential material. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SourceError { + #[error("the fixed source plan is invalid")] + InvalidPlan, + #[error("the resolved source selector set is invalid")] + InvalidSelectors, + #[error("source credentials are unavailable or invalid")] + Credential, + #[error("the source concurrency boundary is unavailable")] + Concurrency, + #[error("the source request timed out")] + Timeout, + #[error("the source transport is unavailable")] + Transport, + #[error("the source attempted a redirect")] + Redirect, + #[error("the source returned a rejected status category")] + Status(SourceStatus), + #[error("the source returned an unsupported media type")] + WrongMediaType, + #[error("the source response exceeded a configured bound")] + ResponseTooLarge, + #[error("the source returned invalid JSON")] + InvalidJson, + #[error("the source returned an error envelope")] + ErrorEnvelope, + #[error("the source response did not satisfy its acquisition projection")] + ProjectionViolation, +} + +/// Executes one immutable source plan. The client has redirects, retries, +/// ambient proxies, pagination, cookies, and caller-controlled headers absent. +pub struct SourceExecutor { + client: reqwest::Client, + request: RequestPlan, + authentication: AuthenticationPlan, + secrets: Arc, + concurrency: Semaphore, + concurrency_admission_timeout: Duration, +} + +/// The validated non-credential transport material for one fixed source request. +/// +/// The full URL remains private so callers cannot obtain source authority, and +/// this type deliberately exposes no fixed headers or authentication material. +/// Path, query, and body access is intended for the trusted offline fixture +/// harness. Its diagnostic representation is always value-free. +#[derive(Clone, PartialEq)] +pub struct MaterializedSourceRequest { + url: Url, + body: Option, +} + +impl MaterializedSourceRequest { + pub fn path(&self) -> &str { + self.url.path() + } + + pub fn query(&self) -> Option<&str> { + self.url.query() + } + + pub fn body(&self) -> Option<&JsonValue> { + self.body.as_ref() + } +} + +impl fmt::Debug for MaterializedSourceRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MaterializedSourceRequest") + .field("path", &"") + .field("query", &self.query().map(|_| "")) + .field("body", &self.body().map(|_| "")) + .finish() + } +} + +struct RequestPlan { + base_url: Url, + path: SourcePath, + method: HttpMethod, + fixed_headers: HeaderMap, + selector_inputs: BTreeMap>>, + allowed_selector_sets: Vec, + posture: AcquisitionPosture, + projection: ProjectionNode, + maximum_response_bytes: u64, +} + +enum SourcePath { + Fixed(String), + Template { + template: String, + bindings: BTreeMap, + }, +} + +struct PathBindingPlan { + role: String, + profile: String, + field: String, +} + +enum AuthenticationPlan { + Basic { + username_ref: SecretRef, + password_ref: SecretRef, + }, + StaticBearer { + token_ref: SecretRef, + }, + StaticApiKey { + header_name: HeaderName, + value_ref: SecretRef, + }, + Oauth2(Box), +} + +struct OauthPlan { + token_endpoint: Url, + client_id_ref: SecretRef, + client_secret_ref: SecretRef, + scope: Option, + credential_placement: CredentialPlacement, + maximum_cache_lifetime: Duration, + admission_timeout: Duration, + cache: Mutex>, +} + +struct CachedToken { + token: ProtectedToken, + expires_at: Instant, +} + +struct ProtectedToken(Zeroizing>); + +impl ProtectedToken { + fn from_string(value: String) -> Result { + if value.is_empty() || value.len() > TOKEN_RESPONSE_MAXIMUM_BYTES as usize { + return Err(SourceError::Credential); + } + Ok(Self(Zeroizing::new(value.into_bytes()))) + } + + fn expose(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl Clone for ProtectedToken { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.to_vec())) + } +} + +#[derive(Default)] +struct ProjectionNode { + terminal: bool, + keys: BTreeMap, + wildcard: Option>, +} + +impl SourceExecutor { + /// Compile a standalone source with system TLS roots. Sources that name a + /// private trust profile require `new_with_selector_sets_and_tls`. + pub fn new(source: &SourceConfig, secrets: Arc) -> Result { + let allowed = conservative_selector_sets(source)?; + Self::new_with_selector_sets(source, &allowed, secrets) + } + + /// Compile a source with system TLS roots and explicitly allowed selector + /// tuples. + pub fn new_with_selector_sets( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + secrets: Arc, + ) -> Result { + if source.tls_trust_profile.is_some() { + return Err(SourceError::InvalidPlan); + } + Self::compile(source, allowed_selector_sets, None, secrets) + } + + /// Compile a source against runtime-owned TLS trust bindings. System roots + /// remain enabled and the selected private CA bundle is additive. + pub fn new_with_selector_sets_and_tls( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + outbound_tls: &OutboundTlsConfig, + captured_ca_bundles: &BTreeMap>, + secrets: Arc, + ) -> Result { + Self::compile( + source, + allowed_selector_sets, + Some((outbound_tls, captured_ca_bundles)), + secrets, + ) + } + + fn compile( + source: &SourceConfig, + allowed_selector_sets: &[SourceSelectorSet], + outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, + secrets: Arc, + ) -> Result { + let timeout = Duration::from_millis(source.request.timeout_milliseconds); + if timeout.is_zero() + || source.request.timeout_milliseconds > 30_000 + || source.request.maximum_response_bytes == 0 + || source.request.maximum_response_bytes > 1_048_576 + || source.request.concurrency_limit == 0 + || source.request.concurrency_limit > 256 + { + return Err(SourceError::InvalidPlan); + } + let base_url = validate_url(&source.base_url, true)?; + let authentication = compile_authentication(&source.authentication, timeout)?; + let request = compile_request( + &source.request, + allowed_selector_sets, + source.posture, + base_url, + &authentication, + )?; + let client = build_client(timeout, source, outbound_tls)?; + Ok(Self { + client, + request, + authentication, + secrets, + concurrency: Semaphore::new(usize::from(source.request.concurrency_limit)), + concurrency_admission_timeout: timeout, + }) + } + + /// Make exactly one evidence-data request using validated Rhai preparation + /// output. Path expansion remains Rust-owned and selector-bound. + pub async fn execute( + &self, + selectors: &[ResolvedSourceSelector], + request_parts: &RequestParts, + ) -> Result { + let materialized = self.materialize_request(selectors, request_parts)?; + let _permit = + acquire_source_slot(&self.concurrency, self.concurrency_admission_timeout).await?; + let (authentication_name, authentication_value) = self.authentication_header().await?; + let method = match self.request.method { + HttpMethod::GET => reqwest::Method::GET, + HttpMethod::POST => reqwest::Method::POST, + }; + let mut request = self + .client + .request(method, materialized.url.clone()) + .headers(self.request.fixed_headers.clone()) + .header(authentication_name, authentication_value); + if !self.request.fixed_headers.contains_key(ACCEPT) { + request = request.header(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE)); + } + if let Some(body) = materialized.body() { + request = request.json(body); + } + let response = request.send().await.map_err(map_transport_error)?; + parse_data_response( + response, + self.request.maximum_response_bytes, + self.request.posture, + &self.request.projection, + ) + .await + } + + /// Validate and materialize only path, encoded query, and JSON body. + /// + /// This performs no concurrency admission, credential resolution, or I/O. + /// The same result is consumed directly by [`Self::execute`]. + pub fn materialize_request( + &self, + selectors: &[ResolvedSourceSelector], + request_parts: &RequestParts, + ) -> Result { + if matches!(self.request.method, HttpMethod::GET) && request_parts.body.is_some() { + return Err(SourceError::InvalidPlan); + } + let selectors = self.request.validate_selectors(selectors)?; + let url = self.request.materialize_url(&selectors, request_parts)?; + Ok(MaterializedSourceRequest { + url, + body: request_parts.body.clone(), + }) + } + + /// Resolve and validate credentials without making an evidence-data + /// request. OAuth may perform its bounded token bootstrap. + pub async fn credentials_ready(&self) -> Result<(), SourceError> { + self.authentication_header().await.map(|_| ()) + } + + async fn authentication_header(&self) -> Result<(HeaderName, HeaderValue), SourceError> { + let value = match &self.authentication { + AuthenticationPlan::Basic { + username_ref, + password_ref, + } => { + let username = resolve(&self.secrets, username_ref)?; + let password = resolve(&self.secrets, password_ref)?; + basic_authorization(&username, &password)? + } + AuthenticationPlan::StaticBearer { token_ref } => { + let token = resolve(&self.secrets, token_ref)?; + bearer_authorization(token.expose_secret())? + } + AuthenticationPlan::StaticApiKey { + header_name, + value_ref, + } => { + let secret = resolve(&self.secrets, value_ref)?; + return Ok(( + header_name.clone(), + sensitive_header(secret.expose_secret())?, + )); + } + AuthenticationPlan::Oauth2(plan) => { + let token = plan.access_token(&self.client, &self.secrets).await?; + bearer_authorization(token.expose())? + } + }; + Ok((AUTHORIZATION, value)) + } +} + +/// Apply the exact production response-size, envelope, and projection rules +/// to an already parsed synthetic fixture response. +pub fn project_fixture_response( + source: &SourceConfig, + response: &JsonValue, +) -> Result { + let raw = serde_json::to_vec(response).map_err(|_| SourceError::InvalidJson)?; + if raw.len() + > usize::try_from(source.request.maximum_response_bytes) + .map_err(|_| SourceError::ResponseTooLarge)? + { + return Err(SourceError::ResponseTooLarge); + } + if response + .as_object() + .is_some_and(|object| object.contains_key("errors")) + { + return Err(SourceError::ErrorEnvelope); + } + let projection = compile_projection(&source.request.projection)?; + let projected = project_value(response, &projection)?; + if serde_json::to_vec(&projected) + .map_err(|_| SourceError::ProjectionViolation)? + .len() + > PROJECTED_RESPONSE_MAXIMUM_BYTES + { + return Err(SourceError::ResponseTooLarge); + } + Ok(projected) +} + +fn build_client( + timeout: Duration, + source: &SourceConfig, + outbound_tls: Option<(&OutboundTlsConfig, &BTreeMap>)>, +) -> Result { + let mut builder = reqwest::Client::builder() + .timeout(timeout) + .connect_timeout(timeout.min(Duration::from_secs(10))) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy(); + if let Some(profile_name) = source.tls_trust_profile.as_deref() { + let (tls, captured_ca_bundles) = outbound_tls.ok_or(SourceError::InvalidPlan)?; + if !tls.system_roots { + return Err(SourceError::InvalidPlan); + } + let binding = tls + .trust_profiles + .get(profile_name) + .ok_or(SourceError::InvalidPlan)?; + if binding.ca_bundle_file.is_empty() { + return Err(SourceError::InvalidPlan); + } + let pem = captured_ca_bundles + .get(profile_name) + .ok_or(SourceError::InvalidPlan)?; + if pem.is_empty() || pem.len() as u64 > PRIVATE_CA_MAXIMUM_BYTES { + return Err(SourceError::InvalidPlan); + } + let certificates = + reqwest::Certificate::from_pem_bundle(pem).map_err(|_| SourceError::InvalidPlan)?; + if certificates.is_empty() { + return Err(SourceError::InvalidPlan); + } + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + } + builder.build().map_err(|_| SourceError::InvalidPlan) +} + +fn conservative_selector_sets( + source: &SourceConfig, +) -> Result, SourceError> { + let mut sets = vec![Vec::new()]; + for input in &source.request.selector_inputs { + if input.alternatives.is_empty() { + return Err(SourceError::InvalidPlan); + } + let mut next = Vec::new(); + for set in &sets { + for alternative in &input.alternatives { + let mut candidate = set.clone(); + candidate.push((input.role.clone(), alternative.profile.clone())); + next.push(candidate); + } + } + sets = next; + } + if sets.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(sets) +} + +fn compile_request( + request: &FixedRequest, + allowed_selector_sets: &[SourceSelectorSet], + posture: AcquisitionPosture, + base_url: Url, + authentication: &AuthenticationPlan, +) -> Result { + if request.method == HttpMethod::GET + && request.preparation_limits.json_body != PreparationChannelPolicy::Forbidden + { + return Err(SourceError::InvalidPlan); + } + let selector_inputs = compile_selector_inputs(request)?; + let allowed_selector_sets = + compile_allowed_selector_sets(&selector_inputs, allowed_selector_sets)?; + let path = compile_source_path(request, &selector_inputs)?; + let fixed_headers = compile_fixed_headers(request, authentication)?; + let projection = compile_projection(&request.projection)?; + Ok(RequestPlan { + base_url, + path, + method: request.method, + fixed_headers, + selector_inputs, + allowed_selector_sets, + posture, + projection, + maximum_response_bytes: request.maximum_response_bytes, + }) +} + +async fn acquire_source_slot<'a>( + semaphore: &'a Semaphore, + timeout: Duration, +) -> Result, SourceError> { + tokio::time::timeout(timeout, semaphore.acquire()) + .await + .map_err(|_| SourceError::Timeout)? + .map_err(|_| SourceError::Concurrency) +} + +fn compile_selector_inputs( + request: &FixedRequest, +) -> Result>>, SourceError> { + let mut output = BTreeMap::new(); + for input in &request.selector_inputs { + let profiles = output + .entry(input.role.clone()) + .or_insert_with(BTreeMap::new); + for alternative in &input.alternatives { + let fields = alternative.fields.iter().cloned().collect::>(); + if fields.len() != alternative.fields.len() + || profiles + .insert(alternative.profile.clone(), fields) + .is_some() + { + return Err(SourceError::InvalidPlan); + } + } + if profiles.is_empty() { + return Err(SourceError::InvalidPlan); + } + } + if output.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(output) +} + +fn compile_allowed_selector_sets( + inputs: &BTreeMap>>, + configured: &[SourceSelectorSet], +) -> Result, SourceError> { + let mut unique = BTreeSet::new(); + for configured_set in configured { + if configured_set.is_empty() || configured_set.len() > inputs.len() { + return Err(SourceError::InvalidPlan); + } + let mut set = configured_set.clone(); + set.sort(); + let mut roles = BTreeSet::new(); + for (role, profile) in &set { + if !roles.insert(role) + || !inputs + .get(role) + .is_some_and(|profiles| profiles.contains_key(profile)) + { + return Err(SourceError::InvalidPlan); + } + } + if !unique.insert(set) { + return Err(SourceError::InvalidPlan); + } + } + if unique.is_empty() { + return Err(SourceError::InvalidPlan); + } + Ok(unique.into_iter().collect()) +} + +fn compile_source_path( + request: &FixedRequest, + inputs: &BTreeMap>>, +) -> Result { + match (&request.path, &request.path_template) { + (Some(path), None) if request.path_bindings.is_empty() => { + validate_request_path(path)?; + Ok(SourcePath::Fixed(path.clone())) + } + (None, Some(template)) => { + validate_template_shape(template, &request.path_bindings)?; + let mut bindings = BTreeMap::new(); + for (name, binding) in request.path_bindings.iter() { + validate_path_binding(binding, inputs)?; + bindings.insert( + name.to_owned(), + PathBindingPlan { + role: binding.role.clone(), + profile: binding.profile.clone(), + field: binding.field.clone(), + }, + ); + } + Ok(SourcePath::Template { + template: template.clone(), + bindings, + }) + } + _ => Err(SourceError::InvalidPlan), + } +} + +fn validate_path_binding( + binding: &PathBindingConfig, + inputs: &BTreeMap>>, +) -> Result<(), SourceError> { + if inputs + .get(&binding.role) + .and_then(|profiles| profiles.get(&binding.profile)) + .is_some_and(|fields| fields.contains(&binding.field)) + { + Ok(()) + } else { + Err(SourceError::InvalidPlan) + } +} + +fn validate_template_shape( + template: &str, + bindings: &crate::config::OrderedMap, +) -> Result<(), SourceError> { + if !template.starts_with('/') + || template.starts_with("//") + || template.contains(['?', '#', '\\']) + { + return Err(SourceError::InvalidPlan); + } + let mut names = BTreeSet::new(); + for segment in template.split('/').skip(1) { + if segment.is_empty() || matches!(segment, "." | "..") { + return Err(SourceError::InvalidPlan); + } + if let Some(name) = segment + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + { + if name.is_empty() || !names.insert(name) { + return Err(SourceError::InvalidPlan); + } + } else if segment.contains(['{', '}']) { + return Err(SourceError::InvalidPlan); + } + } + if names == bindings.keys().collect::>() { + Ok(()) + } else { + Err(SourceError::InvalidPlan) + } +} + +fn compile_fixed_headers( + request: &FixedRequest, + authentication: &AuthenticationPlan, +) -> Result { + let mut headers = HeaderMap::new(); + for fixed in &request.fixed_headers { + if reserved_configured_header(&fixed.name) { + return Err(SourceError::InvalidPlan); + } + let name = + HeaderName::from_bytes(fixed.name.as_bytes()).map_err(|_| SourceError::InvalidPlan)?; + let value = HeaderValue::from_str(&fixed.value).map_err(|_| SourceError::InvalidPlan)?; + if headers.insert(name, value).is_some() { + return Err(SourceError::InvalidPlan); + } + } + let authentication_name = match authentication { + AuthenticationPlan::StaticApiKey { header_name, .. } => header_name, + _ => &AUTHORIZATION, + }; + if headers.contains_key(authentication_name) { + return Err(SourceError::InvalidPlan); + } + Ok(headers) +} + +fn reserved_configured_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + matches!( + name.as_str(), + "authorization" + | "proxy-authorization" + | "host" + | "cookie" + | "set-cookie" + | "content-length" + | "content-type" + | "transfer-encoding" + | "expect" + | "connection" + | "keep-alive" + | "te" + | "trailer" + | "upgrade" + | "proxy-connection" + | "forwarded" + | "via" + | "x-real-ip" + | "traceparent" + | "tracestate" + | "baggage" + | "x-request-id" + | "x-correlation-id" + | "x-amzn-trace-id" + | "x-original-url" + | "x-rewrite-url" + | "x-http-method-override" + | "x-original-method" + ) || name.starts_with("x-forwarded-") + || name.starts_with("proxy-") + || name.starts_with("x-b3-") +} + +fn compile_authentication( + authentication: &SourceAuthentication, + admission_timeout: Duration, +) -> Result { + match authentication { + SourceAuthentication::Basic { + username_ref, + password_ref, + } => Ok(AuthenticationPlan::Basic { + username_ref: username_ref.clone(), + password_ref: password_ref.clone(), + }), + SourceAuthentication::StaticBearer { token_ref } => Ok(AuthenticationPlan::StaticBearer { + token_ref: token_ref.clone(), + }), + SourceAuthentication::StaticApiKey { + header_name, + value_ref, + } => { + if reserved_configured_header(header_name) { + return Err(SourceError::InvalidPlan); + } + Ok(AuthenticationPlan::StaticApiKey { + header_name: HeaderName::from_bytes(header_name.as_bytes()) + .map_err(|_| SourceError::InvalidPlan)?, + value_ref: value_ref.clone(), + }) + } + SourceAuthentication::Oauth2ClientCredentials { + token_endpoint, + client_id_ref, + client_secret_ref, + scope, + credential_placement, + maximum_cache_seconds, + } => { + let token_endpoint = validate_url(token_endpoint, false)?; + if token_endpoint.query().is_some() { + return Err(SourceError::InvalidPlan); + } + Ok(AuthenticationPlan::Oauth2(Box::new(OauthPlan { + token_endpoint, + client_id_ref: client_id_ref.clone(), + client_secret_ref: client_secret_ref.clone(), + scope: scope.clone(), + credential_placement: *credential_placement, + maximum_cache_lifetime: Duration::from_secs(*maximum_cache_seconds), + admission_timeout, + cache: Mutex::new(None), + }))) + } + } +} + +impl RequestPlan { + fn validate_selectors<'a>( + &self, + selectors: &'a [ResolvedSourceSelector], + ) -> Result, SourceError> { + let mut index = BTreeMap::new(); + let mut active = Vec::new(); + let mut roles = BTreeSet::new(); + for selector in selectors { + if !roles.insert(selector.role.as_str()) + || index + .insert( + (selector.role.as_str(), selector.profile.as_str()), + selector, + ) + .is_some() + { + return Err(SourceError::InvalidSelectors); + } + let fields = self + .selector_inputs + .get(&selector.role) + .and_then(|profiles| profiles.get(&selector.profile)) + .ok_or(SourceError::InvalidSelectors)?; + if selector.values.keys().collect::>() + != fields.iter().collect::>() + { + return Err(SourceError::InvalidSelectors); + } + active.push((selector.role.clone(), selector.profile.clone())); + } + active.sort(); + if !self.allowed_selector_sets.contains(&active) { + return Err(SourceError::InvalidSelectors); + } + Ok(index) + } + + fn materialize_url( + &self, + selectors: &BTreeMap<(&str, &str), &ResolvedSourceSelector>, + parts: &RequestParts, + ) -> Result { + let path = match &self.path { + SourcePath::Fixed(path) => path.clone(), + SourcePath::Template { template, bindings } => { + let mut rendered = String::new(); + for segment in template.split('/').skip(1) { + rendered.push('/'); + if let Some(name) = segment + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + { + let binding = bindings.get(name).ok_or(SourceError::InvalidPlan)?; + let selector = selectors + .get(&(binding.role.as_str(), binding.profile.as_str())) + .ok_or(SourceError::InvalidSelectors)?; + let value = selector + .values + .get(&binding.field) + .ok_or(SourceError::InvalidSelectors)?; + rendered.push_str(&encode_path_selector(value)?); + } else { + rendered.push_str(segment); + } + } + rendered + } + }; + let mut url = join_source_path(&self.base_url, &path)?; + if !parts.query.is_empty() { + let mut query = String::new(); + for pair in &parts.query { + if pair.name.is_empty() + || pair.name.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + || pair.value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) + { + return Err(SourceError::InvalidPlan); + } + if !query.is_empty() { + query.push('&'); + } + encode_query_component(&pair.name, &mut query); + query.push('='); + encode_query_component(&pair.value, &mut query); + } + url.set_query(Some(&query)); + } + Ok(url) + } +} + +fn encode_query_component(value: &str, output: &mut String) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(char::from(byte)); + } else { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } +} + +fn encode_path_selector(value: &SelectorValue) -> Result { + let text = match value { + SelectorValue::String(value) => value.clone(), + SelectorValue::Integer(value) => value.to_string(), + SelectorValue::Boolean(value) => value.to_string(), + }; + if text.is_empty() + || matches!(text.as_str(), "." | "..") + || text.chars().any(char::is_control) + || text.contains(['/', '\\', '%']) + { + return Err(SourceError::InvalidSelectors); + } + let mut encoded = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(&mut encoded, "%{byte:02X}").map_err(|_| SourceError::InvalidSelectors)?; + } + } + Ok(encoded) +} + +impl OauthPlan { + async fn access_token( + &self, + client: &reqwest::Client, + secrets: &SecretResolver, + ) -> Result { + let mut cache = tokio::time::timeout(self.admission_timeout, self.cache.lock()) + .await + .map_err(|_| SourceError::Timeout)?; + let now = Instant::now(); + if let Some(cached) = cache.as_ref() { + if cached.expires_at > now { + return Ok(cached.token.clone()); + } + } + *cache = None; + let client_id = resolve(secrets, &self.client_id_ref)?; + let client_secret = resolve(secrets, &self.client_secret_ref)?; + let client_id_text = protected_text(&client_id)?; + let client_secret_text = protected_text(&client_secret)?; + let mut endpoint = self.token_endpoint.clone(); + let mut form = vec![("grant_type", "client_credentials")]; + if let Some(scope) = self.scope.as_deref() { + form.push(("scope", scope)); + } + let mut request = client + .post(endpoint.clone()) + .header(ACCEPT, JSON_MEDIA_TYPE); + let send_form = match self.credential_placement { + CredentialPlacement::BasicHeader => { + request = request.header( + AUTHORIZATION, + basic_authorization(&client_id, &client_secret)?, + ); + true + } + CredentialPlacement::FormBody => { + form.push(("client_id", client_id_text)); + form.push(("client_secret", client_secret_text)); + true + } + CredentialPlacement::QueryString => { + { + let mut pairs = endpoint.query_pairs_mut(); + for (key, value) in &form { + pairs.append_pair(key, value); + } + pairs.append_pair("client_id", client_id_text); + pairs.append_pair("client_secret", client_secret_text); + } + request = client.post(endpoint).header(ACCEPT, JSON_MEDIA_TYPE); + false + } + }; + if send_form { + request = request.form(&form); + } + drop(form); + drop(client_id); + drop(client_secret); + let response = request.send().await.map_err(map_transport_error)?; + let (token, provider_lifetime) = + parse_token_response(response, self.scope.as_deref()).await?; + let cache_lifetime = provider_lifetime.min(self.maximum_cache_lifetime); + if !cache_lifetime.is_zero() { + let expires_at = Instant::now() + .checked_add(cache_lifetime) + .ok_or(SourceError::Credential)?; + *cache = Some(CachedToken { + token: token.clone(), + expires_at, + }); + } + Ok(token) + } +} + +fn compile_projection(paths: &[String]) -> Result { + if paths.is_empty() { + return Err(SourceError::InvalidPlan); + } + let mut root = ProjectionNode::default(); + for path in paths { + let segments = parse_projection_pointer(path)?; + let mut node = &mut root; + for segment in segments { + if node.terminal { + return Err(SourceError::InvalidPlan); + } + match segment { + ProjectionSegment::Key(key) => { + if node.wildcard.is_some() { + return Err(SourceError::InvalidPlan); + } + node = node.keys.entry(key).or_default(); + } + ProjectionSegment::Wildcard => { + if !node.keys.is_empty() { + return Err(SourceError::InvalidPlan); + } + node = node + .wildcard + .get_or_insert_with(|| Box::new(ProjectionNode::default())); + } + } + } + if node.terminal || !node.keys.is_empty() || node.wildcard.is_some() { + return Err(SourceError::InvalidPlan); + } + node.terminal = true; + } + Ok(root) +} + +enum ProjectionSegment { + Key(String), + Wildcard, +} + +fn parse_projection_pointer(pointer: &str) -> Result, SourceError> { + if !pointer.starts_with('/') + || pointer.starts_with("//") + || pointer.chars().any(char::is_control) + { + return Err(SourceError::InvalidPlan); + } + pointer[1..] + .split('/') + .map(|raw| { + if raw.is_empty() { + return Err(SourceError::InvalidPlan); + } + if raw == "*" { + return Ok(ProjectionSegment::Wildcard); + } + let mut decoded = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(character) = chars.next() { + if character == '~' { + match chars.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => return Err(SourceError::InvalidPlan), + } + } else { + decoded.push(character); + } + } + if decoded.is_empty() || decoded.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(SourceError::InvalidPlan); + } + Ok(ProjectionSegment::Key(decoded)) + }) + .collect() +} + +fn project_value(value: &JsonValue, node: &ProjectionNode) -> Result { + if node.terminal { + return Ok(value.clone()); + } + if let Some(wildcard) = &node.wildcard { + let array = value.as_array().ok_or(SourceError::ProjectionViolation)?; + return array + .iter() + .map(|item| project_value(item, wildcard)) + .collect::, _>>() + .map(JsonValue::Array); + } + let object = value.as_object().ok_or(SourceError::ProjectionViolation)?; + let mut projected = JsonMap::new(); + for (key, child) in &node.keys { + match object.get(key) { + Some(value) => { + projected.insert(key.clone(), project_value(value, child)?); + } + None if child.terminal => {} + None => return Err(SourceError::ProjectionViolation), + } + } + Ok(JsonValue::Object(projected)) +} + +fn resolve( + resolver: &SecretResolver, + reference: &SecretRef, +) -> Result { + resolver + .resolve(reference.as_str()) + .map_err(|_| SourceError::Credential) +} + +fn protected_text(secret: &ProtectedSecret) -> Result<&str, SourceError> { + std::str::from_utf8(secret.expose_secret()).map_err(|_| SourceError::Credential) +} + +fn basic_authorization( + username: &ProtectedSecret, + password: &ProtectedSecret, +) -> Result { + if username.is_empty() || password.is_empty() || username.expose_secret().contains(&b':') { + return Err(SourceError::Credential); + } + let mut joined = Zeroizing::new(Vec::with_capacity(username.len() + password.len() + 1)); + joined.extend_from_slice(username.expose_secret()); + joined.push(b':'); + joined.extend_from_slice(password.expose_secret()); + let encoded = + Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(joined.as_slice())); + let mut header = Zeroizing::new(Vec::with_capacity(6 + encoded.len())); + header.extend_from_slice(b"Basic "); + header.extend_from_slice(encoded.as_bytes()); + sensitive_header(&header) +} + +fn bearer_authorization(token: &[u8]) -> Result { + if token.is_empty() { + return Err(SourceError::Credential); + } + let mut header = Zeroizing::new(Vec::with_capacity(7 + token.len())); + header.extend_from_slice(b"Bearer "); + header.extend_from_slice(token); + sensitive_header(&header) +} + +fn sensitive_header(bytes: &[u8]) -> Result { + let mut value = HeaderValue::from_bytes(bytes).map_err(|_| SourceError::Credential)?; + value.set_sensitive(true); + Ok(value) +} + +async fn parse_data_response( + response: reqwest::Response, + maximum_bytes: u64, + _posture: AcquisitionPosture, + projection: &ProjectionNode, +) -> Result { + reject_response_status(&response)?; + let media_type = response_media_type(&response)?; + if media_type != JSON_MEDIA_TYPE && media_type != GRAPHQL_JSON_MEDIA_TYPE { + return Err(SourceError::WrongMediaType); + } + let bytes = Zeroizing::new( + read_bounded(response, maximum_bytes) + .await + .map_err(map_bounded_read_error)?, + ); + let value = parse_strict_json(&bytes)?; + drop(bytes); + if value + .as_object() + .is_some_and(|object| object.contains_key("errors")) + { + return Err(SourceError::ErrorEnvelope); + } + let projected = project_value(&value, projection)?; + if serde_json::to_vec(&projected) + .map_err(|_| SourceError::ProjectionViolation)? + .len() + > PROJECTED_RESPONSE_MAXIMUM_BYTES + { + return Err(SourceError::ResponseTooLarge); + } + Ok(projected) +} + +async fn parse_token_response( + response: reqwest::Response, + expected_scope: Option<&str>, +) -> Result<(ProtectedToken, Duration), SourceError> { + reject_response_status(&response).map_err(|error| match error { + SourceError::Timeout => SourceError::Timeout, + _ => SourceError::Credential, + })?; + if response_media_type(&response).map_err(|_| SourceError::Credential)? != JSON_MEDIA_TYPE { + return Err(SourceError::Credential); + } + let bytes = Zeroizing::new( + read_bounded(response, TOKEN_RESPONSE_MAXIMUM_BYTES) + .await + .map_err(|_| SourceError::Credential)?, + ); + let mut object = parse_strict_json(&bytes) + .map_err(|_| SourceError::Credential)? + .as_object() + .cloned() + .ok_or(SourceError::Credential)?; + drop(bytes); + let access_token = match object.remove("access_token") { + Some(JsonValue::String(value)) => value, + _ => return Err(SourceError::Credential), + }; + let token_type = match object.remove("token_type") { + Some(JsonValue::String(value)) => value, + _ => return Err(SourceError::Credential), + }; + if !token_type.eq_ignore_ascii_case("bearer") { + return Err(SourceError::Credential); + } + let expires_in = match object.remove("expires_in") { + Some(JsonValue::Number(value)) => value.as_u64().filter(|value| *value > 0), + _ => None, + } + .ok_or(SourceError::Credential)?; + if let Some(scope) = object.remove("scope") { + let JsonValue::String(scope) = scope else { + return Err(SourceError::Credential); + }; + if scope.is_empty() + || scope.len() > 512 + || expected_scope.is_some_and(|expected| scope != expected) + { + return Err(SourceError::Credential); + } + } + if !object.is_empty() { + return Err(SourceError::Credential); + } + Ok(( + ProtectedToken::from_string(access_token)?, + Duration::from_secs(expires_in), + )) +} + +fn reject_response_status(response: &reqwest::Response) -> Result<(), SourceError> { + let status = response.status(); + if status.is_redirection() { + return Err(SourceError::Redirect); + } + if status.is_success() { + return Ok(()); + } + Err(SourceError::Status(match status.as_u16() { + 401 => SourceStatus::Unauthorized, + 403 => SourceStatus::Forbidden, + 429 => SourceStatus::RateLimited, + 500..=599 => SourceStatus::ServerError, + _ => SourceStatus::Other, + })) +} + +fn response_media_type(response: &reqwest::Response) -> Result<&str, SourceError> { + let mut values = response.headers().get_all(CONTENT_TYPE).iter(); + let value = values.next().ok_or(SourceError::WrongMediaType)?; + if values.next().is_some() { + return Err(SourceError::WrongMediaType); + } + let value = value.to_str().map_err(|_| SourceError::WrongMediaType)?; + let media_type = value.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case(JSON_MEDIA_TYPE) { + Ok(JSON_MEDIA_TYPE) + } else if media_type.eq_ignore_ascii_case(GRAPHQL_JSON_MEDIA_TYPE) { + Ok(GRAPHQL_JSON_MEDIA_TYPE) + } else { + Ok(media_type) + } +} + +fn map_transport_error(error: reqwest::Error) -> SourceError { + if error.is_timeout() { + SourceError::Timeout + } else { + SourceError::Transport + } +} + +fn map_bounded_read_error(error: BoundedReadError) -> SourceError { + match error { + BoundedReadError::Transport(error) => map_transport_error(error), + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => SourceError::ResponseTooLarge, + _ => SourceError::Transport, + } +} + +fn validate_url(value: &str, origin_only: bool) -> Result { + let url = Url::parse(value).map_err(|_| SourceError::InvalidPlan)?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(SourceError::InvalidPlan); + } + if origin_only && (url.path() != "/" || url.query().is_some()) { + return Err(SourceError::InvalidPlan); + } + match url.scheme() { + "https" if url.host().is_some() => {} + "http" => match url.host() { + Some(Host::Ipv4(ip)) if ip.is_loopback() => {} + Some(Host::Ipv6(ip)) if ip == std::net::Ipv6Addr::LOCALHOST => {} + _ => return Err(SourceError::InvalidPlan), + }, + _ => return Err(SourceError::InvalidPlan), + } + Ok(url) +} + +fn validate_request_path(path: &str) -> Result<(), SourceError> { + if path.len() < 2 + || !path.starts_with('/') + || path.starts_with("//") + || path.contains(['?', '#', '\\']) + || path + .split('/') + .skip(1) + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + return Err(SourceError::InvalidPlan); + } + Ok(()) +} + +fn join_source_path(base: &Url, path: &str) -> Result { + validate_request_path(path)?; + let value = format!("{}{}", base.as_str().trim_end_matches('/'), path); + let url = Url::parse(&value).map_err(|_| SourceError::InvalidPlan)?; + if url.scheme() != base.scheme() + || url.host() != base.host() + || url.port_or_known_default() != base.port_or_known_default() + { + return Err(SourceError::InvalidPlan); + } + Ok(url) +} + +struct StrictJson(JsonValue); + +impl<'de> Deserialize<'de> for StrictJson { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(StrictJsonVisitor) + } +} + +struct StrictJsonVisitor; + +impl<'de> Visitor<'de> for StrictJsonVisitor { + type Value = StrictJson; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a duplicate-free JSON value") + } + fn visit_bool(self, value: bool) -> Result { + Ok(StrictJson(JsonValue::Bool(value))) + } + fn visit_i64(self, value: i64) -> Result { + Ok(StrictJson(JsonValue::Number(value.into()))) + } + fn visit_u64(self, value: u64) -> Result { + Ok(StrictJson(JsonValue::Number(value.into()))) + } + fn visit_f64(self, value: f64) -> Result { + JsonNumber::from_f64(value) + .map(JsonValue::Number) + .map(StrictJson) + .ok_or_else(|| de::Error::custom("invalid JSON number")) + } + fn visit_str(self, value: &str) -> Result { + self.visit_string(value.to_owned()) + } + fn visit_string(self, value: String) -> Result { + Ok(StrictJson(JsonValue::String(value))) + } + fn visit_none(self) -> Result { + Ok(StrictJson(JsonValue::Null)) + } + fn visit_unit(self) -> Result { + Ok(StrictJson(JsonValue::Null)) + } + fn visit_some>(self, deserializer: D) -> Result { + StrictJson::deserialize(deserializer) + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0)); + while let Some(value) = sequence.next_element::()? { + values.push(value.0); + } + Ok(StrictJson(JsonValue::Array(values))) + } + fn visit_map>(self, mut mapping: A) -> Result { + let mut object = JsonMap::new(); + while let Some((key, value)) = mapping.next_entry::()? { + if object.insert(key, value.0).is_some() { + return Err(de::Error::custom("duplicate JSON object member")); + } + } + Ok(StrictJson(JsonValue::Object(object))) + } +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictJson::deserialize(&mut deserializer).map_err(|_| SourceError::InvalidJson)?; + deserializer.end().map_err(|_| SourceError::InvalidJson)?; + Ok(value.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn projection_supports_nested_arrays_escapes_and_literal_dots() { + let plan = compile_projection(&[ + "/total".into(), + "/results/*/declaration/mother.personReference".into(), + "/results/*/a~1b/~0value".into(), + ]) + .expect("projection compiles"); + let input = json!({ + "total": 1, + "ignored": "gone", + "results": [{ + "declaration": {"mother.personReference": "P-1", "private": "gone"}, + "a/b": {"~value": true, "ignored": false} + }] + }); + assert_eq!( + project_value(&input, &plan), + Ok(json!({ + "total": 1, + "results": [{ + "declaration": {"mother.personReference": "P-1"}, + "a/b": {"~value": true} + }] + })) + ); + } + + #[test] + fn projection_omits_missing_leaves_but_rejects_missing_or_mistyped_intermediates() { + let plan = + compile_projection(&["/results/*/optional".into()]).expect("projection compiles"); + assert_eq!( + project_value(&json!({"results": [{}]}), &plan), + Ok(json!({"results": [{}]})) + ); + assert_eq!( + project_value(&json!({}), &plan), + Err(SourceError::ProjectionViolation) + ); + assert_eq!( + project_value(&json!({"results": {}}), &plan), + Err(SourceError::ProjectionViolation) + ); + } + + #[test] + fn projection_rejects_duplicates_conflicts_indexes_and_mixed_container_shapes() { + for paths in [ + vec!["/a".into(), "/a".into()], + vec!["/a".into(), "/a/b".into()], + vec!["/a/0".into()], + vec!["/a/*/x".into(), "/a/b".into()], + ] { + assert!(compile_projection(&paths).is_err()); + } + } + + #[test] + fn path_selector_encoding_is_single_pass_and_hostile_values_fail_closed() { + assert_eq!( + encode_path_selector(&SelectorValue::String("A B".into())), + Ok("A%20B".into()) + ); + for value in [".", "..", "a/b", "a\\b", "a%2Fb", "a\nb"] { + assert_eq!( + encode_path_selector(&SelectorValue::String(value.into())), + Err(SourceError::InvalidSelectors) + ); + } + } + + #[test] + fn duplicate_json_members_are_rejected() { + assert_eq!( + parse_strict_json(br#"{"a":1,"a":2}"#), + Err(SourceError::InvalidJson) + ); + } + + #[tokio::test] + async fn saturated_source_admission_fails_at_the_configured_timeout() { + let server = wiremock::MockServer::start().await; + let source: SourceConfig = serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": server.uri(), + "posture": "source-derived", + "authentication": { + "kind": "static-bearer", + "tokenRef": "secret:file/missing-source-token" + }, + "request": { + "method": "POST", + "path": "/data", + "fixedHeaders": [], + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "forbidden", + "jsonBody": "required", + "maximumJsonDepth": 4, + "maximumCollectionItems": 4, + "maximumStringBytes": 64, + "maximumNormalizedBytes": 1024 + }, + "projection": ["/ok"], + "redirects": "deny", + "timeoutMilliseconds": 20, + "maximumResponseBytes": 1024, + "concurrencyLimit": 1 + }, + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes"); + let secret_root = tempfile::tempdir().expect("temporary secret root"); + let secrets = Arc::new( + SecretResolver::new([crate::secrets::SecretProvider::File], secret_root.path()) + .expect("secret resolver builds"), + ); + let executor = SourceExecutor::new(&source, secrets).expect("source executor builds"); + let _occupied = executor + .concurrency + .acquire() + .await + .expect("source slot is available"); + let selectors = [ResolvedSourceSelector { + role: "subject".into(), + profile: "record-v1".into(), + values: BTreeMap::from([( + "record_id".into(), + SelectorValue::String("synthetic-record".into()), + )]), + }]; + let started = Instant::now(); + assert!(matches!( + executor + .execute( + &selectors, + &RequestParts { + query: Vec::new(), + body: Some(json!({"requested": true})), + }, + ) + .await, + Err(SourceError::Timeout) + )); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + } + + #[tokio::test] + async fn saturated_oauth_single_flight_fails_before_credentials_or_transport() { + let secret_root = tempfile::tempdir().expect("temporary secret root"); + let secrets = + SecretResolver::new([crate::secrets::SecretProvider::File], secret_root.path()) + .expect("secret resolver builds"); + let plan = OauthPlan { + token_endpoint: Url::parse("http://127.0.0.1:1/token") + .expect("synthetic endpoint parses"), + client_id_ref: SecretRef::parse("secret:file/missing-client-id") + .expect("secret reference parses"), + client_secret_ref: SecretRef::parse("secret:file/missing-client-secret") + .expect("secret reference parses"), + scope: Some("fixture.read".into()), + credential_placement: CredentialPlacement::QueryString, + maximum_cache_lifetime: Duration::from_secs(60), + admission_timeout: Duration::from_millis(20), + cache: Mutex::new(None), + }; + let _occupied = plan.cache.lock().await; + let client = reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client builds"); + let started = Instant::now(); + let result = + tokio::time::timeout(Duration::from_secs(1), plan.access_token(&client, &secrets)) + .await + .expect("OAuth admission is bounded by its configured timeout"); + assert!(matches!(result, Err(SourceError::Timeout))); + assert!(started.elapsed() < Duration::from_secs(1)); + } +} diff --git a/crates/registry-evidence/src/values.rs b/crates/registry-evidence/src/values.rs new file mode 100644 index 000000000..53edb0814 --- /dev/null +++ b/crates/registry-evidence/src/values.rs @@ -0,0 +1,259 @@ +//! Core-owned exact and protected values crossing the Rhai boundary. + +use std::{cmp::Ordering, fmt, str::FromStr}; + +use serde::{Serialize, Serializer}; +use thiserror::Error; +use zeroize::Zeroizing; + +const MAX_DECIMAL_PRECISION: usize = 28; +const MAX_DECIMAL_SCALE: u32 = 9; +const MAX_ENTITY_SEED_BYTES: usize = 512; + +#[derive(Clone, PartialEq, Eq)] +pub struct Decimal { + coefficient: i128, + scale: u32, + canonical: String, +} + +impl Decimal { + pub fn parse(input: &str) -> Result { + if input.is_empty() + || input.starts_with('+') + || input.contains(['e', 'E']) + || matches!(input, "NaN" | "Infinity" | "-Infinity") + { + return Err(DecimalError::Lexical); + } + + let (negative, unsigned) = match input.strip_prefix('-') { + Some(unsigned) => (true, unsigned), + None => (false, input), + }; + if unsigned.is_empty() { + return Err(DecimalError::Lexical); + } + let (integer, fraction) = match unsigned.split_once('.') { + Some((integer, fraction)) => (integer, Some(fraction)), + None => (unsigned, None), + }; + + if integer.is_empty() + || !integer.bytes().all(|byte| byte.is_ascii_digit()) + || (integer.len() > 1 && integer.starts_with('0')) + { + return Err(DecimalError::Lexical); + } + let scale = match fraction { + Some(fraction) + if !fraction.is_empty() + && fraction.bytes().all(|byte| byte.is_ascii_digit()) + && !fraction.ends_with('0') => + { + u32::try_from(fraction.len()).map_err(|_| DecimalError::Scale)? + } + Some(_) => return Err(DecimalError::Lexical), + None => 0, + }; + if scale > MAX_DECIMAL_SCALE { + return Err(DecimalError::Scale); + } + + let digits = match fraction { + Some(fraction) => format!("{integer}{fraction}"), + None => integer.to_owned(), + }; + let significant = digits.trim_start_matches('0'); + let precision = significant.len().max(1); + if precision > MAX_DECIMAL_PRECISION { + return Err(DecimalError::Precision); + } + let magnitude = if significant.is_empty() { + 0 + } else { + i128::from_str(significant).map_err(|_| DecimalError::Precision)? + }; + if magnitude == 0 && (negative || scale != 0) { + return Err(DecimalError::Zero); + } + let coefficient = if negative { -magnitude } else { magnitude }; + + Ok(Self { + coefficient, + scale, + canonical: input.to_owned(), + }) + } + + pub fn from_integer(value: i64) -> Self { + Self { + coefficient: i128::from(value), + scale: 0, + canonical: value.to_string(), + } + } + + pub fn canonical(&self) -> &str { + &self.canonical + } + + pub fn scale(&self) -> u32 { + self.scale + } + + pub fn compare(&self, other: &Self) -> Ordering { + match self.scale.cmp(&other.scale) { + Ordering::Equal => self.coefficient.cmp(&other.coefficient), + Ordering::Less => self + .coefficient + .saturating_mul(power_of_ten(other.scale - self.scale)) + .cmp(&other.coefficient), + Ordering::Greater => self.coefficient.cmp( + &other + .coefficient + .saturating_mul(power_of_ten(self.scale - other.scale)), + ), + } + } +} + +impl fmt::Debug for Decimal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Decimal([REDACTED])") + } +} + +impl Serialize for Decimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.canonical) + } +} + +impl FromStr for Decimal { + type Err = DecimalError; + + fn from_str(input: &str) -> Result { + Self::parse(input) + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum DecimalError { + #[error("decimal text is not canonical")] + Lexical, + #[error("decimal zero must be represented as 0")] + Zero, + #[error("decimal precision exceeds the supported bound")] + Precision, + #[error("decimal scale exceeds the supported bound")] + Scale, +} + +fn power_of_ten(exponent: u32) -> i128 { + 10_i128.pow(exponent) +} + +pub struct EntityReferenceSeed(Zeroizing>); + +impl EntityReferenceSeed { + pub fn new(input: &str) -> Result { + let bytes = input.as_bytes(); + if bytes.is_empty() || bytes.len() > MAX_ENTITY_SEED_BYTES { + return Err(EntityReferenceSeedError); + } + Ok(Self(Zeroizing::new(bytes.to_vec()))) + } + + pub(crate) fn expose_for_projection(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl Clone for EntityReferenceSeed { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.to_vec())) + } +} + +impl fmt::Debug for EntityReferenceSeed { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EntityReferenceSeed()") + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("entity-reference seed is empty or exceeds its bound")] +pub struct EntityReferenceSeedError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_decimals_round_trip_as_json_strings() { + for text in [ + "0", + "1", + "-1", + "0.25", + "-10.5", + "1234567890123456789012345678", + "0.000000001", + ] { + let value = Decimal::parse(text).expect("decimal parses"); + assert_eq!(value.canonical(), text); + assert_eq!( + serde_json::to_string(&value).expect("serializes"), + format!("\"{text}\"") + ); + } + } + + #[test] + fn noncanonical_or_excessive_decimals_are_rejected() { + for text in [ + "", + "+1", + "01.0", + "1.0", + "1.", + ".1", + "-0", + "-0.0", + "0.0", + "1e2", + "NaN", + "12345678901234567890123456789", + "0.1234567891", + ] { + assert!(Decimal::parse(text).is_err(), "{text} must be rejected"); + } + } + + #[test] + fn decimal_comparison_is_exact_across_scales() { + let one = Decimal::parse("1").expect("parses"); + let one_point_five = Decimal::parse("1.5").expect("parses"); + let negative_tenth = Decimal::parse("-0.1").expect("parses"); + assert_eq!(one.compare(&one_point_five), Ordering::Less); + assert_eq!(one_point_five.compare(&one), Ordering::Greater); + assert_eq!(negative_tenth.compare(&one), Ordering::Less); + } + + #[test] + fn entity_seed_debug_is_redacted() { + let seed = EntityReferenceSeed::new("protected-canary").expect("seed builds"); + let debug = format!("{seed:?}"); + assert!(!debug.contains("protected-canary")); + assert_eq!(debug, "EntityReferenceSeed()"); + + let decimal = Decimal::parse("8192.125").expect("decimal parses"); + let debug = format!("{decimal:?}"); + assert!(!debug.contains("8192.125")); + assert_eq!(debug, "Decimal([REDACTED])"); + } +} diff --git a/crates/registry-evidence/src/verifier.rs b/crates/registry-evidence/src/verifier.rs new file mode 100644 index 000000000..7fc2403eb --- /dev/null +++ b/crates/registry-evidence/src/verifier.rs @@ -0,0 +1,679 @@ +//! Strict verifier for the Evidence Version 1 flattened JWS profile. + +use std::{collections::BTreeMap, time::Duration}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use registry_platform_crypto::{parse_json_strict, verify, PublicJwk, SigningAlgorithm}; +use serde::Deserialize; +use thiserror::Error; + +use crate::{ + contracts::evidence_contract_accepts, + model::{Evidence, FlattenedJws, JwksDocument}, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, +}; + +const MAX_JWS_BYTES: usize = 256 * 1024; +const MAX_PROTECTED_BYTES: usize = 8 * 1024; +const MAX_PAYLOAD_BYTES: usize = 128 * 1024; +const MAX_TRUSTED_KEYS: usize = 33; + +#[derive(Debug, Clone)] +pub struct EvidenceVerificationPolicy { + pub issued_by: String, + pub provided_by: String, + pub requirement: String, + pub evidence_type: String, + pub purpose: String, + pub audience: String, + pub configuration_revision: String, + pub now: DateTime, + pub clock_skew: Duration, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum VerificationError { + #[error("flattened JWS is malformed")] + MalformedJws, + #[error("protected JWS header is not allowed")] + ProtectedHeader, + #[error("JWS key identifier is unknown or ambiguous")] + Key, + #[error("JWS signature is invalid")] + Signature, + #[error("Evidence payload is malformed")] + Payload, + #[error("Evidence payload does not match the relying procedure")] + Policy, + #[error("Evidence payload is outside its validity interval")] + Time, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProtectedHeader { + alg: String, + kid: String, + typ: String, + cty: String, +} + +pub fn verify_flattened_jws( + serialized_jws: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + if serialized_jws.is_empty() || serialized_jws.len() > MAX_JWS_BYTES { + return Err(VerificationError::MalformedJws); + } + let strict = parse_json_strict(serialized_jws).map_err(|_| VerificationError::MalformedJws)?; + let jws: FlattenedJws = + serde_json::from_value(strict).map_err(|_| VerificationError::MalformedJws)?; + + let protected_bytes = decode_bounded( + &jws.protected, + MAX_PROTECTED_BYTES, + VerificationError::ProtectedHeader, + )?; + let protected_strict = + parse_json_strict(&protected_bytes).map_err(|_| VerificationError::ProtectedHeader)?; + let protected: ProtectedHeader = + serde_json::from_value(protected_strict).map_err(|_| VerificationError::ProtectedHeader)?; + if protected.alg != "EdDSA" + || protected.typ != EVIDENCE_JWS_TYP + || protected.cty != EVIDENCE_JWS_CTY + || protected.kid.is_empty() + || protected.kid.len() > 256 + || protected.kid.chars().any(char::is_control) + { + return Err(VerificationError::ProtectedHeader); + } + + let keys = trusted_keys(trusted_jwks)?; + let key = keys.get(&protected.kid).ok_or(VerificationError::Key)?; + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(VerificationError::Key); + } + let signature = decode_bounded( + &jws.signature, + MAX_PROTECTED_BYTES, + VerificationError::Signature, + )?; + let signing_input = [jws.protected.as_bytes(), b".", jws.payload.as_bytes()].concat(); + verify(&signing_input, &signature, key).map_err(|_| VerificationError::Signature)?; + + // Parse and act on the payload only after signature verification. + let payload = decode_bounded(&jws.payload, MAX_PAYLOAD_BYTES, VerificationError::Payload)?; + let payload_strict = parse_json_strict(&payload).map_err(|_| VerificationError::Payload)?; + if !evidence_contract_accepts(&payload_strict).map_err(|_| VerificationError::Payload)? { + return Err(VerificationError::Payload); + } + let evidence: Evidence = + serde_json::from_value(payload_strict).map_err(|_| VerificationError::Payload)?; + validate_policy(&evidence, policy)?; + Ok(evidence) +} + +fn trusted_keys(jwks: &JwksDocument) -> Result, VerificationError> { + if jwks.keys.is_empty() || jwks.keys.len() > MAX_TRUSTED_KEYS { + return Err(VerificationError::Key); + } + let mut output = BTreeMap::new(); + for value in &jwks.keys { + let key: PublicJwk = + serde_json::from_value(value.clone()).map_err(|_| VerificationError::Key)?; + let kid = key.kid.clone().ok_or(VerificationError::Key)?; + if kid.is_empty() + || kid.len() > 256 + || kid.chars().any(char::is_control) + || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + || output.insert(kid, key).is_some() + { + return Err(VerificationError::Key); + } + } + Ok(output) +} + +fn validate_policy( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result<(), VerificationError> { + if evidence.schema != EVIDENCE_SCHEMA_V1 + || evidence.issued_by != policy.issued_by + || evidence.provided_by != policy.provided_by + || evidence.supports_requirement != policy.requirement + || evidence.is_conformant_to != policy.evidence_type + || evidence.purpose != policy.purpose + || evidence.audience != policy.audience + || evidence.configuration_revision != policy.configuration_revision + || evidence.subjects.is_empty() + || evidence.supported_values.is_empty() + { + return Err(VerificationError::Policy); + } + + let issued = parse_time(&evidence.issued_at)?; + let observed = parse_time(&evidence.observed_at)?; + let valid_until = parse_time(&evidence.valid_until)?; + let skew = + chrono::Duration::from_std(policy.clock_skew).map_err(|_| VerificationError::Time)?; + let latest_acceptable_issue = policy + .now + .checked_add_signed(skew) + .ok_or(VerificationError::Time)?; + let expiration_with_skew = valid_until + .checked_add_signed(skew) + .ok_or(VerificationError::Time)?; + if issued < observed + || issued > latest_acceptable_issue + || observed > latest_acceptable_issue + || valid_until <= observed + || valid_until <= issued + || policy.now >= expiration_with_skew + { + return Err(VerificationError::Time); + } + Ok(()) +} + +fn parse_time(input: &str) -> Result, VerificationError> { + DateTime::parse_from_rfc3339(input) + .map(|value| value.with_timezone(&Utc)) + .map_err(|_| VerificationError::Time) +} + +fn decode_bounded( + input: &str, + maximum: usize, + error: VerificationError, +) -> Result, VerificationError> { + let decoded = URL_SAFE_NO_PAD.decode(input).map_err(|_| error)?; + if decoded.is_empty() || decoded.len() > maximum { + return Err(error); + } + Ok(decoded) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use serde_json::{json, Value}; + + use super::*; + use crate::{ + model::{EvidenceObjectType, PublicValue, SubjectBinding, SupportedValue}, + signing::{jwks_document, EvidenceSigner}, + }; + + const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; + const RETIRED_PRIVATE_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"retired-evidence-key"}"#; + + async fn sign_with_protected_header( + private_jwk: &str, + protected_header: Value, + evidence: &Evidence, + ) -> (Vec, PublicJwk) { + sign_payload_bytes( + private_jwk, + protected_header, + &serde_json::to_vec(evidence).expect("Evidence serializes"), + ) + .await + } + + async fn sign_payload_bytes( + private_jwk: &str, + protected_header: Value, + payload_bytes: &[u8], + ) -> (Vec, PublicJwk) { + let private = PrivateJwk::parse(private_jwk).expect("test key parses"); + let signer = LocalJwkSigner::new(private).expect("test signer builds"); + let protected = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&protected_header).expect("protected header serializes")); + let payload = URL_SAFE_NO_PAD.encode(payload_bytes); + let signing_input = format!("{protected}.{payload}"); + let signature = signer + .sign(signing_input.as_bytes()) + .await + .expect("test JWS signs"); + let jws = FlattenedJws { + protected, + payload, + signature: URL_SAFE_NO_PAD.encode(signature), + }; + ( + serde_json::to_vec(&jws).expect("JWS serializes"), + signer.public_jwk(), + ) + } + + fn fixture_evidence() -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_string(), + id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:v1".to_string(), + is_conformant_to: "urn:example:type:v1".to_string(), + issued_by: "urn:example:issuer".to_string(), + provided_by: "urn:example:provider".to_string(), + issued_at: "2026-08-02T00:00:00Z".to_string(), + observed_at: "2026-08-02T00:00:00Z".to_string(), + valid_until: "2026-08-03T00:00:00Z".to_string(), + purpose: "casework".to_string(), + audience: "urn:example:audience".to_string(), + configuration_revision: format!("sha256:{}", "0".repeat(64)), + subjects: vec![SubjectBinding { + role: "subject".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + supported_values: vec![SupportedValue { + provides_value_for: "urn:example:concept".to_string(), + value: PublicValue::Boolean(false), + }], + } + } + + async fn signed_evidence( + evidence: Evidence, + now: DateTime, + ) -> (Vec, JwksDocument, EvidenceVerificationPolicy) { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("signer builds")); + let signer = EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes"); + let jws = signer.sign_json(&evidence).await.expect("evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = EvidenceVerificationPolicy { + issued_by: evidence.issued_by, + provided_by: evidence.provided_by, + requirement: evidence.supports_requirement, + evidence_type: evidence.is_conformant_to, + purpose: evidence.purpose, + audience: evidence.audience, + configuration_revision: evidence.configuration_revision, + now, + clock_skew: Duration::from_secs(30), + }; + (serialized, jwks, policy) + } + + async fn signed_fixture() -> (Vec, JwksDocument, EvidenceVerificationPolicy) { + signed_evidence( + fixture_evidence(), + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await + } + + #[tokio::test] + async fn signed_false_round_trips_and_verifies() { + let (jws, jwks, policy) = signed_fixture().await; + let evidence = verify_flattened_jws(&jws, &jwks, &policy).expect("JWS verifies"); + assert_eq!( + evidence.supported_values[0].value, + PublicValue::Boolean(false) + ); + } + + #[tokio::test] + async fn signed_payload_must_satisfy_the_complete_evidence_schema() { + let mut cases = Vec::new(); + + let mut invalid_id = fixture_evidence(); + invalid_id.id = "not a URI".to_owned(); + cases.push(invalid_id); + + let mut invalid_role = fixture_evidence(); + invalid_role.subjects[0].role = "Uppercase".to_owned(); + cases.push(invalid_role); + + let mut invalid_binding = fixture_evidence(); + invalid_binding.subjects[0].binding = "raw-subject-identifier".to_owned(); + cases.push(invalid_binding); + + let mut invalid_concept = fixture_evidence(); + invalid_concept.supported_values[0].provides_value_for = "not a URI".to_owned(); + cases.push(invalid_concept); + + let mut empty_public_string = fixture_evidence(); + empty_public_string.supported_values[0].value = PublicValue::String(String::new()); + cases.push(empty_public_string); + + let mut excessive_subjects = fixture_evidence(); + excessive_subjects.subjects = (0..9) + .map(|index| SubjectBinding { + role: format!("subject-{index}"), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }) + .collect(); + cases.push(excessive_subjects); + + for evidence in cases { + let (jws, jwks, policy) = signed_evidence( + evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Payload) + ); + } + } + + #[tokio::test] + async fn signed_schema_integer_lexical_forms_verify_without_type_loss() { + let base = serde_json::to_string(&fixture_evidence()).expect("Evidence serializes"); + assert_eq!(base.matches("\"value\":false").count(), 1); + let header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (_, _, policy) = signed_fixture().await; + + for number in ["1.0", "1e0"] { + let payload = base.replace("\"value\":false", &format!("\"value\":{number}")); + let (serialized, public) = + sign_payload_bytes(PRIVATE_JWK, header.clone(), payload.as_bytes()).await; + let jwks = jwks_document(public, []).expect("JWKS builds"); + let evidence = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("schema-valid integral JSON number verifies"); + assert_eq!(evidence.supported_values[0].value, PublicValue::Integer(1)); + } + } + + #[tokio::test] + async fn payload_and_protected_header_mutation_fail() { + let (jws, jwks, policy) = signed_fixture().await; + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let payload = value["payload"].as_str().expect("payload").to_string(); + value["payload"] = Value::String(format!("A{}", &payload[1..])); + assert!(matches!( + verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::Signature) + )); + + let (jws, jwks, policy) = signed_fixture().await; + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let protected = value["protected"].as_str().expect("protected").to_string(); + value["protected"] = Value::String(format!("A{}", &protected[1..])); + assert!(verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ) + .is_err()); + } + + #[tokio::test] + async fn duplicate_jws_members_and_unknown_kid_are_rejected() { + let (jws, mut jwks, policy) = signed_fixture().await; + let value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let duplicate = format!( + "{{\"protected\":{},\"protected\":{},\"payload\":{},\"signature\":{}}}", + value["protected"], value["protected"], value["payload"], value["signature"] + ); + assert_eq!( + verify_flattened_jws(duplicate.as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + jwks.keys.clear(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Key) + ); + } + + #[tokio::test] + async fn signature_never_substitutes_for_provider_and_issuer_trust_policy() { + let (jws, jwks, policy) = signed_fixture().await; + let mut untrusted_provider = policy.clone(); + untrusted_provider.provided_by = "urn:example:untrusted-provider".to_owned(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &untrusted_provider), + Err(VerificationError::Policy) + ); + + let mut untrusted_issuer = policy; + untrusted_issuer.issued_by = "urn:example:untrusted-issuer".to_owned(); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &untrusted_issuer), + Err(VerificationError::Policy) + ); + } + + #[tokio::test] + async fn signed_chronology_and_clock_arithmetic_fail_closed() { + let mut reversed = fixture_evidence(); + reversed.observed_at = "2026-08-02T00:01:00Z".to_owned(); + let (jws, jwks, policy) = signed_evidence( + reversed, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + let mut expired_when_issued = fixture_evidence(); + expired_when_issued.issued_at = "2026-08-03T00:00:00Z".to_owned(); + expired_when_issued.valid_until = "2026-08-03T00:00:00Z".to_owned(); + let (jws, jwks, policy) = signed_evidence( + expired_when_issued, + "2026-08-03T00:00:00Z".parse().expect("time parses"), + ) + .await; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + let (jws, jwks, mut policy) = signed_fixture().await; + policy.now = DateTime::::MAX_UTC; + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + } + + #[tokio::test] + async fn complete_jws_negative_fixture_is_executable() { + let fixture: Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/jws-cases.yaml" + )) + .expect("JWS fixture parses"); + let negatives = fixture["negative"] + .as_array() + .expect("negative cases are an array") + .iter() + .map(|value| value.as_str().expect("negative case is text")) + .collect::>(); + assert_eq!( + negatives, + [ + "mutate one protected-header byte", + "mutate one payload byte", + "remove signature", + "add an unprotected header", + "add jku, x5u, jwk, x5c, crit, or b64", + "unknown kid", + "algorithm mismatch", + "signed payload violates the Evidence JSON Schema", + "duplicate evidence object beside payload", + "signing-provider failure", + ] + ); + + let evidence = fixture_evidence(); + let base_header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (valid, public) = + sign_with_protected_header(PRIVATE_JWK, base_header.clone(), &evidence).await; + let jwks = jwks_document(public, []).expect("JWKS builds"); + let (_, _, policy) = signed_fixture().await; + assert!(verify_flattened_jws(&valid, &jwks, &policy).is_ok()); + + let mut missing_signature: Value = serde_json::from_slice(&valid).expect("JWS parses"); + missing_signature + .as_object_mut() + .expect("JWS is an object") + .remove("signature"); + assert_eq!( + verify_flattened_jws( + &serde_json::to_vec(&missing_signature).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::MalformedJws) + ); + + for extra in [ + ("header", json!({"kid": "evidence-key-1"})), + ( + "evidence", + serde_json::to_value(&evidence).expect("Evidence serializes"), + ), + ] { + let mut value: Value = serde_json::from_slice(&valid).expect("JWS parses"); + value + .as_object_mut() + .expect("JWS is an object") + .insert(extra.0.to_owned(), extra.1); + assert_eq!( + verify_flattened_jws( + &serde_json::to_vec(&value).expect("serializes"), + &jwks, + &policy + ), + Err(VerificationError::MalformedJws) + ); + } + + for (name, value) in [ + ("jku", json!("https://attacker.invalid/jwks.json")), + ("x5u", json!("https://attacker.invalid/cert.pem")), + ("jwk", json!({"kty": "OKP"})), + ("x5c", json!(["certificate-canary"])), + ("crit", json!(["exp"])), + ("b64", json!(false)), + ] { + let mut header = base_header.clone(); + header + .as_object_mut() + .expect("header is an object") + .insert(name.to_owned(), value); + let (serialized, public) = + sign_with_protected_header(PRIVATE_JWK, header, &evidence).await; + let keys = jwks_document(public, []).expect("JWKS builds"); + assert_eq!( + verify_flattened_jws(&serialized, &keys, &policy), + Err(VerificationError::ProtectedHeader), + "{name}" + ); + } + + for (header, expected) in [ + ( + json!({ + "alg": "EdDSA", "kid": "unknown-key", "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }), + VerificationError::Key, + ), + ( + json!({ + "alg": "HS256", "kid": "evidence-key-1", "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }), + VerificationError::ProtectedHeader, + ), + ] { + let (serialized, _) = sign_with_protected_header(PRIVATE_JWK, header, &evidence).await; + assert_eq!( + verify_flattened_jws(&serialized, &jwks, &policy), + Err(expected) + ); + } + } + + #[tokio::test] + async fn retired_public_key_verifies_only_while_published_and_payload_is_current() { + let evidence = fixture_evidence(); + let header = json!({ + "alg": "EdDSA", + "kid": "retired-evidence-key", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (serialized, retired_public) = + sign_with_protected_header(RETIRED_PRIVATE_JWK, header, &evidence).await; + + let active_private = PrivateJwk::parse(PRIVATE_JWK).expect("active key parses"); + let active_public = LocalJwkSigner::new(active_private) + .expect("active signer builds") + .public_jwk(); + let with_retired = + jwks_document(active_public.clone(), [retired_public]).expect("rotated JWKS builds"); + let without_retired = jwks_document(active_public, []).expect("active JWKS builds"); + let (_, _, policy) = signed_fixture().await; + + assert!(verify_flattened_jws(&serialized, &with_retired, &policy).is_ok()); + assert_eq!( + verify_flattened_jws(&serialized, &without_retired, &policy), + Err(VerificationError::Key) + ); + + let mut outside_window = policy; + outside_window.now = "2026-08-03T00:00:31Z".parse().expect("time parses"); + assert_eq!( + verify_flattened_jws(&serialized, &with_retired, &outside_window), + Err(VerificationError::Time) + ); + } + + #[tokio::test] + async fn active_plus_maximum_retired_keys_is_a_usable_trusted_set() { + let (jws, _, policy) = signed_fixture().await; + let private = PrivateJwk::parse(PRIVATE_JWK).expect("active key parses"); + let active = LocalJwkSigner::new(private) + .expect("active signer builds") + .public_jwk(); + let retired = (0..32).map(|index| { + let mut key = active.clone(); + key.kid = Some(format!("retired-evidence-key-{index:02}")); + key + }); + let maximum = jwks_document(active.clone(), retired).expect("maximum JWKS builds"); + assert_eq!(maximum.keys.len(), MAX_TRUSTED_KEYS); + assert!(verify_flattened_jws(&jws, &maximum, &policy).is_ok()); + + let mut excess = maximum; + let mut extra = active; + extra.kid = Some("retired-evidence-key-excess".to_owned()); + excess + .keys + .push(serde_json::to_value(extra).expect("extra key serializes")); + assert_eq!( + verify_flattened_jws(&jws, &excess, &policy), + Err(VerificationError::Key) + ); + } +} diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs new file mode 100644 index 000000000..61892091f --- /dev/null +++ b/crates/registry-evidence/tests/cli.rs @@ -0,0 +1,117 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +#[test] +fn actual_binary_checks_and_evaluates_an_immutable_project() { + let staged = tempfile::tempdir().expect("temporary deployment"); + let project = Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence", + ); + copy_tree(&project.join("bundle"), &staged.path().join("bundle")); + let secret_root = staged.path().join("secrets"); + fs::create_dir(&secret_root).expect("create private secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("set private secret-root mode"); + + let runtime = fs::read_to_string(project.join("runtime.yaml")).expect("read runtime template"); + let bundle_path = staged.path().join("bundle"); + let bundle_directory = bundle_path.to_str().expect("temporary path is UTF-8"); + let audit_path = staged.path().join("audit.jsonl"); + let runtime = runtime + .replacen("/etc/registry-evidence/bundle", bundle_directory, 1) + .replacen( + "/run/secrets/registry-evidence", + secret_root.to_str().expect("temporary path is UTF-8"), + 1, + ) + .replacen( + "/var/lib/registry-evidence/audit/evidence.jsonl", + audit_path.to_str().expect("temporary path is UTF-8"), + 1, + ); + let runtime_path = staged.path().join("runtime.yaml"); + fs::write(&runtime_path, runtime).expect("stage runtime"); + set_tree_mode(&bundle_path, 0o555, 0o444); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("set immutable runtime mode"); + + let check = invoke(&runtime_path, &["check"]); + let evaluate = invoke( + &runtime_path, + &["evaluate", "--fixture", "fixtures/adult-status-cases.yaml"], + ); + + set_tree_mode(&bundle_path, 0o755, 0o644); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o644)) + .expect("restore runtime mode"); + assert_success( + &check, + "Evidence deployment ", + " passed check (3 requirements)\n", + ); + assert_success( + &evaluate, + "Evidence fixture passed (", + " evaluated cases)\n", + ); +} + +fn invoke(runtime: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidence")) + .arg("--runtime") + .arg(runtime) + .args(arguments) + .env_remove("REGISTRY_EVIDENCE_RUNTIME") + .output() + .expect("evidence binary starts") +} + +fn assert_success(output: &Output, prefix: &str, suffix: &str) { + assert!(output.status.success(), "evidence command failed"); + assert!( + output.stderr.is_empty(), + "evidence command wrote diagnostics" + ); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.starts_with(prefix) && stdout.ends_with(suffix), + "evidence command output shape changed" + ); +} + +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).expect("create staged directory"); + for entry in fs::read_dir(source).expect("read source tree") { + let entry = entry.expect("source entry"); + let target = destination.join(entry.file_name()); + if entry.file_type().expect("source entry type").is_dir() { + copy_tree(&entry.path(), &target); + } else { + fs::copy(entry.path(), target).expect("copy staged artifact"); + } + } +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + let metadata = fs::symlink_metadata(path).expect("staged path metadata"); + if metadata.is_dir() { + for entry in fs::read_dir(path).expect("read staged tree") { + set_tree_mode( + &entry.expect("staged entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("set staged directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("set staged file mode"); + } +} diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs new file mode 100644 index 000000000..519dce550 --- /dev/null +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -0,0 +1,1243 @@ +//! Executable proof for the complete Evidence Version 1 reference deployments. + +#![cfg(unix)] + +use std::collections::BTreeSet; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::bundle::{Bundle, DeploymentInputs}; +use registry_evidence::config::{ConfigError, SelectorInput}; +use registry_evidence::kernel::{ + EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection, +}; +use registry_evidence::model::{ + LookupResult, PublicValue, ScalarOrEntityReference, SubjectBinding, +}; +use registry_evidence::problem::ProblemCode; +use registry_evidence::rhai_runtime::{QueryPair, RequestParts}; +use registry_evidence::runtime::source_failure_problem; +use registry_evidence::selector::{resolve_offline_fixture_authorization, ResolvedAuthorization}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{project_fixture_response, SourceError}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use serde::Deserialize; +use serde_json::{Map as JsonMap, Value}; +use tempfile::TempDir; +use zeroize::Zeroizing; + +const AUDIENCE: &str = "urn:registry-evidence:reference-project-fixtures"; +const BINDING_KEY: &[u8] = b"reference-project-binding-key-v1"; +const TEST_CA: &str = "-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureContract { + fixture: String, + synthetic_only: bool, + common: FixtureCommon, + cases: Vec, + #[serde(rename = "privacyExpectation")] + privacy_expectation: PrivacyExpectation, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureCommon { + observed_at: String, + #[serde(default)] + purpose: Option, + selectors: Value, + #[serde(default, rename = "verified_token_claims")] + verified_token_claims: Option, + #[serde(default, rename = "derivationSelectorInputs")] + derivation_selector_inputs: Option, + #[serde(rename = "expectedRequestParts")] + expected_request_parts: ExpectedRequestParts, + #[serde(rename = "expectedTransport")] + expected_transport: ExpectedTransport, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureCase { + id: String, + #[serde(default)] + purpose: Option, + #[serde(default)] + response: Option, + #[serde(default, rename = "sourceFailure")] + source_failure: Option, + #[serde(default, rename = "bundleMutation")] + bundle_mutation: Option, + #[serde(default, rename = "requestMutation")] + request_mutation: Option, + #[serde(default, rename = "derivationMutation")] + derivation_mutation: Option, + #[serde(default, rename = "derivationParameterMutation")] + derivation_parameter_mutation: Option>, + #[serde(default, rename = "selectorOverrides")] + selector_overrides: Option, + #[serde(default)] + observed_at: Option, + expected: Expected, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct Expected { + #[serde(default)] + lookup: Option, + #[serde(default)] + facts: Option, + #[serde(default)] + value: Option, + #[serde(default, rename = "entityReferenceCount")] + entity_reference_count: Option, + #[serde(default, rename = "rawReferencesDisclosed")] + raw_references_disclosed: Option, + #[serde(default)] + signed: Option, + #[serde(default, rename = "publicProblem")] + public_problem: Option, + #[serde(default)] + error: Option, + #[serde(default, rename = "derivationRuns")] + derivation_runs: Option, + #[serde(default)] + bundle: Option, + #[serde(default, rename = "outputGate")] + output_gate: Option, + #[serde(default, rename = "rejectedBefore")] + rejected_before: Option, + #[serde(default, rename = "sourceRequestCount")] + source_request_count: Option, + #[serde(default, rename = "expectedTransport")] + expected_transport: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedRequestParts { + query: Vec, + body: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedQueryPair { + name: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransport { + #[serde(default)] + path: Option, + #[serde(default)] + query: Option, + #[serde(default)] + body: Option, + #[serde(default, rename = "fixedHeaders")] + fixed_headers: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedHeader { + name: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PrivacyExpectation { + #[serde(rename = "evidenceContains")] + evidence_contains: Vec, + #[serde(rename = "evidenceExcludes")] + evidence_excludes: Vec, + #[serde(rename = "diagnosticsExclude")] + diagnostics_exclude: Vec, +} + +struct LoadedProject { + _temporary: TempDir, + runtime_path: PathBuf, + bundle: Arc, + kernel: OfflineKernel, +} + +struct FixtureExecution<'a> { + bundle: &'a Arc, + kernel: &'a OfflineKernel, + requirement: &'a registry_evidence::config::RequirementConfig, + fixture: &'a FixtureContract, + signer: &'a EvidenceSigner, +} + +impl Drop for LoadedProject { + fn drop(&mut self) { + set_tree_mode(self._temporary.path(), 0o755, 0o644); + } +} + +#[tokio::test] +async fn reference_deployment_projects_execute_the_closed_fixture_contract() { + for project_name in ["dhis2-adult-status", "opencrvs-family-evidence"] { + let project = load_project(project_name); + let signer = fixture_signer().await; + for requirement in &project.bundle.config.requirements { + let fixture_value = project + .bundle + .fixtures + .get(requirement.fixtures.as_str()) + .unwrap_or_else(|| panic!("{project_name}: fixture artifact missing")); + let fixture: FixtureContract = serde_json::from_value( + serde_json::to_value(fixture_value) + .unwrap_or_else(|_| panic!("{project_name}: fixture conversion failed")), + ) + .unwrap_or_else(|_| panic!("{project_name}: fixture vocabulary is not closed")); + validate_contract_shape(project_name, &fixture); + execute_fixture( + project_name, + &project.bundle, + &project.kernel, + requirement, + &fixture, + &signer, + ) + .await; + } + + assert!(project.runtime_path.exists()); + } +} + +fn load_project(project_name: &str) -> LoadedProject { + let project_root = projects_root().join(project_name); + let original_runtime = fs::read_to_string(project_root.join("runtime.yaml")) + .unwrap_or_else(|_| panic!("{project_name}: runtime is unreadable")); + registry_evidence::config::RuntimeConfig::parse_yaml(original_runtime.as_bytes()) + .unwrap_or_else(|_| panic!("{project_name}: checked-in runtime is invalid")); + + let temporary = tempfile::tempdir().expect("temporary project deployment"); + let bundle_root = temporary.path().join("bundle"); + fs::create_dir(&bundle_root).expect("temporary bundle root"); + copy_tree(&project_root.join("bundle"), &bundle_root); + + let secret_root = temporary.path().join("secrets"); + fs::create_dir(&secret_root).expect("temporary secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("temporary secret root is private"); + let audit_path = temporary.path().join("audit.jsonl"); + let ca_path = temporary.path().join("reference-ca.pem"); + fs::write(&ca_path, TEST_CA).expect("temporary CA writes"); + + let mut local_runtime = original_runtime; + replace_once( + &mut local_runtime, + "/etc/registry-evidence/bundle", + &bundle_root.display().to_string(), + ); + replace_once( + &mut local_runtime, + "/run/secrets/registry-evidence", + &secret_root.display().to_string(), + ); + replace_once( + &mut local_runtime, + "/var/lib/registry-evidence/audit/evidence.jsonl", + &audit_path.display().to_string(), + ); + if local_runtime.contains("/etc/registry-evidence/ca/government-internal.pem") { + replace_once( + &mut local_runtime, + "/etc/registry-evidence/ca/government-internal.pem", + &ca_path.display().to_string(), + ); + } + let runtime_path = temporary.path().join("runtime.yaml"); + fs::write(&runtime_path, local_runtime).expect("temporary runtime writes"); + set_tree_mode(&bundle_root, 0o555, 0o444); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("temporary runtime is immutable"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)) + .expect("temporary CA is immutable"); + + let deployment = DeploymentInputs::load(&runtime_path).unwrap_or_else(|error| { + panic!("{project_name}: production deployment loading failed: {error:?}") + }); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)) + .unwrap_or_else(|_| panic!("{project_name}: production kernel compilation failed")); + LoadedProject { + _temporary: temporary, + runtime_path, + bundle, + kernel, + } +} + +fn validate_contract_shape(project_name: &str, fixture: &FixtureContract) { + assert!( + fixture.synthetic_only, + "{project_name}: fixture is not synthetic" + ); + assert!( + fixture.fixture.starts_with("registry.evidence.reference.") + && fixture.fixture.ends_with("/v1"), + "{project_name}: fixture identifier is invalid" + ); + assert!( + !fixture.cases.is_empty() && fixture.cases.len() <= 256, + "{project_name}: fixture case count is invalid" + ); + let mut ids = BTreeSet::new(); + for case in &fixture.cases { + assert!( + !case.id.is_empty() && case.id.len() <= 128 && ids.insert(case.id.as_str()), + "{project_name}: fixture case identifier is invalid or duplicated" + ); + let primary_inputs = [ + case.response.is_some(), + case.source_failure.is_some(), + case.bundle_mutation.is_some(), + case.request_mutation.is_some(), + case.derivation_mutation.is_some(), + case.derivation_parameter_mutation.is_some(), + case.selector_overrides.is_some(), + ] + .into_iter() + .filter(|present| *present) + .count(); + assert_eq!( + primary_inputs, 1, + "{project_name}/{}: case input form is not closed", + case.id + ); + } +} + +async fn execute_fixture( + project_name: &str, + bundle: &Arc, + kernel: &OfflineKernel, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &FixtureContract, + signer: &EvidenceSigner, +) { + let execution = FixtureExecution { + bundle, + kernel, + requirement, + fixture, + signer, + }; + let mut verified_payloads = Vec::new(); + for case in &fixture.cases { + let label = format!("{project_name}/{}", case.id); + if let Some(mutation) = &case.bundle_mutation { + require_name(&label, mutation, "duplicate-disclosure-family"); + execute_bundle_mutation(&label, bundle, requirement, &case.expected); + continue; + } + if let Some(mutation) = &case.request_mutation { + execute_request_mutation(&label, bundle, requirement, fixture, case, mutation); + continue; + } + + let case_object = fixture_case_object(fixture, case); + let common_object = fixture_common_object(fixture); + let resolved = resolve_offline_fixture_authorization( + bundle, + requirement, + Some(&common_object), + &case_object, + AUDIENCE, + ) + .unwrap_or_else(|_| panic!("{label}: authorization or selector resolution failed")); + let source = bundle + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: requirement source is absent")); + let preparation_selectors = selector_projection(&resolved, &source.request.selector_inputs) + .unwrap_or_else(|| panic!("{label}: preparation selector projection failed")); + let prepared = kernel + .prepare(&requirement.id, &preparation_selectors) + .unwrap_or_else(|_| panic!("{label}: request preparation failed")); + if case.selector_overrides.is_none() { + assert_request_parts(&label, &prepared, &fixture.common.expected_request_parts); + } + assert_transport( + &label, + source, + &prepared, + &fixture.common.expected_transport, + ); + if let Some(expected) = &case.expected.expected_transport { + assert_transport(&label, source, &prepared, expected); + } + let derivation_selectors = + selector_projection(&resolved, &requirement.derivation.selector_inputs) + .unwrap_or_else(|| panic!("{label}: derivation selector projection failed")); + if case.selector_overrides.is_none() { + if let Some(expected) = &fixture.common.derivation_selector_inputs { + assert!( + same_json(expected, &derivation_selectors), + "{label}: minimized derivation selectors mismatch" + ); + } else { + assert_eq!( + derivation_selectors, + Value::Object(JsonMap::new()), + "{label}: derivation selectors were not minimized to empty" + ); + } + } + + if case.selector_overrides.is_some() { + assert_expected_count(&label, &case.expected, 1); + continue; + } + if let Some(failure) = &case.source_failure { + execute_source_failure(&label, failure, &case.expected); + continue; + } + let observed_at = observed_at(&label, fixture, case); + if let Some(mutation) = &case.derivation_mutation { + require_name(&label, mutation, "return-raw-reference"); + execute_derivation_mutation( + &label, + &execution, + &derivation_selectors, + observed_at, + &case.expected, + ); + continue; + } + if let Some(mutation) = &case.derivation_parameter_mutation { + execute_parameter_mutation( + &label, + &execution, + mutation, + &derivation_selectors, + observed_at, + &case.expected, + ); + continue; + } + + let response = case + .response + .as_ref() + .unwrap_or_else(|| panic!("{label}: response is absent")); + let projected = project_fixture_response(source, response) + .unwrap_or_else(|_| panic!("{label}: production source projection failed")); + let payload = execute_response( + &label, + &execution, + &resolved, + &derivation_selectors, + observed_at, + &projected, + &case.expected, + ) + .await; + if let Some(payload) = payload { + verified_payloads.push(payload); + } + } + assert_privacy( + project_name, + &fixture.privacy_expectation, + &verified_payloads, + ); +} + +async fn execute_response( + label: &str, + execution: &FixtureExecution<'_>, + resolved: &ResolvedAuthorization, + derivation_selectors: &Value, + observed_at: DateTime, + response: &Value, + expected: &Expected, +) -> Option { + let bundle = execution.bundle; + let kernel = execution.kernel; + let requirement = execution.requirement; + let signer = execution.signer; + assert_expected_count(label, expected, 1); + let lookup = match kernel.extract(&requirement.id, response) { + Ok(lookup) => lookup, + Err(error) => { + assert_kernel_error(label, expected, error, false); + return None; + } + }; + match lookup { + LookupResult::NoMatch => { + assert_lookup(label, expected, "no_match"); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); + assert_public_problem(label, expected, "evidence_not_available"); + None + } + LookupResult::Ambiguous => { + assert_lookup(label, expected, "ambiguous"); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); + assert_public_problem(label, expected, "evidence_not_available"); + None + } + LookupResult::Match(facts) => { + assert_lookup(label, expected, "match"); + if let Some(expected_facts) = &expected.facts { + let actual = serde_json::to_value(&facts) + .unwrap_or_else(|_| panic!("{label}: facts are not representable")); + assert!( + same_json(expected_facts, &actual), + "{label}: exact facts mismatch" + ); + } + let values = match kernel.derive_and_validate_with_selectors( + &requirement.id, + &facts, + derivation_selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) { + Ok(values) => values, + Err(error) => { + assert_derivation(label, expected, true); + assert_kernel_error(label, expected, error, true); + return None; + } + }; + assert_derivation(label, expected, true); + assert_values(label, expected, values.as_slice()); + + let issued_at = observed_at + chrono::Duration::seconds(1); + let subjects = resolved + .subjects + .iter() + .map(|subject| SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + BINDING_KEY, + 1, + &bundle.config.service.trust_domain, + AUDIENCE, + &resolved.purpose, + ) + .unwrap_or_else(|_| panic!("{label}: subject binding failed")), + }) + .collect(); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let evidence = kernel + .construct_evidence( + &requirement.id, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + purpose: &resolved.purpose, + audience: AUDIENCE, + issued_at, + observed_at, + subjects, + }, + ) + .unwrap_or_else(|_| panic!("{label}: evidence construction failed")); + let signed = signer + .sign_json(&evidence) + .await + .unwrap_or_else(|_| panic!("{label}: evidence signing failed")); + let jwks = jwks_document(signer.public_jwk(), []) + .unwrap_or_else(|_| panic!("{label}: fixture JWKS construction failed")); + let verified = verify_flattened_jws( + &serde_json::to_vec(&signed) + .unwrap_or_else(|_| panic!("{label}: signed evidence encoding failed")), + &jwks, + &EvidenceVerificationPolicy { + issued_by: bundle.config.issuer.id.clone(), + provided_by: bundle.config.service.provider_id.clone(), + requirement: requirement.id.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: resolved.purpose.clone(), + audience: AUDIENCE.to_owned(), + configuration_revision: bundle.revision().to_owned(), + now: issued_at, + clock_skew: Duration::from_secs(0), + }, + ) + .unwrap_or_else(|_| panic!("{label}: signed evidence verification failed")); + if expected.signed == Some(false) { + panic!("{label}: successful case prohibited signing"); + } + Some( + serde_json::to_value(verified) + .unwrap_or_else(|_| panic!("{label}: verified payload encoding failed")), + ) + } + } +} + +fn execute_bundle_mutation( + label: &str, + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + expected: &Expected, +) { + assert_eq!( + expected.bundle.as_deref(), + Some("rejected"), + "{label}: bundle rejection expectation is absent" + ); + let mut mutated = bundle.config.clone(); + let mut companion = requirement.clone(); + companion.id.push_str(":fixture-companion"); + companion.evidence_type.push_str(":fixture-companion"); + for concept in &mut companion.concepts { + concept.id.push_str(":fixture-companion"); + } + mutated.requirements.push(companion); + assert_eq!( + mutated.validate(), + Err(ConfigError::Invalid( + "enabled requirements share a disclosure family" + )), + "{label}: unsafe disclosure-family mutation was not rejected" + ); + assert_expected_count(label, expected, 0); + assert_not_signed(label, expected); +} + +fn execute_request_mutation( + label: &str, + bundle: &Bundle, + requirement: ®istry_evidence::config::RequirementConfig, + fixture: &FixtureContract, + case: &FixtureCase, + mutation: &str, +) { + assert_eq!( + case.expected.rejected_before.as_deref(), + Some("source"), + "{label}: request mutation boundary is not exact" + ); + let selectors = fixture + .common + .selectors + .as_object() + .unwrap_or_else(|| panic!("{label}: common selectors are invalid")); + let mut subjects = selectors + .iter() + .map(|(role, selector)| { + let mut selector = selector + .as_object() + .cloned() + .unwrap_or_else(|| panic!("{label}: common selector is invalid")); + selector.insert("role".to_owned(), Value::String(role.clone())); + Value::Object(selector) + }) + .collect::>(); + match mutation { + "swap-subject-roles" => { + assert_eq!(subjects.len(), 2, "{label}: swap mutation needs two roles"); + let first = subjects[0]["role"].clone(); + subjects[0]["role"] = subjects[1]["role"].clone(); + subjects[1]["role"] = first; + } + "supply-grant-derived-candidate" => {} + _ => panic!("{label}: request mutation name is not closed"), + } + let mut case_object = fixture_case_object(fixture, case); + case_object.insert("subjects".to_owned(), Value::Array(subjects)); + let common_object = fixture_common_object(fixture); + assert!( + resolve_offline_fixture_authorization( + bundle, + requirement, + Some(&common_object), + &case_object, + AUDIENCE, + ) + .is_err(), + "{label}: request mutation crossed the authorization boundary" + ); + assert_expected_count(label, &case.expected, 0); + assert_not_signed(label, &case.expected); +} + +fn execute_source_failure(label: &str, failure: &str, expected: &Expected) { + let source_error = match failure { + "timeout" => SourceError::Timeout, + "connection-refused" => SourceError::Transport, + "invalid-media-type" => SourceError::WrongMediaType, + "oversized" => SourceError::ResponseTooLarge, + "malformed-json" => SourceError::InvalidJson, + _ => panic!("{label}: source failure name is not closed"), + }; + assert_eq!( + source_failure_problem(&source_error), + ProblemCode::DependencyUnavailable, + "{label}: source failure did not use the production safe mapping" + ); + assert_eq!( + expected.public_problem.as_deref(), + Some("dependency_unavailable"), + "{label}: source failure public problem is not exact" + ); + assert_expected_count(label, expected, 1); + assert_derivation(label, expected, false); + assert_not_signed(label, expected); +} + +fn execute_derivation_mutation( + label: &str, + execution: &FixtureExecution<'_>, + selectors: &Value, + observed_at: DateTime, + expected: &Expected, +) { + let bundle = execution.bundle; + let requirement = execution.requirement; + let fixture = execution.fixture; + assert_eq!( + expected.output_gate.as_deref(), + Some("rejected"), + "{label}: output-gate expectation is absent" + ); + let mut disposable = bundle.as_ref().clone(); + disposable + .scripts + .get_mut(requirement.derivation.script.as_str()) + .unwrap_or_else(|| panic!("{label}: derivation artifact is absent")) + .source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{concept_id: \"{}\", value: \"PERSON-SYNTHETIC-A\"}}] }}", + requirement.concepts[0].id + ); + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .unwrap_or_else(|_| panic!("{label}: disposable derivation did not compile")); + let response = positive_response(label, fixture); + let source = disposable + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: source is absent")); + let projected = project_fixture_response(source, response) + .unwrap_or_else(|_| panic!("{label}: positive source projection failed")); + assert_eq!( + kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ), + Err(KernelError::Output), + "{label}: raw-reference derivation crossed the output gate" + ); + assert_expected_count(label, expected, 1); + assert_not_signed(label, expected); +} + +fn execute_parameter_mutation( + label: &str, + execution: &FixtureExecution<'_>, + mutation: &JsonMap, + selectors: &Value, + observed_at: DateTime, + expected: &Expected, +) { + let bundle = execution.bundle; + let requirement = execution.requirement; + let fixture = execution.fixture; + let mut disposable = bundle.as_ref().clone(); + let mut config = serde_json::to_value(&disposable.config) + .unwrap_or_else(|_| panic!("{label}: config is not representable")); + let requirements = config["requirements"] + .as_array_mut() + .unwrap_or_else(|| panic!("{label}: requirement list is unavailable")); + let target = requirements + .iter_mut() + .find(|candidate| candidate["id"].as_str() == Some(requirement.id.as_str())) + .unwrap_or_else(|| panic!("{label}: disposable requirement is absent")); + let parameters = target["derivation"]["parameters"] + .as_object_mut() + .unwrap_or_else(|| panic!("{label}: derivation parameters are unavailable")); + for (name, value) in mutation { + assert!( + parameters.contains_key(name), + "{label}: parameter mutation introduced an unknown parameter" + ); + parameters.insert(name.clone(), value.clone()); + } + disposable.config = serde_json::from_value(config) + .unwrap_or_else(|_| panic!("{label}: parameter mutation is not typed")); + disposable + .config + .validate() + .unwrap_or_else(|_| panic!("{label}: parameter mutation broke startup validation")); + let disposable = Arc::new(disposable); + let kernel = OfflineKernel::compile(Arc::clone(&disposable)) + .unwrap_or_else(|_| panic!("{label}: disposable kernel compilation failed")); + let source = disposable + .config + .sources + .get(&requirement.source) + .unwrap_or_else(|| panic!("{label}: source is absent")); + let projected = project_fixture_response(source, positive_response(label, fixture)) + .unwrap_or_else(|_| panic!("{label}: positive source projection failed")); + let outcome = kernel.evaluate_with_selectors( + &requirement.id, + &projected, + selectors, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ); + assert_kernel_error_result(label, expected, outcome, true); + assert_expected_count(label, expected, 1); +} + +fn positive_response<'a>(label: &str, fixture: &'a FixtureContract) -> &'a Value { + fixture + .cases + .iter() + .find(|case| case.id == "positive") + .and_then(|case| case.response.as_ref()) + .unwrap_or_else(|| panic!("{label}: positive companion response is absent")) +} + +fn assert_kernel_error_result( + label: &str, + expected: &Expected, + outcome: Result, + derivation_ran: bool, +) { + match outcome { + Err(error) => assert_kernel_error(label, expected, error, derivation_ran), + Ok(_) => panic!("{label}: expected failure returned a kernel outcome"), + } +} + +fn assert_kernel_error(label: &str, expected: &Expected, error: KernelError, derivation_ran: bool) { + let (expected_signal, expected_problem) = match error { + KernelError::Preparation => ("adapter_input_error", "service_unavailable"), + KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), + KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), + _ => ("service_unavailable", "service_unavailable"), + }; + if let Some(signal) = expected.error.as_deref() { + assert_eq!( + signal, expected_signal, + "{label}: internal error class mismatch" + ); + } + if let Some(problem) = expected.public_problem.as_deref() { + assert_eq!( + problem, expected_problem, + "{label}: public problem mismatch" + ); + } + if expected.error.is_none() && expected.public_problem.is_none() { + panic!("{label}: failing case has no exact error expectation"); + } + assert_not_signed(label, expected); +} + +fn assert_values( + label: &str, + expected: &Expected, + values: &[registry_evidence::model::SupportedValue], +) { + if let Some(expected_value) = &expected.value { + assert_eq!(values.len(), 1, "{label}: scalar value count mismatch"); + let actual = serde_json::to_value(&values[0].value) + .unwrap_or_else(|_| panic!("{label}: scalar value encoding failed")); + assert!( + same_json(expected_value, &actual), + "{label}: scalar value mismatch" + ); + } + if let Some(expected_count) = expected.entity_reference_count { + assert_eq!(values.len(), 1, "{label}: reference concept count mismatch"); + let actual_count = match &values[0].value { + PublicValue::List(items) => items + .iter() + .filter(|item| matches!(item, ScalarOrEntityReference::EntityReference(_))) + .count(), + _ => 0, + }; + assert_eq!( + actual_count, expected_count, + "{label}: entity-reference count mismatch" + ); + } + if let Some(disclosed) = expected.raw_references_disclosed { + let encoded = serde_json::to_string(values) + .unwrap_or_else(|_| panic!("{label}: supported value encoding failed")); + let actual = ["PERSON-SYNTHETIC-A", "PERSON-SYNTHETIC-B"] + .iter() + .any(|reference| encoded.contains(reference)); + assert_eq!( + actual, disclosed, + "{label}: raw-reference disclosure expectation mismatch" + ); + } +} + +fn assert_request_parts(label: &str, actual: &RequestParts, expected: &ExpectedRequestParts) { + let expected_query = expected + .query + .iter() + .map(|pair| QueryPair { + name: pair.name.clone(), + value: pair.value.clone(), + }) + .collect::>(); + assert_eq!( + actual.query, expected_query, + "{label}: request query mismatch" + ); + assert!( + same_optional_json(expected.body.as_ref(), actual.body.as_ref()), + "{label}: request body mismatch" + ); +} + +fn assert_transport( + label: &str, + source: ®istry_evidence::config::SourceConfig, + parts: &RequestParts, + expected: &ExpectedTransport, +) { + if let Some(path) = &expected.path { + assert_eq!( + source.request.path.as_deref(), + Some(path.as_str()), + "{label}: fixed transport path mismatch" + ); + } + if let Some(headers) = &expected.fixed_headers { + let actual = source + .request + .fixed_headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(); + let expected = headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect::>(); + assert_eq!( + actual, expected, + "{label}: fixed transport headers mismatch" + ); + } + if let Some(query) = &expected.query { + assert_eq!( + encode_query(&parts.query), + *query, + "{label}: encoded transport query mismatch" + ); + } + if expected.body.is_some() { + assert!( + same_optional_json(expected.body.as_ref(), parts.body.as_ref()), + "{label}: normalized transport body mismatch" + ); + } +} + +fn encode_query(query: &[QueryPair]) -> String { + let mut encoded = String::new(); + for pair in query { + if !encoded.is_empty() { + encoded.push('&'); + } + encode_query_component(&pair.name, &mut encoded); + encoded.push('='); + encode_query_component(&pair.value, &mut encoded); + } + encoded +} + +fn encode_query_component(value: &str, output: &mut String) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(char::from(byte)); + } else { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } +} + +fn selector_projection( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Option { + let mut output = JsonMap::new(); + for input in inputs { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role)?; + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile)?; + let mut values = JsonMap::new(); + for name in &alternative.fields { + let field = subject.fields.iter().find(|field| &field.name == name)?; + values.insert(name.clone(), field.value.as_json()); + } + output.insert( + input.role.clone(), + serde_json::json!({"profile": alternative.profile, "values": values}), + ); + } + Some(Value::Object(output)) +} + +fn fixture_common_object(fixture: &FixtureContract) -> JsonMap { + let mut common = JsonMap::new(); + common.insert("selectors".to_owned(), fixture.common.selectors.clone()); + if let Some(purpose) = &fixture.common.purpose { + common.insert("purpose".to_owned(), Value::String(purpose.clone())); + } + if let Some(claims) = &fixture.common.verified_token_claims { + common.insert("verified_token_claims".to_owned(), claims.clone()); + } + common +} + +fn fixture_case_object(fixture: &FixtureContract, case: &FixtureCase) -> JsonMap { + let mut output = JsonMap::new(); + if let Some(overrides) = &case.selector_overrides { + output.insert("selectorOverrides".to_owned(), overrides.clone()); + } + if let Some(purpose) = &case.purpose { + output.insert("purpose".to_owned(), Value::String(purpose.clone())); + } + if let Some(claims) = &fixture.common.verified_token_claims { + output.insert("verified_token_claims".to_owned(), claims.clone()); + } + output +} + +fn observed_at(label: &str, fixture: &FixtureContract, case: &FixtureCase) -> DateTime { + DateTime::parse_from_rfc3339( + case.observed_at + .as_deref() + .unwrap_or(&fixture.common.observed_at), + ) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| panic!("{label}: observation instant is invalid")) +} + +fn assert_lookup(label: &str, expected: &Expected, actual: &str) { + if let Some(expected) = expected.lookup.as_deref() { + assert_eq!(expected, actual, "{label}: lookup outcome mismatch"); + } +} + +fn assert_derivation(label: &str, expected: &Expected, actual: bool) { + if let Some(expected) = expected.derivation_runs { + assert_eq!(expected, actual, "{label}: derivation execution mismatch"); + } +} + +fn assert_not_signed(label: &str, expected: &Expected) { + if expected.signed == Some(true) { + panic!("{label}: unsuccessful case required a signature"); + } +} + +fn assert_public_problem(label: &str, expected: &Expected, actual: &str) { + if let Some(expected) = expected.public_problem.as_deref() { + assert_eq!(expected, actual, "{label}: public problem mismatch"); + } +} + +fn assert_expected_count(label: &str, expected: &Expected, actual: usize) { + if let Some(expected) = expected.source_request_count { + assert_eq!(expected, actual, "{label}: source request count mismatch"); + } + assert!( + actual <= 1, + "{label}: fixture attempted multiple source requests" + ); +} + +fn assert_privacy(project_name: &str, expectation: &PrivacyExpectation, payloads: &[Value]) { + let projection = Value::Array(payloads.to_vec()); + let mut strings = Vec::new(); + collect_strings(&projection, &mut strings); + for required in &expectation.evidence_contains { + assert!( + strings.contains(&required.as_str()), + "{project_name}: required Evidence disclosure is absent" + ); + } + for prohibited in &expectation.evidence_excludes { + assert!( + !strings.contains(&prohibited.as_str()), + "{project_name}: prohibited Evidence disclosure is present" + ); + } + const DIAGNOSTIC_SURFACES: &[&str] = &[ + "fixture vocabulary is not closed", + "authorization or selector resolution failed", + "request preparation failed", + "exact facts mismatch", + "scalar value mismatch", + "signed evidence verification failed", + ]; + for prohibited in &expectation.diagnostics_exclude { + assert!( + DIAGNOSTIC_SURFACES + .iter() + .all(|surface| !surface.contains(prohibited)), + "{project_name}: protected value appears in a diagnostic template" + ); + } +} + +fn collect_strings<'a>(value: &'a Value, output: &mut Vec<&'a str>) { + match value { + Value::String(value) => output.push(value), + Value::Array(values) => { + for value in values { + collect_strings(value, output); + } + } + Value::Object(values) => { + for (key, value) in values { + output.push(key); + collect_strings(value, output); + } + } + _ => {} + } +} + +fn same_json(left: &Value, right: &Value) -> bool { + serde_json::to_vec(left).ok() == serde_json::to_vec(right).ok() +} + +fn same_optional_json(left: Option<&Value>, right: Option<&Value>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => same_json(left, right), + _ => false, + } +} + +fn require_name(label: &str, actual: &str, expected: &str) { + assert_eq!(actual, expected, "{label}: mutation name is not closed"); +} + +async fn fixture_signer() -> EvidenceSigner { + const KEY_ID: &str = "evidence-signing-2026-01"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .expect("fixture signer initializes") +} + +fn projects_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/reference/request-adapter/deployment-projects") + .canonicalize() + .expect("reference deployment projects exist") +} + +fn replace_once(text: &mut String, from: &str, to: &str) { + assert_eq!( + text.matches(from).count(), + 1, + "runtime fixture binding drifted" + ); + *text = text.replacen(from, to, 1); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("reference bundle is readable") { + let entry = entry.expect("reference bundle entry is readable"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("entry type is readable").is_dir() { + fs::create_dir(&destination).expect("reference bundle directory copies"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("reference bundle file copies"); + } + } +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + if !path.exists() { + return; + } + if path.is_dir() { + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("directory mode updates"); + for entry in fs::read_dir(path).expect("mode target is readable") { + set_tree_mode( + &entry.expect("mode target entry is readable").path(), + directory_mode, + file_mode, + ); + } + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("file mode updates"); + } +} diff --git a/crates/registry-evidence/tests/live_sources.rs b/crates/registry-evidence/tests/live_sources.rs new file mode 100644 index 000000000..bb7f070b7 --- /dev/null +++ b/crates/registry-evidence/tests/live_sources.rs @@ -0,0 +1,738 @@ +//! Opt-in, ignored, read-only compatibility checks for approved public demos. +//! +//! Credentials and selectors are loaded only from exact owner-only files outside +//! the repository. Errors and status output are deliberately value-free. + +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use registry_platform_crypto::parse_json_strict; +use reqwest::{header::CONTENT_TYPE, redirect::Policy, Client, Response, StatusCode, Url}; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +const MAX_CREDENTIAL_FILE_BYTES: u64 = 16 * 1024; +const MAX_TOKEN_RESPONSE_BYTES: usize = 8 * 1024; +const MAX_SOURCE_RESPONSE_BYTES: usize = 256 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LiveError { + Skipped, + CredentialFile, + Configuration, + Authentication, + Unavailable, + SchemaDrift, + ExcessDisclosure, +} + +#[tokio::test] +#[ignore = "opt-in read-only public-demo check; requires EVIDENCE_DHIS2_LIVE_ENV_FILE"] +async fn dhis2() { + run_live("dhis2", run_dhis2).await; +} + +#[tokio::test] +#[ignore = "opt-in read-only public-demo check; requires EVIDENCE_OPENCRVS_LIVE_ENV_FILE"] +async fn opencrvs() { + run_live("opencrvs", run_opencrvs).await; +} + +async fn run_live(profile: &'static str, operation: F) +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let started = Instant::now(); + let result = operation().await; + let (phase, outcome) = match result { + Ok(()) => ("complete", "pass"), + Err(LiveError::Skipped) => ("configuration", "skip"), + Err(LiveError::CredentialFile | LiveError::Configuration) => { + ("configuration", "inconclusive") + } + Err(LiveError::Authentication) => ("authentication", "inconclusive"), + Err(LiveError::Unavailable) => ("lookup", "inconclusive"), + Err(LiveError::SchemaDrift) => ("lookup-schema", "fail"), + Err(LiveError::ExcessDisclosure) => ("lookup-minimization", "fail"), + }; + eprintln!( + "live-source profile={profile} phase={phase} outcome={outcome} duration_ms={}", + started.elapsed().as_millis() + ); + match result { + Ok(()) | Err(LiveError::Skipped) => {} + Err(LiveError::SchemaDrift | LiveError::ExcessDisclosure) => { + panic!("authenticated live source contract requires investigation") + } + Err(_) => {} + } +} + +async fn run_dhis2() -> Result<(), LiveError> { + let path = credential_path("EVIDENCE_DHIS2_LIVE_ENV_FILE", None)?; + let config = read_exact_credentials( + &path, + &[ + "DHIS2_BASE_URL", + "DHIS2_USERNAME", + "DHIS2_PASSWORD", + "DHIS2_TEST_PROGRAM_ID", + "DHIS2_TEST_ORG_UNIT_ID", + "DHIS2_TEST_TRACKED_ENTITY_ID", + ], + )?; + let base = safe_https_base(required(&config, "DHIS2_BASE_URL")?)?; + let client = live_client()?; + + let metadata = base + .join("api/system/info?fields=version") + .map_err(|_| LiveError::Configuration)?; + let response = client + .get(metadata) + .basic_auth( + required(&config, "DHIS2_USERNAME")?, + Some(required(&config, "DHIS2_PASSWORD")?), + ) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), true)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let metadata = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let metadata = parse_json_strict(&metadata).map_err(|_| LiveError::SchemaDrift)?; + if metadata.get("version").and_then(Value::as_str).is_none() { + return Err(LiveError::SchemaDrift); + } + + let lookup = base + .join("api/tracker/trackedEntities") + .map_err(|_| LiveError::Configuration)?; + let lookup_query = dhis2_lookup_query(&config)?; + let response = client + .get(lookup) + .basic_auth( + required(&config, "DHIS2_USERNAME")?, + Some(required(&config, "DHIS2_PASSWORD")?), + ) + .query(&lookup_query) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), false)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let body = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let body = parse_json_strict(&body).map_err(|_| LiveError::SchemaDrift)?; + validate_dhis2_lookup(&body, required(&config, "DHIS2_TEST_TRACKED_ENTITY_ID")?) +} + +async fn run_opencrvs() -> Result<(), LiveError> { + let path = credential_path("EVIDENCE_OPENCRVS_LIVE_ENV_FILE", None)?; + let config = read_exact_credentials( + &path, + &[ + "OPENCRVS_CLIENT_ID", + "OPENCRVS_SECRET", + "OPENCRVS_URL", + "OPENCRVS_TEST_TRACKING_ID", + ], + )?; + let (token_url, search_url) = opencrvs_urls(required(&config, "OPENCRVS_URL")?)?; + let client = live_client()?; + + let response = client + .post(token_url) + .query(&[ + ("client_id", required(&config, "OPENCRVS_CLIENT_ID")?), + ("client_secret", required(&config, "OPENCRVS_SECRET")?), + ("grant_type", "client_credentials"), + ]) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), true)?; + require_json_media(&response, LiveError::Authentication)?; + let token_body = bounded_body(response, MAX_TOKEN_RESPONSE_BYTES).await?; + let token_body = parse_json_strict(&token_body).map_err(|_| LiveError::Authentication)?; + let mut token_object = token_body + .as_object() + .cloned() + .ok_or(LiveError::Authentication)?; + if !token_object.keys().all(|key| { + matches!( + key.as_str(), + "access_token" | "token_type" | "expires_in" | "scope" + ) + }) { + return Err(LiveError::Authentication); + } + if token_object.remove("token_type").is_some_and(|value| { + !value + .as_str() + .is_some_and(|token_type| token_type.eq_ignore_ascii_case("bearer")) + }) { + return Err(LiveError::Authentication); + } + let token = token_object + .remove("access_token") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .filter(|value| !value.is_empty() && value.len() <= 16 * 1024) + .map(Zeroizing::new) + .ok_or(LiveError::Authentication)?; + + let tracking_id = required(&config, "OPENCRVS_TEST_TRACKING_ID")?; + let search_body = opencrvs_tracking_id_search(tracking_id); + let response = client + .post(search_url) + .bearer_auth(token.as_str()) + .json(&search_body) + .send() + .await + .map_err(|_| LiveError::Unavailable)?; + require_success(response.status(), false)?; + require_json_media(&response, LiveError::SchemaDrift)?; + let body = bounded_body(response, MAX_SOURCE_RESPONSE_BYTES).await?; + let body = parse_json_strict(&body).map_err(|_| LiveError::SchemaDrift)?; + validate_opencrvs_search(&body, tracking_id) +} + +fn dhis2_lookup_query( + config: &BTreeMap>, +) -> Result, LiveError> { + Ok(vec![ + ( + "program", + required(config, "DHIS2_TEST_PROGRAM_ID")?.to_owned(), + ), + ( + "orgUnits", + required(config, "DHIS2_TEST_ORG_UNIT_ID")?.to_owned(), + ), + ( + "trackedEntities", + required(config, "DHIS2_TEST_TRACKED_ENTITY_ID")?.to_owned(), + ), + ( + "fields", + "trackedEntity,attributes[attribute,value]".to_owned(), + ), + ("pageSize", "2".to_owned()), + ("page", "1".to_owned()), + ("totalPages", "true".to_owned()), + ]) +} + +fn validate_dhis2_lookup(body: &Value, expected_tracked_entity: &str) -> Result<(), LiveError> { + let object = body.as_object().ok_or(LiveError::SchemaDrift)?; + if !object + .keys() + .all(|key| matches!(key.as_str(), "pager" | "trackedEntities")) + { + return Err(LiveError::ExcessDisclosure); + } + let pager = object + .get("pager") + .and_then(Value::as_object) + .ok_or(LiveError::SchemaDrift)?; + if pager.get("page").and_then(Value::as_u64) != Some(1) + || pager.get("pageSize").and_then(Value::as_u64) != Some(2) + || !pager + .keys() + .all(|key| matches!(key.as_str(), "page" | "pageSize" | "total" | "pageCount")) + { + return Err(LiveError::SchemaDrift); + } + let records = object + .get("trackedEntities") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + if records.len() > 2 { + return Err(LiveError::ExcessDisclosure); + } + if records.len() != 1 { + return Err(LiveError::Unavailable); + } + let record = records[0].as_object().ok_or(LiveError::SchemaDrift)?; + if !record.contains_key("trackedEntity") || !record.contains_key("attributes") { + return Err(LiveError::SchemaDrift); + } + if !record + .keys() + .all(|key| matches!(key.as_str(), "trackedEntity" | "attributes")) + { + return Err(LiveError::ExcessDisclosure); + } + if record.get("trackedEntity").and_then(Value::as_str) != Some(expected_tracked_entity) { + return Err(LiveError::SchemaDrift); + } + let attributes = record + .get("attributes") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + if attributes.is_empty() { + return Err(LiveError::SchemaDrift); + } + for attribute in attributes { + let attribute = attribute.as_object().ok_or(LiveError::SchemaDrift)?; + if attribute.get("attribute").and_then(Value::as_str).is_none() + || attribute.get("value").and_then(Value::as_str).is_none() + { + return Err(LiveError::SchemaDrift); + } + if !attribute + .keys() + .all(|key| matches!(key.as_str(), "attribute" | "value")) + { + return Err(LiveError::ExcessDisclosure); + } + } + Ok(()) +} + +fn opencrvs_tracking_id_search(tracking_id: &str) -> Value { + json!({ + "query": { + "type": "and", + "clauses": [{ + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": { + "type": "exact", + "term": tracking_id + } + }] + }, + "limit": 2, + "offset": 0 + }) +} + +fn validate_opencrvs_search(body: &Value, expected_tracking_id: &str) -> Result<(), LiveError> { + let object = body.as_object().ok_or(LiveError::SchemaDrift)?; + let results = object + .get("results") + .and_then(Value::as_array) + .ok_or(LiveError::SchemaDrift)?; + let total = object + .get("total") + .and_then(Value::as_u64) + .ok_or(LiveError::SchemaDrift)?; + if results.len() > 2 { + return Err(LiveError::ExcessDisclosure); + } + if results.len() as u64 > total { + return Err(LiveError::SchemaDrift); + } + if total != 1 || results.len() != 1 { + return Err(LiveError::Unavailable); + } + let returned_tracking_id = results[0] + .get("trackingId") + .and_then(Value::as_str) + .ok_or(LiveError::SchemaDrift)?; + if returned_tracking_id != expected_tracking_id { + return Err(LiveError::SchemaDrift); + } + Ok(()) +} + +fn credential_path(variable: &str, default: Option<&str>) -> Result { + let value = env::var_os(variable) + .or_else(|| default.map(Into::into)) + .ok_or(LiveError::Skipped)?; + let path = PathBuf::from(value); + if !path.exists() && default.is_some() && env::var_os(variable).is_none() { + return Err(LiveError::Skipped); + } + if !path.is_absolute() { + return Err(LiveError::CredentialFile); + } + Ok(path) +} + +fn read_exact_credentials( + path: &Path, + required_keys: &[&str], +) -> Result>, LiveError> { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .map_err(|_| LiveError::CredentialFile)?; + let canonical = path.canonicalize().map_err(|_| LiveError::CredentialFile)?; + if canonical.starts_with(&repository) { + return Err(LiveError::CredentialFile); + } + let file = open_owner_only(path)?; + let metadata = file.metadata().map_err(|_| LiveError::CredentialFile)?; + if metadata.len() == 0 || metadata.len() > MAX_CREDENTIAL_FILE_BYTES { + return Err(LiveError::CredentialFile); + } + let mut text = Zeroizing::new(String::new()); + file.take(MAX_CREDENTIAL_FILE_BYTES + 1) + .read_to_string(&mut text) + .map_err(|_| LiveError::CredentialFile)?; + if text.len() as u64 != metadata.len() || text.contains('\0') { + return Err(LiveError::CredentialFile); + } + + let allow = required_keys.iter().copied().collect::>(); + let mut values = BTreeMap::new(); + for line in text.lines() { + if line.is_empty() || line.trim() != line { + return Err(LiveError::CredentialFile); + } + let (key, value) = line.split_once('=').ok_or(LiveError::CredentialFile)?; + if !allow.contains(key) + || value.is_empty() + || value.len() > 16 * 1024 + || values + .insert(key.to_owned(), Zeroizing::new(value.to_owned())) + .is_some() + { + return Err(LiveError::CredentialFile); + } + } + if values.len() != required_keys.len() + || required_keys.iter().any(|key| !values.contains_key(*key)) + { + return Err(LiveError::CredentialFile); + } + Ok(values) +} + +#[cfg(unix)] +fn open_owner_only(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt as _; + + let metadata = fs::symlink_metadata(path).map_err(|_| LiveError::CredentialFile)?; + if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.mode() & 0o777 != 0o600 + { + return Err(LiveError::CredentialFile); + } + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) + .map_err(|_| LiveError::CredentialFile)?; + let file = File::from(fd); + let opened = file.metadata().map_err(|_| LiveError::CredentialFile)?; + if opened.dev() != metadata.dev() + || opened.ino() != metadata.ino() + || opened.uid() != rustix::process::getuid().as_raw() + || opened.uid() != metadata.uid() + || opened.mode() & 0o777 != 0o600 + || opened.nlink() != 1 + { + return Err(LiveError::CredentialFile); + } + Ok(file) +} + +#[cfg(not(unix))] +fn open_owner_only(_path: &Path) -> Result { + Err(LiveError::CredentialFile) +} + +fn required<'a>( + config: &'a BTreeMap>, + key: &str, +) -> Result<&'a str, LiveError> { + config + .get(key) + .map(|value| value.as_str()) + .ok_or(LiveError::Configuration) +} + +fn live_client() -> Result { + Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(REQUEST_TIMEOUT) + .user_agent("registry-evidence-live-source-check/1") + .build() + .map_err(|_| LiveError::Configuration) +} + +fn require_json_media(response: &Response, failure: LiveError) -> Result<(), LiveError> { + let mut values = response.headers().get_all(CONTENT_TYPE).iter(); + let value = values.next().ok_or(failure)?; + if values.next().is_some() { + return Err(failure); + } + let value = value.to_str().map_err(|_| failure)?; + let media_type = value.split(';').next().unwrap_or_default().trim(); + if media_type.eq_ignore_ascii_case("application/json") { + Ok(()) + } else { + Err(failure) + } +} + +fn safe_https_base(value: &str) -> Result { + let mut url = Url::parse(value).map_err(|_| LiveError::Configuration)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(LiveError::Configuration); + } + url.set_query(None); + url.set_fragment(None); + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url) +} + +fn opencrvs_urls(value: &str) -> Result<(Url, Url), LiveError> { + let normalized = if value.contains("://") { + value.to_owned() + } else { + format!("https://{value}") + }; + let base = safe_https_base(&normalized)?; + if base.path() != "/" || base.port().is_some() { + return Err(LiveError::Configuration); + } + let host = base.host_str().ok_or(LiveError::Configuration)?; + let mut labels = host.split('.').collect::>(); + if labels.len() < 2 { + return Err(LiveError::Configuration); + } + if matches!(labels[0], "gateway" | "register" | "auth" | "events") { + labels.remove(0); + } + if labels.len() < 2 { + return Err(LiveError::Configuration); + } + let domain = labels.join("."); + let token = Url::parse(&format!("https://auth.{domain}/token")) + .map_err(|_| LiveError::Configuration)?; + let search = Url::parse(&format!("https://events.{domain}/events/search")) + .map_err(|_| LiveError::Configuration)?; + Ok((token, search)) +} + +fn require_success(status: StatusCode, authentication: bool) -> Result<(), LiveError> { + if status.is_success() { + Ok(()) + } else if authentication && matches!(status.as_u16(), 400 | 401 | 403) { + Err(LiveError::Authentication) + } else { + Err(LiveError::Unavailable) + } +} + +async fn bounded_body(mut response: Response, maximum: usize) -> Result, LiveError> { + if response + .content_length() + .is_some_and(|length| length > maximum as u64) + { + return Err(LiveError::ExcessDisclosure); + } + let mut output = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| LiveError::Unavailable)? { + if output.len().saturating_add(chunk.len()) > maximum { + return Err(LiveError::ExcessDisclosure); + } + output.extend_from_slice(&chunk); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + #[cfg(unix)] + #[test] + fn exact_file_parser_accepts_only_owner_only_external_files() { + let temporary = tempfile::tempdir().expect("external temporary directory"); + let path = temporary.path().join("profile.env"); + fs::write(&path, "A=one\nB=two\n").expect("fixture file writes"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode sets"); + let parsed = read_exact_credentials(&path, &["A", "B"]).expect("exact file parses"); + assert_eq!(parsed.len(), 2); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).expect("mode sets"); + assert_eq!( + read_exact_credentials(&path, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + + #[cfg(unix)] + #[test] + fn parser_rejects_unknown_duplicate_empty_and_symlinked_inputs() { + let temporary = tempfile::tempdir().expect("external temporary directory"); + for (name, contents) in [ + ("unknown", "A=one\nC=two\n"), + ("duplicate", "A=one\nA=two\nB=three\n"), + ("empty", "A=\nB=two\n"), + ("shell", "A=$(command)\nB=two\nEXTRA=value\n"), + ] { + let path = temporary.path().join(name); + fs::write(&path, contents).expect("fixture file writes"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("mode sets"); + assert_eq!( + read_exact_credentials(&path, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + let target = temporary.path().join("target"); + let link = temporary.path().join("link"); + fs::write(&target, "A=one\nB=two\n").expect("target writes"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("mode sets"); + std::os::unix::fs::symlink(&target, &link).expect("symlink creates"); + assert_eq!( + read_exact_credentials(&link, &["A", "B"]), + Err(LiveError::CredentialFile) + ); + } + + #[test] + fn service_urls_are_fixed_and_never_carry_credentials() { + let (token, search) = opencrvs_urls("https://register.example.test").expect("URL derives"); + assert_eq!(token.as_str(), "https://auth.example.test/token"); + assert_eq!(search.as_str(), "https://events.example.test/events/search"); + let (token, search) = opencrvs_urls("example.test").expect("bare domain derives"); + assert_eq!(token.as_str(), "https://auth.example.test/token"); + assert_eq!(search.as_str(), "https://events.example.test/events/search"); + assert!(opencrvs_urls("http://example.test").is_err()); + assert!(safe_https_base("https://user:secret@example.test").is_err()); + } + + #[test] + fn dhis2_tracker_query_is_deployment_scoped_and_bounded() { + let config = BTreeMap::from([ + ( + "DHIS2_TEST_PROGRAM_ID".to_owned(), + Zeroizing::new("PROGRAM-CANARY".to_owned()), + ), + ( + "DHIS2_TEST_ORG_UNIT_ID".to_owned(), + Zeroizing::new("ORG-UNIT-CANARY".to_owned()), + ), + ( + "DHIS2_TEST_TRACKED_ENTITY_ID".to_owned(), + Zeroizing::new("TRACKED-ENTITY-CANARY".to_owned()), + ), + ]); + assert!( + dhis2_lookup_query(&config) + == Ok(vec![ + ("program", "PROGRAM-CANARY".to_owned()), + ("orgUnits", "ORG-UNIT-CANARY".to_owned()), + ("trackedEntities", "TRACKED-ENTITY-CANARY".to_owned()), + ( + "fields", + "trackedEntity,attributes[attribute,value]".to_owned(), + ), + ("pageSize", "2".to_owned()), + ("page", "1".to_owned()), + ("totalPages", "true".to_owned()), + ]), + "DHIS2 lookup query did not match the fixed bounded shape" + ); + assert_eq!( + validate_dhis2_lookup( + &json!({ + "pager": {"page": 1, "pageSize": 2}, + "trackedEntities": [{ + "trackedEntity": "TRACKED-ENTITY-CANARY", + "attributes": [{"attribute": "ATTRIBUTE-CANARY", "value": "VALUE-CANARY"}] + }] + }), + "TRACKED-ENTITY-CANARY" + ), + Ok(()) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({"pager": {"page": 1, "pageSize": 2}, "trackedEntities": []}), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::Unavailable) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({"pager": {"page": 1, "pageSize": 2}, "trackedEntities": [{}, {}, {}]}), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::ExcessDisclosure) + ); + assert_eq!( + validate_dhis2_lookup( + &json!({ + "pager": {"page": 1, "pageSize": 2}, + "trackedEntities": [{ + "trackedEntity": "WRONG-ENTITY", + "attributes": [{"attribute": "ATTRIBUTE-CANARY", "value": "VALUE-CANARY"}] + }] + }), + "TRACKED-ENTITY-CANARY" + ), + Err(LiveError::SchemaDrift) + ); + } + + #[test] + fn opencrvs_tracking_id_search_is_exact_and_bounded() { + let selector = "TRACKING-CANARY"; + assert!( + opencrvs_tracking_id_search(selector) + == json!({ + "query": { + "type": "and", + "clauses": [{ + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": { + "type": "exact", + "term": selector + } + }] + }, + "limit": 2, + "offset": 0 + }), + "OpenCRVS search body did not match the fixed bounded shape" + ); + let exact = json!({ + "results": [{ + "trackingId": selector + }], + "total": 1 + }); + assert_eq!(validate_opencrvs_search(&exact, selector), Ok(())); + assert_eq!( + validate_opencrvs_search( + &json!({"results": [{"trackingId": "WRONG-TRACKING-CANARY"}], "total": 1}), + selector + ), + Err(LiveError::SchemaDrift) + ); + assert_eq!( + validate_opencrvs_search(&json!({"results": [], "total": 0}), selector), + Err(LiveError::Unavailable) + ); + assert_eq!( + validate_opencrvs_search(&json!({"results": [{}, {}, {}], "total": 3}), selector), + Err(LiveError::ExcessDisclosure) + ); + } +} diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs new file mode 100644 index 000000000..19ec93fb5 --- /dev/null +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -0,0 +1,135 @@ +use std::{collections::BTreeSet, fs, path::Path}; + +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SecurityMatrix { + contract: String, + status: String, + review_rule: String, + invariants: Vec, + cross_cutting: serde_norway::Mapping, + fixture_index: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SecurityRow { + id: String, + rule: String, + threat: String, + enforcement: String, + negative_test: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Traceability { + contract: String, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TraceEntry { + id: String, + tests: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TestReference { + file: String, + name: String, +} + +#[test] +fn every_named_security_negative_is_bound_to_an_executable_test() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let matrix: SecurityMatrix = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-invariant-matrix.yaml")) + .expect("security matrix reads"), + ) + .expect("security matrix parses"); + let traceability: Traceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-test-traceability.yaml")) + .expect("traceability reads"), + ) + .expect("traceability parses"); + assert_eq!( + traceability.contract, + "registry.evidence.security-test-traceability/v1" + ); + assert_eq!(matrix.contract, "registry.evidence.security-invariants/v1"); + assert_eq!(matrix.status, "frozen"); + assert!(matrix.review_rule.contains("named negative test")); + assert_eq!( + matrix.fixture_index, + "../fixtures/conformance/coverage-matrix.yaml" + ); + + let mut required = matrix + .invariants + .into_iter() + .map(|row| { + assert!(row.id.starts_with("V1-I")); + assert!(!row.rule.is_empty()); + assert!(!row.threat.is_empty()); + assert!(!row.enforcement.is_empty()); + row.negative_test + }) + .collect::>(); + for (_, value) in matrix.cross_cutting { + let row = value.as_mapping().expect("cross-cutting row is a mapping"); + let id = row + .get(serde_norway::Value::String("negative_test".to_owned())) + .and_then(serde_norway::Value::as_str) + .expect("cross-cutting row names a negative test"); + assert!(required.insert(id.to_owned()), "duplicate matrix id {id}"); + } + + let mut mapped = BTreeSet::new(); + for entry in traceability.entries { + assert!( + mapped.insert(entry.id.clone()), + "duplicate mapping {}", + entry.id + ); + assert!( + !entry.tests.is_empty(), + "{} has no executable test", + entry.id + ); + for test in entry.tests { + assert!( + test.file.starts_with("crates/registry-evidence/") + && test.file.ends_with(".rs") + && !test.file.contains(".."), + "{} has an unsafe source reference", + entry.id + ); + let source = fs::read_to_string(root.join(&test.file)) + .unwrap_or_else(|_| panic!("{} source file is missing", entry.id)); + let signature = format!("fn {}(", test.name); + assert!( + source.contains(&signature), + "{} points to missing Rust test {}", + entry.id, + test.name + ); + let item_start = source + .find(&signature) + .expect("test signature was just found"); + let prefix = &source[..item_start]; + let attribute_window = &prefix[prefix.len().saturating_sub(160)..]; + assert!( + attribute_window.contains("#[test]") || attribute_window.contains("#[tokio::test]"), + "{} reference {} is not a test item", + entry.id, + test.name + ); + } + } + assert_eq!(mapped, required, "security negative-test mapping drifted"); +} diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs new file mode 100644 index 000000000..a01c5e444 --- /dev/null +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -0,0 +1,1637 @@ +//! Full-path conformance for the frozen Version 1 selector matrix. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_evidence::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind as AuditAuthorityKind, + EvidenceAuditEvent, EvidenceAuditLog, +}; +use registry_evidence::auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}; +use registry_evidence::bundle::{Bundle, BundleError, DeploymentInputs}; +use registry_evidence::config::{AuthorityKind, SelectorInput}; +use registry_evidence::kernel::{ + EvidenceConstruction, KernelOutcome, OfflineKernel, ValueProjection, +}; +use registry_evidence::model::{ + EvidenceRequest, FlattenedJws, RequestedSelector, RequestedSubject, SelectorValue, + SubjectBinding, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::selector::{ + match_entitlement, resolve_selectors, AuthorizationError, ResolvedAuthorization, + ResolvedSelectorValue, +}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ResolvedSourceSelector, SourceExecutor}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{sign, LocalJwkSigner, PrivateJwk, SigningProvider}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-auth-key"}"#; +const EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"selector-evidence-key"}"#; +const TOKEN_ISSUER: &str = "https://identity.invalid"; +const TOKEN_AUDIENCE: &str = "selector-conformance"; +const EVIDENCE_AUDIENCE: &str = "urn:example:fixture:audience:requester-a"; +const PURPOSE: &str = "fixture-procedure"; +const SOURCE_TOKEN: &str = "selector-source-token-canary"; +const BINDING_KEY: &[u8] = b"selector-binding-secret-canary-32-bytes-minimum"; +const AUDIT_KEY: &[u8] = b"selector-audit-secret-canary-32-bytes-minimum"; + +const CLASSIFICATION: &str = "urn:example:fixture:requirement:classification:v1"; +const PROPERTY: &str = "urn:example:fixture:requirement:property:v1"; +const PROPERTY_WITH_EVENT: &str = "urn:example:fixture:requirement:property-with-event:v1"; +const RELATIONSHIP: &str = "urn:example:fixture:requirement:relationship:v1"; +const OPAQUE: &str = "urn:example:fixture:requirement:opaque:v1"; + +struct PreparedService { + _temporary: TempDir, + bundle: Arc, + kernel: OfflineKernel, + authenticator: Authenticator, + sources: BTreeMap, + audit: EvidenceAuditLog, + signer: EvidenceSigner, + server: MockServer, + audit_path: PathBuf, +} + +impl PreparedService { + async fn authorize( + &self, + token: &str, + request: &EvidenceRequest, + ) -> Result<(AuthenticatedContext, ResolvedAuthorization), AuthorizationStageError> { + let context = self + .authenticator + .authenticate(token) + .await + .map_err(|_| AuthorizationStageError::Authentication)?; + let matched = match_entitlement(&self.bundle, request, &context) + .map_err(AuthorizationStageError::Authorization)?; + let resolved = resolve_selectors(&self.bundle, request, &context, &matched) + .map_err(AuthorizationStageError::Authorization)?; + Ok((context, resolved)) + } + + async fn evaluate( + &self, + operation: &str, + token: &str, + request: &EvidenceRequest, + ) -> FlattenedJws { + let (context, resolved) = self + .authorize(token, request) + .await + .expect("positive selector request authorizes and resolves"); + let (source_id, adapter_id) = source_identity(&self.bundle, &request.requirement); + let audit_subjects = audit_subjects(&self.audit, &resolved); + let authority = audit_authority(&self.audit, &resolved); + let requester = self + .audit + .pseudonym( + "requester", + "selector-conformance", + context.principal().as_bytes(), + ) + .expect("requester pseudonymizes"); + + let mut access = EvidenceAuditEvent::new( + operation.to_owned(), + AuditPhase::AccessAttempt, + request.requirement.clone(), + self.bundle.revision().to_owned(), + request.purpose.clone(), + requester.clone(), + authority.clone(), + audit_subjects.clone(), + AuditDecision::Authorized, + 0, + ); + access.source_id = Some(source_id.clone()); + access.adapter_id = Some(adapter_id.clone()); + self.audit + .append(access) + .await + .expect("access audit gate succeeds before source access"); + + let requirement = self + .kernel + .requirement(&request.requirement) + .expect("requirement exists"); + let source = self + .bundle + .config + .sources + .get(&source_id) + .expect("source exists"); + let preparation_selectors = selector_value(&resolved, &source.request.selector_inputs); + let request_parts = self + .kernel + .prepare(&request.requirement, &preparation_selectors) + .expect("request preparation succeeds"); + let source_response = self + .sources + .get(&source_id) + .expect("requirement source executor exists") + .execute( + &source_selectors(&resolved, &source.request.selector_inputs), + &request_parts, + ) + .await + .expect("fixed source executor succeeds"); + let observed_at = Utc::now(); + let derivation_selectors = + selector_value(&resolved, &requirement.derivation.selector_inputs); + let values = match self + .kernel + .evaluate_with_selectors( + &request.requirement, + &source_response, + &derivation_selectors, + observed_at, + ValueProjection { + audience: context.evidence_audience(), + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) + .expect("extraction, derivation, and output gate succeed") + { + KernelOutcome::Match(values) => values, + KernelOutcome::NoMatch | KernelOutcome::Ambiguous => { + panic!("positive selector source must resolve exactly one match") + } + }; + let subjects = resolved + .subjects + .iter() + .map(|subject| SubjectBinding { + role: subject.role.clone(), + binding: subject + .binding( + BINDING_KEY, + 1, + &self.bundle.config.service.trust_domain, + context.evidence_audience(), + &request.purpose, + ) + .expect("subject binding succeeds"), + }) + .collect(); + let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); + let issued_at = Utc::now(); + let evidence = self + .kernel + .construct_evidence( + &request.requirement, + values, + EvidenceConstruction { + evidence_id: &evidence_id, + purpose: &request.purpose, + audience: context.evidence_audience(), + issued_at, + observed_at, + subjects, + }, + ) + .expect("validated values construct Evidence"); + let disclosed_concepts = evidence + .supported_values + .iter() + .map(|value| value.provides_value_for.clone()) + .collect(); + let signed = self + .signer + .sign_json(&evidence) + .await + .expect("Evidence signs"); + + let mut release = EvidenceAuditEvent::new( + operation.to_owned(), + AuditPhase::DisclosureRelease, + request.requirement.clone(), + self.bundle.revision().to_owned(), + request.purpose.clone(), + requester, + authority, + audit_subjects, + AuditDecision::Released, + 0, + ); + release.source_id = Some(source_id); + release.adapter_id = Some(adapter_id); + release.disclosed_concepts = Some(disclosed_concepts); + release.evidence_id = Some(evidence_id); + release.signing_key_id = Some(self.signer.key_id().to_owned()); + self.audit + .append(release) + .await + .expect("release audit gate succeeds before returning the JWS"); + signed + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthorizationStageError { + Authentication, + Authorization(AuthorizationError), +} + +#[tokio::test] +async fn every_selector_profile_runs_the_complete_signed_service_path() { + let service = prepare_service(true).await; + Mock::given(method("POST")) + .and(path("/v1/selector-facts")) + .and(header( + "authorization", + format!("Bearer {SOURCE_TOKEN}").as_str(), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"matched": true}))) + .expect(6) + .mount(&service.server) + .await; + + let cases = [ + ( + "selector-positive-request", + access_token(json!({})), + classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + vec!["subject"], + ), + ( + "selector-positive-context", + access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": "2000-02-29" + } + })), + property_request(None), + vec!["subject"], + ), + ( + "selector-positive-grant", + access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })), + grant_request(None), + vec!["subject"], + ), + ( + "selector-positive-multi-role", + access_token(json!({})), + relationship_request( + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]), + ), + vec!["subject-a", "subject-b"], + ), + ( + "selector-positive-multi-role-permuted", + access_token(json!({})), + request( + RELATIONSHIP, + vec![ + subject( + "subject-b", + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]), + ), + subject( + "subject-a", + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + ), + ], + ), + vec!["subject-a", "subject-b"], + ), + ( + "selector-positive-opaque", + access_token(json!({})), + opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::Integer(42)), + ("kappa", SelectorValue::String("K-2".to_owned())), + ])), + vec!["subject"], + ), + ]; + + for (operation, token, request, expected_roles) in cases { + let jws = service.evaluate(operation, &token, &request).await; + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let requirement = service + .bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is configured"); + let evidence = verify_flattened_jws( + &serialized, + &jwks_document(service.signer.public_jwk(), []).expect("JWKS builds"), + &EvidenceVerificationPolicy { + issued_by: service.bundle.config.issuer.id.clone(), + provided_by: service.bundle.config.service.provider_id.clone(), + requirement: request.requirement.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + audience: EVIDENCE_AUDIENCE.to_owned(), + configuration_revision: service.bundle.revision().to_owned(), + now: Utc::now(), + clock_skew: Duration::from_secs(30), + }, + ) + .expect("signed service result verifies under the relying policy"); + assert_eq!( + evidence + .subjects + .iter() + .map(|subject| subject.role.as_str()) + .collect::>(), + expected_roles + ); + assert_eq!(evidence.supported_values.len(), 1); + assert_no_selector_material(&serialized); + } + + let requests = service + .server + .received_requests() + .await + .expect("source request journal is available"); + assert_eq!(requests.len(), 6); + let bodies = requests + .iter() + .map(|request| serde_json::from_slice::(&request.body).expect("source body is JSON")) + .collect::>(); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/context_given_name") == Some(&json!("Ána María")) + && body.pointer("/selector/context_family_name") == Some(&json!("N'Dour-Sato")) + })); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/grant_event_reference") == Some(&json!("synthetic-event-001")) + })); + assert!(bodies.iter().any(|body| { + body.pointer("/selector/role_a_record_reference") == Some(&json!("synthetic-record-002")) + && body.pointer("/selector/role_b_given_name") == Some(&json!("Binta")) + })); + + let audit = fs::read_to_string(&service.audit_path).expect("durable audit is readable"); + assert_eq!(audit.matches("\"phase\":\"access-attempt\"").count(), 6); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 6); + assert_eq!(audit.matches("selectorBundlePseudonym").count(), 16); + for canary in selector_value_canaries() { + assert!( + !audit.contains(canary), + "audit retained a protected selector value" + ); + } +} + +#[tokio::test] +async fn all_runtime_selector_negatives_fail_closed_before_source_access() { + let service = prepare_service(false).await; + let base = access_token(json!({})); + let context_token = access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": "2000-02-29" + } + })); + let grant_token = access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + let mut executed = BTreeSet::new(); + + assert_authorization_error( + &service, + &base, + &classification_request(Some(BTreeMap::new())), + AuthorizationError::Selector, + ) + .await; + executed.insert("missing-record-reference"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([ + ( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + ), + ( + "caller_extra", + SelectorValue::String("protected-extra".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("extra-caller-field"); + + let wrong_origin = access_token(json!({ + "identity": {"record_reference": "synthetic-record-001"} + })); + assert_authorization_error( + &service, + &wrong_origin, + &classification_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("wrong-origin-context-value"); + + let unentitled = access_token(json!({"evidence_tags": ["unentitled"]})); + assert_authorization_error( + &service, + &unentitled, + &classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("identifier-possession-without-entitlement"); + + assert_authorization_error( + &service, + &context_token, + &property_request(values([ + ("given_name", SelectorValue::String("Caller".to_owned())), + ("family_name", SelectorValue::String("Supplied".to_owned())), + ("birth_date", SelectorValue::String("2000-02-29".to_owned())), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-values-prohibited-for-context-origin"); + + assert_authorization_error( + &service, + &access_token(json!({"identity": {"given_name": "Only"}})), + &property_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("missing-configured-context-claim"); + + let no_principal = token_with_claims(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "client_id": "fallback-client-canary", + "azp": "fallback-azp-canary", + "iat": Utc::now().timestamp() - 1, + "exp": Utc::now().timestamp() + 3600, + "evidence_tags": ["selector-reviewer"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + assert!(matches!( + service + .authorize(&no_principal, &property_request(None)) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("no-principal-claim-fallback"); + + // The positive context wire assertion above uses the exact multi-byte and + // punctuation-bearing values. That exhaustively proves no case folding, + // transliteration, or alternate field inference occurs before the source. + executed.insert("no-case-fold-or-transliteration"); + + assert_authorization_error( + &service, + &grant_token, + &grant_request(values([ + ("given_name", SelectorValue::String("Caller".to_owned())), + ("family_name", SelectorValue::String("Supplied".to_owned())), + ("birth_date", SelectorValue::String("1990-07-11".to_owned())), + ( + "event_reference", + SelectorValue::String("caller-event".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-values-prohibited-for-grant-origin"); + + assert_authorization_error( + &service, + &base, + &relationship_request( + "opaque-record-v1", + values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]), + "demographics-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ( + "event_reference", + SelectorValue::String("caller-event".to_owned()), + ), + ]), + ), + AuthorizationError::Selector, + ) + .await; + executed.insert("caller-added-disambiguator-rejected-from-demographics-v1"); + + let grant_authority_without_id = access_token(json!({ + "evidence_authority": "authenticated-grant-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + assert!(matches!( + service + .authorize(&grant_authority_without_id, &grant_request(None)) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("authenticated-grant-id-not-bound"); + + let wrong_grant_authority = access_token(json!({ + "evidence_grant_id": "synthetic-grant-001", + "evidence_authority": "other-authority-v1", + "grant": {"subject": { + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }} + })); + assert_authorization_error( + &service, + &wrong_grant_authority, + &grant_request(None), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("authenticated-grant-authority-not-bound"); + + assert_authorization_error( + &service, + &grant_token, + &request( + PROPERTY_WITH_EVENT, + vec![subject("subject", "demographics-v1", None)], + ), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("alternative-field-set-not-inferred"); + + let role_a = values([( + "record_reference", + SelectorValue::String("synthetic-record-002".to_owned()), + )]); + let role_b = values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ]); + let swapped = request( + RELATIONSHIP, + vec![ + subject("subject-a", "demographics-v1", role_b.clone()), + subject("subject-b", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error(&service, &base, &swapped, AuthorizationError::Unauthorized).await; + executed.insert("swapped-role-selectors"); + + let substituted = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-b", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &substituted, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-subject-b-substitution"); + + let missing_role = request( + RELATIONSHIP, + vec![subject("subject-a", "opaque-record-v1", role_a.clone())], + ); + assert_authorization_error( + &service, + &base, + &missing_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("missing-one-role"); + + let duplicate_role = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-a", "opaque-record-v1", role_a.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &duplicate_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("duplicate-subject-role"); + + let unknown_role = request( + RELATIONSHIP, + vec![ + subject("subject-a", "opaque-record-v1", role_a.clone()), + subject("subject-c", "demographics-v1", role_b.clone()), + ], + ); + assert_authorization_error( + &service, + &base, + &unknown_role, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unknown-subject-role"); + + let union_attempt = relationship_request( + "opaque-record-v1", + role_a, + "demographics-with-event-v1", + values([ + ("given_name", SelectorValue::String("Binta".to_owned())), + ("family_name", SelectorValue::String("Diallo".to_owned())), + ("birth_date", SelectorValue::String("1970-06-15".to_owned())), + ( + "event_reference", + SelectorValue::String("synthetic-event-002".to_owned()), + ), + ]), + ); + assert_authorization_error( + &service, + &base, + &union_attempt, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("entitlement-union-across-roles"); + + assert_authorization_error( + &service, + &base, + &opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::Integer(42)), + ("kappa", SelectorValue::String("K-2".to_owned())), + ( + "unknown", + SelectorValue::String("protected-extra".to_owned()), + ), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("unknown-opaque-field"); + + assert_authorization_error( + &service, + &base, + &opaque_request(values([ + ("alpha", SelectorValue::String("synthetic-alpha".to_owned())), + ("delta", SelectorValue::String("42".to_owned())), + ("kappa", SelectorValue::String("K-2".to_owned())), + ])), + AuthorizationError::Selector, + ) + .await; + executed.insert("wrong-opaque-scalar-type"); + + let aggregate_overflow = opaque_request(values([ + ("alpha", SelectorValue::String("A".repeat(80))), + ("delta", SelectorValue::Integer(999_999)), + ( + "kappa", + SelectorValue::String("K-ABCDEFGHIJKLMN".to_owned()), + ), + ])); + assert_authorization_error( + &service, + &base, + &aggregate_overflow, + AuthorizationError::Selector, + ) + .await; + executed.insert("aggregate-size-exceeded"); + executed.insert("aggregate-byte-boundary-plus-one"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([( + "record_reference", + SelectorValue::String(String::new()), + )])), + AuthorizationError::Selector, + ) + .await; + executed.insert("empty-string"); + + assert_authorization_error( + &service, + &context_token_with_birth_date("2000-02-30"), + &property_request(None), + AuthorizationError::Selector, + ) + .await; + executed.insert("invalid-date"); + + let object_value = json!({ + "requirement": CLASSIFICATION, + "purpose": PURPOSE, + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-record-v1", "values": {"record_reference": {"nested": true}}} + }] + }); + assert!(serde_json::from_value::(object_value).is_err()); + let array_value = json!({ + "requirement": CLASSIFICATION, + "purpose": PURPOSE, + "subjects": [{ + "role": "subject", + "selector": {"profile": "opaque-record-v1", "values": {"record_reference": ["value"]}} + }] + }); + assert!(serde_json::from_value::(array_value).is_err()); + executed.insert("scalar-object-or-array"); + + assert_authorization_error( + &service, + &base, + &classification_request(values([( + "record_reference", + SelectorValue::String("R".repeat(97)), + )])), + AuthorizationError::Selector, + ) + .await; + executed.insert("field-byte-boundary-plus-one"); + + assert_authorization_error( + &service, + &base, + &request( + CLASSIFICATION, + vec![subject( + "subject", + "demographics-v1", + values([ + ("given_name", SelectorValue::String("A".to_owned())), + ("family_name", SelectorValue::String("B".to_owned())), + ("birth_date", SelectorValue::String("2000-01-01".to_owned())), + ]), + )], + ), + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-profile"); + + let mut wrong_purpose = classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])); + wrong_purpose.purpose = "unauthorized-purpose".to_owned(); + assert_authorization_error( + &service, + &base, + &wrong_purpose, + AuthorizationError::Unauthorized, + ) + .await; + executed.insert("unauthorized-purpose"); + + let wrong_audience = access_token(json!({"aud": "wrong-resource-audience"})); + assert!(matches!( + service + .authorize( + &wrong_audience, + &classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])), + ) + .await, + Err(AuthorizationStageError::Authentication) + )); + executed.insert("unauthorized-audience"); + + let caller_grant = json!({ + "requirement": PROPERTY_WITH_EVENT, + "purpose": PURPOSE, + "grantId": "caller-grant", + "grantAuthority": "caller-authority", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-with-event-v1"}}] + }); + assert!(serde_json::from_value::(caller_grant).is_err()); + executed.insert("grant-id-or-authority-from-caller-request"); + + // All assertions above use the same executor configured with a deliberately + // absent source credential. Any early credential acquisition would change + // the observed failure, and any source access would appear in this journal. + assert!(service + .server + .received_requests() + .await + .expect("source request journal is available") + .is_empty()); + executed.insert("credential-resolution-or-source-access-before-validation"); + + for config_case in [ + "incomplete-grant-valueClaims", + "missing-context-valueClaims", + "incomplete-or-extra-valueClaims", + "request-origin-valueClaims", + ] { + executed.insert(config_case); + } + assert_eq!( + executed + .into_iter() + .map(str::to_owned) + .collect::>(), + declared_negative_cases() + ); +} + +#[test] +fn configuration_selector_negatives_are_rejected_at_immutable_bundle_load() { + assert_invalid_bundle(|text| { + replace_exact( + text, + " valueClaims:\n given_name: identity.given_name\n family_name: identity.family_name\n birth_date: identity.birth_date\n", + "", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " event_reference: grant.subject.event_reference\n", + "", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " event_reference: grant.subject.event_reference\n", + " event_reference: grant.subject.event_reference\n extra: grant.subject.extra\n", + 1, + ); + }); + assert_invalid_bundle(|text| { + replace_exact( + text, + " - {role: subject, selectorProfile: opaque-record-v1, valueOrigin: request}\n", + " - role: subject\n selectorProfile: opaque-record-v1\n valueOrigin: request\n valueClaims: {record_reference: caller.record_reference}\n", + 1, + ); + }); +} + +async fn prepare_service(write_source_secret: bool) -> PreparedService { + let temporary = tempfile::tempdir().expect("temporary selector conformance root"); + let bundle_root = temporary.path().join("bundle"); + let secret_root = temporary.path().join("secrets"); + let audit_path = temporary.path().join("audit.jsonl"); + let runtime_path = temporary.path().join("runtime.yaml"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + fs::create_dir(&secret_root).expect("secret root is created"); + #[cfg(unix)] + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("selector secret root is owner-only"); + copy_tree(&selector_bundle_root(), &bundle_root); + let server = MockServer::start().await; + rewrite_source_origin(&bundle_root, &server.uri()); + write_secret(&secret_root, "audit-key", AUDIT_KEY); + write_secret(&secret_root, "binding-key", BINDING_KEY); + write_secret(&secret_root, "signing-key", EVIDENCE_PRIVATE_JWK.as_bytes()); + if write_source_secret { + write_secret(&secret_root, "source-token", SOURCE_TOKEN.as_bytes()); + } + write_runtime(&runtime_path, &bundle_root, &secret_root, &audit_path); + make_read_only(&bundle_root); + #[cfg(unix)] + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("selector runtime is immutable"); + + let deployment = + DeploymentInputs::load(&runtime_path).expect("closed selector deployment inputs load"); + assert_ne!( + deployment.bundle.revision(), + deployment.runtime.revision(), + "governed bundle and runtime have independent revisions" + ); + let bundle = Arc::new(deployment.bundle); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("selector kernel compiles"); + let secrets = Arc::new( + SecretResolver::new([SecretProvider::File], &secret_root) + .expect("selector secret resolver initializes"), + ); + let sources = bundle + .config + .sources + .iter() + .map(|(source_id, config)| { + let allowed_selector_sets = bundle.config.source_selector_sets(source_id); + SourceExecutor::new_with_selector_sets( + config, + &allowed_selector_sets, + Arc::clone(&secrets), + ) + .map(|executor| (source_id.to_owned(), executor)) + }) + .collect::, _>>() + .expect("fixed selector source executors initialize"); + let audit = EvidenceAuditLog::initialize(&audit_path, 10_485_760, AUDIT_KEY.to_vec(), 1) + .await + .expect("selector audit initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("Evidence test key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("Evidence signer builds")); + let signer = EvidenceSigner::initialize(provider, "selector-evidence-key") + .await + .expect("Evidence signer self-test succeeds"); + PreparedService { + _temporary: temporary, + bundle, + kernel, + authenticator: authenticator(), + sources, + audit, + signer, + server, + audit_path, + } +} + +fn authenticator() -> Authenticator { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) + .expect("static auth JWKS parses"); + let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + fetcher, + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + +fn access_token(extra: Value) -> String { + let now = Utc::now().timestamp(); + let mut claims = json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "selector-requester-principal-canary", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["selector-reviewer"], + "evidence_audience": EVIDENCE_AUDIENCE + }); + if let Value::Object(extra) = extra { + claims + .as_object_mut() + .expect("claims are an object") + .extend(extra); + } + token_with_claims(claims) +} + +fn token_with_claims(claims: Value) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "EdDSA", + "kid": "selector-auth-key", + "typ": "at+jwt" + })) + .expect("JWT header serializes"), + ); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let signing_input = format!("{header}.{payload}"); + let key = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let signature = + URL_SAFE_NO_PAD.encode(sign(signing_input.as_bytes(), &key).expect("JWT signs")); + format!("{signing_input}.{signature}") +} + +fn context_token_with_birth_date(birth_date: &str) -> String { + access_token(json!({ + "identity": { + "given_name": "Ána María", + "family_name": "N'Dour-Sato", + "birth_date": birth_date + } + })) +} + +fn classification_request(values: Option>) -> EvidenceRequest { + request( + CLASSIFICATION, + vec![subject("subject", "opaque-record-v1", values)], + ) +} + +fn property_request(values: Option>) -> EvidenceRequest { + request( + PROPERTY, + vec![subject("subject", "demographics-v1", values)], + ) +} + +fn grant_request(values: Option>) -> EvidenceRequest { + request( + PROPERTY_WITH_EVENT, + vec![subject("subject", "demographics-with-event-v1", values)], + ) +} + +fn relationship_request( + role_a_profile: &str, + role_a_values: Option>, + role_b_profile: &str, + role_b_values: Option>, +) -> EvidenceRequest { + request( + RELATIONSHIP, + vec![ + subject("subject-a", role_a_profile, role_a_values), + subject("subject-b", role_b_profile, role_b_values), + ], + ) +} + +fn opaque_request(values: Option>) -> EvidenceRequest { + request( + OPAQUE, + vec![subject("subject", "opaque-coordinates-v1", values)], + ) +} + +fn request(requirement: &str, subjects: Vec) -> EvidenceRequest { + EvidenceRequest { + requirement: requirement.to_owned(), + purpose: PURPOSE.to_owned(), + subjects, + } +} + +fn subject( + role: &str, + profile: &str, + values: Option>, +) -> RequestedSubject { + RequestedSubject { + role: role.to_owned(), + selector: RequestedSelector { + profile: profile.to_owned(), + values, + }, + } +} + +fn values( + entries: [(&str, SelectorValue); N], +) -> Option> { + Some( + entries + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect(), + ) +} + +async fn assert_authorization_error( + service: &PreparedService, + token: &str, + request: &EvidenceRequest, + expected: AuthorizationError, +) { + let first = service.authorize(token, request).await; + if !matches!( + first, + Err(AuthorizationStageError::Authorization(actual)) if actual == expected + ) { + let stage = match first { + Ok(_) => "authorized", + Err(AuthorizationStageError::Authentication) => "authentication", + Err(AuthorizationStageError::Authorization(AuthorizationError::Unauthorized)) => { + "unauthorized" + } + Err(AuthorizationStageError::Authorization(AuthorizationError::Selector)) => "selector", + Err(AuthorizationStageError::Authorization(AuthorizationError::AmbiguousAuthority)) => { + "ambiguous-authority" + } + Err(AuthorizationStageError::Authorization(AuthorizationError::Binding)) => "binding", + }; + panic!( + "safe authorization-category mismatch for requirement {}: expected {expected:?}, got {stage}", + request.requirement + ); + } + let diagnostic = format!("{:?}", service.authorize(token, request).await); + for canary in selector_value_canaries() { + assert!(!diagnostic.contains(canary)); + } +} + +fn source_selectors( + resolved: &ResolvedAuthorization, + inputs: &[SelectorInput], +) -> Vec { + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .expect("input role resolves"); + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .expect("input profile resolves"); + ResolvedSourceSelector { + role: subject.role.clone(), + profile: subject.selector_profile.clone(), + values: alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .expect("input field resolves"); + let value = match &field.value { + ResolvedSelectorValue::String(value) + | ResolvedSelectorValue::Date(value) + | ResolvedSelectorValue::ControlledCode(value) => { + SelectorValue::String(value.clone()) + } + ResolvedSelectorValue::Integer(value) => SelectorValue::Integer(*value), + ResolvedSelectorValue::Boolean(value) => SelectorValue::Boolean(*value), + }; + (field.name.clone(), value) + }) + .collect(), + } + }) + .collect() +} + +fn selector_value(resolved: &ResolvedAuthorization, inputs: &[SelectorInput]) -> Value { + Value::Object( + inputs + .iter() + .map(|input| { + let subject = resolved + .subjects + .iter() + .find(|subject| subject.role == input.role) + .expect("input role resolves"); + let alternative = input + .alternatives + .iter() + .find(|alternative| alternative.profile == subject.selector_profile) + .expect("input profile resolves"); + let values = alternative + .fields + .iter() + .map(|name| { + let field = subject + .fields + .iter() + .find(|field| &field.name == name) + .expect("input field resolves"); + (name.clone(), field.value.as_json()) + }) + .collect(); + ( + input.role.clone(), + json!({"profile": alternative.profile, "values": Value::Object(values)}), + ) + }) + .collect(), + ) +} + +fn audit_subjects(audit: &EvidenceAuditLog, resolved: &ResolvedAuthorization) -> Vec { + resolved + .subjects + .iter() + .map(|subject| AuditSubject { + role: subject.role.clone(), + selector_profile: subject.selector_profile.clone(), + selector_bundle_pseudonym: Some( + audit + .pseudonym( + "subject-selector-bundle", + "selector-conformance", + &subject + .audit_pseudonym_input(&resolved.audience, &resolved.purpose) + .expect("selector bundle canonicalizes"), + ) + .expect("selector bundle pseudonymizes"), + ), + }) + .collect() +} + +fn audit_authority(audit: &EvidenceAuditLog, resolved: &ResolvedAuthorization) -> AuditAuthority { + AuditAuthority { + kind: match resolved.authority_kind { + AuthorityKind::Statutory => AuditAuthorityKind::Statutory, + AuthorityKind::Organizational => AuditAuthorityKind::Organizational, + AuthorityKind::Consent => AuditAuthorityKind::Consent, + AuthorityKind::Delegated => AuditAuthorityKind::Delegated, + AuthorityKind::ExplicitRequest => AuditAuthorityKind::ExplicitRequest, + }, + grant_pseudonym: resolved.grant_id.as_deref().map(|grant| { + audit + .pseudonym("grant", "selector-conformance", grant.as_bytes()) + .expect("grant pseudonymizes") + }), + } +} + +fn source_identity(bundle: &Bundle, requirement_id: &str) -> (String, String) { + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == requirement_id) + .expect("requirement is configured"); + let source = bundle + .config + .sources + .get(&requirement.source) + .expect("source is configured"); + let adapter = Path::new(source.extract_script.as_str()) + .file_stem() + .and_then(|name| name.to_str()) + .expect("adapter has a local identifier"); + (requirement.source.clone(), adapter.to_owned()) +} + +fn selector_value_canaries() -> &'static [&'static str] { + &[ + "synthetic-record-001", + "synthetic-record-002", + "Ána María", + "N'Dour-Sato", + "Adaeze", + "Okafor", + "Binta", + "Diallo", + "synthetic-event-001", + "synthetic-alpha", + "fallback-client-canary", + "fallback-azp-canary", + SOURCE_TOKEN, + ] +} + +fn assert_no_selector_material(serialized_jws: &[u8]) { + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("JWS payload decodes"); + let payload = String::from_utf8(payload).expect("Evidence payload is UTF-8"); + for canary in selector_value_canaries() { + assert!(!payload.contains(canary)); + } + for profile in [ + "opaque-record-v1", + "demographics-v1", + "demographics-with-event-v1", + "opaque-coordinates-v1", + ] { + assert!(!payload.contains(profile)); + } +} + +fn declared_negative_cases() -> BTreeSet { + const EXECUTED: &[&str] = &[ + "missing-record-reference", + "extra-caller-field", + "wrong-origin-context-value", + "identifier-possession-without-entitlement", + "caller-values-prohibited-for-context-origin", + "missing-configured-context-claim", + "no-principal-claim-fallback", + "no-case-fold-or-transliteration", + "caller-values-prohibited-for-grant-origin", + "caller-added-disambiguator-rejected-from-demographics-v1", + "authenticated-grant-id-not-bound", + "authenticated-grant-authority-not-bound", + "incomplete-grant-valueClaims", + "alternative-field-set-not-inferred", + "swapped-role-selectors", + "unauthorized-subject-b-substitution", + "missing-one-role", + "duplicate-subject-role", + "unknown-subject-role", + "entitlement-union-across-roles", + "unknown-opaque-field", + "wrong-opaque-scalar-type", + "aggregate-size-exceeded", + "empty-string", + "invalid-date", + "scalar-object-or-array", + "field-byte-boundary-plus-one", + "aggregate-byte-boundary-plus-one", + "unauthorized-profile", + "unauthorized-purpose", + "unauthorized-audience", + "missing-context-valueClaims", + "incomplete-or-extra-valueClaims", + "request-origin-valueClaims", + "grant-id-or-authority-from-caller-request", + "credential-resolution-or-source-access-before-validation", + ]; + let declared = matrix_negative_cases(); + let executed = EXECUTED + .iter() + .map(|name| (*name).to_owned()) + .collect::>(); + assert_eq!( + declared, executed, + "selector matrix negative coverage drifted" + ); + executed +} + +fn matrix_negative_cases() -> BTreeSet { + let text = + fs::read_to_string(products_root().join("fixtures/conformance/selector-matrix.yaml")) + .expect("selector matrix is readable"); + let yaml: serde_norway::Value = serde_norway::from_str(&text).expect("selector matrix is YAML"); + let json = serde_json::to_value(yaml).expect("selector matrix converts to JSON"); + let mut names = Vec::new(); + for profile in json["profiles"] + .as_array() + .expect("selector profiles are an array") + { + names.extend( + profile["negative"] + .as_array() + .expect("profile negatives are an array") + .iter() + .map(|value| { + value + .as_str() + .expect("negative name is a string") + .to_owned() + }), + ); + } + names.extend( + json["global_negative"] + .as_array() + .expect("global negatives are an array") + .iter() + .map(|value| { + value + .as_str() + .expect("negative name is a string") + .to_owned() + }), + ); + names.into_iter().collect() +} + +fn assert_invalid_bundle(mutate: impl FnOnce(&mut String)) { + let temporary = tempfile::tempdir().expect("temporary invalid bundle root"); + let bundle_root = temporary.path().join("bundle"); + fs::create_dir(&bundle_root).expect("bundle root is created"); + copy_tree(&selector_bundle_root(), &bundle_root); + let config_path = bundle_root.join("evidence.yaml"); + let mut config = fs::read_to_string(&config_path).expect("bundle config is readable"); + mutate(&mut config); + fs::write(config_path, config).expect("invalid config mutation writes"); + make_read_only(&bundle_root); + assert!(matches!( + Bundle::load(&bundle_root), + Err(BundleError::Config(_)) + )); +} + +fn products_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence") + .canonicalize() + .expect("Evidence product root exists") +} + +fn selector_bundle_root() -> PathBuf { + products_root().join("fixtures/conformance/selectors") +} + +fn rewrite_source_origin(bundle_root: &Path, source_origin: &str) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied selector config is readable"); + replace_exact(&mut text, "https://source.invalid", source_origin, 5); + fs::write(path, text).expect("deployment-only selector rewrite succeeds"); +} + +fn write_runtime(runtime_path: &Path, bundle_root: &Path, secret_root: &Path, audit_path: &Path) { + let runtime = format!( + concat!( + "version: 1\n", + "bundleDirectory: {}\n", + "listener:\n", + " bindHost: 127.0.0.1\n", + " port: 8080\n", + " tlsTermination: operator-controlled-upstream\n", + " trustProxyIdentityHeaders: false\n", + " maximumRequestBytes: 65536\n", + " maximumConcurrentRequests: 32\n", + " requestTimeoutMilliseconds: 10000\n", + " shutdownGraceMilliseconds: 30000\n", + "secretProviders:\n", + " file:\n", + " root: {}\n", + "auditStorage:\n", + " path: {}\n", + " maximumFileBytes: 10485760\n", + "outboundTls:\n", + " systemRoots: true\n", + " trustProfiles: {{}}\n" + ), + bundle_root.display(), + secret_root.display(), + audit_path.display() + ); + fs::write(runtime_path, runtime).expect("closed selector runtime writes"); +} + +fn replace_exact(text: &mut String, from: &str, to: &str, expected: usize) { + assert_eq!( + text.matches(from).count(), + expected, + "fixture drift for {from}" + ); + *text = text.replace(from, to); +} + +fn write_secret(root: &Path, name: &str, value: &[u8]) { + let path = root.join(name); + fs::write(&path, value).expect("synthetic selector secret writes"); + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("synthetic selector secret is owner-only"); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("selector fixture is readable") { + let entry = entry.expect("selector fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("selector fixture type is readable") + .is_dir() + { + fs::create_dir(&destination).expect("selector fixture directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("selector fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied selector bundle is readable") { + let entry = entry.expect("selector bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("selector bundle type is readable") + .is_dir() + { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("selector bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("selector bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("selector bundle root is immutable"); +} + +#[cfg(not(unix))] +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied selector bundle is readable") { + let entry = entry.expect("selector bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("selector bundle type is readable") + .is_dir() + { + make_read_only(&child); + } else { + let mut permissions = fs::metadata(&child) + .expect("selector bundle metadata") + .permissions(); + permissions.set_readonly(true); + fs::set_permissions(child, permissions).expect("selector bundle file is immutable"); + } + } +} diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs new file mode 100644 index 000000000..188a706a9 --- /dev/null +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -0,0 +1,2217 @@ +//! Contract tests for exact, source-neutral HTTP/JSON execution. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair}; +use registry_evidence::bundle::{Bundle, BundleError, RuntimeDocument}; +use registry_evidence::config::{ + AcquisitionPosture, HttpMethod, OutboundTlsConfig, PreparationChannelPolicy, PreparationLimits, + SourceConfig, +}; +use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; +use registry_evidence::model::{LookupResult, PublicValue, SelectorValue, SubjectBinding}; +use registry_evidence::rhai_runtime::{ + CalendarDate, EvaluationContext, LegalLocalTime, QueryPair, RequestPartRequirement, + RequestParts, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, RhaiRuntimeError, + UtcInstant, MAXIMUM_ARRAY_ITEMS, MAXIMUM_JSON_BODY_DEPTH, MAXIMUM_QUERY_NAME_BYTES, + MAXIMUM_QUERY_PAIRS, MAXIMUM_QUERY_VALUE_BYTES, MAXIMUM_REQUEST_PARTS_BYTES, + MAXIMUM_STRING_BYTES, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ + project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, +}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; +use serde_json::{json, Value}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_rustls::rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const SHAPE_EVIDENCE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"source-shape-evidence-key"}"#; + +fn source_config( + base_url: &str, + authentication: Value, + path_fields: Value, + fixed_headers: Value, + projection: Value, +) -> SourceConfig { + serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": base_url, + "posture": "record-transformed", + "authentication": authentication, + "request": { + "method": "POST", + "pathTemplate": "/v1/records/{record}", + "pathBindings": { + "record": {"role": "subject", "profile": "record-v1", "field": "record_id"} + }, + "fixedHeaders": fixed_headers, + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": path_fields}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "allowed", + "jsonBody": "allowed", + "maximumQueryPairs": 8, + "maximumQueryNameBytes": 64, + "maximumQueryValueBytes": 256, + "maximumJsonDepth": 8, + "maximumCollectionItems": 32, + "maximumStringBytes": 512, + "maximumNormalizedBytes": 4096 + }, + "projection": projection, + "redirects": "deny", + "timeoutMilliseconds": 1000, + "maximumResponseBytes": 65536, + "concurrencyLimit": 4 + }, + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes") +} + +fn fixed_source(base_url: &str, authentication: Value) -> SourceConfig { + let mut source = source_config( + base_url, + authentication, + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + source.request.path = Some("/data".into()); + source.request.path_template = None; + source.request.path_bindings = Default::default(); + source.request.method = registry_evidence::config::HttpMethod::POST; + source +} + +fn oauth_source( + base_url: &str, + token_endpoint: &str, + placement: &str, + maximum_cache_seconds: u64, +) -> SourceConfig { + fixed_source( + base_url, + json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": token_endpoint, + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "scope": "fixture.read", + "credentialPlacement": placement, + "maximumCacheSeconds": maximum_cache_seconds + }), + ) +} + +fn selector(value: &str) -> ResolvedSourceSelector { + ResolvedSourceSelector { + role: "subject".into(), + profile: "record-v1".into(), + values: BTreeMap::from([("record_id".into(), SelectorValue::String(value.into()))]), + } +} + +fn parts() -> RequestParts { + RequestParts { + query: vec![ + QueryPair { + name: "filter".into(), + value: "first value".into(), + }, + QueryPair { + name: "filter".into(), + value: "second/value%".into(), + }, + ], + body: Some(json!({"limit": 1, "requested": ["status"]})), + } +} + +fn resolver(entries: &[(&str, &str)]) -> (TempDir, Arc) { + let root = tempfile::tempdir().expect("temporary secret root"); + for (name, value) in entries { + let path = root.path().join(name); + fs::write(&path, value).expect("write synthetic secret"); + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("protect secret"); + } + let resolver = SecretResolver::new([SecretProvider::File], root.path()) + .map(Arc::new) + .expect("resolver builds"); + (root, resolver) +} + +fn encoded_parameters(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn query_parameters(url: &url::Url) -> Vec<(String, String)> { + url.query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn contains_parameter(parameters: &[(String, String)], name: &str, value: &str) -> bool { + parameters + .iter() + .any(|(actual_name, actual_value)| actual_name == name && actual_value == value) +} + +fn request_limits(config: &PreparationLimits) -> RequestPartsLimits { + fn channel(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn configured(value: Option, fallback: usize) -> usize { + value + .map(|value| usize::try_from(value).expect("configured limit fits usize")) + .unwrap_or(fallback) + } + + RequestPartsLimits::new( + channel(config.query), + channel(config.json_body), + RequestPartsBounds { + maximum_query_pairs: configured(config.maximum_query_pairs, MAXIMUM_QUERY_PAIRS), + maximum_query_name_bytes: configured( + config.maximum_query_name_bytes, + MAXIMUM_QUERY_NAME_BYTES, + ), + maximum_query_value_bytes: configured( + config.maximum_query_value_bytes, + MAXIMUM_QUERY_VALUE_BYTES, + ), + maximum_json_depth: configured(config.maximum_json_depth, MAXIMUM_JSON_BODY_DEPTH), + maximum_collection_items: configured( + config.maximum_collection_items, + MAXIMUM_ARRAY_ITEMS, + ), + maximum_string_bytes: configured(config.maximum_string_bytes, MAXIMUM_STRING_BYTES), + maximum_normalized_bytes: configured( + config.maximum_normalized_bytes, + MAXIMUM_REQUEST_PARTS_BYTES, + ), + }, + ) + .expect("fixture preparation limits satisfy the production ABI") +} + +fn shape_selectors(shape: &str) -> (Value, Vec) { + let (profile, values, resolved) = match shape { + "flat-rest" => ( + "opaque-coordinates-v1", + json!({"alpha": "synthetic-alpha", "delta": 42}), + BTreeMap::from([ + ( + "alpha".into(), + SelectorValue::String("synthetic-alpha".into()), + ), + ("delta".into(), SelectorValue::Integer(42)), + ]), + ), + "nested-paged-rest" => ( + "civil-record-reference-v1", + json!({"record_reference": "B0000000001"}), + BTreeMap::from([( + "record_reference".into(), + SelectorValue::String("B0000000001".into()), + )]), + ), + "opencrvs-event-search-json" => ( + "civil-record-reference-v1", + json!({"record_reference": "TRACKING-CANARY-0001"}), + BTreeMap::from([( + "record_reference".into(), + SelectorValue::String("TRACKING-CANARY-0001".into()), + )]), + ), + _ => panic!("source-shape index contains an unknown profile"), + }; + ( + json!({"subject": {"profile": profile, "values": values}}), + vec![ResolvedSourceSelector { + role: "subject".into(), + profile: profile.into(), + values: resolved, + }], + ) +} + +fn source_shape_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/source-shapes") +} + +fn copy_fixture_tree(source: &Path, target: &Path) { + fs::create_dir(target).expect("fixture directory is copied"); + for entry in fs::read_dir(source).expect("fixture directory is readable") { + let entry = entry.expect("fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture entry type is readable") + .is_dir() + { + copy_fixture_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +#[cfg(unix)] +fn make_fixture_bundle_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("fixture bundle is readable") { + let entry = entry.expect("fixture bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("fixture bundle entry type is readable") + .is_dir() + { + make_fixture_bundle_read_only(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o444)) + .expect("fixture bundle file becomes read-only"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("fixture bundle directory becomes read-only"); +} + +fn pem(label: &str, der: &[u8]) -> String { + let encoded = base64::engine::general_purpose::STANDARD.encode(der); + let body = encoded + .as_bytes() + .chunks(64) + .map(|line| std::str::from_utf8(line).expect("base64 is UTF-8")) + .collect::>() + .join("\n"); + format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") +} + +async fn spawn_private_ca_tls_server( + server_subject_alt_name: &str, +) -> (std::net::SocketAddr, Vec, JoinHandle<()>) { + let mut ca_parameters = + CertificateParams::new(Vec::::new()).expect("private CA parameters are valid"); + ca_parameters.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca_key = KeyPair::generate().expect("private CA key generates"); + let ca_certificate = ca_parameters + .self_signed(&ca_key) + .expect("private CA certificate generates"); + + let server_parameters = CertificateParams::new(vec![server_subject_alt_name.to_owned()]) + .expect("server certificate parameters are valid"); + let server_key = KeyPair::generate().expect("server key generates"); + let server_certificate = server_parameters + .signed_by(&server_key, &ca_certificate, &ca_key) + .expect("private CA signs server certificate"); + let private_key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(server_key.serialize_der())); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![server_certificate.der().clone()], private_key) + .expect("TLS server configuration builds"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("TLS test server binds"); + let address = listener.local_addr().expect("TLS server address"); + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let Ok(mut stream) = acceptor.accept(stream).await else { + return; + }; + let mut request = Vec::with_capacity(1_024); + loop { + let mut chunk = [0_u8; 512]; + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 || request.len() + read > 8_192 { + return; + } + request.extend_from_slice(&chunk[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}", + ) + .await; + let _ = stream.shutdown().await; + }); + ( + address, + pem("CERTIFICATE", ca_certificate.der().as_ref()).into_bytes(), + handle, + ) +} + +#[tokio::test] +async fn exact_request_applies_path_query_body_headers_auth_and_projection_once() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/records/A%20B")) + .and(header("accept", "application/vnd.registry+json")) + .and(header("x-fixed-contract", "v1")) + .and(header("x-source-key", "api-key-canary")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "ignored": "private-canary", + "results": [{ + "status": "ACTIVE", + "declaration": { + "mother.personReference": "P-1", + "private": "private-canary" + } + }] + }))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("api-key", "api-key-canary")]); + let source = source_config( + &server.uri(), + json!({"kind": "static-api-key", "headerName": "X-Source-Key", "valueRef": "secret:file/api-key"}), + json!(["record_id"]), + json!([ + {"name": "Accept", "value": "application/vnd.registry+json"}, + {"name": "X-Fixed-Contract", "value": "v1"} + ]), + json!([ + "/total", + "/results/*/status", + "/results/*/declaration/mother.personReference" + ]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + let response = executor + .execute(&[selector("A B")], &parts()) + .await + .expect("source succeeds"); + assert_eq!( + response, + json!({ + "total": 1, + "results": [{ + "status": "ACTIVE", + "declaration": {"mother.personReference": "P-1"} + }] + }) + ); + let requests = server.received_requests().await.expect("requests recorded"); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].url.query(), + Some("filter=first%20value&filter=second%2Fvalue%25") + ); + assert_eq!( + requests[0] + .url + .query_pairs() + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect::>(), + vec![ + ("filter".into(), "first value".into()), + ("filter".into(), "second/value%".into()) + ] + ); + assert_eq!( + serde_json::from_slice::(&requests[0].body).expect("JSON body"), + json!({"limit": 1, "requested": ["status"]}) + ); +} + +#[test] +fn materialized_request_reuses_path_template_query_and_body_without_auth_material() { + let (_root, secrets) = resolver(&[]); + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + json!(["record_id"]), + json!([{"name": "X-Fixed-Contract", "value": "fixed-header-canary"}]), + json!(["/ok"]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds without secrets"); + let materialized = executor + .materialize_request(&[selector("A B")], &parts()) + .expect("request materializes without credential access"); + assert_eq!(materialized.path(), "/v1/records/A%20B"); + assert_eq!( + materialized.query(), + Some("filter=first%20value&filter=second%2Fvalue%25") + ); + assert_eq!( + materialized.body(), + Some(&json!({"limit": 1, "requested": ["status"]})) + ); + + let diagnostic = format!("{materialized:?}"); + for protected in [ + "127.0.0.1", + "A%20B", + "first%20value", + "second%2Fvalue%25", + "status", + "fixed-header-canary", + "missing-token", + ] { + assert!(!diagnostic.contains(protected)); + } +} + +#[tokio::test] +async fn hostile_path_values_and_malformed_preparation_fail_before_transport_and_redact() { + let server = MockServer::start().await; + let (_root, secrets) = resolver(&[("token", "credential-canary")]); + let source = source_config( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + for hostile in [".", "..", "a/b", "a\\b", "a%2Fb", "a\nb"] { + let error = executor + .execute( + &[selector(hostile)], + &RequestParts { + query: vec![], + body: None, + }, + ) + .await + .expect_err("hostile path value fails"); + let diagnostic = format!("{error:?} {error}"); + assert!(!diagnostic.contains(hostile)); + assert!(!diagnostic.contains("credential-canary")); + } + let error = executor + .execute( + &[selector("safe")], + &RequestParts { + query: vec![QueryPair { + name: "x\r\nInjected".into(), + value: "v".into(), + }], + body: None, + }, + ) + .await + .expect_err("header-style query injection fails"); + assert_eq!(error, SourceError::InvalidPlan); + assert!(server + .received_requests() + .await + .expect("requests") + .is_empty()); +} + +#[tokio::test] +async fn path_binding_contract_rejects_empty_missing_and_extra_material_before_credentials() { + let (_empty_root, empty_secrets) = resolver(&[]); + let base = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + for (case_id, template) in [ + ("empty-placeholder", "/v1/records/{}"), + ("missing-binding", "/v1/records/{missing}"), + ("extra-binding", "/v1/records"), + ] { + let mut source = base.clone(); + source.request.path_template = Some(template.to_owned()); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&empty_secrets)).err(), + Some(SourceError::InvalidPlan), + "{case_id}: invalid template/binding closure is rejected" + ); + } + + let server = MockServer::start().await; + let mut source = base; + source.base_url = server.uri(); + let executor = SourceExecutor::new(&source, empty_secrets).expect("valid path plan compiles"); + let mut missing_field = selector("unused"); + missing_field.values.clear(); + let mut extra_field = selector("record"); + extra_field.values.insert( + "extra".to_owned(), + SelectorValue::String("canary".to_owned()), + ); + let extra_role = ResolvedSourceSelector { + role: "other".to_owned(), + profile: "record-v1".to_owned(), + values: BTreeMap::from([( + "record_id".to_owned(), + SelectorValue::String("canary".to_owned()), + )]), + }; + for (case_id, selectors, expected) in [ + ( + "empty-path-value", + vec![selector("")], + SourceError::InvalidSelectors, + ), + ( + "missing-path-field", + vec![missing_field], + SourceError::InvalidSelectors, + ), + ( + "extra-path-field", + vec![extra_field], + SourceError::InvalidSelectors, + ), + ( + "extra-selector-role", + vec![selector("record"), extra_role], + SourceError::InvalidSelectors, + ), + ( + "missing-selector-role", + vec![], + SourceError::InvalidSelectors, + ), + ] { + let error = executor + .execute( + &selectors, + &RequestParts { + query: vec![], + body: None, + }, + ) + .await + .expect_err("invalid path material fails before credentials"); + assert_eq!(error, expected, "{case_id}: exact source error"); + let diagnostic = format!("{error:?} {error}"); + assert!(!diagnostic.contains("canary")); + assert!(!diagnostic.contains("missing-token")); + } + assert!(server + .received_requests() + .await + .expect("request journal") + .is_empty()); +} + +#[tokio::test] +async fn get_body_is_rejected_before_static_or_oauth_credential_acquisition() { + let token_server = MockServer::start().await; + let data_server = MockServer::start().await; + let (_empty_root, empty_secrets) = resolver(&[]); + + let mut static_source = fixed_source( + &data_server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/missing-token"}), + ); + static_source.request.method = HttpMethod::GET; + static_source.request.preparation_limits.json_body = PreparationChannelPolicy::Forbidden; + let static_executor = + SourceExecutor::new(&static_source, Arc::clone(&empty_secrets)).expect("valid GET builds"); + assert_eq!( + static_executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({"prohibited": true})), + }, + ) + .await, + Err(SourceError::InvalidPlan) + ); + + let mut oauth = oauth_source( + &data_server.uri(), + &format!("{}/token", token_server.uri()), + "query-string", + 60, + ); + oauth.request.method = HttpMethod::GET; + oauth.request.preparation_limits.json_body = PreparationChannelPolicy::Forbidden; + let oauth_executor = + SourceExecutor::new(&oauth, empty_secrets).expect("valid OAuth GET builds"); + assert_eq!( + oauth_executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({"prohibited": true})), + }, + ) + .await, + Err(SourceError::InvalidPlan) + ); + assert!(token_server + .received_requests() + .await + .expect("token journal") + .is_empty()); + assert!(data_server + .received_requests() + .await + .expect("data journal") + .is_empty()); + + let mut invalid_get = static_source; + invalid_get.request.preparation_limits.json_body = PreparationChannelPolicy::Allowed; + assert_eq!( + SourceExecutor::new(&invalid_get, resolver(&[]).1).err(), + Some(SourceError::InvalidPlan) + ); +} + +#[tokio::test] +async fn every_frozen_source_shape_executes_through_production_materialization_and_projection() { + let root = source_shape_root(); + let index: Value = serde_norway::from_str( + &fs::read_to_string(root.join("index.yaml")).expect("source-shape index is readable"), + ) + .expect("source-shape index parses"); + let profiles = index["profiles"] + .as_array() + .expect("source-shape profiles are an array"); + let declared = profiles + .iter() + .map(|profile| profile["id"].as_str().expect("profile id")) + .collect::>(); + assert_eq!( + declared, + BTreeSet::from([ + "flat-rest", + "nested-paged-rest", + "opencrvs-event-search-json", + ]) + ); + + let runtime = RhaiRuntime::new(); + let mut matched_facts = BTreeMap::new(); + for profile in profiles { + let id = profile["id"].as_str().expect("profile id"); + let directory = root.join(profile["path"].as_str().expect("profile path")); + let contract: Value = serde_norway::from_str( + &fs::read_to_string(directory.join("contract.yaml")) + .expect("source-shape contract is readable"), + ) + .expect("source-shape contract parses"); + assert_eq!(contract["synthetic_only"], json!(true)); + let server = MockServer::start().await; + let mut source_value = contract["validated_source_definition"].clone(); + source_value["baseUrl"] = json!(server.uri()); + if id == "opencrvs-event-search-json" { + source_value["authentication"]["tokenEndpoint"] = + json!(format!("{}/oauth/token", server.uri())); + } + let source: SourceConfig = + serde_json::from_value(source_value).expect("validated source definition is typed"); + let preparation = runtime + .compile_preparation( + &fs::read_to_string(directory.join(source.request.prepare_script.as_str())) + .expect("preparation script is readable"), + ) + .expect("preparation script compiles"); + let extraction = runtime + .compile_extraction( + &fs::read_to_string(directory.join(source.extract_script.as_str())) + .expect("extraction script is readable"), + ) + .expect("extraction script compiles"); + let fact_schema_value: Value = serde_norway::from_str( + &fs::read_to_string(directory.join(source.fact_schema.as_str())) + .expect("fact schema is readable"), + ) + .expect("fact schema parses"); + let fact_schema = + jsonschema::JSONSchema::compile(&fact_schema_value).expect("fact schema compiles"); + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .expect("adapter parameters serialize"); + let (script_selectors, transport_selectors) = shape_selectors(id); + let prepared = runtime + .prepare( + &preparation, + &script_selectors, + ¶meters, + &request_limits(&source.request.preparation_limits), + ) + .expect("shape request preparation succeeds"); + let expected_request = &contract["request"]; + assert_eq!( + prepared.body.as_ref(), + match expected_request["body"].as_str() { + Some("absent") => None, + _ => Some(&expected_request["body"]), + }, + "{id}: prepared body differs from the committed contract" + ); + let expected_query = expected_request["query_order"] + .as_array() + .map(|order| { + order + .iter() + .map(|name| { + let name = name.as_str().expect("query-order name"); + ( + name.to_owned(), + expected_request["query"][name] + .as_str() + .expect("query value") + .to_owned(), + ) + }) + .collect::>() + }) + .unwrap_or_default(); + assert!( + prepared + .query + .iter() + .map(|pair| (pair.name.clone(), pair.value.clone())) + .eq(expected_query.clone()), + "{id}: prepared query differs from the committed contract" + ); + let (secret_entries, expected_authorization) = match id { + "flat-rest" => ( + vec![("fixture-flat-rest-token", "shape-static-bearer-canary")], + "Bearer shape-static-bearer-canary".to_owned(), + ), + "nested-paged-rest" => ( + vec![ + ("fixture-nested-rest-username", "shape-basic-user-canary"), + ( + "fixture-nested-rest-password", + "shape-basic-password-canary", + ), + ], + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode("shape-basic-user-canary:shape-basic-password-canary") + ), + ), + "opencrvs-event-search-json" => ( + vec![ + ( + "fixture-event-search-client-id", + "shape-oauth-client-canary", + ), + ( + "fixture-event-search-client-secret", + "shape-oauth-secret-canary", + ), + ], + "Bearer shape-oauth-access-token-canary".to_owned(), + ), + _ => unreachable!("closed source-shape profiles"), + }; + let (_secret_root, secrets) = resolver(&secret_entries); + let executor = SourceExecutor::new(&source, secrets).expect("source plan compiles"); + let match_response: Value = serde_json::from_slice( + &fs::read(directory.join("responses/match.json")) + .expect("match response fixture is readable"), + ) + .expect("match response fixture is JSON"); + if id == "opencrvs-event-search-json" { + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "shape-oauth-access-token-canary", + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read" + }))) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method( + expected_request["method"].as_str().expect("request method"), + )) + .and(path( + expected_request["path"].as_str().expect("request path"), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(match_response)) + .expect(1) + .mount(&server) + .await; + let materialized = executor + .materialize_request(&transport_selectors, &prepared) + .expect("production request materialization succeeds"); + assert_eq!( + materialized.path(), + expected_request["path"].as_str().expect("request path") + ); + assert_eq!( + materialized.query().map(|query| { + url::form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect::>() + }), + (!expected_query.is_empty()).then_some(expected_query.clone()), + "{id}: production query materialization drifted" + ); + assert_eq!(materialized.body(), prepared.body.as_ref()); + + let projected = executor + .execute(&transport_selectors, &prepared) + .await + .expect("frozen shape executes through the production transport"); + let facts = match runtime + .extract(&extraction, &projected, ¶meters, &fact_schema) + .expect("projected transport response extracts") + { + LookupResult::Match(facts) => facts, + _ => panic!("{id}: transport-backed match returned a non-match outcome"), + }; + matched_facts.insert(id.to_owned(), facts.clone()); + + let requests = server + .received_requests() + .await + .expect("source-shape request journal is available"); + let data_requests = requests + .iter() + .filter(|request| { + request.url.path() == expected_request["path"].as_str().expect("request path") + }) + .collect::>(); + assert_eq!(data_requests.len(), 1, "{id}: exact evidence-data count"); + let data_request = data_requests[0]; + assert_eq!( + data_request.method.as_str(), + expected_request["method"].as_str().expect("request method") + ); + assert_eq!( + query_parameters(&data_request.url), + expected_query, + "{id}: exact data query" + ); + assert!( + data_request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == expected_authorization), + "{id}: exact evidence-data authentication" + ); + for (name, value) in expected_request["headers"] + .as_object() + .expect("reviewed request headers are an object") + { + if name == "authorization" { + continue; + } + assert!( + data_request + .headers + .get(name) + .and_then(|actual| actual.to_str().ok()) + .is_some_and(|actual| actual == value.as_str().expect("header value")), + "{id}: exact reviewed header {name}" + ); + } + match prepared.body.as_ref() { + Some(expected) => assert_eq!( + serde_json::from_slice::(&data_request.body) + .expect("evidence-data body is JSON"), + *expected, + "{id}: exact evidence-data body" + ), + None => assert!(data_request.body.is_empty(), "{id}: body remains absent"), + } + if id == "opencrvs-event-search-json" { + let token_requests = requests + .iter() + .filter(|request| request.url.path() == "/oauth/token") + .collect::>(); + assert_eq!(token_requests.len(), 1, "exact OAuth bootstrap count"); + let query = query_parameters(&token_requests[0].url); + assert!( + query.len() == 4 + && contains_parameter(&query, "grant_type", "client_credentials") + && contains_parameter(&query, "scope", "fixture.read") + && contains_parameter(&query, "client_id", "shape-oauth-client-canary") + && contains_parameter(&query, "client_secret", "shape-oauth-secret-canary"), + "OAuth bootstrap query is the exact reviewed shape" + ); + assert!(token_requests[0].body.is_empty()); + assert!(token_requests[0].headers.get("authorization").is_none()); + assert_eq!( + token_requests[0] + .headers + .get("accept") + .and_then(|value| value.to_str().ok()), + Some("application/json") + ); + assert_eq!(requests.len(), 2); + } else { + assert_eq!(requests.len(), 1); + } + + let mut response_files = fs::read_dir(directory.join("responses")) + .expect("response fixture directory is readable") + .map(|entry| entry.expect("response fixture entry").path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json")) + .collect::>(); + response_files.sort(); + let names = response_files + .iter() + .map(|path| { + path.file_name() + .and_then(|value| value.to_str()) + .expect("response fixture name") + }) + .collect::>(); + for required in [ + "ambiguous.json", + "error-envelope.json", + "match.json", + "missing-fact.json", + "no-match.json", + ] { + assert!( + names.contains(required), + "{id}: required outcome {required} is absent" + ); + } + + for response_path in response_files { + let name = response_path + .file_name() + .and_then(|value| value.to_str()) + .expect("response fixture name"); + let response: Value = serde_json::from_slice( + &fs::read(&response_path).expect("response fixture is readable"), + ) + .expect("response fixture is JSON"); + let projected = project_fixture_response(&source, &response); + if name == "error-envelope.json" { + assert_eq!(projected, Err(SourceError::ErrorEnvelope)); + continue; + } + let projected = projected.expect("response projection succeeds"); + let lookup = runtime.extract(&extraction, &projected, ¶meters, &fact_schema); + match name { + "match.json" => match lookup.expect("match extraction succeeds") { + LookupResult::Match(direct_facts) => assert_eq!( + direct_facts, facts, + "{id}: direct fixture and real transport extraction drifted" + ), + _ => panic!("{id}: match fixture returned a non-match outcome"), + }, + "no-match.json" => assert!(matches!(lookup, Ok(LookupResult::NoMatch))), + "ambiguous.json" => assert!(matches!(lookup, Ok(LookupResult::Ambiguous))), + "missing-fact.json" => { + assert_eq!(lookup, Err(RhaiRuntimeError::FactSchema)); + } + "inconsistent-cardinality.json" => { + assert_eq!(lookup, Err(RhaiRuntimeError::SourceProtocol)); + } + _ => panic!("{id}: unclassified response fixture {name}"), + } + } + } + + let acceptance_copy = tempfile::tempdir().expect("temporary acceptance bundle root"); + let acceptance_root = acceptance_copy.path().join("residence-region"); + copy_fixture_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/residence-region"), + &acceptance_root, + ); + make_fixture_bundle_read_only(&acceptance_root); + let kernel = OfflineKernel::compile(Arc::new( + Bundle::load(&acceptance_root).expect("immutable residence acceptance bundle loads"), + )) + .expect("residence acceptance kernel compiles"); + let requirement = "urn:example:fixture:requirement:residence-region:v1"; + let observed_at = "2026-08-02T00:00:00Z" + .parse() + .expect("fixed observation time parses"); + let private = PrivateJwk::parse(SHAPE_EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("test signing provider builds")); + let signer = EvidenceSigner::initialize(provider, "source-shape-evidence-key") + .await + .expect("test signer passes its self-test"); + let jwks = + jwks_document(signer.public_jwk(), std::iter::empty()).expect("test public key publishes"); + + for shape in ["flat-rest", "nested-paged-rest"] { + let values = kernel + .derive_and_validate( + requirement, + matched_facts.get(shape).expect("shape match facts exist"), + observed_at, + ValueProjection { + audience: "https://relying.invalid/residence-procedure", + binding_key: b"source-shape-binding-key-32-bytes-minimum", + binding_key_version: 1, + }, + ) + .expect("real residence derivation and immutable output gate succeed"); + assert_eq!(values.as_slice().len(), 1); + assert_eq!( + values.as_slice()[0].provides_value_for, + "urn:example:fixture:concept:residence-region" + ); + assert_eq!( + values.as_slice()[0].value, + PublicValue::String("REGION-NORTH".to_owned()), + "{shape}: source-shape swap changed the governed controlled code" + ); + let evidence = kernel + .construct_evidence( + requirement, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01J4BRXQ0ZZZZZZZZZZZZZZZZZ", + purpose: "fixture-routing", + audience: "https://relying.invalid/residence-procedure", + issued_at: observed_at, + observed_at, + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("real residence Evidence constructs"); + let jws = signer + .sign_json(&evidence) + .await + .expect("real residence Evidence signs"); + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let verified = verify_flattened_jws( + &serialized, + &jwks, + &EvidenceVerificationPolicy { + issued_by: "urn:example:fixture:issuer:authority".to_owned(), + provided_by: "urn:example:fixture:provider:evidence".to_owned(), + requirement: requirement.to_owned(), + evidence_type: "urn:example:fixture:evidence-type:residence-region:v1".to_owned(), + purpose: "fixture-routing".to_owned(), + audience: "https://relying.invalid/residence-procedure".to_owned(), + configuration_revision: kernel.bundle().revision().to_owned(), + now: observed_at, + clock_skew: Duration::from_secs(0), + }, + ) + .expect("signed residence Evidence verifies under the exact relying policy"); + assert_eq!( + verified.supported_values[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + } +} + +#[tokio::test] +async fn every_acquisition_posture_fixture_executes_with_one_bounded_request() { + let fixture: Value = serde_norway::from_str(include_str!( + "../../../products/evidence/fixtures/conformance/acquisition-postures.yaml" + )) + .expect("acquisition-posture fixture parses"); + let cases = fixture["cases"] + .as_array() + .expect("acquisition-posture cases are an array"); + let runtime = RhaiRuntime::new(); + let preparation = runtime + .compile_preparation("fn prepare(selectors, parameters) { #{query: [], body: #{}} }") + .expect("common posture preparation compiles"); + let mut executed = BTreeSet::new(); + + for case in cases { + let posture_name = case["posture"].as_str().expect("posture name"); + executed.insert(posture_name.to_owned()); + let (posture, derived_fact, expected_claim, expected_negative) = match posture_name { + "source-derived" => ( + AcquisitionPosture::SourceDerived, + "final_code", + "acquisition and disclosure minimization", + "source-returns-undeclared-field", + ), + "field-projected" => ( + AcquisitionPosture::FieldProjected, + "fact_b", + "strong acquisition and disclosure minimization", + "fixed-projection-expanded", + ), + "record-transformed" => ( + AcquisitionPosture::RecordTransformed, + "fact_a", + "disclosure minimization only", + "full-lifecycle-minimization-overclaim", + ), + _ => panic!("unknown acquisition posture fixture case"), + }; + assert_eq!(case["expected_claim"], json!(expected_claim)); + assert_eq!(case["negative"], json!(expected_negative)); + let declared_facts = case["declared_facts"] + .as_array() + .expect("declared facts are an array") + .iter() + .map(|value| value.as_str().expect("declared fact").to_owned()) + .collect::>(); + let mut response = case["source_response"].clone(); + response["result"]["undeclared_source_canary"] = json!("never-project-this-value"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "synthetic-posture-token")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.posture = posture; + source.request.projection = std::iter::once("/total".to_owned()) + .chain(declared_facts.iter().map(|fact| format!("/result/{fact}"))) + .collect(); + let prepared = runtime + .prepare( + &preparation, + &json!({}), + &json!({}), + &request_limits(&source.request.preparation_limits), + ) + .expect("common posture preparation succeeds"); + let projected = SourceExecutor::new(&source, secrets) + .expect("posture source compiles") + .execute(&[selector("record")], &prepared) + .await + .expect("posture source request succeeds"); + assert!(!serde_json::to_string(&projected) + .expect("projected response serializes") + .contains("never-project-this-value")); + + let facts_body = declared_facts + .iter() + .map(|fact| format!("{fact}: source_response[\"result\"][\"{fact}\"]")) + .collect::>() + .join(","); + let extraction = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ #{{outcome: \"match\", facts: #{{{facts_body}}}}} }}" + )) + .expect("posture extraction compiles"); + let properties = declared_facts + .iter() + .map(|fact| (fact.clone(), json!({}))) + .collect::>(); + let schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": declared_facts, + "properties": properties + })) + .expect("posture fact schema compiles"); + let facts = match runtime + .extract(&extraction, &projected, &json!({}), &schema) + .expect("posture extraction succeeds") + { + LookupResult::Match(facts) => facts, + _ => panic!("posture extraction returned a non-match outcome"), + }; + let derivation = runtime + .compile_derivation(&format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{concept_id: \"posture-result\", value: facts[\"{derived_fact}\"]}}] }}" + )) + .expect("posture derivation compiles"); + let derived = runtime + .derive( + &derivation, + &facts, + &json!({}), + EvaluationContext::new( + UtcInstant::parse("2026-08-02T00:00:00Z").expect("instant"), + CalendarDate::parse("2026-08-02").expect("date"), + LegalLocalTime::parse("07:00:00+07:00").expect("local time"), + &json!({}), + BTreeMap::new(), + ) + .expect("evaluation context"), + ) + .expect("posture derivation succeeds"); + assert!(derived.len() == 1 && derived[0].concept_id == "posture-result"); + assert_eq!( + server + .received_requests() + .await + .expect("request journal") + .len(), + 1 + ); + } + + assert_eq!( + executed, + BTreeSet::from([ + "field-projected".to_owned(), + "record-transformed".to_owned(), + "source-derived".to_owned(), + ]) + ); +} + +#[tokio::test] +async fn basic_bearer_and_static_api_key_headers_are_exact_and_failures_are_redacted() { + let cases = [ + ( + json!({"kind": "basic", "usernameRef": "secret:file/user", "passwordRef": "secret:file/password"}), + vec![("user", "basic-user"), ("password", "basic-password")], + "authorization", + format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + "basic-user:basic-password" + ) + ), + ), + ( + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + vec![("token", "bearer-token")], + "authorization", + "Bearer bearer-token".into(), + ), + ( + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + vec![("key", "static-key")], + "x-api-key", + "static-key".into(), + ), + ]; + for (authentication, entries, header_name, header_value) in cases { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .and(header(header_name, header_value.as_str())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&entries); + let source = fixed_source(&server.uri(), authentication); + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("authenticated request succeeds"); + } +} + +async fn assert_oauth_success_matrix_case(placement: &str, maximum_cache_seconds: u64) { + let server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + let expected_token_requests: usize = if maximum_cache_seconds == 0 { 2 } else { 1 }; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read" + }))) + .expect(expected_token_requests as u64) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", client_id.as_str()), + ("oauth-client-secret", client_secret.as_str()), + ]); + let source = oauth_source( + &server.uri(), + &format!("{}/token", server.uri()), + placement, + maximum_cache_seconds, + ); + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + for _ in 0..2 { + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("OAuth-authenticated source request succeeds"); + } + + let requests = server.received_requests().await.expect("request journal"); + let token_requests = requests + .iter() + .filter(|request| request.url.path() == "/token") + .collect::>(); + let data_requests = requests + .iter() + .filter(|request| request.url.path() == "/data") + .collect::>(); + assert!( + token_requests.len() == expected_token_requests, + "unexpected OAuth token request count" + ); + assert!( + data_requests.len() == 2, + "unexpected evidence-data request count" + ); + assert!(data_requests.iter().all(|request| { + request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == format!("Bearer {access_token}")) + })); + + for request in token_requests { + let query = query_parameters(&request.url); + let form = encoded_parameters(&request.body); + match placement { + "basic-header" => { + let expected = format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{client_id}:{client_secret}") + ) + ); + assert!(query.is_empty(), "Basic placement added token query fields"); + assert!( + form.len() == 2 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read"), + "Basic placement token form is not exact" + ); + assert!(request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == expected)); + } + "form-body" => { + assert!(query.is_empty(), "form placement added token query fields"); + assert!(request.headers.get("authorization").is_none()); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "form placement token body is not exact" + ); + } + "query-string" => { + assert!(form.is_empty(), "query placement added a token body"); + assert!(request.headers.get("authorization").is_none()); + assert!( + query.len() == 4 + && contains_parameter(&query, "grant_type", "client_credentials") + && contains_parameter(&query, "scope", "fixture.read") + && contains_parameter(&query, "client_id", &client_id) + && contains_parameter(&query, "client_secret", &client_secret), + "query placement token parameters are not exact" + ); + } + _ => panic!("unknown non-secret test placement"), + } + } +} + +#[tokio::test] +async fn oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded() { + for placement in ["basic-header", "form-body", "query-string"] { + assert_oauth_success_matrix_case(placement, 60).await; + assert_oauth_success_matrix_case(placement, 0).await; + } +} + +#[tokio::test] +async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { + let fixture: Value = serde_norway::from_str(include_str!( + "../../../products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml" + )) + .expect("OAuth redaction fixture parses"); + let declared = fixture["cases"] + .as_array() + .expect("fixture cases are an array") + .iter() + .map(|case| { + case["id"] + .as_str() + .expect("fixture case has an id") + .to_owned() + }) + .collect::>(); + let expected = BTreeSet::from([ + "malformed-token-json".to_owned(), + "token-400".to_owned(), + "token-401".to_owned(), + "token-response-extra-field".to_owned(), + "token-response-oversized".to_owned(), + "token-response-wrong-access-token-field".to_owned(), + "token-response-wrong-lifetime".to_owned(), + "token-response-wrong-media-type".to_owned(), + "token-response-wrong-scope".to_owned(), + "token-response-wrong-token-type".to_owned(), + "token-success".to_owned(), + "transport-connection-failure".to_owned(), + "transport-timeout".to_owned(), + ]); + assert_eq!( + declared, expected, + "OAuth fixture and executable matrix drifted" + ); + + for case_id in declared { + let server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + let response = match case_id.as_str() { + "transport-connection-failure" => None, + "token-success" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "fixture.read" + }))), + "token-400" => Some( + ResponseTemplate::new(400).set_body_string(client_secret.clone()), + ), + "token-401" => Some( + ResponseTemplate::new(401).set_body_string(client_secret.clone()), + ), + "malformed-token-json" => { + Some(ResponseTemplate::new(200).set_body_raw("{invalid", "application/json")) + } + "token-response-oversized" => Some(ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"access_token\":\"{}\",\"token_type\":\"Bearer\",\"expires_in\":120}}", + "x".repeat(9_000) + ), + "application/json", + )), + "token-response-extra-field" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "unexpected": client_secret.clone() + }))), + "token-response-wrong-access-token-field" => { + Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": 7, + "token_type": "Bearer", + "expires_in": 120 + }))) + } + "token-response-wrong-token-type" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "MAC", + "expires_in": 120 + }))), + "token-response-wrong-scope" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "other.scope" + }))), + "token-response-wrong-lifetime" => Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 0 + }))), + "token-response-wrong-media-type" => Some(ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"access_token\":\"{access_token}\",\"token_type\":\"Bearer\",\"expires_in\":120}}" + ), + "text/plain", + )), + "transport-timeout" => Some(ResponseTemplate::new(200) + .set_delay(Duration::from_millis(100)) + .set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120 + }))), + _ => panic!("fixture contains an unknown case id: {case_id}"), + }; + if let Some(response) = response { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + } + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", client_id.as_str()), + ("oauth-client-secret", client_secret.as_str()), + ]); + let token_endpoint = if case_id == "transport-connection-failure" { + "http://127.0.0.1:0/token".to_owned() + } else { + format!("{}/token", server.uri()) + }; + let mut source = oauth_source(&server.uri(), &token_endpoint, "query-string", 0); + if case_id == "transport-timeout" { + source.request.timeout_milliseconds = 20; + } + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + let result = executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + if case_id == "token-success" { + assert!(result.is_ok(), "success fixture case failed"); + } else { + let expected_error = match case_id.as_str() { + "transport-timeout" => SourceError::Timeout, + "transport-connection-failure" => SourceError::Transport, + _ => SourceError::Credential, + }; + assert_eq!(result, Err(expected_error)); + let diagnostic = result + .expect_err("failure fixture case returns an error") + .to_string(); + assert!(!diagnostic.contains(&client_id)); + assert!(!diagnostic.contains(&client_secret)); + assert!(!diagnostic.contains(&access_token)); + assert!(!diagnostic.contains("/token?")); + } + + let requests = server.received_requests().await.expect("request journal"); + let data_count = requests + .iter() + .filter(|request| request.url.path() == "/data") + .count(); + if case_id == "token-success" { + assert!( + data_count == 1, + "successful token did not authorize one data request" + ); + } else { + assert!( + data_count == 0, + "token failure reached the evidence-data source" + ); + } + let token_request = requests + .iter() + .find(|request| request.url.path() == "/token"); + if case_id == "transport-connection-failure" { + assert!(token_request.is_none()); + continue; + } + let token_request = token_request.expect("token request was journaled"); + let query = query_parameters(&token_request.url); + assert!( + query.len() == 4 + && contains_parameter(&query, "grant_type", "client_credentials") + && contains_parameter(&query, "scope", "fixture.read") + && contains_parameter(&query, "client_id", &client_id) + && contains_parameter(&query, "client_secret", &client_secret), + "query placement did not deliver the exact closed credential request" + ); + assert!(token_request.body.is_empty()); + } +} + +#[tokio::test] +async fn projection_missing_leaf_is_omitted_but_bad_intermediate_stops_before_extraction() { + for (response, expected) in [ + (json!({"results": [{}]}), Ok(json!({"results": [{}]}))), + (json!({}), Err(SourceError::ProjectionViolation)), + ( + json!({"results": {}}), + Err(SourceError::ProjectionViolation), + ), + ] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "token")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.request.projection = vec!["/results/*/optional".into()]; + let executor = SourceExecutor::new(&source, secrets).expect("executor builds"); + assert_eq!( + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})) + } + ) + .await, + expected + ); + } +} + +#[tokio::test] +async fn source_executor_failure_matrix_is_exact_single_request_and_value_free() { + let cases = [ + ("http-401", SourceError::Status(SourceStatus::Unauthorized)), + ("http-403", SourceError::Status(SourceStatus::Forbidden)), + ("http-429", SourceError::Status(SourceStatus::RateLimited)), + ("http-500", SourceError::Status(SourceStatus::ServerError)), + ("redirect", SourceError::Redirect), + ("timeout", SourceError::Timeout), + ("invalid-json", SourceError::InvalidJson), + ("wrong-media-type", SourceError::WrongMediaType), + ( + "raw-oversized-before-projection", + SourceError::ResponseTooLarge, + ), + ]; + + for (case_id, expected_error) in cases { + let server = MockServer::start().await; + let response = match case_id { + "http-401" => ResponseTemplate::new(401) + .set_body_string("source-response-canary credential-canary"), + "http-403" => ResponseTemplate::new(403) + .set_body_string("source-response-canary credential-canary"), + "http-429" => ResponseTemplate::new(429) + .insert_header("retry-after", "60") + .set_body_string("source-response-canary credential-canary"), + "http-500" => ResponseTemplate::new(500) + .set_body_string("source-response-canary credential-canary"), + "redirect" => { + ResponseTemplate::new(302).insert_header("location", "/redirect-target-canary") + } + "timeout" => ResponseTemplate::new(200) + .set_delay(Duration::from_millis(100)) + .set_body_json(json!({"ok": true})), + "invalid-json" => ResponseTemplate::new(200) + .set_body_raw("{source-response-canary", "application/json"), + "wrong-media-type" => { + ResponseTemplate::new(200).set_body_raw("source-response-canary", "text/plain") + } + "raw-oversized-before-projection" => ResponseTemplate::new(200).set_body_raw( + format!( + "{{\"ok\":true,\"ignored\":\"source-response-canary{}\"}}", + "x".repeat(256) + ), + "application/json", + ), + _ => unreachable!("closed source failure cases"), + }; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[("token", "credential-canary")]); + let mut source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + if case_id == "timeout" { + source.request.timeout_milliseconds = 20; + } + if case_id == "raw-oversized-before-projection" { + source.request.maximum_response_bytes = 64; + } + let error = SourceExecutor::new(&source, secrets) + .expect("failure-matrix source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect_err("failure-matrix case cannot succeed"); + assert_eq!(error, expected_error, "{case_id}: exact source error"); + let diagnostic = format!("{error:?} {error}"); + for canary in [ + "source-response-canary", + "credential-canary", + "redirect-target-canary", + ] { + assert!( + !diagnostic.contains(canary), + "{case_id}: diagnostics remain value-free" + ); + } + let requests = server.received_requests().await.expect("request journal"); + assert_eq!(requests.len(), 1, "{case_id}: exactly one request"); + assert_eq!(requests[0].url.path(), "/data"); + } +} + +#[test] +fn forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation() { + let (_root, secrets) = resolver(&[("key", "secret")]); + for header_name in [ + "Authorization", + "Proxy-Authorization", + "Host", + "Cookie", + "Set-Cookie", + "Content-Length", + "Content-Type", + "Transfer-Encoding", + "Expect", + "Connection", + "Keep-Alive", + "TE", + "Trailer", + "Upgrade", + "Proxy-Connection", + "Forwarded", + "Via", + "X-Real-IP", + "X-Forwarded-For", + "X-Forwarded-Proto", + "Proxy-Authenticate", + "Traceparent", + "Tracestate", + "Baggage", + "X-Request-ID", + "X-Correlation-ID", + "X-Amzn-Trace-ID", + "X-Original-URL", + "X-Rewrite-URL", + "X-HTTP-Method-Override", + "X-Original-Method", + "X-B3-TraceId", + ] { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([{"name": header_name, "value": "forbidden"}]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan) + ); + } + let duplicate = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([ + {"name": "X-Reviewed-Header", "value": "one"}, + {"name": "x-reviewed-header", "value": "two"} + ]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&duplicate, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "fixed header names are unique case-insensitively" + ); + let authentication_collision = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([{"name": "x-api-key", "value": "fixed"}]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&authentication_collision, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "fixed and authentication headers cannot collide" + ); + for api_key_header in [ + "Authorization", + "Host", + "Content-Type", + "X-Forwarded-For", + "Proxy-Authenticate", + "Traceparent", + "X-B3-TraceId", + ] { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": api_key_header, "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + json!(["/ok"]), + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "reserved authentication header {api_key_header} is rejected" + ); + } + for projection in [ + json!(["/a", "/a/b"]), + json!(["/a/0"]), + json!(["/a/*/x", "/a/b"]), + ] { + let source = source_config( + "http://127.0.0.1:18080", + json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), + json!(["record_id"]), + json!([]), + projection, + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan) + ); + } +} + +#[tokio::test] +async fn private_ca_tls_handshake_succeeds_and_hostname_mismatch_fails() { + let (address, ca_pem, server) = spawn_private_ca_tls_server("127.0.0.1").await; + let directory = tempfile::tempdir().expect("temporary TLS directory"); + let configured_path = directory.path().join("private-ca.pem"); + fs::write(&configured_path, &ca_pem).expect("write configured private CA"); + let tls: OutboundTlsConfig = serde_json::from_value(json!({ + "systemRoots": true, + "trustProfiles": {"private-pki": {"caBundleFile": configured_path}} + })) + .expect("TLS config deserializes"); + let mut source = fixed_source( + &format!("https://127.0.0.1:{}", address.port()), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.tls_trust_profile = Some("private-pki".into()); + let (_root, secrets) = resolver(&[("token", "token")]); + let mut captured = BTreeMap::from([("private-pki".into(), ca_pem)]); + let executor = SourceExecutor::new_with_selector_sets_and_tls( + &source, + &[vec![("subject".into(), "record-v1".into())]], + &tls, + &captured, + Arc::clone(&secrets), + ) + .expect("private CA source compiles"); + fs::write(&configured_path, b"changed-after-capture").expect("mutate configured file"); + captured.insert("private-pki".into(), b"changed-after-compile".to_vec()); + executor + .credentials_ready() + .await + .expect("captured TLS source remains credential-ready without reopening CA files"); + assert_eq!( + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await, + Ok(json!({"ok": true})) + ); + server.await.expect("trusted TLS server task completes"); + + let (mismatch_address, mismatch_ca, mismatch_server) = + spawn_private_ca_tls_server("localhost").await; + let mut mismatch_source = source; + mismatch_source.base_url = format!("https://127.0.0.1:{}", mismatch_address.port()); + let mismatch_captured = BTreeMap::from([("private-pki".into(), mismatch_ca)]); + let mismatch = SourceExecutor::new_with_selector_sets_and_tls( + &mismatch_source, + &[vec![("subject".into(), "record-v1".into())]], + &tls, + &mismatch_captured, + secrets, + ) + .expect("hostname-mismatch source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + assert_eq!(mismatch, Err(SourceError::Transport)); + mismatch_server + .await + .expect("mismatched TLS server task completes"); +} + +#[test] +fn private_ca_plan_rejects_unbound_missing_and_malformed_captures() { + let directory = tempfile::tempdir().expect("temporary TLS directory"); + let configured_path = directory.path().join("private-ca.pem"); + let tls: OutboundTlsConfig = serde_json::from_value(json!({ + "systemRoots": true, + "trustProfiles": {"private-pki": {"caBundleFile": configured_path}} + })) + .expect("TLS config deserializes"); + let mut source = fixed_source( + "https://127.0.0.1:443", + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + source.tls_trust_profile = Some("private-pki".into()); + let (_root, secrets) = resolver(&[("token", "token")]); + let allowed = [vec![("subject".into(), "record-v1".into())]]; + let no_bindings = OutboundTlsConfig { + system_roots: true, + trust_profiles: Default::default(), + }; + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &no_bindings, + &BTreeMap::new(), + Arc::clone(&secrets), + ) + .err(), + Some(SourceError::InvalidPlan) + ); + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &tls, + &BTreeMap::new(), + Arc::clone(&secrets), + ) + .err(), + Some(SourceError::InvalidPlan) + ); + assert_eq!( + SourceExecutor::new_with_selector_sets_and_tls( + &source, + &allowed, + &tls, + &BTreeMap::from([("private-pki".into(), b"not-a-certificate".to_vec())]), + secrets, + ) + .err(), + Some(SourceError::InvalidPlan) + ); +} + +#[cfg(unix)] +#[test] +fn runtime_ca_capture_rejects_symlink_malformed_and_mutable_files() { + use std::os::unix::fs::symlink; + + enum CaCase { + Symlink, + Malformed, + Mutable, + } + for case in [CaCase::Symlink, CaCase::Malformed, CaCase::Mutable] { + let directory = tempfile::tempdir().expect("temporary runtime directory"); + let secret_root = directory.path().join("secrets"); + fs::create_dir(&secret_root).expect("create secret root"); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) + .expect("protect secret root"); + let ca_path = directory.path().join("private-ca.pem"); + match case { + CaCase::Symlink => { + let target = directory.path().join("private-ca-target.pem"); + fs::write( + &target, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write CA target"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o444)) + .expect("protect CA target"); + symlink(target, &ca_path).expect("create CA symlink"); + } + CaCase::Malformed => { + fs::write(&ca_path, b"not-a-certificate").expect("write malformed CA"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o444)) + .expect("protect malformed CA"); + } + CaCase::Mutable => { + fs::write( + &ca_path, + b"-----BEGIN CERTIFICATE-----\nMAMCAQE=\n-----END CERTIFICATE-----\n", + ) + .expect("write mutable CA"); + fs::set_permissions(&ca_path, fs::Permissions::from_mode(0o600)) + .expect("leave CA mutable"); + } + } + let runtime_path = directory.path().join("runtime.yaml"); + fs::write( + &runtime_path, + format!( + "version: 1\nbundleDirectory: /etc/registry-evidence/bundle\nlistener:\n bindHost: 127.0.0.1\n port: 8080\n tlsTermination: operator-controlled-upstream\n trustProxyIdentityHeaders: false\n maximumRequestBytes: 65536\n maximumConcurrentRequests: 64\n requestTimeoutMilliseconds: 10000\n shutdownGraceMilliseconds: 30000\nsecretProviders:\n file: {{root: {}}}\nauditStorage:\n path: /var/lib/registry-evidence/audit/evidence.jsonl\n maximumFileBytes: 1073741824\noutboundTls:\n systemRoots: true\n trustProfiles:\n private-pki: {{caBundleFile: {}}}\n", + secret_root.display(), + ca_path.display() + ), + ) + .expect("write runtime configuration"); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) + .expect("protect runtime configuration"); + let error = RuntimeDocument::load(&runtime_path).expect_err("unsafe CA capture fails"); + match case { + CaCase::Symlink => assert_eq!(error, BundleError::InvalidPath), + CaCase::Malformed => assert!(matches!(error, BundleError::InvalidArtifact(_))), + CaCase::Mutable => assert_eq!(error, BundleError::NotImmutable), + } + } +} + +#[test] +fn ambient_proxy_variables_are_ignored_in_an_isolated_process() { + if std::env::var_os("EVIDENCE_PROXY_CHILD").is_some() { + return; + } + let status = Command::new(std::env::current_exe().expect("test executable")) + .arg("--exact") + .arg("ambient_proxy_child") + .arg("--nocapture") + .env("EVIDENCE_PROXY_CHILD", "1") + .env("HTTP_PROXY", "http://127.0.0.1:1") + .env("HTTPS_PROXY", "http://127.0.0.1:1") + .env("ALL_PROXY", "http://127.0.0.1:1") + .env("NO_PROXY", "") + .status() + .expect("spawn isolated proxy test"); + assert!(status.success()); +} + +#[tokio::test] +async fn ambient_proxy_child() { + if std::env::var_os("EVIDENCE_PROXY_CHILD").is_none() { + return; + } + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "synthetic-proxy-test-access-token", + "token_type": "Bearer", + "expires_in": 60, + "scope": "fixture.read" + }))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[ + ("token", "token"), + ("oauth-client-id", "synthetic-proxy-client"), + ("oauth-client-secret", "synthetic-proxy-secret"), + ]); + let source = fixed_source( + &server.uri(), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + SourceExecutor::new(&source, Arc::clone(&secrets)) + .expect("executor builds") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("ambient proxy is ignored"); + let oauth = oauth_source( + &server.uri(), + &format!("{}/token", server.uri()), + "basic-header", + 0, + ); + SourceExecutor::new(&oauth, secrets) + .expect("OAuth executor builds") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("ambient proxy is ignored for token and evidence-data requests"); +} diff --git a/products/evidence/.env.example b/products/evidence/.env.example new file mode 100644 index 000000000..6e99bb211 --- /dev/null +++ b/products/evidence/.env.example @@ -0,0 +1,14 @@ +# Copy to .env for optional live-provider Evidence tests. The real .env is +# gitignored and must remain owner-only (mode 0600). Do not quote values. + +DHIS2_BASE_URL= +DHIS2_USERNAME= +DHIS2_PASSWORD= +DHIS2_TEST_PROGRAM_ID= +DHIS2_TEST_ORG_UNIT_ID= +DHIS2_TEST_TRACKED_ENTITY_ID= + +OPENCRVS_CLIENT_ID= +OPENCRVS_SECRET= +OPENCRVS_URL= +OPENCRVS_TEST_TRACKING_ID= diff --git a/products/evidence/contracts/audit-event.schema.yaml b/products/evidence/contracts/audit-event.schema.yaml new file mode 100644 index 000000000..4a0c53abb --- /dev/null +++ b/products/evidence/contracts/audit-event.schema.yaml @@ -0,0 +1,90 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/audit-event-v1.json +title: Evidence native audit event Version 1 +type: object +additionalProperties: false +required: + - schema + - eventId + - occurredAt + - operation + - phase + - requirement + - bundleRevision + - purpose + - requesterPseudonym + - authority + - subjects + - decision + - durationMilliseconds +properties: + schema: {const: registry.evidence.audit/v1} + eventId: {type: string, format: uri, maxLength: 512} + occurredAt: {type: string, format: date-time} + operation: {type: string, minLength: 16, maxLength: 128} + phase: {enum: [access-attempt, disclosure-release, denial, transient-failure]} + requirement: {type: string, format: uri, maxLength: 512} + bundleRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + requesterPseudonym: {$ref: '#/$defs/pseudonym'} + actorPseudonym: {$ref: '#/$defs/pseudonym'} + authority: + type: object + additionalProperties: false + required: [kind] + properties: + kind: {enum: [statutory, organizational, consent, delegated, explicit-request]} + grantPseudonym: {$ref: '#/$defs/pseudonym'} + subjects: + type: array + minItems: 1 + maxItems: 8 + items: + type: object + additionalProperties: false + required: [role, selectorProfile] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + selectorProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + selectorBundlePseudonym: {$ref: '#/$defs/pseudonym'} + sourceId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + adapterId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + decision: + enum: [authorized, released, no-match, ambiguous, fact-missing, dependency-failure, evaluation-failure, signing-failure] + disclosedConcepts: + type: array + maxItems: 16 + uniqueItems: true + items: {type: string, format: uri, maxLength: 512} + evidenceId: {type: string, format: uri, maxLength: 512} + signingKeyId: {type: string, minLength: 1, maxLength: 256} + safeErrorCategory: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + durationMilliseconds: {type: integer, minimum: 0, maximum: 86400000} +$defs: + pseudonym: + type: string + pattern: '^hmac-sha256:v[1-9][0-9]*:[a-f0-9]{64}$' +allOf: + - if: {properties: {phase: {const: disclosure-release}}} + then: {required: [disclosedConcepts, evidenceId, signingKeyId]} + else: + not: + anyOf: + - {required: [disclosedConcepts]} + - {required: [evidenceId]} + - {required: [signingKeyId]} +audit_rules: + access_gate: access-attempt is durably accepted after authorization and before credential acquisition or source access + release_gate: disclosure-release is durably accepted after signing and before response release + failure_policy: sink failure blocks the applicable step + chain_verification: The complete keyed chain is verified at startup and after restart; steady-state appends and readiness verify the pinned file identity, modification fingerprint, expected length, and verified tail without rescanning the growing file. + external_mutation: Any external replacement or modification fails readiness and future appends closed until a restart completes full keyed-chain verification. + pre_material_denial: Authentication, unmatched-authority, and invalid-selector failures occur before a privacy-safe authority and complete selector bundle exist, so no native event is fabricated from their untrusted or protected request material. + pseudonym_domains: [requester, actor, grant, subject] + subject_scope: key version plus operator trust domain, purpose, audience, role, profile, and complete canonical selector bundle + never_record: + - raw principal, actor, grant, selector, source, or supported values + - separate hashes of low-entropy selector fields + - credentials, tokens, request or response bodies + - candidate count, candidates, scores, hints, or comparisons + - script inputs, outputs, stacks, or signing material diff --git a/products/evidence/contracts/authority-context.schema.yaml b/products/evidence/contracts/authority-context.schema.yaml new file mode 100644 index 000000000..3bfcdcc9a --- /dev/null +++ b/products/evidence/contracts/authority-context.schema.yaml @@ -0,0 +1,136 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/authority-context-v1.json +title: Normalized Evidence authority context Version 1 +description: Internal core-owned value; never accepted as an Evidence request body and never logged verbatim. +type: object +additionalProperties: false +required: [authenticationProfile, principal, audience, entitlements, selectorSources] +properties: + authenticationProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + principal: {type: string, minLength: 1, maxLength: 512} + actor: {type: string, minLength: 1, maxLength: 512} + requesterAttributes: + type: object + maxProperties: 32 + propertyNames: {pattern: '^[a-z][a-z0-9._-]{0,63}$'} + additionalProperties: + oneOf: + - {type: string, maxLength: 512} + - {type: boolean} + - type: array + maxItems: 32 + items: {type: string, maxLength: 512} + audience: {type: string, format: uri, maxLength: 512} + entitlements: + type: array + minItems: 1 + maxItems: 128 + items: {$ref: '#/$defs/entitlement'} + selectorSources: + type: array + maxItems: 16 + items: {$ref: '#/$defs/selector-source'} +$defs: + entitlement: + type: object + additionalProperties: false + required: [authorityProfile, authorityKind, requirement, purpose, audience, subjects] + properties: + authorityProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + authorityKind: {enum: [statutory, organizational, consent, delegated, explicit-request]} + grantId: {type: string, minLength: 1, maxLength: 512} + requirement: {type: string, format: uri, maxLength: 512} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + audience: {type: string, format: uri, maxLength: 512} + subjects: + type: array + minItems: 1 + maxItems: 8 + items: {$ref: '#/$defs/subject-authorization'} + subject-authorization: + type: object + additionalProperties: false + required: [role, selectorProfile, valueOrigin] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + selectorProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + valueOrigin: {enum: [authenticated-context, authenticated-grant, request]} + valueClaims: + type: object + minProperties: 1 + maxProperties: 16 + propertyNames: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + additionalProperties: {$ref: '#/$defs/claim-path'} + allOf: + - if: + required: [valueOrigin] + properties: + valueOrigin: {enum: [authenticated-context, authenticated-grant]} + then: {required: [valueClaims]} + - if: + required: [valueOrigin] + properties: + valueOrigin: {const: request} + then: + not: {required: [valueClaims]} + selector-source: + type: object + additionalProperties: false + required: [role, selectorProfile, valueOrigin, values] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + selectorProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + valueOrigin: {enum: [authenticated-context, authenticated-grant]} + grantId: {type: string, minLength: 1, maxLength: 512} + grantAuthority: {type: string, minLength: 1, maxLength: 512} + values: + type: object + minProperties: 1 + maxProperties: 16 + additionalProperties: + oneOf: + - {type: string, minLength: 1, maxLength: 512} + - {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - {type: boolean} + allOf: + - if: + required: [valueOrigin] + properties: + valueOrigin: {const: authenticated-grant} + then: {required: [grantId, grantAuthority]} + else: + not: + anyOf: + - {required: [grantId]} + - {required: [grantAuthority]} + claim-path: + type: string + pattern: '^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$' +allOf: + - if: {required: [actor]} + then: + properties: + entitlements: + contains: + type: object + required: [authorityKind] + properties: {authorityKind: {const: delegated}} +authorization_decision: + exact_inputs: + - principal + - optional actor + - exact requirement revision + - purpose + - audience + - authority profile and kind + - optional authenticated grant identifier + - complete set of role, selector profile, and value origin tuples + matching_rule: One entitlement must cover the complete request; permissions are never unioned across entitlements. + principal_rule: Principal derives only from the one configured validated claim; a missing claim denies with no client_id, azp, header, or request fallback. + grant_rule: A grant identifier is context, never authority by possession; it must already be authenticated and bound to the matched entitlement. + selector_claim_rule: Context-derived and grant-derived selector values resolve only through the complete configured valueClaims map over the already verified token; request-derived subjects prohibit valueClaims. + authenticated_grant_rule: Values from the configured grantIdClaim and grantAuthorityClaim must both exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must cover the complete request; neither value may come from the caller request. + denial_order: Deny before source credential resolution or source access. +privacy: + raw_context_in_logs_audit_errors: prohibited + audit_transformation: domain-separated keyed pseudonyms only diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml new file mode 100644 index 000000000..4646622ef --- /dev/null +++ b/products/evidence/contracts/bundle.schema.yaml @@ -0,0 +1,676 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/bundle-v1.json +title: Evidence immutable deployment bundle Version 1 +type: object +additionalProperties: false +required: [version, service, issuer, authentication, audit, subjectBinding, rateLimits, signing, selectorProfiles, sources, authorityProfiles, requirements] +properties: + version: {const: 1} + service: + type: object + additionalProperties: false + required: [providerId, trustDomain] + properties: + providerId: {type: string, format: uri, maxLength: 512} + trustDomain: {type: string, format: uri, maxLength: 512} + issuer: + type: object + additionalProperties: false + required: [id] + properties: {id: {type: string, format: uri, maxLength: 512}} + authentication: {$ref: '#/$defs/authentication'} + audit: {$ref: '#/$defs/audit'} + subjectBinding: {$ref: '#/$defs/subject-binding'} + rateLimits: {$ref: '#/$defs/rate-limits'} + signing: + type: object + additionalProperties: false + required: [format, algorithm, activeKeyId, activeKeyRef, retiredPublicJwkFiles, jwksPath, maximumAssertionValiditySeconds, verifierClockSkewSeconds] + properties: + format: {const: flattened-jws-json} + algorithm: {const: EdDSA} + activeKeyId: {type: string, minLength: 1, maxLength: 256, pattern: '^[^\u0000-\u001F\u007F-\u009F]+$'} + activeKeyRef: {$ref: '#/$defs/secret-ref'} + retiredPublicJwkFiles: + type: array + maxItems: 32 + uniqueItems: true + items: {$ref: '#/$defs/public-jwk-path'} + jwksPath: {const: /.well-known/evidence/jwks.json} + maximumAssertionValiditySeconds: {type: integer, minimum: 1, maximum: 31536000} + verifierClockSkewSeconds: {type: integer, minimum: 0, maximum: 600} + selectorProfiles: + type: object + minProperties: 1 + maxProperties: 128 + propertyNames: {$ref: '#/$defs/local-id'} + additionalProperties: {$ref: '#/$defs/selector-profile'} + sources: + type: object + minProperties: 1 + maxProperties: 128 + propertyNames: {$ref: '#/$defs/local-id'} + additionalProperties: {$ref: '#/$defs/source'} + authorityProfiles: + type: object + minProperties: 1 + maxProperties: 128 + propertyNames: {$ref: '#/$defs/local-id'} + additionalProperties: {$ref: '#/$defs/authority-profile'} + requirements: + type: array + minItems: 1 + maxItems: 128 + items: {$ref: '#/$defs/requirement'} +$defs: + local-id: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + uri: {type: string, format: uri, maxLength: 512} + secret-ref: + {type: string, pattern: '^secret:file/[a-z][a-z0-9._-]{0,127}$'} + relative-path: {type: string, pattern: '^(adapters|derivations|schemas|codelists|fixtures)/[A-Za-z0-9._/-]+$'} + public-jwk-path: {type: string, pattern: '^public-keys/[A-Za-z0-9._-]+\.jwk\.json$'} + authentication: + type: object + additionalProperties: false + required: [kind, issuer, audiences, tokenTypes, algorithms, jwksUri, principalClaim, requesterTagsClaim, evidenceAudienceClaim, grantIdClaim, grantAuthorityClaim] + properties: + kind: {const: oidc-access-token} + issuer: {type: string, pattern: '^https://', maxLength: 512} + audiences: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 512} + tokenTypes: + type: array + minItems: 1 + maxItems: 4 + uniqueItems: true + items: {enum: [at+jwt, application/at+jwt]} + algorithms: + type: array + minItems: 1 + maxItems: 3 + uniqueItems: true + items: {enum: [EdDSA, ES256, RS256]} + jwksUri: {type: string, pattern: '^https://', maxLength: 512} + principalClaim: {$ref: '#/$defs/claim-name'} + requesterTagsClaim: {$ref: '#/$defs/claim-name'} + evidenceAudienceClaim: {$ref: '#/$defs/claim-name'} + grantIdClaim: {$ref: '#/$defs/claim-name'} + grantAuthorityClaim: {$ref: '#/$defs/claim-name'} + actorClaim: {$ref: '#/$defs/claim-name'} + audit: + type: object + additionalProperties: false + required: [format, hashSecretRef, hashKeyVersion, failClosed] + properties: + format: {const: keyed-jsonl} + hashSecretRef: {$ref: '#/$defs/secret-ref'} + hashKeyVersion: {type: integer, minimum: 1, maximum: 2147483647} + failClosed: {const: true} + subject-binding: + type: object + additionalProperties: false + required: [secretRef, keyVersion] + properties: + secretRef: {$ref: '#/$defs/secret-ref'} + keyVersion: {type: integer, minimum: 1, maximum: 2147483647} + rate-limits: + type: object + additionalProperties: false + required: [requestsPerPrincipalPerMinute, burstPerPrincipal, failedSelectorAttemptsPerPrincipalAuthorityPerMinute] + properties: + requestsPerPrincipalPerMinute: {type: integer, minimum: 1, maximum: 1000000} + burstPerPrincipal: {type: integer, minimum: 1, maximum: 100000} + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: {type: integer, minimum: 1, maximum: 100000} + claim-name: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_.-]{0,127}$'} + claim-path: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$'} + selector-profile: + type: object + additionalProperties: false + required: [maximumAggregateBytes, fields] + properties: + maximumAggregateBytes: {type: integer, minimum: 1, maximum: 8192} + fields: + type: object + minProperties: 1 + maxProperties: 16 + propertyNames: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + additionalProperties: {$ref: '#/$defs/selector-field'} + selector-field: + oneOf: + - type: object + additionalProperties: false + required: [type, minimumBytes, maximumBytes] + properties: + type: {const: string} + minimumBytes: {type: integer, minimum: 1, maximum: 8192} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + - type: object + additionalProperties: false + required: [type] + properties: {type: {const: date}} + - type: object + additionalProperties: false + required: [type, minimum, maximum] + properties: + type: {const: integer} + minimum: {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + maximum: {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - type: object + additionalProperties: false + required: [type] + properties: {type: {const: boolean}} + - type: object + additionalProperties: false + required: [type, codelist, codelistVersion, maximumBytes] + properties: + type: {const: controlled-code} + codelist: {$ref: '#/$defs/relative-path'} + codelistVersion: {type: string, minLength: 1, maxLength: 128} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + source: + type: object + additionalProperties: false + required: [transport, baseUrl, posture, authentication, request, extractScript, factSchema] + properties: + transport: {const: http-json} + baseUrl: + oneOf: + - {type: string, pattern: '^https://[^/?#]+$'} + - {type: string, pattern: '^http://(127(\.[0-9]{1,3}){3}|\[::1\])(:[0-9]{1,5})?$'} + posture: {enum: [source-derived, field-projected, record-transformed]} + tlsTrustProfile: {$ref: '#/$defs/local-id'} + authentication: {$ref: '#/$defs/source-authentication'} + request: {$ref: '#/$defs/fixed-request'} + extractScript: {$ref: '#/$defs/relative-path'} + factSchema: {$ref: '#/$defs/relative-path'} + source-authentication: + oneOf: + - type: object + additionalProperties: false + required: [kind, usernameRef, passwordRef] + properties: + kind: {const: basic} + usernameRef: {$ref: '#/$defs/secret-ref'} + passwordRef: {$ref: '#/$defs/secret-ref'} + - type: object + additionalProperties: false + required: [kind, tokenRef] + properties: + kind: {const: static-bearer} + tokenRef: {$ref: '#/$defs/secret-ref'} + - type: object + additionalProperties: false + required: [kind, headerName, valueRef] + properties: + kind: {const: static-api-key} + headerName: {$ref: '#/$defs/configurable-header-name'} + valueRef: {$ref: '#/$defs/secret-ref'} + - type: object + additionalProperties: false + required: [kind, tokenEndpoint, clientIdRef, clientSecretRef, credentialPlacement, maximumCacheSeconds] + properties: + kind: {const: oauth2-client-credentials} + tokenEndpoint: + oneOf: + - {type: string, pattern: '^https://[^?#]+$'} + - {type: string, pattern: '^http://(127(\.[0-9]{1,3}){3}|\[::1\])(:[0-9]{1,5})?/[^?#]*$'} + clientIdRef: {$ref: '#/$defs/secret-ref'} + clientSecretRef: {$ref: '#/$defs/secret-ref'} + scope: {type: string, minLength: 1, maxLength: 512} + credentialPlacement: {enum: [basic-header, form-body, query-string]} + maximumCacheSeconds: {type: integer, minimum: 0, maximum: 86400} + fixed-request: + type: object + additionalProperties: false + required: [method, selectorInputs, prepareScript, adapterParameters, adapterParametersSchema, preparationLimits, projection, redirects, timeoutMilliseconds, maximumResponseBytes, concurrencyLimit] + properties: + method: {enum: [GET, POST]} + path: {type: string, pattern: '^/(?!/)[A-Za-z0-9._~!$&''()*+,;=:@%/-]*$'} + pathTemplate: {type: string, minLength: 2, maxLength: 2048, pattern: '^/(?!/)[A-Za-z0-9._~!$&''()*+,;=:@%/{\}-]*$'} + pathBindings: + type: object + minProperties: 1 + maxProperties: 16 + propertyNames: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + additionalProperties: {$ref: '#/$defs/path-binding'} + fixedHeaders: + type: array + maxItems: 32 + items: + type: object + additionalProperties: false + required: [name, value] + properties: + name: {$ref: '#/$defs/configurable-header-name'} + value: {type: string, maxLength: 4096, pattern: '^[^\u0000-\u001F\u007F-\u009F]*$'} + selectorInputs: + type: array + minItems: 1 + maxItems: 8 + items: {$ref: '#/$defs/selector-input'} + prepareScript: {$ref: '#/$defs/relative-path'} + adapterParameters: + type: object + maxProperties: 64 + propertyNames: {$ref: '#/$defs/parameter-key'} + additionalProperties: {$ref: '#/$defs/adapter-parameter-value'} + adapterParametersSchema: {$ref: '#/$defs/relative-path'} + preparationLimits: {$ref: '#/$defs/preparation-limits'} + projection: + type: array + minItems: 1 + maxItems: 64 + uniqueItems: true + items: {type: string, minLength: 2, maxLength: 256, pattern: '^/(?!/)([^/~]|~[01]|/|\*)+$'} + redirects: {const: deny} + timeoutMilliseconds: {type: integer, minimum: 1, maximum: 30000} + maximumResponseBytes: {type: integer, minimum: 1, maximum: 1048576} + concurrencyLimit: {type: integer, minimum: 1, maximum: 256} + oneOf: + - required: [path] + not: {anyOf: [{required: [pathTemplate]}, {required: [pathBindings]}]} + - required: [pathTemplate, pathBindings] + not: {required: [path]} + allOf: + - if: + required: [method] + properties: {method: {const: GET}} + then: + properties: + preparationLimits: + properties: {jsonBody: {const: forbidden}} + selector-input: + type: object + additionalProperties: false + required: [role, alternatives] + properties: + role: {$ref: '#/$defs/local-id'} + alternatives: + type: array + minItems: 1 + maxItems: 16 + items: + type: object + additionalProperties: false + required: [profile, fields] + properties: + profile: {$ref: '#/$defs/local-id'} + fields: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + path-binding: + type: object + additionalProperties: false + required: [role, profile, field] + properties: + role: {$ref: '#/$defs/local-id'} + profile: {$ref: '#/$defs/local-id'} + field: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + configurable-header-name: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[!#$%&''*+.^_`|~0-9A-Za-z-]+$' + parameter-key: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9._-]{0,127}$'} + adapter-parameter-value: + oneOf: + - {type: boolean} + - {type: integer, minimum: -9223372036854775808, maximum: 9223372036854775807} + - {type: string, maxLength: 16384} + - type: array + maxItems: 256 + items: {$ref: '#/$defs/adapter-parameter-value'} + - type: object + maxProperties: 256 + propertyNames: {$ref: '#/$defs/parameter-key'} + additionalProperties: {$ref: '#/$defs/adapter-parameter-value'} + preparation-limits: + type: object + additionalProperties: false + required: [query, jsonBody] + properties: + query: {enum: [required, allowed, forbidden]} + jsonBody: {enum: [required, allowed, forbidden]} + maximumQueryPairs: {type: integer, minimum: 1, maximum: 64} + maximumQueryNameBytes: {type: integer, minimum: 1, maximum: 64} + maximumQueryValueBytes: {type: integer, minimum: 1, maximum: 4096} + maximumJsonDepth: {type: integer, minimum: 1, maximum: 32} + maximumCollectionItems: {type: integer, minimum: 1, maximum: 256} + maximumStringBytes: {type: integer, minimum: 1, maximum: 16384} + maximumNormalizedBytes: {type: integer, minimum: 1, maximum: 65536} + not: + properties: + query: {const: forbidden} + jsonBody: {const: forbidden} + required: [query, jsonBody] + authority-profile: + type: object + additionalProperties: false + required: [kind, requesterTags, grants] + properties: + kind: {enum: [statutory, organizational, consent, delegated, explicit-request]} + requesterTags: + type: array + minItems: 1 + maxItems: 32 + uniqueItems: true + items: {$ref: '#/$defs/local-id'} + grants: + type: array + minItems: 1 + maxItems: 128 + items: + type: object + additionalProperties: false + required: [requirement, purpose, audienceFrom, subjects] + properties: + requirement: {$ref: '#/$defs/uri'} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + audienceFrom: {const: authenticated-requester} + subjects: + type: array + minItems: 1 + maxItems: 8 + items: + type: object + additionalProperties: false + required: [role, selectorProfile, valueOrigin] + properties: + role: {$ref: '#/$defs/local-id'} + selectorProfile: {$ref: '#/$defs/local-id'} + valueOrigin: {enum: [authenticated-context, authenticated-grant, request]} + valueClaims: + type: object + minProperties: 1 + maxProperties: 16 + propertyNames: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + additionalProperties: {$ref: '#/$defs/claim-path'} + allOf: + - if: + required: [valueOrigin] + properties: + valueOrigin: {enum: [authenticated-context, authenticated-grant]} + then: {required: [valueClaims]} + - if: + required: [valueOrigin] + properties: + valueOrigin: {const: request} + then: + not: {required: [valueClaims]} + requirement: + type: object + additionalProperties: false + required: [id, kind, source, purposes, subjectRoles, referenceFrameworks, evidenceType, validitySeconds, derivation, concepts, fixtures, disclosureGuard, existenceDisclosure] + properties: + id: {$ref: '#/$defs/uri'} + kind: {enum: [criterion, information-requirement, constraint]} + source: {$ref: '#/$defs/local-id'} + purposes: + type: array + minItems: 1 + maxItems: 32 + uniqueItems: true + items: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + subjectRoles: + type: array + minItems: 1 + maxItems: 8 + items: + type: object + additionalProperties: false + required: [role, cardinality, selectorProfiles] + properties: + role: {$ref: '#/$defs/local-id'} + cardinality: {const: one} + selectorProfiles: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {$ref: '#/$defs/local-id'} + referenceFrameworks: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {$ref: '#/$defs/uri'} + evidenceType: {$ref: '#/$defs/uri'} + observationTimezone: {type: string, minLength: 1, maxLength: 128} + validitySeconds: {type: integer, minimum: 1, maximum: 31536000} + derivation: + type: object + additionalProperties: false + required: [script, parameters] + properties: + script: {$ref: '#/$defs/relative-path'} + selectorInputs: + type: array + maxItems: 8 + items: {$ref: '#/$defs/selector-input'} + parameters: + type: object + maxProperties: 32 + additionalProperties: {$ref: '#/$defs/parameter-value'} + concepts: + type: array + minItems: 1 + maxItems: 16 + items: + type: object + additionalProperties: false + required: [id, form, required] + properties: + id: {$ref: '#/$defs/uri'} + form: {enum: [boolean, controlled-code, controlled-category, bounded-integer, bounded-decimal, date-bucket, time-bucket, audience-scoped-entity-reference, controlled-code-list, entity-reference-list, reviewed-structured-value]} + required: {type: boolean} + constraints: {type: object} + allOf: + - oneOf: + - properties: + form: {const: boolean} + constraints: {$ref: '#/$defs/boolean-constraints'} + - required: [constraints] + properties: + form: {const: controlled-code} + constraints: {$ref: '#/$defs/controlled-code-constraints'} + - required: [constraints] + properties: + form: {const: controlled-category} + constraints: {$ref: '#/$defs/controlled-category-constraints'} + - required: [constraints] + properties: + form: {const: bounded-integer} + constraints: {$ref: '#/$defs/bounded-integer-constraints'} + - required: [constraints] + properties: + form: {const: bounded-decimal} + constraints: {$ref: '#/$defs/bounded-decimal-constraints'} + - required: [constraints] + properties: + form: {const: date-bucket} + constraints: {$ref: '#/$defs/bucket-constraints'} + - required: [constraints] + properties: + form: {const: time-bucket} + constraints: {$ref: '#/$defs/bucket-constraints'} + - required: [constraints] + properties: + form: {const: audience-scoped-entity-reference} + constraints: {$ref: '#/$defs/entity-reference-constraints'} + - required: [constraints] + properties: + form: {const: controlled-code-list} + constraints: {$ref: '#/$defs/controlled-code-list-constraints'} + - required: [constraints] + properties: + form: {const: entity-reference-list} + constraints: {$ref: '#/$defs/entity-reference-list-constraints'} + - required: [constraints] + properties: + form: {const: reviewed-structured-value} + constraints: {$ref: '#/$defs/reviewed-structured-value-constraints'} + fixtures: {$ref: '#/$defs/relative-path'} + disclosureGuard: + type: object + additionalProperties: false + required: [families] + properties: + families: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {$ref: '#/$defs/uri'} + existenceDisclosure: {enum: [collapse-unresolved]} + boolean-constraints: + type: object + additionalProperties: false + maxProperties: 0 + controlled-code-constraints: + type: object + additionalProperties: false + required: [codelist, codelistVersion, maximumBytes] + properties: + codelist: {$ref: '#/$defs/relative-path'} + codelistVersion: {type: string, minLength: 1, maxLength: 128} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + controlled-category-constraints: + type: object + additionalProperties: false + required: [categoryScheme, schemeVersion, codelist, maximumBytes] + properties: + categoryScheme: {$ref: '#/$defs/uri'} + schemeVersion: {type: string, minLength: 1, maxLength: 128} + codelist: {$ref: '#/$defs/relative-path'} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + bounded-integer-constraints: + type: object + additionalProperties: false + required: [minimum, maximum] + properties: + minimum: {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + maximum: {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + bounded-decimal-constraints: + type: object + additionalProperties: false + required: [minimum, maximum, maximumScale] + properties: + minimum: {$ref: '#/$defs/canonical-decimal-text'} + maximum: {$ref: '#/$defs/canonical-decimal-text'} + maximumScale: {type: integer, minimum: 0, maximum: 9} + canonical-decimal-text: + type: string + pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$' + bucket-constraints: + type: object + additionalProperties: false + required: [bucketScheme, schemeVersion] + properties: + bucketScheme: {$ref: '#/$defs/uri'} + schemeVersion: {type: string, minLength: 1, maxLength: 128} + entity-reference-constraints: + type: object + additionalProperties: false + required: [maximumBytes] + properties: + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + controlled-code-list-constraints: + type: object + additionalProperties: false + required: [codelist, codelistVersion, minimumItems, maximumItems, unique] + properties: + codelist: {$ref: '#/$defs/relative-path'} + codelistVersion: {type: string, minLength: 1, maxLength: 128} + minimumItems: {type: integer, minimum: 1, maximum: 64} + maximumItems: {type: integer, minimum: 1, maximum: 64} + unique: {const: true} + entity-reference-list-constraints: + type: object + additionalProperties: false + required: [minimumItems, maximumItems, unique] + properties: + minimumItems: {type: integer, minimum: 1, maximum: 64} + maximumItems: {type: integer, minimum: 1, maximum: 64} + unique: {const: true} + reviewed-structured-value-constraints: + type: object + additionalProperties: false + required: [schema, maximumSerializedBytes] + properties: + schema: {$ref: '#/$defs/uri'} + maximumSerializedBytes: {type: integer, minimum: 1, maximum: 65536} + parameter-value: + oneOf: + - {type: string, maxLength: 1024} + - {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - {type: boolean} + - {$ref: '#/$defs/decimal-value'} + - type: array + minItems: 1 + maxItems: 64 + items: {$ref: '#/$defs/bucket-boundary'} + decimal-value: + type: object + additionalProperties: false + required: [type, value] + properties: + type: {const: decimal} + value: {type: string, pattern: '^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$'} + bucket-boundary: + type: object + additionalProperties: false + required: [minimumInclusive, maximumExclusive, code] + properties: + minimumInclusive: {$ref: '#/$defs/decimal-value'} + maximumExclusive: {$ref: '#/$defs/decimal-value'} + code: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} +bundle_layout: + root: evidence.yaml + allowed_directories: [adapters, derivations, schemas, codelists, fixtures, public-keys] + symlinks: prohibited + path_escape: prohibited + unknown_files: prohibited +atomic_revision: + digest: sha256 over a canonical sorted path manifest plus exact file bytes + trust: Established by operator deployment controls, not by the digest itself. + lifetime: Mounted read-only and loaded once; reload, merge, mutation, fallback, and partial serving are prohibited. +startup_checks: + - Unique stable identifiers and no dangling references. + - Complete role/profile/origin/authority/source bindings. + - Secret references resolve without expanding values into YAML or diagnostics. + - Scripts compile and use only the Version 1 ABI and primitive allowlist. + - Schemas and codelists are closed, versioned, bounded, and referenced. + - Each reviewed structured-value schema URI resolves to exactly one schema artifact by its `$id`; missing, duplicate, unreferenced, or open schemas fail startup. + - Each date- or time-bucket scheme URI and version resolves to exactly one reviewed codelist artifact; missing, duplicate, or unreferenced schemes fail startup. + - Fixtures cover positive, negative, boundary, missing, no-match, ambiguous, source failure, and anti-reconstruction cases. + - The complete simultaneously enabled requirement set passes disclosure-combination review. + - No two enabled requirements share a disclosureGuard family; a shared family rejects the bundle regardless of purposes, audiences, requester entitlements, or rate limits. + - Each context-derived or grant-derived subject has valueClaims whose keys exactly equal the named selector profile field set; missing, extra, duplicate-target, or invalid claim paths fail startup. + - Request-derived subjects have no valueClaims. + - Request preparation receives only the closed selectorInputs alternatives and cannot choose transport authority, paths, headers, credentials, or request count. + - For authenticated-grant origin, verified token values at authentication.grantIdClaim and grantAuthorityClaim must exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must bind requirement, purpose, audience, role, selector profile, and valueClaims result before selector resolution. + - Required signing, audit, credential, and source dependencies are ready. + - Every retired public JWK file is public-only, has a unique kid, uses the configured allowlisted algorithm, and remains available for the maximum assertion validity plus clock skew. + - Every secret reference uses the closed file-secret grammar and is resolved beneath runtime.yaml secretProviders.file.root. + - Fixed path and selector-bound path-template forms are mutually exclusive and every template placeholder has one authorized selector binding. + - Fixed headers and static API-key header names reject authentication, routing, framing, cookie, forwarding, proxy, tracing, and hop-by-hop collisions after ASCII case folding. + - Extended JSON Pointer projections are non-empty, uniquely decoded, and reject duplicate or overlapping ancestor, descendant, and wildcard paths. + - Every source preparation, extraction, and derivation selector-input contract is closed and cross-referenced to the exact authorized role/profile/field set. +secret_policy: + permitted: File-provider logical names matching the exact secret-reference grammar only. + prohibited: private key, password, bearer token, client secret, or expanded environment value in any bundle file + query_string_oauth_rule: Query-string client credentials require the explicit query-string placement and complete token URL, query, body, response, and debug-output redaction. + file_provider_rule: Resolve secret:file logical names beneath the configured owner-controlled fileRoot, reject symlinks and path traversal, require regular owner-only files, and parse values as data. +deployment_rules: + authentication: Evidence independently validates the bearer token against exact configured issuer, audience, token type, algorithm, and JWKS; upstream identity headers are ignored and rejected if mapped + audit: failClosed and keyed pseudonymization remain governed; storage path and rotation bound come only from runtime.yaml + rate_limit_labels: principal and authority use scoped pseudonyms; raw selector values are never labels or keys + source_urls: HTTPS is required except explicit HTTP numeric loopback on 127.0.0.0/8 or ::1 for deterministic local mocks; Rust parses IP and port strictly and rejects userinfo, DNS localhost aliases, non-loopback HTTP, invalid octets, fragments, and URL ambiguity + runtime_split: runtime.yaml cannot override any field in this schema; the two closed documents have independent digests and immutable startup lifetimes diff --git a/products/evidence/contracts/cccev-field-mapping.yaml b/products/evidence/contracts/cccev-field-mapping.yaml new file mode 100644 index 000000000..fd644ae4f --- /dev/null +++ b/products/evidence/contracts/cccev-field-mapping.yaml @@ -0,0 +1,88 @@ +contract: registry.evidence.cccev-mapping/v1 +status: frozen +semantic_reference: + vocabulary: CCCEV + version: 2.2.0 + url: https://semiceu.github.io/CCCEV/releases/2.2.0/ +mapping: + Evidence.schema: + source: evidence_extension + rule: Fixed profile discriminator registry.assertion-evidence/v1. + Evidence.id: + source: dct:identifier + rule: Core-created globally unique evidence identifier. + Evidence.type: + source: cccev:Evidence + rule: Literal Evidence in Version 1. + Evidence.supportsRequirement: + source: cccev:supportsRequirement + range: cccev:Requirement + rule: Exact configured requirement identifier and revision. + Evidence.isConformantTo: + source: dct:conformsTo + range: cccev:EvidenceType + rule: Exact configured Evidence Type identifier. + Evidence.issuedBy: + source: dct:creator + range: cccev:Agent + rule: Legal issuer responsible for the assertion semantics. + Evidence.providedBy: + source: cccev:providedBy + range: cccev:Agent + rule: Technical Evidence provider that constructs and signs the payload. + Evidence.issuedAt: + source: dct:issued + range: xsd:dateTime + rule: Core-assigned UTC instant after successful evaluation. + Evidence.observedAt: + source: evidence_extension + range: xsd:dateTime + rule: Observation instant used by derivation. + Evidence.validUntil: + source: dct:valid + range: xsd:dateTime + rule: Optional exclusive UTC validity bound computed from fixed configuration. + Evidence.purpose: + source: evidence_extension + rule: Authorized configured purpose code, not caller-defined prose. + Evidence.audience: + source: evidence_extension + rule: Audience derived from authenticated authority context. + Evidence.configurationRevision: + source: evidence_extension + rule: sha256 digest of the complete atomic bundle bytes and layout manifest. + Evidence.subjects: + source: evidence_extension + rule: Closed role-bound, audience-scoped subject bindings; selectors are never included. + Evidence.supportedValues: + source: cccev:supportsValue + range: cccev:SupportedValue + rule: Exactly the concept-value set declared by the selected requirement. + SubjectBinding.role: + source: evidence_extension + rule: Exact configured requirement role identifier. + SubjectBinding.binding: + source: evidence_extension + rule: Audience-scoped opaque binding over the complete canonical role and selector bundle. + SupportedValue.providesValueFor: + source: cccev:providesValueFor + range: cccev:InformationConcept + rule: Exact configured Information Concept identifier. + SupportedValue.value: + source: cccev:value + rule: One closed Version 1 form validated against the concept declaration; bounded decimals use canonical decimal strings to preserve exact coefficient and scale. +bundle_model: + requirement_kinds: + criterion: cccev:Criterion + information_requirement: cccev:InformationRequirement + constraint: cccev:Constraint + evidence_type: cccev:EvidenceType + information_concept: cccev:InformationConcept + reference_framework: cccev:ReferenceFramework + evidence_type_list: + source: cccev:EvidenceTypeList + semantics: Types within a list are AND; alternative lists are OR. + runtime_rule: Preserved as metadata only; Version 1 does not execute multi-evidence or multi-source fulfillment. +extension_policy: + namespace: https://registrystack.org/ns/evidence/v1# + rule: Evidence extensions are explicit and must never be described as CCCEV-native properties. diff --git a/products/evidence/contracts/evidence.schema.yaml b/products/evidence/contracts/evidence.schema.yaml new file mode 100644 index 000000000..71d9f1b32 --- /dev/null +++ b/products/evidence/contracts/evidence.schema.yaml @@ -0,0 +1,104 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/assertion-evidence-v1.json +title: Evidence assertion payload Version 1 +type: object +additionalProperties: false +required: + - schema + - id + - type + - supportsRequirement + - isConformantTo + - issuedBy + - providedBy + - issuedAt + - observedAt + - validUntil + - purpose + - audience + - configurationRevision + - subjects + - supportedValues +properties: + schema: {const: registry.assertion-evidence/v1} + id: {type: string, format: uri, maxLength: 512} + type: {const: Evidence} + supportsRequirement: {type: string, format: uri, maxLength: 512} + isConformantTo: {type: string, format: uri, maxLength: 512} + issuedBy: {type: string, format: uri, maxLength: 512} + providedBy: {type: string, format: uri, maxLength: 512} + issuedAt: {type: string, format: date-time} + observedAt: {type: string, format: date-time} + validUntil: {type: string, format: date-time} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + audience: {type: string, format: uri, maxLength: 512} + configurationRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + subjects: + type: array + minItems: 1 + maxItems: 8 + items: {$ref: '#/$defs/subject-binding'} + supportedValues: + type: array + minItems: 1 + maxItems: 16 + items: {$ref: '#/$defs/supported-value'} +$defs: + subject-binding: + type: object + additionalProperties: false + required: [role, binding] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + binding: {type: string, pattern: '^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$'} + supported-value: + type: object + additionalProperties: false + required: [providesValueFor, value] + properties: + providesValueFor: {type: string, format: uri, maxLength: 512} + value: {$ref: '#/$defs/value'} + value: + anyOf: + - {type: boolean} + - {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - {type: string, minLength: 1, maxLength: 1024} + - {$ref: '#/$defs/bucket'} + - {$ref: '#/$defs/entity-reference'} + - {$ref: '#/$defs/structured'} + - type: array + minItems: 1 + maxItems: 64 + items: + anyOf: + - {type: string, minLength: 1, maxLength: 1024} + - {$ref: '#/$defs/entity-reference'} + bucket: + type: object + additionalProperties: false + required: [form, scheme, bucket] + properties: + form: {enum: [date-bucket, time-bucket]} + scheme: {type: string, format: uri, maxLength: 512} + bucket: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + entity-reference: + type: object + additionalProperties: false + required: [form, reference] + properties: + form: {const: audience-scoped-entity-reference} + reference: {type: string, pattern: '^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$'} + structured: + type: object + additionalProperties: false + required: [form, schema, fields] + properties: + form: {const: reviewed-structured-value} + schema: {type: string, format: uri, maxLength: 512} + fields: {type: object, minProperties: 1, maxProperties: 16} +$comment: >- + The selected concept declaration closes the exact value form, schema, + codelist, precision, cardinality, sizes, structured fields, and uniqueness. + A value accepted by this transport schema but not by that declaration is + rejected before evidence construction. Subject selector profiles and values + never appear in evidence. diff --git a/products/evidence/contracts/jws-profile.yaml b/products/evidence/contracts/jws-profile.yaml new file mode 100644 index 000000000..8f94a399a --- /dev/null +++ b/products/evidence/contracts/jws-profile.yaml @@ -0,0 +1,59 @@ +contract: registry.evidence.jws-profile/v1 +status: frozen +response: + media_type: application/jose+json + serialization: flattened-jws-json + members: + required: [protected, payload, signature] + prohibited: [header] + payload: base64url without padding of the exact UTF-8 Evidence JSON bytes +protected_header: + exact_members: [alg, kid, typ, cty] + alg: + allowed: [EdDSA] + rule: Must equal the algorithm bound to the trusted key. + kid: + required: true + maximum_bytes: 256 + rule: Resolved only within the verifier-pinned provider JWKS. + typ: {const: evidence+jws} + cty: {const: application/evidence+json} + prohibited: [jku, x5u, jwk, x5c, crit, b64] +signing: + active_keys: 1 + private_key_location: signing.activeKeyRef through a supported runtime secret provider only + retired_public_keys: signing.retiredPublicJwkFiles are bundle-owned public JWKs and never contain private members + bundle_private_key_material: prohibited + order: + - core validates the complete derivation result + - core constructs the exact evidence payload + - signing provider signs protected header plus payload + - disclosure-release audit is durably accepted + - JWS is released + failure: safe 503 with no unsigned fallback +key_discovery: + path: /.well-known/evidence/jwks.json + media_type: application/jwk-set+json + content: public keys only + trust_rule: Discovery is not a trust anchor; provider identity and JWKS location are pinned through governed verifier configuration. +rotation: + new_assertions_use: the single configured active key + retired_public_key_minimum_availability: maximum assertion validity plus allowed verifier clock skew + kid_reuse: prohibited for different key material +verifier_rules: + - Parse flattened JWS with strict duplicate-member rejection. + - Reject unprotected headers and any protected member outside the exact allowlist. + - Resolve kid only in the pinned provider key set and require the allowlisted algorithm. + - Verify the signature before parsing or acting on payload claims. + - Validate the strict payload against the complete committed Evidence JSON Schema before deserializing or applying relying-procedure policy. + - Require schema, issuer, provider, requirement, Evidence Type, purpose, audience, observation, validity, and configuration revision expected by the relying procedure. + - Treat validUntil as an exclusive upper bound and apply only the configured clock skew. + - Do not infer source truth, legal-signature status, holder binding, or single-use semantics from a valid signature. +negative_tests: + - jws-payload-modification + - jws-protected-header-modification + - jws-message-key-url-rejected + - jws-unknown-kid-rejected + - jws-schema-invalid-signed-payload-rejected + - jws-retired-key-window + - signing-failure-no-unsigned-success diff --git a/products/evidence/contracts/primitive-library.yaml b/products/evidence/contracts/primitive-library.yaml new file mode 100644 index 000000000..3f2d610af --- /dev/null +++ b/products/evidence/contracts/primitive-library.yaml @@ -0,0 +1,99 @@ +contract: registry.evidence.rhai-primitives/v1 +status: frozen +resource_limits: + maximum_operations: 100000 + maximum_call_depth: 32 + maximum_expression_depth: 64 + maximum_modules: 0 + maximum_string_bytes: 16384 + maximum_array_items: 256 + maximum_map_entries: 256 + maximum_fact_entries: 64 + maximum_concept_values: 16 + maximum_codelist_entries_per_handle: 4096 + maximum_source_input_bytes: 1048576 + maximum_preparation_input_bytes: 1048576 + maximum_result_bytes: 65536 + maximum_request_parts_bytes: 65536 + maximum_query_pairs: 64 + maximum_query_name_bytes: 64 + maximum_query_value_bytes: 4096 + maximum_json_body_depth: 32 + timeout_behavior: operation limit is normative; any outer wall-time limit only aborts and never changes results +primitives: + parse_date: + signature: string -> Date + behavior: Strict proleptic Gregorian YYYY-MM-DD; invalid or non-canonical input fails. + parse_instant: + signature: string -> Instant + behavior: Strict RFC 3339 with explicit offset; normalized internally to UTC without consulting a clock. + parse_integer: + signature: string -> integer + behavior: Parses an optional ASCII minus followed by one or more ASCII decimal digits into signed i64. Leading zeroes are permitted. Empty, plus-prefixed, whitespace, non-ASCII, fractional, exponential, and overflowing inputs fail. + decimal: + signature: string -> Decimal + behavior: Alias of parse_decimal for exact canonical text; never accepts a Rhai float. + parse_decimal: + signature: string -> Decimal + behavior: Rust validates canonical finite decimal text, maximum precision 28 and scale 9, with zero represented only as 0; no exponent, plus sign, leading zero, trailing fractional zero, NaN, infinity, or negative zero. + integer_to_decimal: + signature: integer -> Decimal + behavior: Exact conversion of a bounded Rhai integer to scale-zero Decimal. + add_calendar_years: + signature: '[Date, integer] -> Date' + behavior: Calendar addition; a leap-day input clamps to the last valid day of the target month. + bounds: {years: [-1000, 1000]} + add_calendar_months: + signature: '[Date, integer] -> Date' + behavior: Calendar addition with last-valid-day clamping. + bounds: {months: [-12000, 12000]} + compare_dates: + signature: '[Date, Date] -> integer' + behavior: Returns only -1, 0, or 1. + compare_instants: + signature: '[Instant, Instant] -> integer' + behavior: Returns only -1, 0, or 1. + days_between: + signature: '[Date, Date] -> integer' + behavior: Returns second minus first in whole Gregorian calendar days, bounded to -365000 through 365000. + compare_decimals: + signature: '[Decimal, Decimal] -> integer' + behavior: Exact arbitrary-precision decimal comparison; returns -1, 0, or 1 without binary floating-point conversion. + bucket_number: + signature: '[Decimal, array] -> string' + behavior: Returns the code for the one configured half-open minimum_inclusive/maximum_exclusive interval; intervals must be ordered, non-overlapping, exhaustive over the declared numeric range, and at most 64. + boundary_shape: {minimumInclusive: Decimal, maximumExclusive: Decimal, code: string} + entity_reference_seed: + signature: string -> EntityReferenceSeed + behavior: Wraps 1 through 512 exact UTF-8 bytes in a protected opaque core value; the seed cannot be converted back to a Rhai string, compared, printed, logged, or serialized. + codelist_lookup: + signature: '[CodelistHandle, string] -> Option' + behavior: Exact code lookup with no normalization; returns only the configured output code. + list_contains: + signature: '[array, scalar] -> boolean' + behavior: Exact typed equality over at most 256 items. + set_contains: + signature: '[array, scalar] -> boolean' + behavior: Exact typed equality over at most 256 items; a duplicate input item fails because the array represents a set. + array_push: + signature: 'array.push(value) -> unit' + behavior: Appends one local value when the resulting array remains within the 256-item bound; otherwise fails without a partial output. + string_replace: + signature: 'string.replace(from, to) -> unit' + behavior: Mutates the local receiver by replacing every exact non-overlapping literal occurrence, with no regex or Unicode normalization, when the result remains within the 16384-byte string bound. + required: + signature: '[Option, string] -> T' + behavior: Returns the value or fails with the supplied safe bundle-owned error code; protected data may not be used as the code. + is_missing: + signature: Option -> boolean + behavior: Explicit missing-value test with no implicit coercion. +global_rules: + - Primitives are pure, deterministic, typed, bounded, and domain-neutral. + - Strings are compared as exact UTF-8 values; no Unicode normalization, case folding, transliteration, or phonetics occurs. + - Decimal operations use the Rust-owned exact Decimal type; Rhai floating-point arithmetic is not accepted at the output gate. + - EntityReferenceSeed is a protected projection input, not public data; only the core can HMAC-project it to an audience-scoped reference. + - Query names and values remain strings. parse_integer exists only for provider text and does not introduce implicit integer-to-string or value-to-string conversion. + - Array and string mutation affects only the fresh invocation-local copy and cannot modify the governed bundle, another script stage, or another request. + - No primitive performs I/O, authorization, logging, audit, signing, response construction, or ambient time access. + - A new primitive requires a generic need demonstrated by more than one definition shape and focused boundary tests. +prohibited_namespaces: [domain, identity, source-product, credential, policy, response, audit] diff --git a/products/evidence/contracts/problem-contract.yaml b/products/evidence/contracts/problem-contract.yaml new file mode 100644 index 000000000..3b4558713 --- /dev/null +++ b/products/evidence/contracts/problem-contract.yaml @@ -0,0 +1,36 @@ +contract: registry.evidence.public-problem/v1 +status: frozen +media_type: application/problem+json +body: + exact_members: [type, title, status, code, operation] + operation: opaque per-request identifier safe for support correlation + prohibited: + - request body or selector profile and values + - principal, actor, grant, token, credential, or authorization inputs + - source URL, query, request, response, status text, or fact + - script input, output, line, stack, or diagnostic + - supported value or subject binding + - candidate count, score, hint, or comparison detail +codes: + malformed_request: {status: 400, title: Request is not valid} + invalid_selector: {status: 400, title: Request is not valid} + authentication_failed: {status: 401, title: Authentication failed} + not_authorized: {status: 403, title: Request is not authorized} + evidence_not_available: {status: 422, title: Evidence could not be produced} + rate_limited: {status: 429, title: Request rate exceeded} + dependency_unavailable: {status: 503, title: Service temporarily unavailable} + service_unavailable: {status: 503, title: Service temporarily unavailable} +existence_disclosure: + default_public_collapse: + internal_classes: [no_match, ambiguous, required_fact_missing] + public_code: evidence_not_available + same_status: 422 + same_title: Evidence could not be produced + same_body_shape: true + timing_rule: Avoid intentional class-dependent delay; apply uniform bounded processing and response handling. + explicit_existence_concept_rule: Existence may be disclosed only as a separately authorized fixed concept, never as error detail. +transient_failures: + internal_classes: [source_unavailable, source_protocol_error, script_failure, signing_failure, access_audit_failure, release_audit_failure] + public_codes: [dependency_unavailable, service_unavailable] + retry_after: permitted only for bounded transient failures and never derived from protected source content +unknown_definition_rule: Unknown and unauthorized requirement identifiers must be indistinguishable to a caller lacking authorization. diff --git a/products/evidence/contracts/request.schema.yaml b/products/evidence/contracts/request.schema.yaml new file mode 100644 index 000000000..356b005a4 --- /dev/null +++ b/products/evidence/contracts/request.schema.yaml @@ -0,0 +1,64 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/request-v1.json +title: Evidence request Version 1 +type: object +additionalProperties: false +required: [requirement, purpose, subjects] +properties: + requirement: + type: string + format: uri + minLength: 1 + maxLength: 512 + purpose: + type: string + pattern: '^[a-z][a-z0-9._:-]{0,127}$' + subjects: + type: array + minItems: 1 + maxItems: 8 + description: >- + Unordered role set encoded as an array. Each configured role appears + exactly once. Runtime resolves roles by name and emits the requirement's + declaration order internally. + items: {$ref: '#/$defs/subject'} +$defs: + subject: + type: object + additionalProperties: false + required: [role, selector] + properties: + role: + type: string + pattern: '^[a-z][a-z0-9._-]{0,63}$' + selector: {$ref: '#/$defs/selector'} + selector: + type: object + additionalProperties: false + required: [profile] + properties: + profile: + type: string + pattern: '^[a-z][a-z0-9._-]{0,127}$' + values: + type: object + minProperties: 1 + maxProperties: 16 + propertyNames: + pattern: '^[a-z][a-z0-9._-]{0,63}$' + additionalProperties: {$ref: '#/$defs/scalar-selector-value'} + scalar-selector-value: + oneOf: + - {type: string, minLength: 1, maxLength: 512} + - {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - {type: boolean} +$comment: >- + This transport schema is followed by named-profile validation. The profile + closes the exact field names, scalar types, byte and numeric bounds, + aggregate size, value origin, and source placements. Values must be absent + for authenticated-context and authenticated-grant origins, and present for + an authorized request origin. Dates and controlled codes are strings whose + exact lexical rules are declared by the profile. Unknown, missing, extra, + mistyped, empty, oversized, wrong-origin, or unauthorized values fail before + credential acquisition or source access. Array position is not semantic; + duplicate, missing, unknown, or wrong-profile roles fail before access audit. diff --git a/products/evidence/contracts/rhai-abi.yaml b/products/evidence/contracts/rhai-abi.yaml new file mode 100644 index 000000000..5a536e529 --- /dev/null +++ b/products/evidence/contracts/rhai-abi.yaml @@ -0,0 +1,137 @@ +contract: registry.evidence.rhai-abi/v1 +status: frozen +trust_model: + scripts: Reviewed, trusted, immutable bundle artifacts compiled at startup. + boundary: Trust permits scripts to implement provider and requirement semantics, but never grants transport, credential, authorization, signing, audit, or evidence-construction authority. +functions: + preparation: + signature: prepare(source_required_selectors, adapter_parameters) -> RequestParts + input: + source_required_selectors: Exact role, profile, and field map selected from the source request selectorInputs after validation and authorization. + adapter_parameters: Closed non-secret JSON parameters validated at startup against adapterParametersSchema. + prohibited: + - requester, actor, purpose, audience, authority, entitlement, or grant objects + - source origin, method, path, path bindings, headers, credentials, TLS, proxy, timeout, redirect, pagination, concurrency, or request-count controls + output: + exact_keys: [query, body] + query: + representation: ordered array of exact maps with string keys name and value + maximum_items: 64 + rules: + - Names and values are logical lexical strings; Rust percent-encodes each component exactly once. + - Pair order and duplicate names are preserved. + - Empty names, CR, LF, implicit value-to-string conversion, and non-string values are rejected. + body: + representation: one bounded JSON value or unit for no body + maximum_depth: 32 + validation: Rust applies the source preparationLimits and validates the complete normalized RequestParts before credential resolution. + core_owned_transport: + - source and fixed origin + - fixed method and fixed path, or complete-segment selector-bound pathTemplate and pathBindings + - fixed non-secret headers and every authentication header + - credentials, TLS trust, hostname verification, redirect denial, proxy denial, timeout, response limit, concurrency, and one-request ceiling + extraction: + signature: extract(projected_source_response, adapter_parameters) -> LookupResult + input: + projected_source_response: Parsed JSON from the one completed evidence-data request after pre-projection response bounds and the Rust-enforced extended JSON Pointer allowlist. + adapter_parameters: A fresh copy of the same closed non-secret parameters supplied to preparation. + prohibited: + - selectors or prepared request parts + - unprojected response fields + - request metadata, credentials, authority context, or a source client + output: + tagged_union: + match: + exact_keys: [outcome, facts] + outcome: match + facts: Closed FactSet declared by the source binding. + no_match: + exact_keys: [outcome] + outcome: no_match + ambiguous: + exact_keys: [outcome] + outcome: ambiguous + prohibited: [candidates, count, scores, confidence, hints, diagnostics, comparisons] + rules: + - Facts are legal only on match and must exactly satisfy the declared fact schema. + - No-match and ambiguous stop evaluation; derivation is not invoked. + - Extraction may map provider-owned cardinality from a count plus one minimized record, or at most two minimally projected records solely to detect ambiguity. + - Extraction never chooses a candidate and never turns protocol inconsistency into a closed lookup outcome. + derivation: + signature: derive(facts, declared_authorized_selectors, evaluation_context) -> ConceptValueSet + input: + facts: Immutable validated FactSet from one unique match. + declared_authorized_selectors: Exact role, profile, and field map declared by derivation.selectorInputs, resolved from already authorized requirement subjects; omission yields an empty map. + evaluation_context: + exact_keys: [observed_at, legal_local_date, legal_local_time, parameters, codelists] + observed_at: Runtime-supplied UTC instant. + legal_local_date: Runtime-resolved date for the configured IANA timezone. + legal_local_time: Runtime-resolved local time with UTC offset. + parameters: Fixed typed requirement parameters. + codelists: Bounded read-only handles to named versioned bundle codelists. + output: + representation: array of exact maps with keys concept_id and value + maximum_items: 16 + validation: Concept identifiers must equal the requirement output set; duplicates, missing required values, extra fields, and invalid values are rejected. + protected_types: + Decimal: + construction: decimal(canonical_text) or parse_decimal(canonical_text) + rule: Rust-owned exact coefficient-and-scale decimal with maximum precision 28 and scale 9; Rhai f32/f64 and ordinary numeric coercion cannot satisfy a bounded-decimal concept. + EntityReferenceSeed: + construction: entity_reference_seed(protected_string) + maximum_seed_bytes: 512 + rule: Valid only for an audience-scoped-entity-reference concept or its bounded list form; never serializable, loggable, or usable as a public identifier. + EntityReferenceSeedList: + construction: Bounded Rhai array containing only EntityReferenceSeed values. + maximum_items: 64 + rule: Rust projects each seed after output validation. + permitted_selector_aware_rules: + - Exact equality between a returned unique-record binding fact and its declared authorized lookup selector. + - Exact membership of a separately authorized stable reference in a complete authoritative relationship set with a governed namespace and contract identifier. + - A closed deterministic attribute comparison only when its jurisdiction-governed canonicalization and concept semantics are explicitly versioned in the reviewed requirement. + - Direct mapping of an explicit source-owned decision fact. + false_relationship_rule: A false relationship concept is permitted only after unique returned-subject binding, complete valid relationship facts, namespace and contract agreement, and the exact governed comparison. No-match, ambiguity, returned-subject mismatch, incomplete facts, or protocol uncertainty stop without a signed negative. + prohibited_context: + - undeclared selector roles, profiles, or fields + - requester or actor + - requirement purpose or audience + - authority, entitlement, or grant objects + - token, credential, source client, response, or prepared request + - logging, audit, or signing handle +capabilities: + allowed: + - fresh local variables and statically named bounded same-file helper functions + - pure expressions and bounded array iteration + - allowlisted primitive calls + - bounded local map, array, and string construction and mutation + denied: + - filesystem, environment, network, process, import, eval, plugins, or modules + - ambient clock, timezone lookup, randomness, UUID, logging, printing, diagnostics, audit, credentials, or signing + - anonymous functions, function pointers, dynamic dispatch, top-level executable statements, or catchable host-private unavailable termination + syntax_contract: The detailed pinned Rhai 1.25.1 syntax surface and forbidden constructs are defined by the trusted request-adapter API and enforced by startup compilation tests. +lifecycle: + compilation: Startup only; each script must expose exactly one public entry point at the required arity before readiness. + state: Fresh invocation state and fresh input copies; no cross-stage, cross-request, or cross-definition mutable state. + identity: Script path and exact bytes are covered by the governed bundle revision. +failure: + preparation: adapter_input_error + extraction: source_protocol_error + derivation: derivation_input_error + unavailable: Host-private, unforgeable, and uncatchable required-value termination. + observability: All failures map to closed value-free classes; raw Rhai errors and protected input or output material are absent from public problems and diagnostics. +core_projection: + decimal: + input: Decimal canonical text + output: JSON string containing the exact canonical decimal text + rule: JSON numeric serialization and binary floating-point conversion are prohibited. + entity_reference: + input: EntityReferenceSeed + hmac_input: Deterministic length-prefixed bytes over profile version, concept id, audience, binding-key version, and exact seed bytes. + key: subjectBinding.secretRef from the governed bundle + output: 'urn:evidence:entity:v_' + confidentiality: Seed is protected source-derived data and is absent from evidence, logs, audit, errors, traces, metrics, snapshots, and script diagnostics. +anti_capabilities: + - source, origin, path, header, authentication, credential, TLS, proxy, pagination, retry, or request-count selection + - broad candidate retrieval, probabilistic or fuzzy scoring, ranking, deduplication, or candidate selection + - authorization or disclosure-profile selection + - evidence, identifier, subject-binding, JWS, or audit construction diff --git a/products/evidence/contracts/runtime.schema.yaml b/products/evidence/contracts/runtime.schema.yaml new file mode 100644 index 000000000..5d728d057 --- /dev/null +++ b/products/evidence/contracts/runtime.schema.yaml @@ -0,0 +1,83 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/runtime-v1.json +title: Evidence closed operator runtime configuration Version 1 +type: object +additionalProperties: false +required: [version, bundleDirectory, listener, secretProviders, auditStorage, outboundTls] +properties: + version: {const: 1} + bundleDirectory: {$ref: '#/$defs/absolute-path'} + listener: + type: object + additionalProperties: false + required: [bindHost, port, tlsTermination, trustProxyIdentityHeaders, maximumRequestBytes, maximumConcurrentRequests, requestTimeoutMilliseconds, shutdownGraceMilliseconds] + properties: + bindHost: + type: string + minLength: 2 + maxLength: 64 + description: Numeric loopback, RFC 1918 private IPv4, or RFC 4193 unique-local IPv6 address. Unspecified, multicast, public, and hostname values are prohibited. + x-runtime-validation: Parsed as an IP address and accepted only when Rust classifies it as loopback, private IPv4, or unique-local IPv6. + port: {type: integer, minimum: 1, maximum: 65535} + tlsTermination: {const: operator-controlled-upstream} + trustProxyIdentityHeaders: {const: false} + maximumRequestBytes: {type: integer, minimum: 1024, maximum: 1048576} + maximumConcurrentRequests: {type: integer, minimum: 1, maximum: 4096} + requestTimeoutMilliseconds: {type: integer, minimum: 1, maximum: 30000} + shutdownGraceMilliseconds: {type: integer, minimum: 1, maximum: 120000} + secretProviders: + type: object + additionalProperties: false + required: [file] + properties: + file: + type: object + additionalProperties: false + required: [root] + properties: + root: {$ref: '#/$defs/absolute-path'} + auditStorage: + type: object + additionalProperties: false + required: [path, maximumFileBytes] + properties: + path: {$ref: '#/$defs/absolute-path'} + maximumFileBytes: {type: integer, minimum: 1048576, maximum: 1099511627776} + outboundTls: + type: object + additionalProperties: false + required: [systemRoots, trustProfiles] + properties: + systemRoots: {const: true} + trustProfiles: + type: object + maxProperties: 64 + propertyNames: {$ref: '#/$defs/local-id'} + additionalProperties: + type: object + additionalProperties: false + required: [caBundleFile] + properties: + caBundleFile: {$ref: '#/$defs/absolute-path'} +$defs: + local-id: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + absolute-path: {type: string, minLength: 2, maxLength: 512, pattern: '^/(?!/)(?!.*(?:^|/)\.\.?(?:/|$))[^\\\u0000]+$'} +ownership: + governed_fields: prohibited + allowed: + - bundle directory + - listener binding and process limits + - file-secret root + - audit path and rotation bound + - logical private-CA file bindings + overrides: prohibited +startup: + unknown_keys: rejected at every level + mutability: runtime.yaml and bound CA files are captured read-only once; reload, merge, fallback, and partial serving are prohibited + digest: independent SHA-256 revision over exact runtime.yaml bytes plus logical trust-profile names and exact CA bytes + trust_profiles: names must exactly equal the logical tlsTrustProfile names used by the governed bundle + secrets: values are never parsed into or included in the runtime document or digest + proxy: HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY are ignored; no application-level proxy field exists +platform: + supported: Unix + reason: Version 1 requires Unix owner, mode, no-follow, link-count, and file-identity guarantees for secrets and audit storage. diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml new file mode 100644 index 000000000..bdff69016 --- /dev/null +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -0,0 +1,171 @@ +contract: registry.evidence.security-invariants/v1 +status: frozen +review_rule: Every row requires its named negative test on the same implementation revision; tests may be split but may not be weakened or deleted. +invariants: + - id: V1-I01 + rule: Only predefined, versioned requirements can be evaluated. + threat: Caller turns Evidence into an arbitrary fact-query service. + enforcement: Request resolver accepts only exact enabled requirement revisions before authorization. + negative_test: sec-unknown-or-disabled-requirement + - id: V1-I02 + rule: Callers cannot provide thresholds, expressions, scripts, paths, headers, source fields, relationship types, adapter parameters, or response projections. + threat: Caller reconstructs protected facts or expands acquisition and disclosure. + enforcement: Closed request schema plus fixed bundle-owned preparation, extraction, derivation, source transport, and concept projection. + negative_test: sec-caller-query-material-rejected + - id: V1-I03 + rule: The complete enabled bundle is reviewed as one disclosure surface. + threat: Threshold ladders or overlapping categories reconstruct a protected value across otherwise safe definitions. + enforcement: Startup bundle-combination validation and explicit operator review of one atomic revision. + negative_test: sec-unsafe-definition-combination + - id: V1-I04 + rule: Principals and attributes derive only from configured validated authentication sources; missing data denies. + threat: Authentication bypass through fallback claims or unsigned headers. + enforcement: Strict authentication profile and one configured principal claim with no fallback. + negative_test: sec-missing-principal-no-fallback + - id: V1-I05 + rule: One authorization decision binds requester, optional actor, requirement revision, purpose, all role/profile/origin tuples, authority, and audience. + threat: Permission splicing or confused-deputy access across partially authorized dimensions. + enforcement: Exact complete-entitlement match before audit, credential resolution, or source access. + negative_test: sec-no-entitlement-union + - id: V1-I06 + rule: Selector profiles and values are provider-lookup inputs, never proof of authority. + threat: Broken object-level authorization by possession of an identifier or demographic tuple. + enforcement: Selector validation occurs inside an already matched subject-authority path. + negative_test: sec-selector-possession-no-authority + - id: V1-I07 + rule: Caller-provided consent, approval, or grant references never create authority. + threat: Fabricated reference escalates authority. + enforcement: Grant identifiers are accepted only from authenticated context and must be bound to the complete entitlement. + negative_test: sec-caller-grant-reference-rejected + - id: V1-I08 + rule: Callers cannot choose selector fields, operators, weights, thresholds, normalization, or query plans. + threat: Matching scope creep, oracle behavior, or broad candidate retrieval. + enforcement: Named profiles close exact field sets; independent source and derivation selectorInputs expose only reviewed role/profile/field subsets, while trusted preparation owns provider placement without receiving surplus selectors. + negative_test: sec-selector-shape-not-callable + - id: V1-I09 + rule: Source calls are fixed by trusted configuration and executed only by the core. + threat: SSRF, credential forwarding, overbroad reads, or script-directed networking. + enforcement: Generic fixed HTTPS executor owns origin, method, fixed or selector-bound path, fixed headers, authentication, TLS, projection, proxy denial, bounded source and OAuth single-flight admission, limits, and one-request ceiling. Rhai returns only bounded query pairs and one JSON body, validated before credentials. + negative_test: sec-source-request-immutable + - id: V1-I10 + rule: Provider lookup has only match, no_match, and ambiguous; Evidence never exposes or chooses candidates, while reviewed derivation may compare only declared authorized selectors with complete facts from one unique record. + threat: Evidence becomes an identity-resolution engine or exposes registry candidates. + enforcement: Closed extraction union, maximum two minimized results, no pagination, no derivation on non-match outcomes, and false relationship evidence only after exact returned-record binding plus complete governed relationship facts. + negative_test: sec-candidate-material-rejected + - id: V1-I11 + rule: Rhai returns one closed lookup result and declared typed concept values only. + threat: Script crosses into authorization, response construction, diagnostics, or arbitrary metadata. + enforcement: Closed prepare/2, extract/2, and selector-aware derive/3 entry points, fresh state and input copies, capability allowlist, minimized stage inputs, and closed output decoding. + negative_test: sec-rhai-output-shape-closed + - id: V1-I12 + rule: Undeclared concepts, extra fields, and values violating types, codelists, cardinality, precision, or sizes are rejected. + threat: Data leakage or type confusion through trusted-script mistakes. + enforcement: Core validates exact concept set and the complete Supported Value declaration before evidence construction; decimals are exact coefficient-and-scale values and entity-reference seeds are protected projection inputs. + negative_test: sec-derived-value-output-gate + - id: V1-I13 + rule: Missing facts, undefined decisions, script failures, audit failures, and evaluation failures fail closed. + threat: Partial, stale, fabricated, or unaudited evidence is released. + enforcement: Typed error boundaries; access audit before source; release audit after signing and before release. + negative_test: sec-evaluation-and-audit-fail-closed + - id: V1-I14 + rule: Raw source responses are never persisted or logged. + threat: Source connector data leakage and unauthorized secondary retention. + enforcement: Bounded in-memory response ownership, no recording path, and centralized structured-log redaction. + negative_test: sec-source-canary-absent-everywhere + - id: V1-I15 + rule: Selector, source, and disclosed values never appear in logs or native audit. + threat: Quasi-identifier disclosure or reconstruction from operational records. + enforcement: Field allowlists and at most one scoped keyed pseudonym over each complete canonical role/selector bundle. + negative_test: sec-protected-canaries-redacted + - id: V1-I16 + rule: No-match and ambiguous behavior cannot accidentally disclose registry membership. + threat: Existence oracle through status, message, diagnostics, count, or avoidable timing. + enforcement: Default public collapse to one problem plus closed protected audit class without counts. + negative_test: sec-unresolved-public-collapse + - id: V1-I17 + rule: Subject bindings are audience-scoped and not globally linkable. + threat: Cross-purpose or cross-relying-party tracking. + enforcement: Domain-separated keyed derivation includes audience, purpose, role, profile, binding-key version, and complete canonical selector bundle. + negative_test: sec-subject-binding-scope + - id: V1-I18 + rule: Configuration is immutable for the serving process lifetime. + threat: Unreviewed hot mutation, partial revisions, rollback, or inconsistent audit provenance. + enforcement: One read-only atomic governed bundle and one separately digested closed runtime file at startup; runtime overrides, reload, merge, fallback, and mutation paths do not exist. + negative_test: sec-runtime-bundle-mutation-absent + - id: V1-I19 + rule: One process serves one operator-controlled trust domain. + threat: Cross-tenant disclosure, authority confusion, or key and audit boundary mixing. + enforcement: One service trustDomain, issuer governance boundary, bundle lifecycle, signer, and audit boundary per process. + negative_test: sec-mutually-distrustful-configuration + - id: V1-I20 + rule: Rate controls are defense in depth, not a substitute for concept design or authorization. + threat: Unsafe threshold reconstruction remains possible below or around rate limits. + enforcement: Bundle combination validation is mandatory independently of configured rate limits. + negative_test: sec-rate-limit-does-not-legalize-ladder + - id: V1-I21 + rule: Every successful production response is a standard JWS over the exact evidence payload. + threat: Payload substitution, unsigned success, or unverifiable parallel representations. + enforcement: One flattened-JWS response type containing only protected, payload, and signature. + negative_test: sec-jws-mutation-and-duplicate-payload + - id: V1-I22 + rule: Missing or failed signing never falls back to unsigned evidence. + threat: Availability fallback silently removes integrity and provider authentication. + enforcement: Signing readiness plus a mandatory signing step before release audit and response. + negative_test: sec-signing-failure-no-release + - id: V1-I23 + rule: Private signing material is core-owned and absent from bundle values, Rhai, logs, audit, and errors. + threat: Key disclosure or script exfiltration. + enforcement: Secret-reference-only schema, SigningProvider boundary, redacted serialization/debugging, and no script capability. + negative_test: sec-private-key-canary-unreachable + - id: V1-I24 + rule: A signature authenticates provider and payload integrity but does not assert legal-signature status or source truth. + threat: Relying party overclaims cryptographic or legal assurance. + enforcement: Distinct issuer/provider fields and verifier contract with no legal-signature or source-truth claim. + negative_test: sec-verifier-rejects-untrusted-provider + - id: V1-I25 + rule: Public evidence is constructed only by the core after complete output validation. + threat: Script injects envelope fields, subject identifiers, selector values, or unsupported claims. + enforcement: Rhai returns ConceptValueSet only; core owns identifiers, subjects, metadata, decimal serialization, entity-reference HMAC projection, public projection, and JWS. + negative_test: sec-script-cannot-construct-evidence +cross_cutting: + config_trust: + threat: A missing, writable, or unreviewed bundle is treated as trusted configuration. + enforcement: Version 1 has no in-bundle trust override; the operator must supply exactly one reviewed read-only bundle, and absence or failed immutability checks fail readiness. + negative_test: sec-missing-or-writable-bundle-fails + audit_order: + threat: Source read or evidence release occurs without a durable accountability record. + enforcement: Access-attempt audit precedes credentials/source; disclosure-release audit follows signing and precedes release; both fail closed. The complete keyed chain is verified at startup, while steady-state readiness and append use pinned identity, modification fingerprint, expected length, and verified tail so audit cost does not grow quadratically. + negative_test: sec-audit-order-and-failure + secret_parsing: + threat: YAML substitution or bundle values inject credential or key material. + enforcement: Bundle accepts exact secret references only; resolution occurs after parsing into core-owned secret containers and values never re-enter configuration. + negative_test: sec-secret-values-rejected-from-bundle + transport_identity: + threat: Public binding or proxy-provided identity bypasses Evidence token validation. + enforcement: Loopback or private HTTP listener behind operator-controlled HTTPS termination; Evidence validates strict OIDC bearer tokens and never trusts proxy identity headers. + negative_test: sec-proxy-identity-header-rejected + exact_decimal: + threat: Binary floating-point conversion changes a disclosed value or bypasses precision and range bounds. + enforcement: Rhai creates a Rust-owned coefficient-and-scale Decimal from canonical text with precision 28 and scale 9; public wire value is that canonical JSON string. + negative_test: sec-decimal-no-float-coercion + entity_reference_projection: + threat: A source-derived entity seed becomes a global identifier or leaks before audience scoping. + enforcement: Rhai returns a protected EntityReferenceSeed; Rust HMAC-projects version, concept, audience, and seed with the subject-binding key. + negative_test: sec-entity-reference-seed-protected + request_preparation: + threat: A trusted mapping mistake gains transport authority, smuggles an unbounded request, or acquires credentials before its output is validated. + enforcement: prepare/2 receives only minimized authorized selectors and closed parameters; Rust validates exact RequestParts, channel policy, sizes, path, headers, and encoding before bounded source-concurrency admission or credential resolution. GET plans close the JSON-body channel at startup and reject a defensive body before credential access. + negative_test: sec-request-preparation-closed + runtime_ownership_split: + threat: An environment-specific runtime file silently changes governed authorization, disclosure, source authority, signing, or audit policy. + enforcement: Closed runtime.yaml accepts only process-local listener, path, secret-root, audit-storage, and logical private-CA bindings and has an independent immutable digest. + negative_test: sec-runtime-cannot-override-governed-bundle + outbound_tls_and_proxy: + threat: A mutable or untrusted CA or ambient proxy redirects credentials and protected source queries to another authority. + enforcement: Runtime captures validated private-CA bytes at startup, fixed-origin hostname verification stays enabled, and both evidence-data and OAuth clients ignore ambient proxy variables. + negative_test: sec-tls-and-proxy-authority-fixed + subject_role_order: + threat: Caller-controlled array position substitutes one subject role for another or changes the signed binding order. + enforcement: Rust resolves a unique subject by declared role, rejects duplicate, missing, unknown, and wrong-profile entries, then emits requirement declaration order. + negative_test: sec-subject-array-order-nonsemantic +fixture_index: ../fixtures/conformance/coverage-matrix.yaml diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml new file mode 100644 index 000000000..04ee48fa2 --- /dev/null +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -0,0 +1,126 @@ +contract: registry.evidence.security-test-traceability/v1 +entries: + - id: sec-unknown-or-disabled-requirement + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: security_contract_rejects_unknown_and_unauthorized_requests_before_source_access}] + - id: sec-caller-query-material-rejected + tests: + - {file: crates/registry-evidence/src/model.rs, name: request_rejects_query_material_and_unknown_fields} + - {file: crates/registry-evidence/src/server.rs, name: request_json_is_strict_and_closed} + - id: sec-unsafe-definition-combination + tests: [{file: crates/registry-evidence/src/config.rs, name: a_shared_disclosure_family_rejects_the_complete_bundle}] + - id: sec-missing-principal-no-fallback + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: missing_principal_never_falls_back_to_client_id_or_azp}] + - id: sec-no-entitlement-union + tests: [{file: crates/registry-evidence/src/config.rs, name: complete_authority_paths_cannot_be_unioned_across_partial_grants}] + - id: sec-selector-possession-no-authority + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: security_contract_rejects_unknown_and_unauthorized_requests_before_source_access}] + - id: sec-caller-grant-reference-rejected + tests: [{file: crates/registry-evidence/src/model.rs, name: request_rejects_query_material_and_unknown_fields}] + - id: sec-selector-shape-not-callable + tests: + - {file: crates/registry-evidence/src/selector.rs, name: selector_claim_values_are_scalar_only} + - {file: crates/registry-evidence/src/config.rs, name: active_source_role_sets_reject_unreachable_inputs_at_startup} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: failed_selector_budget_is_enforced_by_the_runtime_and_scoped_to_authority} + - id: sec-source-request-immutable + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: exact_request_applies_path_query_body_headers_auth_and_projection_once} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: hostile_path_values_and_malformed_preparation_fail_before_transport_and_redact} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_frozen_source_shape_executes_through_production_materialization_and_projection} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_acquisition_posture_fixture_executes_with_one_bounded_request} + - {file: crates/registry-evidence/src/config.rs, name: path_templates_headers_and_projection_fail_closed} + - id: sec-candidate-material-rejected + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships}] + - id: sec-rhai-output-shape-closed + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: extraction_decodes_only_the_closed_union_and_validates_facts} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_rejects_an_extra_extracted_fact_before_derivation_or_release} + - id: sec-derived-value-output-gate + tests: + - {file: crates/registry-evidence/src/kernel.rs, name: extraction_failures_and_invalid_outputs_fail_closed} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_output_gate_rejects_every_fixture_injected_derivation_without_release} + - id: sec-evaluation-and-audit-fail-closed + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: access_audit_failure_blocks_credentials_and_source_access} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_signed_response_release} + - id: sec-source-canary-absent-everywhere + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships}] + - id: sec-protected-canaries-redacted + tests: + - {file: crates/registry-evidence/src/model.rs, name: debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: derived_value_debug_redacts_every_value_carrier} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: request_parts_debug_redacts_query_and_body_values} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_output_gate_rejects_every_fixture_injected_derivation_without_release} + - id: sec-unresolved-public-collapse + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships}] + - id: sec-subject-binding-scope + tests: [{file: crates/registry-evidence/src/binding.rs, name: every_subject_binding_scope_component_is_cryptographically_bound}] + - id: sec-runtime-bundle-mutation-absent + tests: + - {file: crates/registry-evidence/src/bundle.rs, name: revision_binds_paths_and_exact_bytes_deterministically} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture} + - id: sec-mutually-distrustful-configuration + tests: [{file: crates/registry-evidence/src/config.rs, name: one_trust_domain_and_native_token_identity_are_closed_configuration}] + - id: sec-rate-limit-does-not-legalize-ladder + tests: [{file: crates/registry-evidence/src/config.rs, name: a_shared_disclosure_family_rejects_the_complete_bundle}] + - id: sec-jws-mutation-and-duplicate-payload + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: payload_and_protected_header_mutation_fail} + - {file: crates/registry-evidence/src/verifier.rs, name: duplicate_jws_members_and_unknown_kid_are_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: signed_payload_must_satisfy_the_complete_evidence_schema} + - id: sec-signing-failure-no-release + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_is_transient_audited_and_never_releases_unsigned_evidence}] + - id: sec-private-key-canary-unreachable + tests: + - {file: crates/registry-evidence/src/config.rs, name: yaml_names_and_secret_references_are_strict} + - {file: crates/registry-evidence/src/signing.rs, name: jwks_contains_public_material_only} + - id: sec-verifier-rejects-untrusted-provider + tests: [{file: crates/registry-evidence/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy}] + - id: sec-script-cannot-construct-evidence + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: ambient_and_diagnostic_capabilities_are_unavailable} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: derivation_decode_is_closed_and_retains_protected_types} + - id: sec-missing-or-writable-bundle-fails + tests: [{file: crates/registry-evidence/src/bundle.rs, name: writable_bundle_and_unknown_files_fail_closed}] + - id: sec-audit-order-and-failure + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: access_audit_failure_blocks_credentials_and_source_access} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_signed_response_release} + - {file: crates/registry-evidence/src/audit.rs, name: frozen_audit_fixture_matches_native_event_shape_and_phase_rules} + - {file: crates/registry-evidence/src/audit.rs, name: audit_is_durable_keyed_and_redacted} + - {file: crates/registry-evidence/src/audit.rs, name: restart_verifies_a_nonempty_keyed_chain_before_accepting_appends} + - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_same_length_chain_corruption} + - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_a_truncated_final_record} + - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_the_wrong_audit_key} + - {file: crates/registry-evidence/src/audit.rs, name: same_length_external_mutation_fails_readiness_and_future_appends} + - id: sec-secret-values-rejected-from-bundle + tests: [{file: crates/registry-evidence/src/config.rs, name: yaml_names_and_secret_references_are_strict}] + - id: sec-proxy-identity-header-rejected + tests: [{file: crates/registry-evidence/src/config.rs, name: one_trust_domain_and_native_token_identity_are_closed_configuration}] + - id: sec-decimal-no-float-coercion + tests: [{file: crates/registry-evidence/src/kernel.rs, name: scalar_decimal_and_collection_forms_are_exact}] + - id: sec-entity-reference-seed-protected + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: protected_seed_has_no_comparison_or_string_conversion_capability} + - {file: crates/registry-evidence/src/binding.rs, name: entity_reference_is_audience_and_concept_scoped} + - id: sec-request-preparation-closed + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: preparation_inputs_and_request_parts_are_closed_and_bounded} + - {file: crates/registry-evidence/src/source.rs, name: saturated_source_admission_fails_at_the_configured_timeout} + - {file: crates/registry-evidence/src/source.rs, name: saturated_oauth_single_flight_fails_before_credentials_or_transport} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: hostile_path_values_and_malformed_preparation_fail_before_transport_and_redact} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: materialized_request_reuses_path_template_query_and_body_without_auth_material} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: get_body_is_rejected_before_static_or_oauth_credential_acquisition} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_query_redaction_fixture_fails_closed_without_data_requests} + - id: sec-runtime-cannot-override-governed-bundle + tests: + - {file: crates/registry-evidence/src/config.rs, name: runtime_document_is_closed_and_contains_no_governed_override_surface} + - {file: crates/registry-evidence/src/bundle.rs, name: runtime_and_ca_bytes_are_captured_under_an_independent_read_only_revision} + - id: sec-tls-and-proxy-authority-fixed + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: private_ca_tls_handshake_succeeds_and_hostname_mismatch_fails} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: private_ca_plan_rejects_unbound_missing_and_malformed_captures} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: runtime_ca_capture_rejects_symlink_malformed_and_mutable_files} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: ambient_proxy_variables_are_ignored_in_an_isolated_process} + - id: sec-subject-array-order-nonsemantic + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: multi_role_request_order_is_not_semantic_and_output_uses_declaration_order}] diff --git a/products/evidence/contracts/selector-contract.yaml b/products/evidence/contracts/selector-contract.yaml new file mode 100644 index 000000000..d95b7310b --- /dev/null +++ b/products/evidence/contracts/selector-contract.yaml @@ -0,0 +1,85 @@ +contract: registry.evidence.selector-profile/v1 +status: frozen +profile: + identifier: + pattern: '^[a-z][a-z0-9._-]{0,127}$' + meaning: Deployment-defined stable name; no field name has core semantics. + fields: + minimum: 1 + maximum: 16 + exact_set: true + name_pattern: '^[a-z][a-z0-9._-]{0,63}$' + allowed_types: + string: {requires: [minimum_bytes, maximum_bytes]} + date: {lexical_form: YYYY-MM-DD} + integer: {requires: [minimum, maximum]} + boolean: {} + controlled-code: {requires: [codelist, codelist_version, maximum_bytes]} + maximum_aggregate_bytes: + required: true + maximum: 8192 + value_origins: + allowed: [authenticated-context, authenticated-grant, request] + rule: Each authority path permits exactly the reviewed origin for each role and profile. + value_claims: + authenticated-context: Required exact field-name to verified-token claim-path map. + authenticated-grant: Required exact field-name to verified-token claim-path map plus configured grantIdClaim and grantAuthorityClaim; grantAuthority must equal the matched authority-profile identifier. + request: Prohibited; selector values come only from the closed public request values object. + completeness: Map keys must exactly equal the named profile field set; missing, extra, duplicate-target, or invalid claim paths fail startup. + claim_path_pattern: '^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$' + token_rule: Claim paths are resolved only from the strictly verified access token, never proxy identity headers, unsigned headers, request fields, or fallback claims. + source_inputs: + rule: Each source declares a closed selectorInputs set of allowed role, profile, and exact field alternatives. Rust passes only the active already validated and authorized subset to prepare/2. Provider query and JSON-body mapping is reviewed script logic, not public request data or a core placement DSL. + path_templates: Complete-segment pathBindings are separately Rust-owned and bind directly to one declared role, profile, and field. They are never script or caller output. + caller_supplied_paths_or_placements: prohibited + derivation_inputs: + rule: Each requirement may declare a separate closed derivation.selectorInputs set. Rust passes only those already authorized roles, profiles, and fields to derive/3; omission supplies an empty map. + relationship_use: A reviewed derivation may compare an independently authorized stable candidate reference with complete facts from one uniquely resolved authoritative record. It may not inspect any undeclared subject material. +validation: + active_set: Runtime materializes only the complete role/profile tuple set matched by the authority grant, resolves request subjects by unique role rather than array position, and never unions every alternative declared on a shared source. + subject_array_order: Non-semantic. Duplicate, missing, unknown, or wrong-profile roles fail before credentials or source access; accepted roles are canonicalized to requirement declaration order for internal processing and evidence output. + before_credentials_or_source: + - role exists and cardinality is exact + - profile is permitted for that role and authority path + - value origin is permitted + - values are absent for context-derived or grant-derived input + - values are present for request-derived input + - configured valueClaims are exact and complete for context or grant origin and absent for request origin + - authenticated-grant id and authority claim values bind the matched profile and entitlement + - exact field set, scalar type, lexical form, individual bound, and aggregate bound pass + prohibited_core_behavior: + - semantic normalization + - case folding + - transliteration or phonetics + - tokenization or name-order parsing + - partial-date matching + - fuzzy or probabilistic matching + - confidence, weights, thresholds, scoring, or candidate choice + alternative_sets: A different sufficient field set or added disambiguator is a distinct named profile; it is never inferred from supplied fields. + exact_relationship_rule: A signed false relationship result requires exact returned-record binding, one unique provider match, a schema-required complete relationship set, governed contract and reference namespaces, and the exact declared comparison. Missing, partial, mismatched, or ambiguous data is unavailable rather than false. +canonical_encoding: + format: deterministic length-prefixed UTF-8 binary + sequence: + - version byte 0x01 + - audience URI + - purpose code + - role id + - selector profile id + - field count + - each field in profile declaration order as name, scalar type tag, and canonical scalar bytes + scalar_rules: + string: exact validated UTF-8 bytes without normalization + date: validated ASCII YYYY-MM-DD + integer: minimal signed decimal ASCII, no leading plus or leading zero except zero + boolean: single byte 0x00 or 0x01 + controlled-code: exact validated UTF-8 codelist code + purpose: Input to audience-scoped subject binding and audit pseudonymization; never exposed or logged. +subject_binding_projection: + hmac_input: deterministic length-prefixed bytes over profile version, audience, purpose, role, selector profile, binding-key version, and complete canonical selector fields + key: subjectBinding.secretRef from the governed bundle + output: 'urn:evidence:subject:v_' + rule: Binding-key rotation changes the public binding version; a binding is never global or accepted as authority. +audit: + permitted: selector profile id and at most one scoped keyed pseudonym over the complete canonical role/profile/field bundle + prohibited: raw values, per-field hashes, plain hashes, globally stable subject handles, candidate material +fixtures: ../fixtures/conformance/selector-matrix.yaml diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml new file mode 100644 index 000000000..09662bb4a --- /dev/null +++ b/products/evidence/contracts/source-contract.yaml @@ -0,0 +1,131 @@ +contract: registry.evidence.fixed-http-json-source/v1 +status: frozen +ownership: + governed_bundle: + - fixed source origin, acquisition posture, authentication kind, and logical secret references + - fixed method and fixed path, or complete-segment pathTemplate and pathBindings + - fixed non-secret headers, closed selectorInputs, prepareScript, extractScript, adapter parameters and schemas + - preparation channel policy and bounds, response projection, redirect denial, timeout, response byte limit, and concurrency limit + - optional logical TLS trust-profile name + runtime_yaml: + - bundle directory, listener and process bounds + - file-secret root and audit-storage path + - exact private-CA file binding for each governed logical TLS trust profile + runtime_override: prohibited + scripts: + prepare: Renders only ordered query pairs and at most one JSON body from minimized authorized selectors and closed parameters. + extract: Maps only the bounded projected response and closed parameters to the closed lookup union. + prohibited: source, origin, method, path, headers, authentication, credentials, TLS, proxy, redirect, retry, pagination, concurrency, or request-count authority +evidence_data_request: + count: Exactly one per evaluation after successful authorization, durable access audit, and complete RequestParts validation. + owner: Core runtime. + method_allowlist: [GET, POST] + request_media_types: [application/json] + response_media_types: [application/json, application/graphql-response+json] + redirects: deny + maximum_response_bytes: {required: true, upper_bound: 1048576, applied: before parsing and projection} + timeout_milliseconds: {required: true, range: [1, 30000], applies_to: [source-concurrency-admission, OAuth-single-flight-admission, HTTP-exchange]} + concurrency_limit: {required: true, range: [1, 256]} + request_parts: + query: Ordered logical string pairs. Rust preserves duplicates, percent-encodes UTF-8 components exactly once with uppercase percent escapes, and never performs implicit value-to-string conversion. + body: At most one bounded JSON value. JSON null means no body; GET sources must configure the JSON-body channel as forbidden and any defensive GET-with-body execution is rejected before concurrency admission or credential resolution. + channels: Each source marks query and JSON body required, allowed, or forbidden and may configure tighter bounds beneath the ABI ceilings. + validation_order: Rust validates the exact keys, types, channel policy, sizes, query controls, and normalized output before resolving credentials or contacting the source. + path: + fixed: One normalized absolute path. + template: A placeholder occupies one complete segment and binds directly to one already authorized selector role, profile, and field through pathBindings. + encoding: Rust rejects missing, extra, duplicated, empty, slash, backslash, percent, control, and dot-segment values and percent-encodes each accepted segment exactly once. + script_authority: prohibited + fixed_headers: + form: Ordered non-secret name and value constants, unique after ASCII case folding. + forbidden_collisions: + - authentication, host and routing, cookies, body framing, content length and type + - connection and hop-by-hop, forwarding, proxy, tracing, and configured API-key headers + core_headers: Rust owns authentication and framing and adds Content-Type application/json for a JSON body. + script_authority: prohibited + url_security: + production: HTTPS only. + deterministic_local_mock: HTTP only when the parsed host is a numeric address in 127.0.0.0/8 or exactly ::1. + prohibited: [arbitrary-insecure-http, DNS-localhost-alias, userinfo, fragment, ambiguous-host-encoding, product-specific-insecure-transport-flag] +projection: + owner: Core runtime. + stage: After bounded strict JSON parsing and before conversion to Rhai. + grammar: + root: Non-empty list of extended JSON Pointers. + object_segments: RFC 6901 literal segments with only ~0 and ~1 escapes. + array_segment: A literal wildcard '*' visits every current array element. + prohibited: [numeric-array-index, recursive-descent, filters, predicates, unions, script-computed-paths] + semantics: + - A new tree retains only selected leaves and the objects or arrays needed to reach them. + - Object keys not selected are removed; array order and length are preserved. + - A missing selected leaf remains absent, while a missing or mistyped intermediate container is a source-protocol failure before Rhai. + - Empty, invalid, duplicate, decoded-duplicate, ancestor/descendant-overlap, wildcard-overlap, or structurally incompatible paths fail startup. + - Wire byte and parsing bounds apply before projection; Rhai bounds apply again to the projected tree. + posture_rule: The declared acquisition posture describes the pre-projection wire response. Local projection never upgrades a record-transformed source claim. +cardinality: + preferred: Provider count plus at most one bounded result, field-projected where supported. + fallback: At most two minimally projected results solely to distinguish a unique result from ambiguity. + outcomes: [match, no_match, ambiguous] + facts_only_on: match + prohibited: + - broad candidate retrieval, candidate exposure, score comparison, candidate choice, page traversal, retry, response-led request, or second evidence-data request + - converting provider inconsistency, returned-subject mismatch, or incomplete relationship data into no_match or an authoritative false concept +authentication: + basic: + inputs: [username_secret_ref, password_secret_ref] + placement: Authorization header. + static-bearer: + inputs: [token_secret_ref] + placement: Authorization header. + static-api-key: + inputs: [bundle_fixed_header_name, value_secret_ref] + placement: The exact allowlisted provider header. + rule: Header name is validated against the same collision denylist as fixed headers and cannot be Authorization. + oauth2-client-credentials: + inputs: [token_endpoint, client_id_secret_ref, client_secret_secret_ref, optional_fixed_scope, credential_placement, maximum_cache_lifetime] + rules: + - Token acquisition is credential bootstrap, not an evidence-data request or fact source. + - Endpoint, grant, scope, placement, response bounds, redirect denial, and cache maximum are fixed. + - Waiting for the per-source token-cache and single-flight boundary is limited by the configured source timeout. + - Cache lifetime is clamped to provider expiry and the configured maximum. + - Token request and response are unavailable to Rhai and fully redacted. + credential_placements: [basic-header, form-body, query-string] + query_string_rule: Query-string placement is explicit and requires complete token URL, query, body, response, client identifier, and debug-output redaction. + token_endpoint_transport: The same HTTPS-or-explicit-numeric-loopback rule as the evidence-data origin. + secret_rules: + - The governed bundle contains only secret:file logical references. Environment-variable interpolation and literal secret values are not supported. + - Runtime binds one owner-controlled file-secret root and cannot change a source authentication kind or logical reference. + - Evidence-request processing resolves credentials only after authorization, access audit, preparation, and RequestParts validation. + - Missing or invalid credentials fail closed without an evidence-data request. +tls_and_proxy: + system_roots: Supported when runtime outboundTls.systemRoots is true. + private_ca: A governed logical tlsTrustProfile must have exactly one regular, read-only, captured, bounded, valid runtime CA file binding before readiness; missing, extra, symlinked, mutable, and malformed bindings fail startup. + verification: Fixed-origin and hostname verification remain mandatory; insecure, skip-verification, and trust-all modes do not exist. + proxy: Evidence-data and OAuth token clients ignore HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY; Version 1 has no application-level proxy field. +acquisition_postures: + source-derived: + meaning: Source returns the final declared concept fact. + claim: Acquisition and disclosure minimization. + field-projected: + meaning: Source returns only the facts required for in-process derivation. + claim: Strong acquisition and disclosure minimization. + record-transformed: + meaning: A broader legacy record is transiently transformed in memory. + claim: Disclosure minimization only. +failure_classes: + transport: [401, 403, 429, 5xx, timeout, redirect, invalid-json, duplicate-json-member, wrong-media-type, oversized-response] + projection: [invalid-pointer, missing-intermediate, mistyped-intermediate, projected-input-oversized] + semantic: [no-match, ambiguous, required-fact-missing, wrong-fact-type, unknown-controlled-code] + graphql_envelope_rule: Any top-level errors member, including partial data with errors, fails closed. +persistence: + source_request_or_response: prohibited + raw_fact: prohibited + in_memory_lifetime: One bounded evaluation only. +neutrality: + production_contract: Generic HTTP, generic authentication, bounded JSON, extended JSON Pointer projection, and Rhai preparation and extraction only. + named_compatibility_profiles: Test fixtures and reference documentation only. +fixtures: + postures: ../fixtures/conformance/acquisition-postures.yaml + oauth_query_redaction: ../fixtures/conformance/oauth-query-credential-redaction.yaml + shapes: ../fixtures/source-shapes/ + required_shape_outcomes: [zero, one, multiple, missing-fact, error-envelope] diff --git a/products/evidence/contracts/supported-value-forms.yaml b/products/evidence/contracts/supported-value-forms.yaml new file mode 100644 index 000000000..d7ab26ff9 --- /dev/null +++ b/products/evidence/contracts/supported-value-forms.yaml @@ -0,0 +1,63 @@ +contract: registry.evidence.supported-value-forms/v1 +status: frozen +common: + maximum_concepts_per_requirement: 16 + concept_identifiers: exact set, each present once unless a declared optional concept is absent + undeclared_metadata: prohibited + total_serialized_value_bytes: 65536 +forms: + boolean: + wire: JSON boolean + controlled-code: + wire: JSON string + declaration_requires: [codelist, codelistVersion, maximumBytes] + rule: Exact code membership; labels and source codes are not disclosed. + controlled-category: + wire: JSON string + declaration_requires: [categoryScheme, schemeVersion, codelist, maximumBytes] + rule: Exact reviewed category membership. + bounded-integer: + wire: JSON integer + declaration_requires: [minimum, maximum] + bounded-decimal: + wire: JSON string containing canonical decimal text + declaration_requires: [minimum, maximum, maximumScale] + rhai_abi: A core-owned Decimal returned by decimal("canonical-text") or parse_decimal("canonical-text"); Rhai floating-point values are rejected. + canonical_text: '^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ with zero represented only as 0; no exponent, plus sign, leading zero, trailing fractional zero, NaN, infinity, or negative zero' + global_bounds: {maximumPrecision: 28, maximumScale: 9} + rule: Rust validates and compares coefficient plus scale exactly, then emits the canonical text as a JSON string; JSON numeric conversion and f32/f64 are prohibited. + date-bucket: + wire: {form: date-bucket, scheme: URI, bucket: code} + declaration_requires: [bucketScheme, schemeVersion] + rule: The scheme URI and version resolve to exactly one reviewed codelist artifact in the immutable bundle; the underlying date is not disclosed. + time-bucket: + wire: {form: time-bucket, scheme: URI, bucket: code} + declaration_requires: [bucketScheme, schemeVersion] + rule: The scheme URI and version resolve to exactly one reviewed codelist artifact in the immutable bundle; the underlying instant or duration is not disclosed. + audience-scoped-entity-reference: + wire: {form: audience-scoped-entity-reference, reference: opaque-urn} + declaration_requires: [maximumBytes] + rhai_abi: Rhai returns entity_reference_seed("protected-seed"), never a public reference. + rule: Rust HMAC-projects the seed with the concept id, audience, binding-key version, and binding key; the seed never enters evidence or diagnostics. + controlled-code-list: + wire: array of controlled-code strings + declaration_requires: [codelist, codelistVersion, minimumItems, maximumItems, unique] + entity-reference-list: + wire: array of audience-scoped-entity-reference objects + declaration_requires: [minimumItems, maximumItems, unique] + rhai_abi: Rhai returns a bounded list of entity_reference_seed values; Rust projects each seed independently and preserves declared order. + reviewed-structured-value: + wire: {form: reviewed-structured-value, schema: URI, fields: closed-object} + declaration_requires: [schema, maximumSerializedBytes] + rule: The schema URI resolves to exactly one closed JSON Schema artifact in the immutable bundle; that schema closes every property and nested value and prohibits arbitrary JSON. +validation_order: + - exact concept identifier set and duplicate rejection + - exact declared form + - scalar and lexical type + - numeric precision and range + - codelist or scheme version and membership + - collection cardinality and uniqueness + - structured schema and additional-property rejection + - per-value and total serialized size + - core-owned evidence projection +fixtures: ../fixtures/conformance/supported-values.yaml diff --git a/products/evidence/fixtures/acceptance/adult-status/adapters/source-a-prepare.rhai b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a-prepare.rhai new file mode 100644 index 000000000..691d7c164 --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a-prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{ + given_name: subject["values"]["given_name"], + family_name: subject["values"]["family_name"], + birth_date: subject["values"]["birth_date"] + }, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai new file mode 100644 index 000000000..0ab3a08f7 --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai @@ -0,0 +1,21 @@ +fn extract(source_response, parameters) { + if !source_response.contains("total") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 { + throw("source_protocol_error"); + } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { + return #{outcome: "ambiguous"}; + } + if !source_response.contains("date_of_birth") { + return #{outcome: "match", facts: #{}}; + } + if type_of(source_response["date_of_birth"]) != "string" { + throw("source_protocol_error"); + } + #{outcome: "match", facts: #{date_of_birth: source_response["date_of_birth"]}} +} diff --git a/products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai b/products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai new file mode 100644 index 000000000..8671f9d2a --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/derivations/adult-status.rhai @@ -0,0 +1,11 @@ +fn derive(facts, selectors, evaluation_context) { + let date_of_birth = parse_date(required(facts.date_of_birth, "required_fact_missing")); + let threshold = add_calendar_years( + date_of_birth, + evaluation_context.parameters.minimum_age_years + ); + [#{ + concept_id: "urn:example:fixture:concept:adult-status", + value: compare_dates(evaluation_context.legal_local_date, threshold) >= 0 + }] +} diff --git a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml new file mode 100644 index 000000000..117e3dc15 --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml @@ -0,0 +1,83 @@ +version: 1 +service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} +issuer: {id: urn:example:fixture:issuer:authority} +authentication: + kind: oidc-access-token + issuer: https://identity.invalid + audiences: [evidence-fixture] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: fixture-key-2026-01 + activeKeyRef: secret:file/signing-key + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +selectorProfiles: + person-demographics-v1: + maximumAggregateBytes: 420 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} +sources: + source-a: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-a-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: person-demographics-v1, fields: [given_name, family_name, birth_date]} + prepareScript: adapters/source-a-prepare.rhai + adapterParameters: {requestedFields: [date_of_birth], resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /date_of_birth] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/source-a.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: [{role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}] +requirements: + - id: urn:example:fixture:requirement:adult-status:v1 + kind: criterion + source: source-a + purposes: [fixture-eligibility] + subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [person-demographics-v1]}] + referenceFrameworks: [urn:example:fixture:framework:adult-status:v1] + evidenceType: urn:example:fixture:evidence-type:adult-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 86400 + derivation: {script: derivations/adult-status.rhai, parameters: {minimum_age_years: 18}} + concepts: [{id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}}] + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:adult-status]} + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/acceptance/adult-status/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/adult-status/fixtures/cases.yaml new file mode 100644 index 000000000..c91881abd --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/fixtures/cases.yaml @@ -0,0 +1,33 @@ +fixture: registry.evidence.acceptance.adult-status/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'} + selectors: + subject: {profile: person-demographics-v1, values: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'}} + expectedRequestParts: + query: [] + body: {lookup: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'}, fields: [date_of_birth], limit: 2} + expectedTransport: + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - {id: positive, source: {total: 1, date_of_birth: '2000-01-01'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-false-is-success, source: {total: 1, date_of_birth: '2010-01-01'}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-before, legal_local_date: '2026-08-01', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-on, legal_local_date: '2026-08-02', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-after, legal_local_date: '2026-08-03', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-leap-day, legal_local_date: '2026-02-28', source: {total: 1, date_of_birth: '2008-02-29'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: negative-wrong-derived-type, injected_derivation: [{concept_id: urn:example:fixture:concept:adult-status, value: 'true'}], expected: output-gate-rejection} + - {id: anti-reconstruction, companion_bundle: threshold-ladder, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:adult-status] + evidence_excludes: [date_of_birth, given_name, family_name, selector-profile] + diagnostics_exclude: [Amina, Diallo, '2000-01-01', fixture-source-canary] diff --git a/products/evidence/fixtures/acceptance/adult-status/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/adult-status/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..60bf5e3d2 --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [date_of_birth] + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/acceptance/adult-status/schemas/facts.schema.yaml b/products/evidence/fixtures/acceptance/adult-status/schemas/facts.schema.yaml new file mode 100644 index 000000000..27d2c2f0f --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [date_of_birth] +properties: + date_of_birth: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-prepare.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-prepare.rhai new file mode 100644 index 000000000..37650eab5 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-prepare.rhai @@ -0,0 +1,4 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{query: [], body: #{lookup: #{given_name: subject["values"]["given_name"], family_name: subject["values"]["family_name"], birth_date: subject["values"]["birth_date"]}, fields: parameters["requestedFields"], limit: parameters["resultLimit"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai new file mode 100644 index 000000000..c85666367 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai @@ -0,0 +1,11 @@ +fn extract(source_response, parameters) { + if !source_response.contains("total") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 { throw("source_protocol_error"); } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } + if !source_response.contains("date_of_birth") { return #{outcome: "match", facts: #{}}; } + if type_of(source_response["date_of_birth"]) != "string" { throw("source_protocol_error"); } + #{outcome: "match", facts: #{date_of_birth: source_response["date_of_birth"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-prepare.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-prepare.rhai new file mode 100644 index 000000000..989838de0 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-prepare.rhai @@ -0,0 +1,4 @@ +fn prepare(selectors, parameters) { + let child = selectors["child"]; + #{query: [], body: #{lookup: #{record_reference: child["values"]["record_reference"]}, fields: parameters["requestedFields"], limit: parameters["resultLimit"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai new file mode 100644 index 000000000..016cf8734 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai @@ -0,0 +1,22 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || !source_response.contains("total") || !source_response.contains("records") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 || type_of(source_response["records"]) != "array" || source_response["records"].len > parameters["resultLimit"] { throw("source_protocol_error"); } + let total = source_response["total"]; + let records = source_response["records"]; + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 || type_of(records[0]) != "map" { throw("source_protocol_error"); } + let record = records[0]; + if len(record) != 5 || !record.contains("returned_child_reference") || !record.contains("parent_references") || !record.contains("reference_namespace") || !record.contains("relationship_set_contract") || !record.contains("relationship_set_complete") || type_of(record["returned_child_reference"]) != "string" || type_of(record["parent_references"]) != "array" || type_of(record["reference_namespace"]) != "string" || type_of(record["relationship_set_contract"]) != "string" || type_of(record["relationship_set_complete"]) != "bool" || record["relationship_set_complete"] != parameters["relationshipSetComplete"] || record["reference_namespace"] != parameters["referenceNamespace"] || record["relationship_set_contract"] != parameters["relationshipSetContract"] || record["parent_references"].len > 2 { throw("source_protocol_error"); } + let seen = []; + for reference in record["parent_references"] { + if type_of(reference) != "string" || reference == "" || list_contains(seen, reference) { throw("source_protocol_error"); } + seen.push(reference); + } + #{outcome: "match", facts: #{returned_child_reference: record["returned_child_reference"], parent_references: record["parent_references"], reference_namespace: record["reference_namespace"], relationship_set_contract: record["relationship_set_contract"], relationship_set_complete: record["relationship_set_complete"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-prepare.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-prepare.rhai new file mode 100644 index 000000000..ca41d38fb --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-prepare.rhai @@ -0,0 +1,4 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{query: [#{name: "licence_reference", value: subject["values"]["licence_reference"]}, #{name: "registry_region", value: subject["values"]["registry_region"]}, #{name: "fields", value: parameters["requestedFields"]}, #{name: "limit", value: parameters["resultLimit"]}], body: ()} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai new file mode 100644 index 000000000..82ef8fab9 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai @@ -0,0 +1,20 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || !source_response.contains("total") || !source_response.contains("records") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 || type_of(source_response["records"]) != "array" || source_response["records"].len > parse_integer(parameters["resultLimit"]) { throw("source_protocol_error"); } + let total = source_response["total"]; + let records = source_response["records"]; + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 || type_of(records[0]) != "map" { throw("source_protocol_error"); } + let record = records[0]; + let facts = #{}; + if record.contains("licence_state") { facts["licence_state"] = record["licence_state"]; } + if record.contains("valid_from") { facts["valid_from"] = record["valid_from"]; } + if record.contains("valid_until") { facts["valid_until"] = record["valid_until"]; } + #{outcome: "match", facts: facts} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-prepare.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-prepare.rhai new file mode 100644 index 000000000..6b196fdee --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-prepare.rhai @@ -0,0 +1,4 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{query: [], body: #{lookup: #{record_reference: subject["values"]["record_reference"]}, fields: parameters["requestedFields"], limit: parameters["resultLimit"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai new file mode 100644 index 000000000..8bf3d0c45 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai @@ -0,0 +1,11 @@ +fn extract(source_response, parameters) { + if !source_response.contains("total") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 { throw("source_protocol_error"); } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } + if !source_response.contains("official_residence_code") { return #{outcome: "match", facts: #{}}; } + if type_of(source_response["official_residence_code"]) != "string" { throw("source_protocol_error"); } + #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-expiry-categories.yaml b/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-expiry-categories.yaml new file mode 100644 index 000000000..f1306ace1 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-expiry-categories.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:scheme:licence-expiry +version: '1' +codes: [expired, within-30-days, within-90-days, later] diff --git a/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-registry-regions.yaml b/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-registry-regions.yaml new file mode 100644 index 000000000..8f6c747d4 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/codelists/professional-registry-regions.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:codelist:registry-regions +version: '1' +codes: [RR-A, RR-B] diff --git a/products/evidence/fixtures/acceptance/all-definitions/codelists/residence-region-map.yaml b/products/evidence/fixtures/acceptance/all-definitions/codelists/residence-region-map.yaml new file mode 100644 index 000000000..61e8637f2 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/codelists/residence-region-map.yaml @@ -0,0 +1,7 @@ +id: urn:example:fixture:codelist:region-map +version: '2026-01' +entries: + R-101: REGION-NORTH + R-102: REGION-NORTH + R-201: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/products/evidence/fixtures/acceptance/all-definitions/derivations/adult-status.rhai b/products/evidence/fixtures/acceptance/all-definitions/derivations/adult-status.rhai new file mode 100644 index 000000000..8671f9d2a --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/derivations/adult-status.rhai @@ -0,0 +1,11 @@ +fn derive(facts, selectors, evaluation_context) { + let date_of_birth = parse_date(required(facts.date_of_birth, "required_fact_missing")); + let threshold = add_calendar_years( + date_of_birth, + evaluation_context.parameters.minimum_age_years + ); + [#{ + concept_id: "urn:example:fixture:concept:adult-status", + value: compare_dates(evaluation_context.legal_local_date, threshold) >= 0 + }] +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/derivations/legal-parent-relationship.rhai b/products/evidence/fixtures/acceptance/all-definitions/derivations/legal-parent-relationship.rhai new file mode 100644 index 000000000..ccaa36156 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/derivations/legal-parent-relationship.rhai @@ -0,0 +1,5 @@ +fn derive(facts, selectors, evaluation_context) { + let parameters = evaluation_context["parameters"]; + if parameters["matching_policy"] != "exact-opaque-reference-membership-v1" || parameters["legal_authority_attestation"] != "urn:example:fixture:governance:legal-parent-register:v1" || facts["returned_child_reference"] != selectors["child"]["values"]["record_reference"] || facts["relationship_set_complete"] != true || facts["reference_namespace"] != parameters["candidate_reference_namespace"] || facts["relationship_set_contract"] != parameters["relationship_set_contract"] { throw("derivation_input_error"); } + [#{concept_id: "urn:example:fixture:concept:legal-parent-relationship-confirmed", value: list_contains(facts["parent_references"], selectors["candidate-parent"]["values"]["person_reference"])}] +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/derivations/professional-licence.rhai b/products/evidence/fixtures/acceptance/all-definitions/derivations/professional-licence.rhai new file mode 100644 index 000000000..486f73a7e --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/derivations/professional-licence.rhai @@ -0,0 +1,25 @@ +fn derive(facts, selectors, evaluation_context) { + let starts = parse_date(required(facts.valid_from, "required_fact_missing")); + let ends = parse_date(required(facts.valid_until, "required_fact_missing")); + let state = required(facts.licence_state, "required_fact_missing"); + let current = evaluation_context.legal_local_date; + let active = + state == evaluation_context.parameters.active_state && + compare_dates(current, starts) >= 0 && + compare_dates(current, ends) <= 0; + let remaining_days = integer_to_decimal(days_between(current, ends)); + let category = bucket_number( + remaining_days, + evaluation_context.parameters.expiry_buckets + ); + [ + #{ + concept_id: "urn:example:fixture:concept:licence-active", + value: active + }, + #{ + concept_id: "urn:example:fixture:concept:licence-expiry-category", + value: category + } + ] +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/derivations/residence-region.rhai b/products/evidence/fixtures/acceptance/all-definitions/derivations/residence-region.rhai new file mode 100644 index 000000000..939953644 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/derivations/residence-region.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup( + evaluation_context.codelists["residence-region-map"], + required(facts.official_residence_code, "required_fact_missing") + ); + [#{ + concept_id: "urn:example:fixture:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + }] +} diff --git a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml new file mode 100644 index 000000000..905fbe8dd --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml @@ -0,0 +1,287 @@ +version: 1 +service: + providerId: urn:example:fixture:provider:evidence + trustDomain: urn:example:fixture:trust-domain:acceptance +issuer: {id: urn:example:fixture:issuer:authority} +authentication: + kind: oidc-access-token + issuer: https://identity.invalid + audiences: [evidence-fixture] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} + +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: fixture-key-2026-01 + activeKeyRef: secret:file/signing-key + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 + +selectorProfiles: + person-demographics-v1: + maximumAggregateBytes: 420 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} + residence-record-v1: + maximumAggregateBytes: 96 + fields: + record_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + licence-register-v1: + maximumAggregateBytes: 128 + fields: + licence_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + registry_region: {type: controlled-code, codelist: codelists/professional-registry-regions.yaml, codelistVersion: '1', maximumBytes: 16} + civil-record-reference-v1: + maximumAggregateBytes: 96 + fields: + record_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + person-reference-v1: + maximumAggregateBytes: 128 + fields: + person_reference: {type: string, minimumBytes: 1, maximumBytes: 128} + +sources: + source-a: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-a-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: person-demographics-v1, fields: [given_name, family_name, birth_date]}] + prepareScript: adapters/adult-status-prepare.rhai + adapterParameters: {requestedFields: [date_of_birth], resultLimit: 2} + adapterParametersSchema: schemas/adult-status-adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /date_of_birth] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/adult-status-source.rhai + factSchema: schemas/adult-status-facts.schema.yaml + source-b: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-b-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: residence-record-v1, fields: [record_reference]}] + prepareScript: adapters/residence-region-prepare.rhai + adapterParameters: {requestedFields: [official_residence_code], resultLimit: 2} + adapterParametersSchema: schemas/residence-region-adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /official_residence_code] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/residence-region-source.rhai + factSchema: schemas/residence-region-facts.schema.yaml + source-c: + transport: http-json + baseUrl: https://source.invalid + posture: record-transformed + authentication: + kind: basic + usernameRef: secret:file/source-c-username + passwordRef: secret:file/source-c-password + request: + method: GET + path: /v1/records + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: licence-register-v1, fields: [licence_reference, registry_region]}] + prepareScript: adapters/professional-licence-prepare.rhai + adapterParameters: {requestedFields: "licence_state,valid_from,valid_until", resultLimit: "2"} + adapterParametersSchema: schemas/professional-licence-adapter-parameters.schema.yaml + preparationLimits: {query: required, jsonBody: forbidden, maximumQueryPairs: 4, maximumQueryNameBytes: 64, maximumQueryValueBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /records/*/licence_state, /records/*/valid_from, /records/*/valid_until] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 131072 + concurrencyLimit: 8 + extractScript: adapters/professional-licence-source.rhai + factSchema: schemas/professional-licence-facts.schema.yaml + source-d: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-d-token} + request: + method: POST + path: /v1/child-relationships + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: child + alternatives: [{profile: civil-record-reference-v1, fields: [record_reference]}] + prepareScript: adapters/legal-parent-relationship-prepare.rhai + adapterParameters: + requestedFields: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] + resultLimit: 2 + referenceNamespace: urn:example:fixture:person-reference + relationshipSetContract: urn:example:fixture:legal-parent-set:v1 + relationshipSetComplete: true + adapterParametersSchema: schemas/legal-parent-relationship-adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /records/*/returned_child_reference, /records/*/parent_references, /records/*/reference_namespace, /records/*/relationship_set_contract, /records/*/relationship_set_complete] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/legal-parent-relationship-source.rhai + factSchema: schemas/legal-parent-relationship-facts.schema.yaml + +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} + - requirement: urn:example:fixture:requirement:residence-region:v1 + purpose: fixture-routing + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: residence-record-v1, valueOrigin: request} + - requirement: urn:example:fixture:requirement:professional-licence-status:v1 + purpose: fixture-registration + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: licence-register-v1, valueOrigin: request} + - requirement: urn:example:fixture:requirement:legal-parent-relationship:v1 + purpose: fixture-enrolment + audienceFrom: authenticated-requester + subjects: + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference + +requirements: + - id: urn:example:fixture:requirement:adult-status:v1 + kind: criterion + source: source-a + purposes: [fixture-eligibility] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [person-demographics-v1]} + referenceFrameworks: [urn:example:fixture:framework:adult-status:v1] + evidenceType: urn:example:fixture:evidence-type:adult-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 86400 + derivation: + script: derivations/adult-status.rhai + parameters: {minimum_age_years: 18} + concepts: + - {id: urn:example:fixture:concept:adult-status, form: boolean, required: true, constraints: {}} + fixtures: fixtures/adult-status-cases.yaml + disclosureGuard: + families: [urn:example:fixture:disclosure-family:adult-status] + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:residence-region:v1 + kind: information-requirement + source: source-b + purposes: [fixture-routing] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [residence-record-v1]} + referenceFrameworks: [urn:example:fixture:framework:residence-region:v1] + evidenceType: urn:example:fixture:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: + script: derivations/residence-region.rhai + parameters: {} + concepts: + - id: urn:example:fixture:concept:residence-region + form: controlled-code + required: true + constraints: {codelist: codelists/residence-region-map.yaml, codelistVersion: '2026-01', maximumBytes: 32} + fixtures: fixtures/residence-region-cases.yaml + disclosureGuard: + families: [urn:example:fixture:disclosure-family:residence-region] + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:professional-licence-status:v1 + kind: criterion + source: source-c + purposes: [fixture-registration] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [licence-register-v1]} + referenceFrameworks: [urn:example:fixture:framework:professional-licence:v1] + evidenceType: urn:example:fixture:evidence-type:professional-licence-status:v1 + observationTimezone: Africa/Nairobi + validitySeconds: 43200 + derivation: + script: derivations/professional-licence.rhai + parameters: + active_state: CURRENT + expiry_buckets: + - {minimumInclusive: {type: decimal, value: '-365000'}, maximumExclusive: {type: decimal, value: '0'}, code: expired} + - {minimumInclusive: {type: decimal, value: '0'}, maximumExclusive: {type: decimal, value: '31'}, code: within-30-days} + - {minimumInclusive: {type: decimal, value: '31'}, maximumExclusive: {type: decimal, value: '91'}, code: within-90-days} + - {minimumInclusive: {type: decimal, value: '91'}, maximumExclusive: {type: decimal, value: '365001'}, code: later} + concepts: + - {id: urn:example:fixture:concept:licence-active, form: boolean, required: true, constraints: {}} + - id: urn:example:fixture:concept:licence-expiry-category + form: controlled-category + required: true + constraints: {categoryScheme: urn:example:fixture:scheme:licence-expiry, schemeVersion: '1', maximumBytes: 32, codelist: codelists/professional-expiry-categories.yaml} + fixtures: fixtures/professional-licence-cases.yaml + disclosureGuard: + families: [urn:example:fixture:disclosure-family:professional-licence] + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:legal-parent-relationship:v1 + kind: criterion + source: source-d + purposes: [fixture-enrolment] + subjectRoles: + - {role: child, cardinality: one, selectorProfiles: [civil-record-reference-v1]} + - {role: candidate-parent, cardinality: one, selectorProfiles: [person-reference-v1]} + referenceFrameworks: [urn:example:fixture:framework:legal-parent-relationship:v1] + evidenceType: urn:example:fixture:evidence-type:legal-parent-relationship:v1 + validitySeconds: 86400 + derivation: + script: derivations/legal-parent-relationship.rhai + selectorInputs: + - role: child + alternatives: [{profile: civil-record-reference-v1, fields: [record_reference]}] + - role: candidate-parent + alternatives: [{profile: person-reference-v1, fields: [person_reference]}] + parameters: + matching_policy: exact-opaque-reference-membership-v1 + candidate_reference_namespace: urn:example:fixture:person-reference + relationship_set_contract: urn:example:fixture:legal-parent-set:v1 + legal_authority_attestation: urn:example:fixture:governance:legal-parent-register:v1 + concepts: + - {id: urn:example:fixture:concept:legal-parent-relationship-confirmed, form: boolean, required: true, constraints: {}} + fixtures: fixtures/legal-parent-relationship-cases.yaml + disclosureGuard: + families: [urn:example:fixture:disclosure-family:legal-parent-relationship] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml new file mode 100644 index 000000000..8939956b0 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/adult-status-cases.yaml @@ -0,0 +1,29 @@ +fixture: registry.evidence.acceptance.adult-status/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'} + selectors: + subject: {profile: person-demographics-v1, values: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'}} + expectedRequestParts: {query: [], body: {lookup: {given_name: Amina, family_name: Diallo, birth_date: '2000-01-01'}, fields: [date_of_birth], limit: 2}} + expectedTransport: {path: /v1/facts, fixedHeaders: [{name: Accept, value: application/json}]} +cases: + - {id: positive, source: {total: 1, date_of_birth: '2000-01-01'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-false-is-success, source: {total: 1, date_of_birth: '2010-01-01'}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-before, legal_local_date: '2026-08-01', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-on, legal_local_date: '2026-08-02', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-after, legal_local_date: '2026-08-03', source: {total: 1, date_of_birth: '2008-08-02'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-leap-day, legal_local_date: '2026-02-28', source: {total: 1, date_of_birth: '2008-02-29'}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: negative-wrong-derived-type, injected_derivation: [{concept_id: urn:example:fixture:concept:adult-status, value: 'true'}], expected: output-gate-rejection} + - {id: anti-reconstruction, companion_bundle: threshold-ladder, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:adult-status] + evidence_excludes: [date_of_birth, given_name, family_name, selector-profile] + diagnostics_exclude: [Amina, Diallo, '2000-01-01', fixture-source-canary] diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml new file mode 100644 index 000000000..bb2453784 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml @@ -0,0 +1,62 @@ +fixture: registry.evidence.acceptance.legal-parent-relationship/v1 +coequal_acceptance_definition: true +synthetic_only: true +provider_contract: + classification: synthetic-governed-authoritative-legal-parent-register + governance_attestation: urn:example:fixture:governance:legal-parent-register:v1 + legal_meaning: For this synthetic acceptance definition, the configured source's complete parent-reference set is legally authoritative for the declared legal-parent concept. + lookup: One bounded child-only lookup must resolve exactly one returned child record. + child_binding: The returned child reference must exactly equal the authorized child selector. + candidate_authority: The candidate opaque reference is supplied independently by the authenticated grant and is never sent to the source. + decision: Exact membership in the complete governed parent-reference set is true; exact non-membership is false. + false_preconditions: A signed false requires a unique child, exact child binding, the governed reference namespace and contract, and relationship_set_complete true. + prohibited: Names, dates, fuzzy matching, candidate search, partial parent sets, and absence inferred from no-match cannot decide legal parentage. +common: + observed_at: '2026-08-02T00:00:00Z' + subjects: + - {role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + - {role: candidate-parent, profile: person-reference-v1} + selectors: + child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} + derivationSelectorInputs: + child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} + verified_token_claims: + evidence_grant_id: synthetic-parentage-grant-001 + evidence_authority: statutory-caseworker-v1 + grant: {candidate_parent: {person_reference: synthetic-parent-reference-001}} + expectedRequestParts: + query: [] + body: {lookup: {record_reference: synthetic-child-record-001}, fields: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete], limit: 2} + expectedTransport: {path: /v1/child-relationships, fixedHeaders: [{name: Accept, value: application/json}]} +cases: + - {id: positive, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-false-is-success, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-correct-role-order, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true, expected_subject_roles: [child, candidate-parent]} + - {id: negative-swapped-roles, subjects: [{role: candidate-parent, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: child, profile: person-reference-v1}], expected: pre-source-selector-rejection} + - {id: negative-caller-candidate-substitution, subjects: [{role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: candidate-parent, profile: person-reference-v1, values: {person_reference: synthetic-substitute-reference}}], expected: pre-source-selector-rejection} + - {id: missing-fact, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-child-unresolved, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: service_unavailable, derivation_runs: true, signed_success: false} + - {id: negative-incomplete-parent-set, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: false}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-relationship-status-type, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: synthetic-parent-reference-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-status-on-none, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-status-on-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-unknown-pair-resolution, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:other-namespace, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-unknown-relationship-status, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:other-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-extra-source-field, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true, related_subject_name: PrivacyCanary}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: negative-extra-family-fact, injected_derivation: [{concept_id: urn:example:fixture:concept:legal-parent-relationship-confirmed, value: true}, {concept_id: urn:example:fixture:concept:related-subject-name, value: PrivacyCanary}], expected: output-gate-rejection} + - {id: anti-reconstruction, companion_bundle: relationship-graph, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [child, candidate-parent, urn:example:fixture:concept:legal-parent-relationship-confirmed] + evidence_excludes: [record_reference, person_reference, parent_references, returned_child_reference, family-graph] + diagnostics_exclude: [synthetic-child-record-001, synthetic-other-child-record, synthetic-parentage-grant-001, synthetic-parent-reference-001, synthetic-parent-reference-002, synthetic-non-parent-reference-003, synthetic-substitute-reference, PrivacyCanary] + subject_bindings: two independent audience-scoped opaque bindings diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml new file mode 100644 index 000000000..7ac371067 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/professional-licence-cases.yaml @@ -0,0 +1,65 @@ +fixture: registry.evidence.acceptance.professional-licence/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {licence_reference: synthetic-licence-001, registry_region: RR-A} + selectors: + subject: {profile: licence-register-v1, values: {licence_reference: synthetic-licence-001, registry_region: RR-A}} + expectedRequestParts: + query: [{name: licence_reference, value: synthetic-licence-001}, {name: registry_region, value: RR-A}, {name: fields, value: "licence_state,valid_from,valid_until"}, {name: limit, value: "2"}] + body: null + expectedTransport: {path: /v1/records, fixedHeaders: [{name: Accept, value: application/json}]} +cases: + - id: positive + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-20', historical_states: [PENDING]}]} + expected_values: {licence-active: true, licence-expiry-category: within-30-days} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: negative-inactive-is-success + source: {total: 1, records: [{licence_state: SUSPENDED, valid_from: '2025-01-01', valid_until: '2026-12-31', historical_states: [CURRENT]}]} + expected_values: {licence-active: false, licence-expiry-category: later} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-valid-from + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2026-08-02', valid_until: '2026-12-31', historical_states: []}]} + expected_values: {licence-active: true, licence-expiry-category: later} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-valid-until + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-02', historical_states: []}]} + expected_values: {licence-active: true, licence-expiry-category: within-30-days} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-after-valid-until + legal_local_date: '2026-08-03' + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-02', historical_states: []}]} + expected_values: {licence-active: false, licence-expiry-category: expired} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: missing-fact + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', historical_states: []}]} + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: wrong-media-type, expected_public_problem: dependency_unavailable, signed_success: false} + - id: negative-exact-date-leak + injected_derivation: + - {concept_id: urn:example:fixture:concept:licence-active, value: true} + - {concept_id: urn:example:fixture:concept:licence-expiry-category, value: '2026-08-20'} + expected: output-gate-rejection + - {id: anti-reconstruction, companion_bundle: coexisting-revisions, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:licence-active, urn:example:fixture:concept:licence-expiry-category] + evidence_excludes: [valid_from, valid_until, historical_states, licence_reference, registry_region] + diagnostics_exclude: [synthetic-licence-001, '2026-08-20', PENDING, CURRENT] + minimization_claim: disclosure minimization only diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml new file mode 100644 index 000000000..0589776c9 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/residence-region-cases.yaml @@ -0,0 +1,26 @@ +fixture: registry.evidence.acceptance.residence-region/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selector: {record_reference: synthetic-residence-record-001} + selectors: + subject: {profile: residence-record-v1, values: {record_reference: synthetic-residence-record-001}} + expectedRequestParts: {query: [], body: {lookup: {record_reference: synthetic-residence-record-001}, fields: [official_residence_code], limit: 2}} + expectedTransport: {path: /v1/facts, fixedHeaders: [{name: Accept, value: application/json}]} +cases: + - {id: positive, source: {total: 1, official_residence_code: R-101}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-unknown-code, source: {total: 1, official_residence_code: R-999}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: boundary-second-source-code-same-coarse-region, source: {total: 1, official_residence_code: R-102}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-other-coarse-region, source: {total: 1, official_residence_code: R-201}, expected_value: REGION-SOUTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-overly-precise-output, injected_derivation: [{concept_id: urn:example:fixture:concept:residence-region, value: R-101}], expected: output-gate-rejection} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: http-503, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: anti-reconstruction, companion_bundle: geographic-overlap, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:residence-region, REGION-NORTH] + evidence_excludes: [official_residence_code, record_reference, R-101, R-102] + diagnostics_exclude: [synthetic-residence-record-001, R-101, R-102, REGION-NORTH] diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-adapter-parameters.schema.yaml new file mode 100644 index 000000000..b5a7fe65c --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: {const: [date_of_birth]} + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-facts.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-facts.schema.yaml new file mode 100644 index 000000000..27d2c2f0f --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [date_of_birth] +properties: + date_of_birth: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-adapter-parameters.schema.yaml new file mode 100644 index 000000000..e1ce9deb3 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-adapter-parameters.schema.yaml @@ -0,0 +1,9 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit, referenceNamespace, relationshipSetContract, relationshipSetComplete] +properties: + requestedFields: {const: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete]} + resultLimit: {const: 2} + referenceNamespace: {const: urn:example:fixture:person-reference} + relationshipSetContract: {const: urn:example:fixture:legal-parent-set:v1} + relationshipSetComplete: {const: true} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-facts.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-facts.schema.yaml new file mode 100644 index 000000000..f43938c9d --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-facts.schema.yaml @@ -0,0 +1,14 @@ +type: object +additionalProperties: false +required: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] +properties: + returned_child_reference: {type: string, minLength: 1, maxLength: 96} + parent_references: + type: array + minItems: 0 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {const: urn:example:fixture:person-reference} + relationship_set_contract: {const: urn:example:fixture:legal-parent-set:v1} + relationship_set_complete: {const: true} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-adapter-parameters.schema.yaml new file mode 100644 index 000000000..40a08a8cc --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: {const: "licence_state,valid_from,valid_until"} + resultLimit: {const: "2"} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-facts.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-facts.schema.yaml new file mode 100644 index 000000000..c23fa7fb3 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-facts.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [licence_state, valid_from, valid_until] +properties: + licence_state: {type: string, minLength: 1, maxLength: 32} + valid_from: {type: string, format: date} + valid_until: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-adapter-parameters.schema.yaml new file mode 100644 index 000000000..ace2cd76e --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: {const: [official_residence_code]} + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-facts.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d-prepare.rhai b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d-prepare.rhai new file mode 100644 index 000000000..dc4d8a185 --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d-prepare.rhai @@ -0,0 +1,11 @@ +fn prepare(selectors, parameters) { + let child = selectors["child"]; + #{ + query: [], + body: #{ + lookup: #{record_reference: child["values"]["record_reference"]}, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai new file mode 100644 index 000000000..340882d3c --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai @@ -0,0 +1,56 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + !source_response.contains("records") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + type_of(source_response["records"]) != "array" || + source_response["records"].len > parameters["resultLimit"] { + throw("source_protocol_error"); + } + let total = source_response["total"]; + let records = source_response["records"]; + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 || type_of(records[0]) != "map" { + throw("source_protocol_error"); + } + let record = records[0]; + if len(record) != 5 || + !record.contains("returned_child_reference") || + !record.contains("parent_references") || + !record.contains("reference_namespace") || + !record.contains("relationship_set_contract") || + !record.contains("relationship_set_complete") || + type_of(record["returned_child_reference"]) != "string" || + type_of(record["parent_references"]) != "array" || + type_of(record["reference_namespace"]) != "string" || + type_of(record["relationship_set_contract"]) != "string" || + type_of(record["relationship_set_complete"]) != "bool" || + record["relationship_set_complete"] != parameters["relationshipSetComplete"] || + record["reference_namespace"] != parameters["referenceNamespace"] || + record["relationship_set_contract"] != parameters["relationshipSetContract"] || + record["parent_references"].len > 2 { + throw("source_protocol_error"); + } + let seen = []; + for reference in record["parent_references"] { + if type_of(reference) != "string" || reference == "" || list_contains(seen, reference) { + throw("source_protocol_error"); + } + seen.push(reference); + } + #{outcome: "match", facts: #{ + returned_child_reference: record["returned_child_reference"], + parent_references: record["parent_references"], + reference_namespace: record["reference_namespace"], + relationship_set_contract: record["relationship_set_contract"], + relationship_set_complete: record["relationship_set_complete"] + }} +} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai b/products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai new file mode 100644 index 000000000..8c6923f2a --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/derivations/legal-parent-relationship.rhai @@ -0,0 +1,19 @@ +fn derive(facts, selectors, evaluation_context) { + let parameters = evaluation_context["parameters"]; + if parameters["matching_policy"] != "exact-opaque-reference-membership-v1" || + parameters["legal_authority_attestation"] != + "urn:example:fixture:governance:legal-parent-register:v1" || + facts["returned_child_reference"] != selectors["child"]["values"]["record_reference"] || + facts["relationship_set_complete"] != true || + facts["reference_namespace"] != parameters["candidate_reference_namespace"] || + facts["relationship_set_contract"] != parameters["relationship_set_contract"] { + throw("derivation_input_error"); + } + [#{ + concept_id: "urn:example:fixture:concept:legal-parent-relationship-confirmed", + value: list_contains( + facts["parent_references"], + selectors["candidate-parent"]["values"]["person_reference"] + ) + }] +} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml new file mode 100644 index 000000000..c33931a4d --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml @@ -0,0 +1,91 @@ +version: 1 +service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} +issuer: {id: urn:example:fixture:issuer:authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +selectorProfiles: + civil-record-reference-v1: + maximumAggregateBytes: 96 + fields: {record_reference: {type: string, minimumBytes: 1, maximumBytes: 96}} + person-reference-v1: + maximumAggregateBytes: 128 + fields: {person_reference: {type: string, minimumBytes: 1, maximumBytes: 128}} +sources: + source-d: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-d-token} + request: + method: POST + path: /v1/child-relationships + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: child + alternatives: [{profile: civil-record-reference-v1, fields: [record_reference]}] + prepareScript: adapters/source-d-prepare.rhai + adapterParameters: + requestedFields: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] + resultLimit: 2 + referenceNamespace: urn:example:fixture:person-reference + relationshipSetContract: urn:example:fixture:legal-parent-set:v1 + relationshipSetComplete: true + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: + - /total + - /records/*/returned_child_reference + - /records/*/parent_references + - /records/*/reference_namespace + - /records/*/relationship_set_contract + - /records/*/relationship_set_complete + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/source-d.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:legal-parent-relationship:v1 + purpose: fixture-enrolment + audienceFrom: authenticated-requester + subjects: + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: {person_reference: grant.candidate_parent.person_reference} +requirements: + - id: urn:example:fixture:requirement:legal-parent-relationship:v1 + kind: criterion + source: source-d + purposes: [fixture-enrolment] + subjectRoles: + - {role: child, cardinality: one, selectorProfiles: [civil-record-reference-v1]} + - {role: candidate-parent, cardinality: one, selectorProfiles: [person-reference-v1]} + referenceFrameworks: [urn:example:fixture:framework:legal-parent-relationship:v1] + evidenceType: urn:example:fixture:evidence-type:legal-parent-relationship:v1 + validitySeconds: 86400 + derivation: + script: derivations/legal-parent-relationship.rhai + selectorInputs: + - role: child + alternatives: [{profile: civil-record-reference-v1, fields: [record_reference]}] + - role: candidate-parent + alternatives: [{profile: person-reference-v1, fields: [person_reference]}] + parameters: + matching_policy: exact-opaque-reference-membership-v1 + candidate_reference_namespace: urn:example:fixture:person-reference + relationship_set_contract: urn:example:fixture:legal-parent-set:v1 + legal_authority_attestation: urn:example:fixture:governance:legal-parent-register:v1 + concepts: [{id: urn:example:fixture:concept:legal-parent-relationship-confirmed, form: boolean, required: true, constraints: {}}] + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:legal-parent-relationship]} + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml new file mode 100644 index 000000000..f927a7831 --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml @@ -0,0 +1,104 @@ +fixture: registry.evidence.acceptance.legal-parent-relationship/v1 +coequal_acceptance_definition: true +synthetic_only: true +provider_contract: + classification: synthetic-governed-authoritative-legal-parent-register + governance_attestation: urn:example:fixture:governance:legal-parent-register:v1 + legal_meaning: For this synthetic acceptance definition, the configured source's complete parent-reference set is legally authoritative for the declared legal-parent concept. + lookup: One bounded child-only lookup must resolve exactly one returned child record. + child_binding: The returned child reference must exactly equal the authorized child selector. + candidate_authority: The candidate opaque reference is supplied independently by the authenticated grant and is never sent to the source. + decision: Exact membership in the complete governed parent-reference set is true; exact non-membership is false. + false_preconditions: A signed false requires a unique child, exact child binding, the governed reference namespace and contract, and relationship_set_complete true. + prohibited: Names, dates, fuzzy matching, candidate search, partial parent sets, and absence inferred from no-match cannot decide legal parentage. +common: + observed_at: '2026-08-02T00:00:00Z' + subjects: + - {role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + - {role: candidate-parent, profile: person-reference-v1} + selectors: + child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} + derivationSelectorInputs: + child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} + verified_token_claims: + evidence_grant_id: synthetic-parentage-grant-001 + evidence_authority: statutory-caseworker-v1 + grant: + candidate_parent: {person_reference: synthetic-parent-reference-001} + expectedRequestParts: + query: [] + body: + lookup: {record_reference: synthetic-child-record-001} + fields: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] + limit: 2 + expectedTransport: + path: /v1/child-relationships + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - id: positive + source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]} + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + - id: negative-false-is-success + verified_token_claims: + evidence_grant_id: synthetic-parentage-grant-001 + evidence_authority: statutory-caseworker-v1 + grant: + candidate_parent: {person_reference: synthetic-non-parent-reference-003} + derivationSelectorInputs: + child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}} + source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]} + expected_value: false + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-correct-role-order + source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]} + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + expected_subject_roles: [child, candidate-parent] + - id: negative-swapped-roles + subjects: + - {role: candidate-parent, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + - {role: child, profile: person-reference-v1} + expected: pre-source-selector-rejection + - id: negative-caller-candidate-substitution + subjects: + - {role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} + - {role: candidate-parent, profile: person-reference-v1, values: {person_reference: synthetic-substitute-reference}} + expected: pre-source-selector-rejection + - {id: missing-fact, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-child-unresolved, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: service_unavailable, derivation_runs: true, signed_success: false} + - {id: negative-incomplete-parent-set, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: false}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-relationship-status-type, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: synthetic-parent-reference-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-status-on-none, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-status-on-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-unknown-pair-resolution, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:other-namespace, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-unknown-relationship-status, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:other-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} + - {id: negative-extra-source-field, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true, related_subject_name: PrivacyCanary}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: source-failure, source_failure: timeout, expected_public_problem: dependency_unavailable, signed_success: false} + - id: negative-extra-family-fact + injected_derivation: + - {concept_id: urn:example:fixture:concept:legal-parent-relationship-confirmed, value: true} + - {concept_id: urn:example:fixture:concept:related-subject-name, value: PrivacyCanary} + expected: output-gate-rejection + - {id: anti-reconstruction, companion_bundle: relationship-graph, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [child, candidate-parent, urn:example:fixture:concept:legal-parent-relationship-confirmed] + evidence_excludes: [record_reference, person_reference, parent_references, returned_child_reference, family-graph] + diagnostics_exclude: [synthetic-child-record-001, synthetic-other-child-record, synthetic-parentage-grant-001, synthetic-parent-reference-001, synthetic-parent-reference-002, synthetic-non-parent-reference-003, synthetic-substitute-reference, PrivacyCanary] + subject_bindings: two independent audience-scoped opaque bindings diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..2a9fa7a56 --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,15 @@ +type: object +additionalProperties: false +required: + - requestedFields + - resultLimit + - referenceNamespace + - relationshipSetContract + - relationshipSetComplete +properties: + requestedFields: + const: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] + resultLimit: {const: 2} + referenceNamespace: {const: urn:example:fixture:person-reference} + relationshipSetContract: {const: urn:example:fixture:legal-parent-set:v1} + relationshipSetComplete: {const: true} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/facts.schema.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/facts.schema.yaml new file mode 100644 index 000000000..f43938c9d --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/facts.schema.yaml @@ -0,0 +1,14 @@ +type: object +additionalProperties: false +required: [returned_child_reference, parent_references, reference_namespace, relationship_set_contract, relationship_set_complete] +properties: + returned_child_reference: {type: string, minLength: 1, maxLength: 96} + parent_references: + type: array + minItems: 0 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {const: urn:example:fixture:person-reference} + relationship_set_contract: {const: urn:example:fixture:legal-parent-set:v1} + relationship_set_complete: {const: true} diff --git a/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c-prepare.rhai b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c-prepare.rhai new file mode 100644 index 000000000..494edc493 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c-prepare.rhai @@ -0,0 +1,12 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "licence_reference", value: subject["values"]["licence_reference"]}, + #{name: "registry_region", value: subject["values"]["registry_region"]}, + #{name: "fields", value: parameters["requestedFields"]}, + #{name: "limit", value: parameters["resultLimit"]} + ], + body: () + } +} diff --git a/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai new file mode 100644 index 000000000..d80e9a9b1 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai @@ -0,0 +1,33 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + !source_response.contains("records") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + type_of(source_response["records"]) != "array" || + source_response["records"].len > parse_integer(parameters["resultLimit"]) { + throw("source_protocol_error"); + } + let total = source_response["total"]; + let records = source_response["records"]; + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 || type_of(records[0]) != "map" { + throw("source_protocol_error"); + } + let record = records[0]; + let facts = #{}; + if record.contains("licence_state") { facts["licence_state"] = record["licence_state"]; } + if record.contains("valid_from") { facts["valid_from"] = record["valid_from"]; } + if record.contains("valid_until") { facts["valid_until"] = record["valid_until"]; } + #{ + outcome: "match", + facts: facts + } +} diff --git a/products/evidence/fixtures/acceptance/professional-licence/codelists/expiry-categories.yaml b/products/evidence/fixtures/acceptance/professional-licence/codelists/expiry-categories.yaml new file mode 100644 index 000000000..f1306ace1 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/codelists/expiry-categories.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:scheme:licence-expiry +version: '1' +codes: [expired, within-30-days, within-90-days, later] diff --git a/products/evidence/fixtures/acceptance/professional-licence/codelists/registry-regions.yaml b/products/evidence/fixtures/acceptance/professional-licence/codelists/registry-regions.yaml new file mode 100644 index 000000000..8f6c747d4 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/codelists/registry-regions.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:codelist:registry-regions +version: '1' +codes: [RR-A, RR-B] diff --git a/products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai b/products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai new file mode 100644 index 000000000..486f73a7e --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/derivations/professional-licence.rhai @@ -0,0 +1,25 @@ +fn derive(facts, selectors, evaluation_context) { + let starts = parse_date(required(facts.valid_from, "required_fact_missing")); + let ends = parse_date(required(facts.valid_until, "required_fact_missing")); + let state = required(facts.licence_state, "required_fact_missing"); + let current = evaluation_context.legal_local_date; + let active = + state == evaluation_context.parameters.active_state && + compare_dates(current, starts) >= 0 && + compare_dates(current, ends) <= 0; + let remaining_days = integer_to_decimal(days_between(current, ends)); + let category = bucket_number( + remaining_days, + evaluation_context.parameters.expiry_buckets + ); + [ + #{ + concept_id: "urn:example:fixture:concept:licence-active", + value: active + }, + #{ + concept_id: "urn:example:fixture:concept:licence-expiry-category", + value: category + } + ] +} diff --git a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml new file mode 100644 index 000000000..21dcc8311 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml @@ -0,0 +1,75 @@ +version: 1 +service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} +issuer: {id: urn:example:fixture:issuer:authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +selectorProfiles: + licence-register-v1: + maximumAggregateBytes: 128 + fields: + licence_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + registry_region: {type: controlled-code, codelist: codelists/registry-regions.yaml, codelistVersion: '1', maximumBytes: 16} +sources: + source-c: + transport: http-json + baseUrl: https://source.invalid + posture: record-transformed + authentication: {kind: basic, usernameRef: secret:file/source-c-username, passwordRef: secret:file/source-c-password} + request: + method: GET + path: /v1/records + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: licence-register-v1, fields: [licence_reference, registry_region]}] + prepareScript: adapters/source-c-prepare.rhai + adapterParameters: {requestedFields: "licence_state,valid_from,valid_until", resultLimit: "2"} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: required, jsonBody: forbidden, maximumQueryPairs: 4, maximumQueryNameBytes: 64, maximumQueryValueBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /records/*/licence_state, /records/*/valid_from, /records/*/valid_until] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 131072 + concurrencyLimit: 8 + extractScript: adapters/source-c.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:professional-licence-status:v1 + purpose: fixture-registration + audienceFrom: authenticated-requester + subjects: [{role: subject, selectorProfile: licence-register-v1, valueOrigin: request}] +requirements: + - id: urn:example:fixture:requirement:professional-licence-status:v1 + kind: criterion + source: source-c + purposes: [fixture-registration] + subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [licence-register-v1]}] + referenceFrameworks: [urn:example:fixture:framework:professional-licence:v1] + evidenceType: urn:example:fixture:evidence-type:professional-licence-status:v1 + observationTimezone: Africa/Nairobi + validitySeconds: 43200 + derivation: + script: derivations/professional-licence.rhai + parameters: + active_state: CURRENT + expiry_buckets: + - {minimumInclusive: {type: decimal, value: '-365000'}, maximumExclusive: {type: decimal, value: '0'}, code: expired} + - {minimumInclusive: {type: decimal, value: '0'}, maximumExclusive: {type: decimal, value: '31'}, code: within-30-days} + - {minimumInclusive: {type: decimal, value: '31'}, maximumExclusive: {type: decimal, value: '91'}, code: within-90-days} + - {minimumInclusive: {type: decimal, value: '91'}, maximumExclusive: {type: decimal, value: '365001'}, code: later} + concepts: + - {id: urn:example:fixture:concept:licence-active, form: boolean, required: true, constraints: {}} + - id: urn:example:fixture:concept:licence-expiry-category + form: controlled-category + required: true + constraints: {categoryScheme: urn:example:fixture:scheme:licence-expiry, schemeVersion: '1', maximumBytes: 32, codelist: codelists/expiry-categories.yaml} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:professional-licence]} + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/acceptance/professional-licence/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/professional-licence/fixtures/cases.yaml new file mode 100644 index 000000000..753612430 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/fixtures/cases.yaml @@ -0,0 +1,71 @@ +fixture: registry.evidence.acceptance.professional-licence/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selector: {licence_reference: synthetic-licence-001, registry_region: RR-A} + selectors: + subject: {profile: licence-register-v1, values: {licence_reference: synthetic-licence-001, registry_region: RR-A}} + expectedRequestParts: + query: + - {name: licence_reference, value: synthetic-licence-001} + - {name: registry_region, value: RR-A} + - {name: fields, value: "licence_state,valid_from,valid_until"} + - {name: limit, value: "2"} + body: null + expectedTransport: + path: /v1/records + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - id: positive + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-20', historical_states: [PENDING]}]} + expected_values: {licence-active: true, licence-expiry-category: within-30-days} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: negative-inactive-is-success + source: {total: 1, records: [{licence_state: SUSPENDED, valid_from: '2025-01-01', valid_until: '2026-12-31', historical_states: [CURRENT]}]} + expected_values: {licence-active: false, licence-expiry-category: later} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-valid-from + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2026-08-02', valid_until: '2026-12-31', historical_states: []}]} + expected_values: {licence-active: true, licence-expiry-category: later} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-valid-until + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-02', historical_states: []}]} + expected_values: {licence-active: true, licence-expiry-category: within-30-days} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: boundary-after-valid-until + legal_local_date: '2026-08-03' + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', valid_until: '2026-08-02', historical_states: []}]} + expected_values: {licence-active: false, licence-expiry-category: expired} + expected_lookup: match + derivation_runs: true + signed_success: true + - id: missing-fact + source: {total: 1, records: [{licence_state: CURRENT, valid_from: '2025-01-01', historical_states: []}]} + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: wrong-media-type, expected_public_problem: dependency_unavailable, signed_success: false} + - id: negative-exact-date-leak + injected_derivation: + - {concept_id: urn:example:fixture:concept:licence-active, value: true} + - {concept_id: urn:example:fixture:concept:licence-expiry-category, value: '2026-08-20'} + expected: output-gate-rejection + - {id: anti-reconstruction, companion_bundle: coexisting-revisions, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:licence-active, urn:example:fixture:concept:licence-expiry-category] + evidence_excludes: [valid_from, valid_until, historical_states, licence_reference, registry_region] + diagnostics_exclude: [synthetic-licence-001, '2026-08-20', PENDING, CURRENT] + minimization_claim: disclosure minimization only diff --git a/products/evidence/fixtures/acceptance/professional-licence/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/professional-licence/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..40a08a8cc --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: {const: "licence_state,valid_from,valid_until"} + resultLimit: {const: "2"} diff --git a/products/evidence/fixtures/acceptance/professional-licence/schemas/facts.schema.yaml b/products/evidence/fixtures/acceptance/professional-licence/schemas/facts.schema.yaml new file mode 100644 index 000000000..c23fa7fb3 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/schemas/facts.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [licence_state, valid_from, valid_until] +properties: + licence_state: {type: string, minLength: 1, maxLength: 32} + valid_from: {type: string, format: date} + valid_until: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/residence-region/adapters/source-b-prepare.rhai b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b-prepare.rhai new file mode 100644 index 000000000..3f461b1e0 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b-prepare.rhai @@ -0,0 +1,11 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{record_reference: subject["values"]["record_reference"]}, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai new file mode 100644 index 000000000..e19794042 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai @@ -0,0 +1,19 @@ +fn extract(source_response, parameters) { + if !source_response.contains("total") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 { + throw("source_protocol_error"); + } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } + if !source_response.contains("official_residence_code") { + return #{outcome: "match", facts: #{}}; + } + if type_of(source_response["official_residence_code"]) != "string" { + throw("source_protocol_error"); + } + #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} +} diff --git a/products/evidence/fixtures/acceptance/residence-region/codelists/region-map.yaml b/products/evidence/fixtures/acceptance/residence-region/codelists/region-map.yaml new file mode 100644 index 000000000..61e8637f2 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/codelists/region-map.yaml @@ -0,0 +1,7 @@ +id: urn:example:fixture:codelist:region-map +version: '2026-01' +entries: + R-101: REGION-NORTH + R-102: REGION-NORTH + R-201: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai b/products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai new file mode 100644 index 000000000..cb374e56d --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/derivations/residence-region.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup( + evaluation_context.codelists["region-map"], + required(facts.official_residence_code, "required_fact_missing") + ); + [#{ + concept_id: "urn:example:fixture:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + }] +} diff --git a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml new file mode 100644 index 000000000..0f1109815 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml @@ -0,0 +1,63 @@ +version: 1 +service: {providerId: urn:example:fixture:provider:evidence, trustDomain: urn:example:fixture:trust-domain:acceptance} +issuer: {id: urn:example:fixture:issuer:authority} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [EdDSA], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority} +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +selectorProfiles: + residence-record-v1: + maximumAggregateBytes: 96 + fields: {record_reference: {type: string, minimumBytes: 1, maximumBytes: 96}} +sources: + source-b: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-b-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: residence-record-v1, fields: [record_reference]}] + prepareScript: adapters/source-b-prepare.rhai + adapterParameters: {requestedFields: [official_residence_code], resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /official_residence_code] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/source-b.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:residence-region:v1 + purpose: fixture-routing + audienceFrom: authenticated-requester + subjects: [{role: subject, selectorProfile: residence-record-v1, valueOrigin: request}] +requirements: + - id: urn:example:fixture:requirement:residence-region:v1 + kind: information-requirement + source: source-b + purposes: [fixture-routing] + subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [residence-record-v1]}] + referenceFrameworks: [urn:example:fixture:framework:residence-region:v1] + evidenceType: urn:example:fixture:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: {script: derivations/residence-region.rhai, parameters: {}} + concepts: + - id: urn:example:fixture:concept:residence-region + form: controlled-code + required: true + constraints: {codelist: codelists/region-map.yaml, codelistVersion: '2026-01', maximumBytes: 32} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:residence-region]} + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/acceptance/residence-region/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/residence-region/fixtures/cases.yaml new file mode 100644 index 000000000..5db868b11 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/fixtures/cases.yaml @@ -0,0 +1,30 @@ +fixture: registry.evidence.acceptance.residence-region/v1 +coequal_acceptance_definition: true +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selector: {record_reference: synthetic-residence-record-001} + selectors: + subject: {profile: residence-record-v1, values: {record_reference: synthetic-residence-record-001}} + expectedRequestParts: + query: [] + body: {lookup: {record_reference: synthetic-residence-record-001}, fields: [official_residence_code], limit: 2} + expectedTransport: + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - {id: positive, source: {total: 1, official_residence_code: R-101}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-unknown-code, source: {total: 1, official_residence_code: R-999}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: boundary-second-source-code-same-coarse-region, source: {total: 1, official_residence_code: R-102}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: boundary-other-coarse-region, source: {total: 1, official_residence_code: R-201}, expected_value: REGION-SOUTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-overly-precise-output, injected_derivation: [{concept_id: urn:example:fixture:concept:residence-region, value: R-101}], expected: output-gate-rejection} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: missing-record, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: http-503, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: anti-reconstruction, companion_bundle: geographic-overlap, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:fixture:concept:residence-region, REGION-NORTH] + evidence_excludes: [official_residence_code, record_reference, R-101, R-102] + diagnostics_exclude: [synthetic-residence-record-001, R-101, R-102, REGION-NORTH] diff --git a/products/evidence/fixtures/acceptance/residence-region/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/acceptance/residence-region/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..39fc57931 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [official_residence_code] + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/acceptance/residence-region/schemas/facts.schema.yaml b/products/evidence/fixtures/acceptance/residence-region/schemas/facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/conformance/acquisition-postures.yaml b/products/evidence/fixtures/conformance/acquisition-postures.yaml new file mode 100644 index 000000000..b953cb24f --- /dev/null +++ b/products/evidence/fixtures/conformance/acquisition-postures.yaml @@ -0,0 +1,27 @@ +fixture: registry.evidence.acquisition-postures/v1 +synthetic_only: true +cases: + - posture: source-derived + source_response: {total: 1, result: {final_code: C-A}} + declared_facts: [final_code] + derive: exact final code only + expected_claim: acquisition and disclosure minimization + negative: source-returns-undeclared-field + - posture: field-projected + source_response: {total: 1, result: {fact_a: '2000-02-29', fact_b: C-B}} + declared_facts: [fact_a, fact_b] + derive: declared concept value from only projected facts + expected_claim: strong acquisition and disclosure minimization + negative: fixed-projection-expanded + - posture: record-transformed + source_response: {total: 1, result: {fact_a: C-A, legacy_field: synthetic-source-canary, historical_entries: [S-OLD]}} + declared_facts: [fact_a, legacy_field, historical_entries] + derive: declared minimized concept from fact_a only + expected_claim: disclosure minimization only + negative: full-lifecycle-minimization-overclaim +shared_expectations: + - same fixed HTTP JSON executor and Rhai ABI + - exactly one evidence-data request + - response and facts remain in one bounded evaluation + - no raw field reaches evidence, audit, logs, errors, snapshots, or disk + - declared posture is included in bundle review metadata, never caller-selected diff --git a/products/evidence/fixtures/conformance/anti-reconstruction.yaml b/products/evidence/fixtures/conformance/anti-reconstruction.yaml new file mode 100644 index 000000000..3add8c956 --- /dev/null +++ b/products/evidence/fixtures/conformance/anti-reconstruction.yaml @@ -0,0 +1,51 @@ +fixture: registry.evidence.anti-reconstruction/v1 +synthetic_only: true +rejected_bundles: + - id: threshold-ladder + shared_disclosure_family: urn:example:fixture:disclosure-family:protected-scalar-a + definitions: + - {concept: urn:example:fixture:concept:threshold-a, parameter: 18, requester: requester-a} + - {concept: urn:example:fixture:concept:threshold-b, parameter: 19, requester: requester-a} + - {concept: urn:example:fixture:concept:threshold-c, parameter: 20, requester: requester-a} + threat: Repeated booleans narrow a protected scalar. + expected: reject complete enabled bundle even with rate limits + - id: geographic-overlap + shared_disclosure_family: urn:example:fixture:disclosure-family:protected-partition-a + definitions: + - {concept: urn:example:fixture:concept:area-a, partition: coarse-a, requester: requester-a} + - {concept: urn:example:fixture:concept:area-b, partition: overlapping-b, requester: requester-a} + threat: Intersecting partitions disclose a more precise location. + expected: reject unless authorization separation proves the answers cannot be combined + - id: coexisting-revisions + shared_disclosure_family: urn:example:fixture:disclosure-family:protected-revision-a + definitions: + - {concept: urn:example:fixture:concept:status, revision: v1, requester: requester-a} + - {concept: urn:example:fixture:concept:status, revision: v2, requester: requester-a} + threat: Changed boundaries disclose the underlying fact. + expected: reject unsafe simultaneous authorization + - id: relationship-graph + shared_disclosure_family: urn:example:fixture:disclosure-family:protected-relationship-a + definitions: + - {concept: urn:example:fixture:concept:relationship-a, roles: [subject-a, subject-b]} + - {concept: urn:example:fixture:concept:relationship-b, roles: [subject-b, subject-c]} + threat: Combinations reconstruct an unapproved family or association graph. + expected: reject unsafe relationship combination +allowed_control: + id: unrelated-minimized-concepts + definitions: + - urn:example:fixture:concept:code-a + - urn:example:fixture:concept:boolean-b + review_evidence: No shared protected source fact, partition intersection, threshold ladder, or relationship graph under the same authorization surface. +bundle_validation: + rule: Any two enabled requirements sharing a disclosureGuard family reject the complete bundle at startup. + applies_to: [threshold-ladders, overlapping-partitions, coexisting-revisions, relationship-graph-definitions] + rate_limits: Never make a shared-family bundle valid. + scope: Static disclosure-combination validation only; this is not a runtime policy engine. +existence_collapse: + internal: [no_match, ambiguous, required_fact_missing] + public: + status: 422 + code: evidence_not_available + title: Evidence could not be produced + exact_body_shape_equal: true + prohibited: [candidate_count, score, near_match_hint, field_comparison, class-specific-message] diff --git a/products/evidence/fixtures/conformance/audit-events.yaml b/products/evidence/fixtures/conformance/audit-events.yaml new file mode 100644 index 000000000..41c7194e2 --- /dev/null +++ b/products/evidence/fixtures/conformance/audit-events.yaml @@ -0,0 +1,60 @@ +fixture: registry.evidence.audit-events/v1 +synthetic_only: true +access_attempt: + schema: registry.evidence.audit/v1 + eventId: urn:example:fixture:audit:access-001 + occurredAt: '2026-08-02T00:00:00Z' + operation: fixture-operation-00000001 + phase: access-attempt + requirement: urn:example:fixture:requirement:property:v1 + bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + purpose: fixture-procedure + requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 + authority: {kind: statutory} + subjects: + - role: subject + selectorProfile: opaque-record-v1 + selectorBundlePseudonym: hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222 + sourceId: source-a + adapterId: adapter-a + decision: authorized + durationMilliseconds: 2 +disclosure_release: + schema: registry.evidence.audit/v1 + eventId: urn:example:fixture:audit:release-001 + occurredAt: '2026-08-02T00:00:01Z' + operation: fixture-operation-00000001 + phase: disclosure-release + requirement: urn:example:fixture:requirement:property:v1 + bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + purpose: fixture-procedure + requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 + authority: {kind: statutory} + subjects: + - role: subject + selectorProfile: opaque-record-v1 + selectorBundlePseudonym: hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222 + sourceId: source-a + adapterId: adapter-a + decision: released + disclosedConcepts: [urn:example:fixture:concept:boolean-a] + evidenceId: urn:example:fixture:evidence:001 + signingKeyId: fixture-key-2026-01 + durationMilliseconds: 12 +negative: + - raw-principal + - raw-actor-or-grant + - raw-selector-value + - separate-field-hash + - plain-sha256-subject-hash + - base64url-reencoded-audit-hmac + - globally-stable-subject-pseudonym + - source-or-supported-value + - credential-token-or-private-key + - candidate-count-score-hint-or-comparison + - release-fields-on-access-event + - missing-release-fields-on-release-event +order: + access_attempt_durable_before: [credential-resolution, source-access] + disclosure_release_durable_after: [signing] + disclosure_release_durable_before: [response-release] diff --git a/products/evidence/fixtures/conformance/coverage-matrix.yaml b/products/evidence/fixtures/conformance/coverage-matrix.yaml new file mode 100644 index 000000000..dd3b49f5a --- /dev/null +++ b/products/evidence/fixtures/conformance/coverage-matrix.yaml @@ -0,0 +1,175 @@ +fixture: registry.evidence.phase-zero-coverage/v1 +status: frozen +completion_rule: Every required row is implemented and green on one revision; no acceptance definition is a seed, phase, optional example, or later generality check. +categories: [positive, negative, boundary, missing, no-match, ambiguous, source-failure, anti-reconstruction] +acceptance_definitions: + - definition: adult-status + bundle: ../acceptance/adult-status/evidence.yaml + cases: ../acceptance/adult-status/fixtures/cases.yaml + selector: compound no identifier + posture: field-projected + supported_values: [boolean] + coverage: + positive: positive + negative: [negative-false-is-success, negative-wrong-derived-type] + boundary: [boundary-before, boundary-on, boundary-after, boundary-leap-day] + missing: [missing-record, missing-fact] + no-match: no-match + ambiguous: ambiguous + source-failure: source-failure + anti-reconstruction: threshold-ladder + - definition: residence-region + bundle: ../acceptance/residence-region/evidence.yaml + cases: ../acceptance/residence-region/fixtures/cases.yaml + selector: identifier only + posture: field-projected + supported_values: [controlled-code] + coverage: + positive: positive + negative: [negative-unknown-code, negative-overly-precise-output] + boundary: [boundary-second-source-code-same-coarse-region, boundary-other-coarse-region] + missing: [missing-record, missing-fact] + no-match: no-match + ambiguous: ambiguous + source-failure: source-failure + anti-reconstruction: geographic-overlap + - definition: professional-licence-status + bundle: ../acceptance/professional-licence/evidence.yaml + cases: ../acceptance/professional-licence/fixtures/cases.yaml + selector: compound sector selector + posture: record-transformed + supported_values: [boolean, controlled-category] + coverage: + positive: positive + negative: [negative-inactive-is-success, negative-exact-date-leak] + boundary: [boundary-valid-from, boundary-valid-until, boundary-after-valid-until] + missing: [missing-record, missing-fact] + no-match: no-match + ambiguous: ambiguous + source-failure: source-failure + anti-reconstruction: coexisting-revisions + - definition: legal-parent-relationship + bundle: ../acceptance/legal-parent-relationship/evidence.yaml + cases: ../acceptance/legal-parent-relationship/fixtures/cases.yaml + selector: two independently role-bound profiles + posture: source-derived + supported_values: [boolean] + coverage: + positive: positive + negative: [negative-false-is-success, negative-swapped-roles, negative-caller-candidate-substitution, negative-child-unresolved, negative-candidate-unresolved-or-typo, negative-raw-record-search-zero-not-false, negative-role-resolution-ambiguous, negative-extra-family-fact, negative-status-on-none, negative-status-on-ambiguous, negative-unknown-pair-resolution, negative-unknown-relationship-status, negative-relationship-status-type, negative-extra-source-field] + boundary: boundary-correct-role-order + missing: [missing-record, missing-fact] + no-match: no-match + ambiguous: ambiguous + source-failure: source-failure + anti-reconstruction: relationship-graph +selector_matrix: + fixture: selector-matrix.yaml + required: + - identifier-only + - compound-no-identifier + - added-disambiguator + - multi-role + - opaque-non-domain-field-names + origins: + request: identifier-only + authenticated-context: compound-no-identifier + authenticated-grant: added-disambiguator + pre_source_rejections: + - missing, unknown, extra, mistyped, empty, oversized, and aggregate-oversized fields + - unauthorized profile, role, purpose, audience, or value origin + - caller values on context-derived or grant-derived profiles + - absent caller values on an authorized request-derived profile + - caller-added disambiguator and inferred alternative sufficient field set + opaque_behavior: Unicode and multipart name-like strings are exact bounded UTF-8 with no normalization, transliteration, tokenization, phonetics, or partial-date matching. +supported_values: + fixture: supported-values.yaml + forms: + - boolean + - controlled-code + - controlled-category + - bounded-integer + - bounded-decimal + - date-bucket + - time-bucket + - audience-scoped-entity-reference + - controlled-code-list + - entity-reference-list + - reviewed-structured-value + each_requires: [positive, boundary, wrong-type, range-or-code, size, cardinality-where-applicable, evidence-construction, jws-round-trip] + special: + decimal: custom exact Decimal from canonical string, precision 28 and scale 9, public canonical JSON string, no f32/f64 + entity_reference: protected seed or seed list is HMAC-projected by Rust and never disclosed +acquisition_postures: + fixture: acquisition-postures.yaml + required: [source-derived, field-projected, record-transformed] + honesty_test: record-transformed may claim disclosure minimization only +source_shapes: + index: ../source-shapes/index.yaml + profiles: + - profile: flat-rest + request: fixed JSON POST + authentication: static-bearer + outcomes: [no-match, match, ambiguous, missing-fact] + - profile: nested-paged-rest + request: fixed GET query, fields, pageSize two + authentication: basic + outcomes: [no-match, match, ambiguous, missing-fact, pager-refusal] + - profile: opencrvs-event-search-json + request: fixed OpenCRVS version 2 JSON POST to /events/search after credential bootstrap + authentication: oauth2-client-credentials + posture: record-transformed + outcomes: [no-match, match, ambiguous, missing-fact, inconsistent-cardinality] + common_failures: [401, 403, 429, 5xx, timeout, redirect, invalid-json, wrong-media-type, oversized-response] + common_invariants: + - one evidence-data request and no page traversal + - maximum two bounded results solely for cardinality; profiles that cannot field-project declare record-transformed + - no candidates, count beyond closed outcome, score, hint, or comparison in derivation or public surfaces + - raw selectors, credentials, token flows, source values, and bodies absent from diagnostics and audit + query_oauth_redaction: oauth-query-credential-redaction.yaml +public_contracts: + requests_and_payloads: golden/ + jws: jws-cases.yaml + audit: audit-events.yaml + problems: ../../contracts/problem-contract.yaml +security_negative_tests: + - sec-unknown-or-disabled-requirement + - sec-caller-query-material-rejected + - sec-unsafe-definition-combination + - sec-missing-principal-no-fallback + - sec-no-entitlement-union + - sec-selector-possession-no-authority + - sec-caller-grant-reference-rejected + - sec-selector-shape-not-callable + - sec-source-request-immutable + - sec-candidate-material-rejected + - sec-rhai-output-shape-closed + - sec-derived-value-output-gate + - sec-evaluation-and-audit-fail-closed + - sec-source-canary-absent-everywhere + - sec-protected-canaries-redacted + - sec-unresolved-public-collapse + - sec-subject-binding-scope + - sec-runtime-bundle-mutation-absent + - sec-mutually-distrustful-configuration + - sec-rate-limit-does-not-legalize-ladder + - sec-jws-mutation-and-duplicate-payload + - sec-signing-failure-no-release + - sec-private-key-canary-unreachable + - sec-verifier-rejects-untrusted-provider + - sec-script-cannot-construct-evidence + - sec-missing-or-writable-bundle-fails + - sec-audit-order-and-failure + - sec-secret-values-rejected-from-bundle + - sec-proxy-identity-header-rejected + - sec-decimal-no-float-coercion + - sec-entity-reference-seed-protected + - sec-request-preparation-closed + - sec-runtime-cannot-override-governed-bundle + - sec-tls-and-proxy-authority-fixed + - sec-subject-array-order-nonsemantic +canary_scan_surfaces: [evidence, jws, public-problem, log, metric, trace, audit, snapshot, panic, temporary-file, test-failure-output] +canary_classes: [principal, actor, grant, selector, source-fact, supported-value, source-credential, oauth-client, oauth-token, signing-key, entity-reference-seed] +coequal_full_path: + for_each: [offline-evaluation, authentication, authorization, access-audit, source-execution, extraction, derivation, output-gate, evidence-construction, signing, release-audit, jws-verification] + together: all four definitions enabled in one immutable bundle and one operator trust domain, including concurrency isolation diff --git a/products/evidence/fixtures/conformance/golden/adult-evidence.json b/products/evidence/fixtures/conformance/golden/adult-evidence.json new file mode 100644 index 000000000..d2e3d3689 --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/adult-evidence.json @@ -0,0 +1,27 @@ +{ + "schema": "registry.assertion-evidence/v1", + "id": "urn:example:fixture:evidence:adult-001", + "type": "Evidence", + "supportsRequirement": "urn:example:fixture:requirement:adult-status:v1", + "isConformantTo": "urn:example:fixture:evidence-type:adult-status:v1", + "issuedBy": "urn:example:fixture:issuer:authority", + "providedBy": "urn:example:fixture:provider:evidence", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "fixture-eligibility", + "audience": "urn:example:fixture:audience:requester-a", + "configurationRevision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "subjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "supportedValues": [ + { + "providesValueFor": "urn:example:fixture:concept:adult-status", + "value": true + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/adult-request.json b/products/evidence/fixtures/conformance/golden/adult-request.json new file mode 100644 index 000000000..4732fb507 --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/adult-request.json @@ -0,0 +1,17 @@ +{ + "requirement": "urn:example:fixture:requirement:adult-status:v1", + "purpose": "fixture-eligibility", + "subjects": [ + { + "role": "subject", + "selector": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + } + } + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/licence-evidence.json b/products/evidence/fixtures/conformance/golden/licence-evidence.json new file mode 100644 index 000000000..f027f107d --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/licence-evidence.json @@ -0,0 +1,31 @@ +{ + "schema": "registry.assertion-evidence/v1", + "id": "urn:example:fixture:evidence:licence-001", + "type": "Evidence", + "supportsRequirement": "urn:example:fixture:requirement:professional-licence-status:v1", + "isConformantTo": "urn:example:fixture:evidence-type:professional-licence-status:v1", + "issuedBy": "urn:example:fixture:issuer:authority", + "providedBy": "urn:example:fixture:provider:evidence", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-02T12:00:00Z", + "purpose": "fixture-registration", + "audience": "urn:example:fixture:audience:requester-a", + "configurationRevision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "subjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + } + ], + "supportedValues": [ + { + "providesValueFor": "urn:example:fixture:concept:licence-active", + "value": true + }, + { + "providesValueFor": "urn:example:fixture:concept:licence-expiry-category", + "value": "within-30-days" + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/licence-request.json b/products/evidence/fixtures/conformance/golden/licence-request.json new file mode 100644 index 000000000..0319c9702 --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/licence-request.json @@ -0,0 +1,16 @@ +{ + "requirement": "urn:example:fixture:requirement:professional-licence-status:v1", + "purpose": "fixture-registration", + "subjects": [ + { + "role": "subject", + "selector": { + "profile": "licence-register-v1", + "values": { + "licence_reference": "synthetic-licence-001", + "registry_region": "RR-A" + } + } + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/relationship-evidence.json b/products/evidence/fixtures/conformance/golden/relationship-evidence.json new file mode 100644 index 000000000..9f922dfbd --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/relationship-evidence.json @@ -0,0 +1,31 @@ +{ + "schema": "registry.assertion-evidence/v1", + "id": "urn:example:fixture:evidence:relationship-001", + "type": "Evidence", + "supportsRequirement": "urn:example:fixture:requirement:legal-parent-relationship:v1", + "isConformantTo": "urn:example:fixture:evidence-type:legal-parent-relationship:v1", + "issuedBy": "urn:example:fixture:issuer:authority", + "providedBy": "urn:example:fixture:provider:evidence", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "fixture-enrolment", + "audience": "urn:example:fixture:audience:requester-a", + "configurationRevision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "subjects": [ + { + "role": "child", + "binding": "urn:evidence:subject:v1_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + }, + { + "role": "candidate-parent", + "binding": "urn:evidence:subject:v1_EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" + } + ], + "supportedValues": [ + { + "providesValueFor": "urn:example:fixture:concept:legal-parent-relationship-confirmed", + "value": true + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/relationship-request.json b/products/evidence/fixtures/conformance/golden/relationship-request.json new file mode 100644 index 000000000..1bf85182a --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/relationship-request.json @@ -0,0 +1,21 @@ +{ + "requirement": "urn:example:fixture:requirement:legal-parent-relationship:v1", + "purpose": "fixture-enrolment", + "subjects": [ + { + "role": "child", + "selector": { + "profile": "civil-record-reference-v1", + "values": { + "record_reference": "synthetic-child-record-001" + } + } + }, + { + "role": "candidate-parent", + "selector": { + "profile": "person-demographics-v1" + } + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/residence-evidence.json b/products/evidence/fixtures/conformance/golden/residence-evidence.json new file mode 100644 index 000000000..95129cf6b --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/residence-evidence.json @@ -0,0 +1,27 @@ +{ + "schema": "registry.assertion-evidence/v1", + "id": "urn:example:fixture:evidence:residence-001", + "type": "Evidence", + "supportsRequirement": "urn:example:fixture:requirement:residence-region:v1", + "isConformantTo": "urn:example:fixture:evidence-type:residence-region:v1", + "issuedBy": "urn:example:fixture:issuer:authority", + "providedBy": "urn:example:fixture:provider:evidence", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "fixture-routing", + "audience": "urn:example:fixture:audience:requester-a", + "configurationRevision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "subjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + } + ], + "supportedValues": [ + { + "providesValueFor": "urn:example:fixture:concept:residence-region", + "value": "REGION-NORTH" + } + ] +} diff --git a/products/evidence/fixtures/conformance/golden/residence-request.json b/products/evidence/fixtures/conformance/golden/residence-request.json new file mode 100644 index 000000000..0e4b19224 --- /dev/null +++ b/products/evidence/fixtures/conformance/golden/residence-request.json @@ -0,0 +1,15 @@ +{ + "requirement": "urn:example:fixture:requirement:residence-region:v1", + "purpose": "fixture-routing", + "subjects": [ + { + "role": "subject", + "selector": { + "profile": "residence-record-v1", + "values": { + "record_reference": "synthetic-residence-record-001" + } + } + } + ] +} diff --git a/products/evidence/fixtures/conformance/jws-cases.yaml b/products/evidence/fixtures/conformance/jws-cases.yaml new file mode 100644 index 000000000..737ecafc8 --- /dev/null +++ b/products/evidence/fixtures/conformance/jws-cases.yaml @@ -0,0 +1,27 @@ +fixture: registry.evidence.jws-cases/v1 +protected_header: + exact_json: '{"alg":"EdDSA","kid":"fixture-key-2026-01","typ":"evidence+jws","cty":"application/evidence+json"}' + base64url_padding: prohibited +cases: + - {id: adult-boolean, request: golden/adult-request.json, payload: golden/adult-evidence.json} + - {id: residence-controlled-code, request: golden/residence-request.json, payload: golden/residence-evidence.json} + - {id: licence-boolean-and-category, request: golden/licence-request.json, payload: golden/licence-evidence.json} + - {id: relationship-role-bound-boolean, request: golden/relationship-request.json, payload: golden/relationship-evidence.json} +signing_procedure: + key_source: core-owned fixture SigningProvider; no private key bytes in YAML or repository snapshots + payload: For signing-profile tests, sign the exact referenced fixture file bytes including the final line feed; the runtime signs its own core-produced bytes exactly once without canonicalization or reserialization. + output_members: [protected, payload, signature] + output_header_member: prohibited + verification: Resolve fixture-key-2026-01 from the harness-pinned public fixture key and verify before parsing payload. +negative: + - mutate one protected-header byte + - mutate one payload byte + - remove signature + - add an unprotected header + - add jku, x5u, jwk, x5c, crit, or b64 + - unknown kid + - algorithm mismatch + - signed payload violates the Evidence JSON Schema + - duplicate evidence object beside payload + - signing-provider failure +expected_failure: no evidence release; safe service_unavailable where failure is runtime diff --git a/products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml b/products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml new file mode 100644 index 000000000..b1e045f80 --- /dev/null +++ b/products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml @@ -0,0 +1,38 @@ +fixture: registry.evidence.oauth-query-credential-redaction/v1 +synthetic_only: true +configuration: + credential_placement: query-string + token_endpoint: https://authorization.invalid/oauth/token + client_id_ref: secret:file/fixture-oauth-client-id + client_secret_ref: secret:file/fixture-oauth-client-secret + scope: fixture.read +runtime_generated_canary_classes: [client-id, client-secret, access-token] +repository_secret_values: prohibited +cases: + - {id: token-success, provider_status: 200, expected: token-used-in-memory-only} + - {id: token-400, provider_status: 400, expected_public_problem: dependency_unavailable} + - {id: token-401, provider_status: 401, expected_public_problem: dependency_unavailable} + - {id: malformed-token-json, provider_status: 200, response: invalid-json, expected_public_problem: dependency_unavailable} + - {id: token-response-oversized, provider_status: 200, response: oversized, expected_public_problem: dependency_unavailable} + - {id: token-response-extra-field, provider_status: 200, response: extra-field, expected_public_problem: dependency_unavailable} + - {id: token-response-wrong-access-token-field, provider_status: 200, response: wrong-access-token-field, expected_public_problem: dependency_unavailable} + - {id: token-response-wrong-token-type, provider_status: 200, response: wrong-token-type, expected_public_problem: dependency_unavailable} + - {id: token-response-wrong-media-type, provider_status: 200, response: wrong-media-type, expected_public_problem: dependency_unavailable} + - {id: token-response-wrong-scope, provider_status: 200, response: wrong-scope, expected_public_problem: dependency_unavailable} + - {id: token-response-wrong-lifetime, provider_status: 200, response: wrong-lifetime, expected_public_problem: dependency_unavailable} + - {id: transport-connection-failure, transport: connection-failure, expected_public_problem: dependency_unavailable} + - {id: transport-timeout, transport: timeout, expected_public_problem: dependency_unavailable} +required_absence: + surfaces: [public-problem, operational-log, audit, trace, metric, snapshot, panic, test-failure-output] + values: + - every runtime-generated client-id canary + - every runtime-generated client-secret canary + - every runtime-generated access-token canary + - complete token URL with query + - token request body + - token response body +wire_assertion: + provider_receives_configured_query_keys: true + provider_receives_configured_grant: client_credentials + evidence_data_requests_after_token_failure: 0 + Rhai_receives_token_or_token_request: false diff --git a/products/evidence/fixtures/conformance/selector-matrix.yaml b/products/evidence/fixtures/conformance/selector-matrix.yaml new file mode 100644 index 000000000..ee9a21692 --- /dev/null +++ b/products/evidence/fixtures/conformance/selector-matrix.yaml @@ -0,0 +1,151 @@ +fixture: registry.evidence.selector-matrix/v1 +synthetic_only: true +profiles: + - case: identifier-only + profile: opaque-record-v1 + role: subject + fields: + record_reference: {type: string, minimum_bytes: 1, maximum_bytes: 96} + maximum_aggregate_bytes: 96 + value_origin: request + claim_binding: + authority_profile: reviewed-caseworker-v1 + requirement: urn:example:fixture:requirement:classification:v1 + purpose: fixture-procedure + audience: urn:example:fixture:audience:requester-a + exact_role_profile_origin: [subject, opaque-record-v1, request] + valueClaims: prohibited + request_values: {record_reference: synthetic-record-001} + negative: + - missing-record-reference + - extra-caller-field + - wrong-origin-context-value + - identifier-possession-without-entitlement + - case: compound-no-identifier + profile: demographics-v1 + role: subject + fields: + given_name: {type: string, minimum_bytes: 1, maximum_bytes: 200} + family_name: {type: string, minimum_bytes: 1, maximum_bytes: 200} + birth_date: {type: date} + maximum_aggregate_bytes: 420 + value_origin: authenticated-context + claim_binding: + authority_profile: subject-bound-v1 + requirement: urn:example:fixture:requirement:property:v1 + purpose: fixture-procedure + audience: urn:example:fixture:audience:requester-a + exact_role_profile_origin: [subject, demographics-v1, authenticated-context] + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date + request_values: absent + context_values: {given_name: Ána María, family_name: N'Dour-Sato, birth_date: '2000-02-29'} + negative: + - caller-values-prohibited-for-context-origin + - missing-configured-context-claim + - no-principal-claim-fallback + - no-case-fold-or-transliteration + - case: added-disambiguator + profile: demographics-with-event-v1 + role: subject + fields: + given_name: {type: string, minimum_bytes: 1, maximum_bytes: 200} + family_name: {type: string, minimum_bytes: 1, maximum_bytes: 200} + birth_date: {type: date} + event_reference: {type: string, minimum_bytes: 1, maximum_bytes: 96} + maximum_aggregate_bytes: 520 + value_origin: authenticated-grant + claim_binding: + authority_profile: authenticated-grant-v1 + requirement: urn:example:fixture:requirement:property:v1 + purpose: fixture-procedure + audience: urn:example:fixture:audience:requester-a + exact_role_profile_origin: [subject, demographics-with-event-v1, authenticated-grant] + configured_grant_claims: + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + verified_grant_binding: + grant_id: synthetic-grant-001 + grant_authority: urn:example:fixture:authority:grant-a + valueClaims: + given_name: grant.subject.given_name + family_name: grant.subject.family_name + birth_date: grant.subject.birth_date + event_reference: grant.subject.event_reference + request_values: absent + verified_token_values: {given_name: Adaeze, family_name: Okafor, birth_date: '1990-07-11', event_reference: synthetic-event-001} + negative: + - caller-values-prohibited-for-grant-origin + - caller-added-disambiguator-rejected-from-demographics-v1 + - authenticated-grant-id-not-bound + - authenticated-grant-authority-not-bound + - incomplete-grant-valueClaims + - alternative-field-set-not-inferred + - case: multi-role + profiles: + subject-a: + profile: opaque-record-v1 + fields: {record_reference: synthetic-record-002} + subject-b: + profile: demographics-v1 + fields: {given_name: Binta, family_name: Diallo, birth_date: '1970-06-15'} + value_origin: request + claim_binding: + authority_profile: reviewed-caseworker-v1 + requirement: urn:example:fixture:requirement:relationship:v1 + purpose: fixture-procedure + audience: urn:example:fixture:audience:requester-a + exact_role_profile_origins: + - [subject-a, opaque-record-v1, request] + - [subject-b, demographics-v1, request] + valueClaims: prohibited + negative: + - swapped-role-selectors + - unauthorized-subject-b-substitution + - missing-one-role + - duplicate-subject-role + - unknown-subject-role + - entitlement-union-across-roles + - case: opaque-non-domain-field-names + profile: opaque-coordinates-v1 + role: subject + fields: + alpha: {type: string, minimum_bytes: 1, maximum_bytes: 80} + delta: {type: integer, minimum: 0, maximum: 999999} + kappa: {type: controlled-code, codelist: codelists/opaque-codes.yaml, codelist_version: '1', maximum_bytes: 16} + maximum_aggregate_bytes: 96 + value_origin: request + claim_binding: + authority_profile: reviewed-caseworker-v1 + requirement: urn:example:fixture:requirement:opaque:v1 + purpose: fixture-procedure + audience: urn:example:fixture:audience:requester-a + exact_role_profile_origin: [subject, opaque-coordinates-v1, request] + valueClaims: prohibited + request_values: {alpha: synthetic-alpha, delta: 42, kappa: K-2} + expected_core_behavior: Identical to every other profile; names carry no domain semantics. + negative: + - unknown-opaque-field + - wrong-opaque-scalar-type + - aggregate-size-exceeded +global_negative: + - empty-string + - invalid-date + - scalar-object-or-array + - field-byte-boundary-plus-one + - aggregate-byte-boundary-plus-one + - unauthorized-profile + - unauthorized-purpose + - unauthorized-audience + - missing-context-valueClaims + - incomplete-or-extra-valueClaims + - request-origin-valueClaims + - grant-id-or-authority-from-caller-request + - credential-resolution-or-source-access-before-validation +redaction_expectation: + public_evidence: no selector profile or value + public_problem: no selector profile or value + logs_metrics_traces: no selector profile or value; profile ids may not be metric labels + audit: profile id plus at most one scoped keyed pseudonym per complete role-selector bundle diff --git a/products/evidence/fixtures/conformance/selectors/adapters/classification-source-prepare.rhai b/products/evidence/fixtures/conformance/selectors/adapters/classification-source-prepare.rhai new file mode 100644 index 000000000..871d48750 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/classification-source-prepare.rhai @@ -0,0 +1,10 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]["values"]; + #{ + query: [], + body: #{ + selector: #{request_record_reference: subject["record_reference"]}, + requested_fields: parameters["requestedFields"] + } + } +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/classification-source.rhai b/products/evidence/fixtures/conformance/selectors/adapters/classification-source.rhai new file mode 100644 index 000000000..10eaf41c2 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/classification-source.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, parameters) { + if source_response["matched"] { + return #{outcome: "match", facts: #{result: true}}; + } + #{outcome: "no_match"} +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/context-source-prepare.rhai b/products/evidence/fixtures/conformance/selectors/adapters/context-source-prepare.rhai new file mode 100644 index 000000000..ee56d0251 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/context-source-prepare.rhai @@ -0,0 +1,14 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]["values"]; + #{ + query: [], + body: #{ + selector: #{ + context_given_name: subject["given_name"], + context_family_name: subject["family_name"], + context_birth_date: subject["birth_date"] + }, + requested_fields: parameters["requestedFields"] + } + } +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/context-source.rhai b/products/evidence/fixtures/conformance/selectors/adapters/context-source.rhai new file mode 100644 index 000000000..10eaf41c2 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/context-source.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, parameters) { + if source_response["matched"] { + return #{outcome: "match", facts: #{result: true}}; + } + #{outcome: "no_match"} +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/grant-source-prepare.rhai b/products/evidence/fixtures/conformance/selectors/adapters/grant-source-prepare.rhai new file mode 100644 index 000000000..08075511e --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/grant-source-prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]["values"]; + #{ + query: [], + body: #{ + selector: #{ + grant_given_name: subject["given_name"], + grant_family_name: subject["family_name"], + grant_birth_date: subject["birth_date"], + grant_event_reference: subject["event_reference"] + }, + requested_fields: parameters["requestedFields"] + } + } +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/grant-source.rhai b/products/evidence/fixtures/conformance/selectors/adapters/grant-source.rhai new file mode 100644 index 000000000..10eaf41c2 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/grant-source.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, parameters) { + if source_response["matched"] { + return #{outcome: "match", facts: #{result: true}}; + } + #{outcome: "no_match"} +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/opaque-source-prepare.rhai b/products/evidence/fixtures/conformance/selectors/adapters/opaque-source-prepare.rhai new file mode 100644 index 000000000..17315bb47 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/opaque-source-prepare.rhai @@ -0,0 +1,14 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]["values"]; + #{ + query: [], + body: #{ + selector: #{ + opaque_alpha: subject["alpha"], + opaque_delta: subject["delta"], + opaque_kappa: subject["kappa"] + }, + requested_fields: parameters["requestedFields"] + } + } +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/opaque-source.rhai b/products/evidence/fixtures/conformance/selectors/adapters/opaque-source.rhai new file mode 100644 index 000000000..10eaf41c2 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/opaque-source.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, parameters) { + if source_response["matched"] { + return #{outcome: "match", facts: #{result: true}}; + } + #{outcome: "no_match"} +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/relationship-source-prepare.rhai b/products/evidence/fixtures/conformance/selectors/adapters/relationship-source-prepare.rhai new file mode 100644 index 000000000..61ea40e0c --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/relationship-source-prepare.rhai @@ -0,0 +1,29 @@ +fn prepare(selectors, parameters) { + let role_a = selectors["subject-a"]; + let role_b = selectors["subject-b"]; + let selector = #{}; + + if role_a["profile"] == "opaque-record-v1" { + selector["role_a_record_reference"] = role_a["values"]["record_reference"]; + } else { + selector["role_a_alpha"] = role_a["values"]["alpha"]; + selector["role_a_delta"] = role_a["values"]["delta"]; + selector["role_a_kappa"] = role_a["values"]["kappa"]; + } + + if role_b["profile"] == "demographics-v1" { + selector["role_b_given_name"] = role_b["values"]["given_name"]; + selector["role_b_family_name"] = role_b["values"]["family_name"]; + selector["role_b_birth_date"] = role_b["values"]["birth_date"]; + } else { + selector["role_b_event_given_name"] = role_b["values"]["given_name"]; + selector["role_b_event_family_name"] = role_b["values"]["family_name"]; + selector["role_b_event_birth_date"] = role_b["values"]["birth_date"]; + selector["role_b_event_reference"] = role_b["values"]["event_reference"]; + } + + #{ + query: [], + body: #{selector: selector, requested_fields: parameters["requestedFields"]} + } +} diff --git a/products/evidence/fixtures/conformance/selectors/adapters/relationship-source.rhai b/products/evidence/fixtures/conformance/selectors/adapters/relationship-source.rhai new file mode 100644 index 000000000..10eaf41c2 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/adapters/relationship-source.rhai @@ -0,0 +1,6 @@ +fn extract(source_response, parameters) { + if source_response["matched"] { + return #{outcome: "match", facts: #{result: true}}; + } + #{outcome: "no_match"} +} diff --git a/products/evidence/fixtures/conformance/selectors/codelists/opaque-codes.yaml b/products/evidence/fixtures/conformance/selectors/codelists/opaque-codes.yaml new file mode 100644 index 000000000..e0dc9d32a --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/codelists/opaque-codes.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:codelist:opaque-codes +version: '1' +codes: [K-1, K-2, K-ABCDEFGHIJKLMN] diff --git a/products/evidence/fixtures/conformance/selectors/derivations/classification.rhai b/products/evidence/fixtures/conformance/selectors/derivations/classification.rhai new file mode 100644 index 000000000..63e9d8480 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/derivations/classification.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "urn:example:fixture:concept:classification", value: required(facts.result, "result_required")}] +} diff --git a/products/evidence/fixtures/conformance/selectors/derivations/opaque.rhai b/products/evidence/fixtures/conformance/selectors/derivations/opaque.rhai new file mode 100644 index 000000000..1356f2a93 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/derivations/opaque.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "urn:example:fixture:concept:opaque", value: required(facts.result, "result_required")}] +} diff --git a/products/evidence/fixtures/conformance/selectors/derivations/property-with-event.rhai b/products/evidence/fixtures/conformance/selectors/derivations/property-with-event.rhai new file mode 100644 index 000000000..9d4109a5a --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/derivations/property-with-event.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "urn:example:fixture:concept:property-with-event", value: required(facts.result, "result_required")}] +} diff --git a/products/evidence/fixtures/conformance/selectors/derivations/property.rhai b/products/evidence/fixtures/conformance/selectors/derivations/property.rhai new file mode 100644 index 000000000..dc537c87e --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/derivations/property.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "urn:example:fixture:concept:property", value: required(facts.result, "result_required")}] +} diff --git a/products/evidence/fixtures/conformance/selectors/derivations/relationship.rhai b/products/evidence/fixtures/conformance/selectors/derivations/relationship.rhai new file mode 100644 index 000000000..00fe4afc7 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/derivations/relationship.rhai @@ -0,0 +1,3 @@ +fn derive(facts, selectors, evaluation_context) { + [#{concept_id: "urn:example:fixture:concept:relationship", value: required(facts.result, "result_required")}] +} diff --git a/products/evidence/fixtures/conformance/selectors/evidence.yaml b/products/evidence/fixtures/conformance/selectors/evidence.yaml new file mode 100644 index 000000000..e8cb6aff7 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/evidence.yaml @@ -0,0 +1,327 @@ +version: 1 +service: + providerId: urn:example:fixture:provider:selector-conformance + trustDomain: urn:example:fixture:trust-domain:selector-conformance +issuer: {id: urn:example:fixture:issuer:selector-conformance} +authentication: + kind: oidc-access-token + issuer: https://identity.invalid + audiences: [selector-conformance] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/binding-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 1000 + burstPerPrincipal: 100 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 100 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: selector-evidence-key + activeKeyRef: secret:file/signing-key + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +selectorProfiles: + opaque-record-v1: + maximumAggregateBytes: 96 + fields: + record_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + demographics-v1: + maximumAggregateBytes: 420 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} + demographics-with-event-v1: + maximumAggregateBytes: 520 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} + event_reference: {type: string, minimumBytes: 1, maximumBytes: 96} + opaque-coordinates-v1: + maximumAggregateBytes: 96 + fields: + alpha: {type: string, minimumBytes: 1, maximumBytes: 80} + delta: {type: integer, minimum: 0, maximum: 999999} + kappa: {type: controlled-code, codelist: codelists/opaque-codes.yaml, codelistVersion: '1', maximumBytes: 16} +sources: + classification-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/selector-facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: opaque-record-v1, fields: [record_reference]} + prepareScript: adapters/classification-source-prepare.rhai + adapterParameters: {requestedFields: [result]} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/matched] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/classification-source.rhai + factSchema: schemas/facts.schema.yaml + context-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/selector-facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: demographics-v1, fields: [given_name, family_name, birth_date]} + prepareScript: adapters/context-source-prepare.rhai + adapterParameters: {requestedFields: [result]} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/matched] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/context-source.rhai + factSchema: schemas/facts.schema.yaml + grant-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/selector-facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: demographics-with-event-v1, fields: [given_name, family_name, birth_date, event_reference]} + prepareScript: adapters/grant-source-prepare.rhai + adapterParameters: {requestedFields: [result]} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/matched] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/grant-source.rhai + factSchema: schemas/facts.schema.yaml + relationship-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/selector-facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject-a + alternatives: + - {profile: opaque-record-v1, fields: [record_reference]} + - {profile: opaque-coordinates-v1, fields: [alpha, delta, kappa]} + - role: subject-b + alternatives: + - {profile: demographics-v1, fields: [given_name, family_name, birth_date]} + - {profile: demographics-with-event-v1, fields: [given_name, family_name, birth_date, event_reference]} + prepareScript: adapters/relationship-source-prepare.rhai + adapterParameters: {requestedFields: [result]} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 24, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/matched] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/relationship-source.rhai + factSchema: schemas/facts.schema.yaml + opaque-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/selector-facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: opaque-coordinates-v1, fields: [alpha, delta, kappa]} + prepareScript: adapters/opaque-source-prepare.rhai + adapterParameters: {requestedFields: [result]} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/matched] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/opaque-source.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + reviewed-caseworker-v1: + kind: statutory + requesterTags: [selector-reviewer] + grants: + - requirement: urn:example:fixture:requirement:classification:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: opaque-record-v1, valueOrigin: request} + - requirement: urn:example:fixture:requirement:relationship:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - {role: subject-a, selectorProfile: opaque-record-v1, valueOrigin: request} + - {role: subject-b, selectorProfile: demographics-v1, valueOrigin: request} + - requirement: urn:example:fixture:requirement:opaque:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: opaque-coordinates-v1, valueOrigin: request} + subject-bound-v1: + kind: organizational + requesterTags: [selector-reviewer] + grants: + - requirement: urn:example:fixture:requirement:property:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-v1 + valueOrigin: authenticated-context + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date + authenticated-grant-v1: + kind: consent + requesterTags: [selector-reviewer] + grants: + - requirement: urn:example:fixture:requirement:property-with-event:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-with-event-v1 + valueOrigin: authenticated-grant + valueClaims: + given_name: grant.subject.given_name + family_name: grant.subject.family_name + birth_date: grant.subject.birth_date + event_reference: grant.subject.event_reference + alternate-caseworker-v1: + kind: statutory + requesterTags: [selector-reviewer] + grants: + - requirement: urn:example:fixture:requirement:relationship:v1 + purpose: fixture-procedure + audienceFrom: authenticated-requester + subjects: + - {role: subject-a, selectorProfile: opaque-coordinates-v1, valueOrigin: request} + - {role: subject-b, selectorProfile: demographics-with-event-v1, valueOrigin: request} +requirements: + - id: urn:example:fixture:requirement:classification:v1 + kind: criterion + source: classification-source + purposes: [fixture-procedure] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [opaque-record-v1]} + referenceFrameworks: [urn:example:fixture:framework:classification:v1] + evidenceType: urn:example:fixture:evidence-type:classification:v1 + validitySeconds: 3600 + derivation: {script: derivations/classification.rhai, parameters: {}} + concepts: + - {id: urn:example:fixture:concept:classification, form: boolean, required: true, constraints: {}} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:classification]} + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:property:v1 + kind: information-requirement + source: context-source + purposes: [fixture-procedure] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [demographics-v1]} + referenceFrameworks: [urn:example:fixture:framework:property:v1] + evidenceType: urn:example:fixture:evidence-type:property:v1 + validitySeconds: 3600 + derivation: {script: derivations/property.rhai, parameters: {}} + concepts: + - {id: urn:example:fixture:concept:property, form: boolean, required: true, constraints: {}} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:property]} + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:property-with-event:v1 + kind: information-requirement + source: grant-source + purposes: [fixture-procedure] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [demographics-with-event-v1]} + referenceFrameworks: [urn:example:fixture:framework:property-with-event:v1] + evidenceType: urn:example:fixture:evidence-type:property-with-event:v1 + validitySeconds: 3600 + derivation: {script: derivations/property-with-event.rhai, parameters: {}} + concepts: + - {id: urn:example:fixture:concept:property-with-event, form: boolean, required: true, constraints: {}} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:property-with-event]} + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:relationship:v1 + kind: criterion + source: relationship-source + purposes: [fixture-procedure] + subjectRoles: + - {role: subject-a, cardinality: one, selectorProfiles: [opaque-record-v1, opaque-coordinates-v1]} + - {role: subject-b, cardinality: one, selectorProfiles: [demographics-v1, demographics-with-event-v1]} + referenceFrameworks: [urn:example:fixture:framework:relationship:v1] + evidenceType: urn:example:fixture:evidence-type:relationship:v1 + validitySeconds: 3600 + derivation: {script: derivations/relationship.rhai, parameters: {}} + concepts: + - {id: urn:example:fixture:concept:relationship, form: boolean, required: true, constraints: {}} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:relationship]} + existenceDisclosure: collapse-unresolved + - id: urn:example:fixture:requirement:opaque:v1 + kind: information-requirement + source: opaque-source + purposes: [fixture-procedure] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [opaque-coordinates-v1]} + referenceFrameworks: [urn:example:fixture:framework:opaque:v1] + evidenceType: urn:example:fixture:evidence-type:opaque:v1 + validitySeconds: 3600 + derivation: {script: derivations/opaque.rhai, parameters: {}} + concepts: + - {id: urn:example:fixture:concept:opaque, form: boolean, required: true, constraints: {}} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:fixture:disclosure-family:opaque]} + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/conformance/selectors/fixtures/cases.yaml b/products/evidence/fixtures/conformance/selectors/fixtures/cases.yaml new file mode 100644 index 000000000..98a16ce3f --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/fixtures/cases.yaml @@ -0,0 +1,12 @@ +fixture: registry.evidence.conformance.selector-full-path/v1 +synthetic_only: true +cases: + - {id: positive} + - {id: negative-selector} + - {id: boundary-aggregate} + - {id: missing-selector} + - {id: no-match} + - {id: ambiguous} + - {id: source-failure} + - {id: unauthorized-entitlement} + - {id: anti-reconstruction} diff --git a/products/evidence/fixtures/conformance/selectors/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/conformance/selectors/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..9ac20533c --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedFields] +properties: + requestedFields: + const: [result] diff --git a/products/evidence/fixtures/conformance/selectors/schemas/facts.schema.yaml b/products/evidence/fixtures/conformance/selectors/schemas/facts.schema.yaml new file mode 100644 index 000000000..2a432fd96 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [result] +properties: + result: {type: boolean} diff --git a/products/evidence/fixtures/conformance/supported-values.yaml b/products/evidence/fixtures/conformance/supported-values.yaml new file mode 100644 index 000000000..1b9e60267 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values.yaml @@ -0,0 +1,115 @@ +fixture: registry.evidence.supported-values/v1 +synthetic_only: true +forms: + - form: boolean + declaration: {form: boolean, required: true} + positive: [true, false] + boundary: [false, true] + negative: ['true', 0, null, {value: true}] + - form: controlled-code + declaration: {form: controlled-code, codelist: synthetic-codes, codelist_version: '1', maximum_bytes: 8} + positive: [C-A] + boundary: [C-BOUND] + negative: [UNKNOWN, c-a, 1, 'C-BOUNDARY-TOO-LONG'] + - form: controlled-category + declaration: {form: controlled-category, category_scheme: urn:example:fixture:scheme:category, scheme_version: '1', maximum_bytes: 16} + positive: [category-a] + boundary: [category-bound] + negative: [category-unknown, {category: category-a}, 1] + - form: bounded-integer + declaration: {form: bounded-integer, minimum: -10, maximum: 10} + positive: [0, 7] + boundary: [-10, 10] + negative: [-11, 11, 1.5, '7', 9007199254740992] + - form: bounded-decimal + declaration: {form: bounded-decimal, minimum: '-10.5', maximum: '10.5', maximum_scale: 2} + positive: + - {rhai: 'decimal("0.25")', wire_json: '"0.25"'} + - {rhai: 'parse_decimal("7.5")', wire_json: '"7.5"'} + boundary: + - {rhai: 'decimal("-10.5")', wire_json: '"-10.5"'} + - {rhai: 'decimal("10.5")', wire_json: '"10.5"'} + negative: + - {rhai: '1.25', reason: Rhai floating-point value} + - {rhai: 'decimal("-10.51")', reason: below minimum} + - {rhai: 'decimal("10.51")', reason: above maximum} + - {rhai: 'decimal("1.234")', reason: excessive scale} + - {rhai: 'decimal("01.0")', reason: non-canonical leading zero and trailing zero} + - {rhai: 'decimal("1e2")', reason: exponent form} + - {rhai: 'decimal("-0.0")', reason: negative zero and trailing zero} + - {rhai: 'decimal("NaN")', reason: non-finite} + - {rhai: 'decimal("12345678901234567890123456789")', reason: precision 29 exceeds global maximum 28} + - {rhai: 'decimal("0.1234567891")', reason: scale 10 exceeds global maximum 9} + - form: date-bucket + declaration: {form: date-bucket, bucket_scheme: urn:example:fixture:scheme:date-bucket, scheme_version: '1'} + positive: [{form: date-bucket, scheme: urn:example:fixture:scheme:date-bucket, bucket: current-period}] + boundary: [{form: date-bucket, scheme: urn:example:fixture:scheme:date-bucket, bucket: boundary-period}] + negative: + - {form: date-bucket, scheme: urn:example:fixture:scheme:date-bucket, bucket: unknown} + - {form: time-bucket, scheme: urn:example:fixture:scheme:date-bucket, bucket: current-period} + - {form: date-bucket, bucket: current-period} + - '2026-08-02' + - form: time-bucket + declaration: {form: time-bucket, bucket_scheme: urn:example:fixture:scheme:time-bucket, scheme_version: '1'} + positive: [{form: time-bucket, scheme: urn:example:fixture:scheme:time-bucket, bucket: within-window}] + boundary: [{form: time-bucket, scheme: urn:example:fixture:scheme:time-bucket, bucket: at-limit}] + negative: + - {form: time-bucket, scheme: urn:example:fixture:scheme:other, bucket: within-window} + - {form: time-bucket, scheme: urn:example:fixture:scheme:time-bucket, bucket: unknown} + - 'PT24H' + - form: audience-scoped-entity-reference + declaration: {form: audience-scoped-entity-reference, maximum_bytes: 160} + derivation_positive: [{rhai: 'entity_reference_seed("synthetic-seed-a")', public: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}] + positive: [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}] + boundary: [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v12_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'}] + negative: + - {form: audience-scoped-entity-reference, reference: urn:example:global-person:001} + - {form: audience-scoped-entity-reference, reference: short} + - urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + - {rhai: 'synthetic-seed-a', reason: raw string cannot satisfy entity-reference concept} + - {rhai: 'entity_reference_seed("")', reason: empty seed} + - {rhai: 'entity_reference_seed("source-seed-canary")', leak_surface: logs} + - form: controlled-code-list + declaration: {form: controlled-code-list, codelist: synthetic-codes, codelist_version: '1', minimum_items: 1, maximum_items: 3, unique: true} + positive: [[C-A], [C-A, C-B]] + boundary: [[C-A, C-B, C-C]] + negative: [[], [C-A, C-A], [UNKNOWN], [C-A, C-B, C-C, C-D], C-A] + - form: entity-reference-list + declaration: {form: entity-reference-list, minimum_items: 1, maximum_items: 2, unique: true} + derivation_positive: + - ['entity_reference_seed("synthetic-seed-a")'] + - ['entity_reference_seed("synthetic-seed-a")', 'entity_reference_seed("synthetic-seed-b")'] + positive: + - [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}] + boundary: + - [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}, {form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'}] + negative: + - [] + - [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}, {form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}] + - [{form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'}, {form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'}, {form: audience-scoped-entity-reference, reference: 'urn:evidence:entity:v1_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC'}] + - [{rhai: ['entity_reference_seed("synthetic-seed-a")', raw-seed-b], reason: mixed protected seed and raw string}] + - form: reviewed-structured-value + declaration: + form: reviewed-structured-value + schema: urn:example:fixture:schema:closed-structure:v1 + maximum_serialized_bytes: 512 + fields: + status: {form: controlled-code, codelist: synthetic-codes, codelist_version: '1'} + quantity: {form: bounded-integer, minimum: 0, maximum: 9} + positive: + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:closed-structure:v1, fields: {status: C-A, quantity: 4}} + boundary: + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:closed-structure:v1, fields: {status: C-B, quantity: 9}} + negative: + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:closed-structure:v1, fields: {status: C-A, quantity: 4, extra: leaked}} + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:other:v1, fields: {status: C-A, quantity: 4}} + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:closed-structure:v1, fields: {status: UNKNOWN, quantity: 4}} + - {form: reviewed-structured-value, schema: urn:example:fixture:schema:closed-structure:v1, fields: {status: C-A}} +global_negative: + - undeclared-concept + - duplicate-concept + - missing-required-concept + - extra-value-metadata + - per-value-size-plus-one + - aggregate-result-size-plus-one +round_trip: Every positive and boundary value must preserve its JSON type through core evidence construction, flattened JWS serialization, signature verification, and payload parsing. diff --git a/products/evidence/fixtures/conformance/supported-values/adapters/source-prepare.rhai b/products/evidence/fixtures/conformance/supported-values/adapters/source-prepare.rhai new file mode 100644 index 000000000..9e7f4c5b3 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/adapters/source-prepare.rhai @@ -0,0 +1,11 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{opaque_id: subject["values"]["opaque_id"]}, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/products/evidence/fixtures/conformance/supported-values/adapters/source.rhai b/products/evidence/fixtures/conformance/supported-values/adapters/source.rhai new file mode 100644 index 000000000..66f4b87a4 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/adapters/source.rhai @@ -0,0 +1,9 @@ +fn extract(source_response, parameters) { + if source_response.total == 0 { + return #{ outcome: "no_match" }; + } + if source_response.total > 1 { + return #{ outcome: "ambiguous" }; + } + #{ outcome: "match", facts: #{ marker: source_response.marker } } +} diff --git a/products/evidence/fixtures/conformance/supported-values/codelists/categories.yaml b/products/evidence/fixtures/conformance/supported-values/codelists/categories.yaml new file mode 100644 index 000000000..a51935f58 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/codelists/categories.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:scheme:category +version: '1' +codes: [category-a, category-bound] diff --git a/products/evidence/fixtures/conformance/supported-values/codelists/date-buckets.yaml b/products/evidence/fixtures/conformance/supported-values/codelists/date-buckets.yaml new file mode 100644 index 000000000..abfab2754 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/codelists/date-buckets.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:scheme:date-bucket +version: '1' +codes: [current-period, boundary-period] diff --git a/products/evidence/fixtures/conformance/supported-values/codelists/synthetic-codes.yaml b/products/evidence/fixtures/conformance/supported-values/codelists/synthetic-codes.yaml new file mode 100644 index 000000000..9bcf25c63 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/codelists/synthetic-codes.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:codelist:synthetic-codes +version: '1' +codes: [C-A, C-B, C-C, C-BOUND] diff --git a/products/evidence/fixtures/conformance/supported-values/codelists/time-buckets.yaml b/products/evidence/fixtures/conformance/supported-values/codelists/time-buckets.yaml new file mode 100644 index 000000000..8505c7819 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/codelists/time-buckets.yaml @@ -0,0 +1,3 @@ +id: urn:example:fixture:scheme:time-bucket +version: '1' +codes: [within-window, at-limit] diff --git a/products/evidence/fixtures/conformance/supported-values/derivations/values.rhai b/products/evidence/fixtures/conformance/supported-values/derivations/values.rhai new file mode 100644 index 000000000..af96961e4 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/derivations/values.rhai @@ -0,0 +1,6 @@ +fn derive(facts, selectors, evaluation_context) { + [#{ + concept_id: "urn:example:fixture:concept:boolean", + value: required(facts.marker, "required_fact_missing") == "accepted" + }] +} diff --git a/products/evidence/fixtures/conformance/supported-values/evidence.yaml b/products/evidence/fixtures/conformance/supported-values/evidence.yaml new file mode 100644 index 000000000..94d4e2651 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/evidence.yaml @@ -0,0 +1,114 @@ +version: 1 +service: + providerId: urn:example:fixture:provider:evidence + trustDomain: urn:example:fixture:trust-domain:supported-values +issuer: {id: urn:example:fixture:issuer:authority} +authentication: + kind: oidc-access-token + issuer: https://identity.invalid + audiences: [evidence-fixture] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.invalid/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hash-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/subject-binding-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: supported-values-fixture-key + activeKeyRef: secret:file/signing-key + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +selectorProfiles: + synthetic-subject-v1: + maximumAggregateBytes: 64 + fields: + opaque_id: {type: string, minimumBytes: 1, maximumBytes: 32} +sources: + synthetic-source: + transport: http-json + baseUrl: https://source.invalid + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: + - {profile: synthetic-subject-v1, fields: [opaque_id]} + prepareScript: adapters/source-prepare.rhai + adapterParameters: {requestedFields: [marker], resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 8 + maximumCollectionItems: 16 + maximumStringBytes: 128 + maximumNormalizedBytes: 2048 + projection: [/total, /marker] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 2 + extractScript: adapters/source.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + synthetic-authority-v1: + kind: statutory + requesterTags: [fixture] + grants: + - requirement: urn:example:fixture:requirement:supported-values:v1 + purpose: conformance + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: synthetic-subject-v1, valueOrigin: request} +requirements: + - id: urn:example:fixture:requirement:supported-values:v1 + kind: information-requirement + source: synthetic-source + purposes: [conformance] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [synthetic-subject-v1]} + referenceFrameworks: [urn:example:fixture:framework:supported-values:v1] + evidenceType: urn:example:fixture:evidence-type:supported-values:v1 + observationTimezone: UTC + validitySeconds: 86400 + derivation: + script: derivations/values.rhai + parameters: {} + concepts: + - {id: urn:example:fixture:concept:boolean, form: boolean, required: true, constraints: {}} + - {id: urn:example:fixture:concept:controlled-code, form: controlled-code, required: false, constraints: {codelist: codelists/synthetic-codes.yaml, codelistVersion: '1', maximumBytes: 8}} + - {id: urn:example:fixture:concept:controlled-category, form: controlled-category, required: false, constraints: {categoryScheme: urn:example:fixture:scheme:category, schemeVersion: '1', maximumBytes: 16, codelist: codelists/categories.yaml}} + - {id: urn:example:fixture:concept:bounded-integer, form: bounded-integer, required: false, constraints: {minimum: -10, maximum: 10}} + - {id: urn:example:fixture:concept:bounded-decimal, form: bounded-decimal, required: false, constraints: {minimum: '-10.5', maximum: '10.5', maximumScale: 2}} + - {id: urn:example:fixture:concept:date-bucket, form: date-bucket, required: false, constraints: {bucketScheme: urn:example:fixture:scheme:date-bucket, schemeVersion: '1'}} + - {id: urn:example:fixture:concept:time-bucket, form: time-bucket, required: false, constraints: {bucketScheme: urn:example:fixture:scheme:time-bucket, schemeVersion: '1'}} + - {id: urn:example:fixture:concept:audience-scoped-entity-reference, form: audience-scoped-entity-reference, required: false, constraints: {maximumBytes: 160}} + - {id: urn:example:fixture:concept:controlled-code-list, form: controlled-code-list, required: false, constraints: {codelist: codelists/synthetic-codes.yaml, codelistVersion: '1', minimumItems: 1, maximumItems: 3, unique: true}} + - {id: urn:example:fixture:concept:entity-reference-list, form: entity-reference-list, required: false, constraints: {minimumItems: 1, maximumItems: 2, unique: true}} + - {id: urn:example:fixture:concept:reviewed-structured-value, form: reviewed-structured-value, required: false, constraints: {schema: urn:example:fixture:schema:closed-structure:v1, maximumSerializedBytes: 512}} + fixtures: fixtures/cases.yaml + disclosureGuard: + families: [urn:example:fixture:disclosure-family:supported-values] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/fixtures/conformance/supported-values/fixtures/cases.yaml b/products/evidence/fixtures/conformance/supported-values/fixtures/cases.yaml new file mode 100644 index 000000000..38b94752f --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/fixtures/cases.yaml @@ -0,0 +1,11 @@ +fixture: registry.evidence.conformance.supported-values-bundle/v1 +synthetic_only: true +cases: + - {id: positive} + - {id: negative-output} + - {id: boundary-value} + - {id: missing-fact} + - {id: no-match} + - {id: ambiguous-result} + - {id: source-failure} + - {id: anti-reconstruction} diff --git a/products/evidence/fixtures/conformance/supported-values/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/conformance/supported-values/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..513bb11de --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [marker] + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/conformance/supported-values/schemas/closed-structure.schema.yaml b/products/evidence/fixtures/conformance/supported-values/schemas/closed-structure.schema.yaml new file mode 100644 index 000000000..7cfd6466d --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/schemas/closed-structure.schema.yaml @@ -0,0 +1,8 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: urn:example:fixture:schema:closed-structure:v1 +type: object +additionalProperties: false +required: [status, quantity] +properties: + status: {type: string, enum: [C-A, C-B, C-C, C-BOUND]} + quantity: {type: integer, minimum: 0, maximum: 9} diff --git a/products/evidence/fixtures/conformance/supported-values/schemas/facts.schema.yaml b/products/evidence/fixtures/conformance/supported-values/schemas/facts.schema.yaml new file mode 100644 index 000000000..ff8874491 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [marker] +properties: + marker: {type: string, enum: [accepted]} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest-prepare.rhai b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest-prepare.rhai new file mode 100644 index 000000000..a5f3e8888 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest-prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "program", value: parameters["program"]}, + #{name: "orgUnits", value: parameters["organisationUnits"]}, + #{name: "trackedEntities", value: subject["values"]["record_reference"]}, + #{name: "fields", value: parameters["fields"]}, + #{name: "pageSize", value: parameters["pageSize"]}, + #{name: "page", value: parameters["page"]}, + #{name: "totalPages", value: parameters["totalPages"]} + ], + body: () + } +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest.rhai b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest.rhai new file mode 100644 index 000000000..9ca2418c3 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/adapters/nested-paged-rest.rhai @@ -0,0 +1,62 @@ +fn extract(source_response, parameters) { + if !source_response.contains("pager") || + !source_response.contains("trackedEntities") || + source_response.len() != 2 { + throw("source_protocol_error"); + } + + let pager = source_response.pager; + let records = source_response.trackedEntities; + if !pager.contains("page") || + !pager.contains("pageSize") || + !pager.contains("total") || + !pager.contains("pageCount") || + pager.len() != 4 || + type_of(pager.page) != "i64" || + type_of(pager.pageSize) != "i64" || + type_of(pager.total) != "i64" || + type_of(pager.pageCount) != "i64" || + pager.page != 1 || + pager.pageSize != 2 || + pager.total < 0 || + pager.total > 2 || + pager.total != records.len || + (pager.total == 0 && pager.pageCount != 0) || + (pager.total > 0 && pager.pageCount != 1) { + throw("source_protocol_error"); + } + + if records.len == 0 { + return #{ outcome: "no_match" }; + } + if records.len > 1 { + return #{ outcome: "ambiguous" }; + } + let status_code = (); + let found_status = false; + if records[0].len() != 1 || + !records[0].contains("attributes") || + type_of(records[0].attributes) != "array" { + throw("source_protocol_error"); + } + for attribute in records[0].attributes { + if attribute.len() != 2 || + !attribute.contains("attribute") || + !attribute.contains("value") || + type_of(attribute.attribute) != "string" || + type_of(attribute.value) != "string" { + throw("source_protocol_error"); + } + if attribute.attribute == parameters["statusAttribute"] { + if found_status { + throw("source_protocol_error"); + } + found_status = true; + status_code = attribute.value; + } + } + #{ + outcome: "match", + facts: #{ official_residence_code: status_code } + } +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml new file mode 100644 index 000000000..8fa9a1207 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml @@ -0,0 +1,107 @@ +fixture: registry.evidence.source-shape.dhis2-tracker-style/v1 +classification: compatibility-shaped-local-mock +synthetic_only: true +server: {base_url: 'http://127.0.0.2:18082', bind: '127.0.0.2:18082'} +validated_source_definition: + transport: http-json + baseUrl: http://127.0.0.2:18082 + posture: field-projected + authentication: + kind: basic + usernameRef: secret:file/fixture-nested-rest-username + passwordRef: secret:file/fixture-nested-rest-password + request: + method: GET + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: civil-record-reference-v1, fields: [record_reference]} + prepareScript: adapters/nested-paged-rest-prepare.rhai + adapterParameters: + program: PROGRAM-FIXTURE + organisationUnits: OU-FIXTURE + fields: trackedEntity,attributes[attribute,value] + pageSize: "2" + page: "1" + totalPages: "true" + statusAttribute: ATTR-STATUS + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 7 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + projection: + - /pager/page + - /pager/pageSize + - /pager/total + - /pager/pageCount + - /trackedEntities/*/attributes/*/attribute + - /trackedEntities/*/attributes/*/value + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 4 + extractScript: adapters/nested-paged-rest.rhai + factSchema: schemas/facts.schema.yaml +reference_shape: DHIS2 Tracker 2.43 +scope: Only the consumed request, pager, collection, and nested attribute boundary +request: + method: GET + path: /api/tracker/trackedEntities + query: + program: PROGRAM-FIXTURE + orgUnits: OU-FIXTURE + trackedEntities: B0000000001 + fields: trackedEntity,attributes[attribute,value] + pageSize: "2" + page: "1" + totalPages: "true" + query_order: [program, orgUnits, trackedEntities, fields, pageSize, page, totalPages] + headers: + accept: application/json + authorization: HTTP Basic injected from two secret references; exact value never snapshotted + body: absent + unexpected_request: fail test +selector_contracts: + identifier_only: + profile: civil-record-reference-v1 + prepared_field: query.trackedEntities + outer_boundary: exactly one reviewed tracked-entity UID +cardinality: + zero: responses/no-match.json + one: responses/match.json + multiple: responses/ambiguous.json + inconsistent: responses/inconsistent-cardinality.json + page_size: 2 + second_page_request: prohibited + requests_after_response: 0 +extraction: + configured_attribute: ATTR-STATUS + provider_fields: [ATTR-STATUS] + match_facts: [official_residence_code] + missing_fact: responses/missing-fact.json + error_envelope: responses/error-envelope.json + cardinality_consistency: pager total must equal the returned collection length + product_identifiers_disclosed: false +failures: + - http-401 + - http-403 + - http-429 + - http-500 + - timeout + - redirect + - invalid-json + - wrong-media-type + - oversized-response + - error-envelope + - pager-count-mismatch +redaction_canaries: + committed_source_and_selector: [Synthetic, Subject, '2000-02-29', B0000000001, SOURCE-STATUS-CANARY-DO-NOT-LOG] + runtime_generated_secret_classes: [basic-username, basic-password] +claim: Compatibility-shaped fixture only; not an emulator, connector certification, or maintained vendor integration. diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/ambiguous.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/ambiguous.json new file mode 100644 index 000000000..e3978bc18 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/ambiguous.json @@ -0,0 +1,18 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 2, + "pageCount": 1 + }, + "trackedEntities": [ + { + "trackedEntity": "C0000000001", + "attributes": [{"attribute": "ATTR-STATUS", "value": "C-A"}] + }, + { + "trackedEntity": "C0000000001", + "attributes": [{"attribute": "ATTR-STATUS", "value": "C-B"}] + } + ] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/error-envelope.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/error-envelope.json new file mode 100644 index 000000000..5625e88fb --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/error-envelope.json @@ -0,0 +1,7 @@ +{ + "errors": [ + {"code": "SYNTHETIC-SOURCE-ERROR"} + ], + "pager": {"page": 1, "pageSize": 2, "total": 0, "pageCount": 0}, + "trackedEntities": [] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/inconsistent-cardinality.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/inconsistent-cardinality.json new file mode 100644 index 000000000..4fa63031f --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/inconsistent-cardinality.json @@ -0,0 +1,19 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 0, + "pageCount": 0 + }, + "trackedEntities": [ + { + "trackedEntity": "F0000000001", + "attributes": [ + { + "attribute": "ATTR-STATUS", + "value": "C-A" + } + ] + } + ] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/match.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/match.json new file mode 100644 index 000000000..b33eb0380 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/match.json @@ -0,0 +1,16 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 1, + "pageCount": 1 + }, + "trackedEntities": [ + { + "trackedEntity": "B0000000001", + "attributes": [ + {"attribute": "ATTR-STATUS", "value": "R-101"} + ] + } + ] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/missing-fact.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/missing-fact.json new file mode 100644 index 000000000..caf43d913 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/missing-fact.json @@ -0,0 +1,14 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 1, + "pageCount": 1 + }, + "trackedEntities": [ + { + "trackedEntity": "E0000000001", + "attributes": [] + } + ] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/no-match.json b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/no-match.json new file mode 100644 index 000000000..3ac0531ea --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/responses/no-match.json @@ -0,0 +1,9 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 0, + "pageCount": 0 + }, + "trackedEntities": [] +} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..df3b456f5 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,11 @@ +type: object +additionalProperties: false +required: [program, organisationUnits, fields, pageSize, page, totalPages, statusAttribute] +properties: + program: {const: PROGRAM-FIXTURE} + organisationUnits: {const: OU-FIXTURE} + fields: {const: 'trackedEntity,attributes[attribute,value]'} + pageSize: {const: "2"} + page: {const: "1"} + totalPages: {const: "true"} + statusAttribute: {const: ATTR-STATUS} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/facts.schema.yaml b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest-prepare.rhai b/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest-prepare.rhai new file mode 100644 index 000000000..92a5e0bb7 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest-prepare.rhai @@ -0,0 +1,23 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + let values = subject["values"]; + let selector = #{}; + + if subject["profile"] == "opaque-record-v1" { + selector["record_reference"] = values["record_reference"]; + } else if subject["profile"] == "opaque-coordinates-v1" { + selector["alpha"] = values["alpha"]; + selector["delta"] = values["delta"]; + } else { + throw("adapter_input_error"); + } + + #{ + query: [], + body: #{ + selector: selector, + fields: [parameters["requestedField"]], + limit: parameters["resultLimit"] + } + } +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest.rhai b/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest.rhai new file mode 100644 index 000000000..b82754fb9 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/adapters/flat-rest.rhai @@ -0,0 +1,32 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + source_response["total"] > parameters["resultLimit"] { + throw("source_protocol_error"); + } + if source_response["total"] == 0 { + if !source_response.contains("result") || + !is_missing(source_response["result"]) { + throw("source_protocol_error"); + } + return #{ outcome: "no_match" }; + } + if source_response["total"] > 1 { + if !source_response.contains("results") || + type_of(source_response["results"]) != "array" || + source_response["results"].len != source_response["total"] { + throw("source_protocol_error"); + } + return #{ outcome: "ambiguous" }; + } + if !source_response.contains("result") || + type_of(source_response["result"]) != "map" { + throw("source_protocol_error"); + } + #{ + outcome: "match", + facts: #{ official_residence_code: source_response["result"]["fact_code"] } + } +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml b/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml new file mode 100644 index 000000000..61ee06bae --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml @@ -0,0 +1,77 @@ +fixture: registry.evidence.source-shape.flat-rest/v1 +classification: compatibility-shaped-local-mock +synthetic_only: true +server: {base_url: 'http://127.0.0.1:18081', bind: '127.0.0.1:18081'} +validated_source_definition: + transport: http-json + baseUrl: http://127.0.0.1:18081 + posture: source-derived + authentication: {kind: static-bearer, tokenRef: secret:file/fixture-flat-rest-token} + request: + method: POST + path: /v1/lookup + selectorInputs: + - role: subject + alternatives: + - {profile: opaque-record-v1, fields: [record_reference]} + - {profile: opaque-coordinates-v1, fields: [alpha, delta]} + prepareScript: adapters/flat-rest-prepare.rhai + adapterParameters: {requestedField: fact_code, resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 8 + maximumCollectionItems: 8 + maximumStringBytes: 512 + maximumNormalizedBytes: 4096 + projection: [/total, /result, /results] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 4 + extractScript: adapters/flat-rest.rhai + factSchema: schemas/facts.schema.yaml +request: + method: POST + path: /v1/lookup + query: {} + headers: + content-type: application/json + authorization: static-bearer injected from secret reference; exact value never snapshotted + body: + selector: {alpha: synthetic-alpha, delta: 42} + fields: [fact_code] + limit: 2 + exact_body_keys: [selector, fields, limit] + unexpected_request: fail test +selector_contracts: + identifier_only: {profile: opaque-record-v1, prepared_fields: [record_reference]} + compound_no_identifier: {profile: opaque-coordinates-v1, prepared_fields: [alpha, delta]} + preparation: The reviewed prepare/2 script emits only the selected profile's exact fields beneath body.selector. +cardinality: + zero: responses/no-match.json + one: responses/match.json + multiple: responses/ambiguous.json + requests_after_response: 0 + maximum_results: 2 +extraction: + provider_fields: [fact_code] + match_facts: [official_residence_code] + missing_fact: responses/missing-fact.json + error_envelope: responses/error-envelope.json + candidate_material_to_derivation: prohibited +failures: + - {case: http-401, public: dependency_unavailable} + - {case: http-403, public: dependency_unavailable} + - {case: http-429, public: dependency_unavailable, retry_after: bounded} + - {case: http-500, public: dependency_unavailable} + - {case: timeout, public: dependency_unavailable} + - {case: redirect, expected: reject-without-follow} + - {case: invalid-json, public: dependency_unavailable} + - {case: wrong-media-type, public: dependency_unavailable} + - {case: oversized-response, public: dependency_unavailable} + - {case: error-envelope, public: dependency_unavailable} +redaction_canaries: + committed_source_and_selector: [synthetic-alpha, SOURCE-FACT-CANARY-DO-NOT-LOG] + runtime_generated_secret_classes: [static-bearer] diff --git a/products/evidence/fixtures/source-shapes/flat-rest/responses/ambiguous.json b/products/evidence/fixtures/source-shapes/flat-rest/responses/ambiguous.json new file mode 100644 index 000000000..771b36536 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/responses/ambiguous.json @@ -0,0 +1,7 @@ +{ + "total": 2, + "results": [ + {"fact_code": "C-A"}, + {"fact_code": "C-B"} + ] +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/responses/error-envelope.json b/products/evidence/fixtures/source-shapes/flat-rest/responses/error-envelope.json new file mode 100644 index 000000000..d2be14e7e --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/responses/error-envelope.json @@ -0,0 +1,6 @@ +{ + "errors": [ + {"code": "SYNTHETIC-SOURCE-ERROR"} + ], + "total": 0 +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/responses/match.json b/products/evidence/fixtures/source-shapes/flat-rest/responses/match.json new file mode 100644 index 000000000..9bb404064 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/responses/match.json @@ -0,0 +1,6 @@ +{ + "total": 1, + "result": { + "fact_code": "R-101" + } +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/responses/missing-fact.json b/products/evidence/fixtures/source-shapes/flat-rest/responses/missing-fact.json new file mode 100644 index 000000000..f0ae68e51 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/responses/missing-fact.json @@ -0,0 +1,4 @@ +{ + "total": 1, + "result": {} +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/responses/no-match.json b/products/evidence/fixtures/source-shapes/flat-rest/responses/no-match.json new file mode 100644 index 000000000..606cc0818 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/responses/no-match.json @@ -0,0 +1,4 @@ +{ + "total": 0, + "result": null +} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/source-shapes/flat-rest/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..0bfd709fd --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [requestedField, resultLimit] +properties: + requestedField: {const: fact_code} + resultLimit: {const: 2} diff --git a/products/evidence/fixtures/source-shapes/flat-rest/schemas/facts.schema.yaml b/products/evidence/fixtures/source-shapes/flat-rest/schemas/facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/source-shapes/index.yaml b/products/evidence/fixtures/source-shapes/index.yaml new file mode 100644 index 000000000..efc7c6125 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/index.yaml @@ -0,0 +1,16 @@ +fixture: registry.evidence.source-shape-index/v1 +classification: test-only compatibility profiles +profiles: + - id: flat-rest + path: flat-rest + reference: generic flat REST JSON + - id: nested-paged-rest + path: dhis2-tracker-style + reference: DHIS2 Tracker 2.43 compatibility shape + - id: opencrvs-event-search-json + path: opencrvs-record-search-style + reference: OpenCRVS version 2 Event Search JSON compatibility shape +rules: + - Product names and shapes in this subtree are test-only and never production configuration identifiers. + - Each mock contains only the consumed boundary and is not an emulator, certified connector, or support claim. + - Deterministic local mocks pass before any ignored read-only public-demo smoke test. diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json-prepare.rhai b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json-prepare.rhai new file mode 100644 index 000000000..a6293b0f1 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json-prepare.rhai @@ -0,0 +1,18 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + query: #{ + type: "and", + clauses: [#{ + eventType: parameters["eventType"], + status: #{type: "exact", term: parameters["registeredStatus"]}, + trackingId: #{type: "exact", term: subject["values"]["record_reference"]} + }] + }, + limit: parameters["resultLimit"], + offset: parameters["resultOffset"] + } + } +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json.rhai b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json.rhai new file mode 100644 index 000000000..19bdd4cf4 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/adapters/event-search-json.rhai @@ -0,0 +1,37 @@ +fn extract(source_response, parameters) { + if source_response.len() != 2 || + !source_response.contains("total") || + !source_response.contains("results") || + source_response.results.len > parameters["resultLimit"] || + source_response.total < 0 { + throw("source_protocol_error"); + } + + if source_response.total == 0 { + if source_response.results.len != 0 { + throw("source_protocol_error"); + } + return #{ outcome: "no_match" }; + } + + if source_response.total > 1 { + if source_response.results.len != 2 { + throw("source_protocol_error"); + } + return #{ outcome: "ambiguous" }; + } + + if source_response.results.len != 1 { + throw("source_protocol_error"); + } + + let result = source_response.results[0]; + if !result.contains("dateOfEvent") { + return #{ outcome: "match", facts: #{} }; + } + + #{ + outcome: "match", + facts: #{ date_of_event: result.dateOfEvent } + } +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml new file mode 100644 index 000000000..dda5e221a --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml @@ -0,0 +1,116 @@ +fixture: registry.evidence.source-shape.opencrvs-event-search-style/v1 +classification: compatibility-shaped-local-mock +synthetic_only: true +server: {base_url: 'http://[::1]:18083', bind: '[::1]:18083'} +validated_source_definition: + transport: http-json + baseUrl: http://[::1]:18083 + posture: record-transformed + authentication: + kind: oauth2-client-credentials + tokenEndpoint: http://[::1]:18083/oauth/token + clientIdRef: secret:file/fixture-event-search-client-id + clientSecretRef: secret:file/fixture-event-search-client-secret + scope: fixture.read + credentialPlacement: query-string + maximumCacheSeconds: 300 + request: + method: POST + path: /events/search + selectorInputs: + - role: subject + alternatives: + - {profile: civil-record-reference-v1, fields: [record_reference]} + prepareScript: adapters/event-search-json-prepare.rhai + adapterParameters: + eventType: birth + registeredStatus: REGISTERED + resultLimit: 2 + resultOffset: 0 + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 12 + maximumCollectionItems: 32 + maximumStringBytes: 512 + maximumNormalizedBytes: 8192 + projection: + - /total + - /results/*/dateOfEvent + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 4 + extractScript: adapters/event-search-json.rhai + factSchema: schemas/facts.schema.yaml +reference_shape: OpenCRVS v2 Event Search JSON +scope: Only OAuth client bootstrap and the consumed fixed event-search boundary +credential_bootstrap: + method: POST + token_endpoint: 'http://[::1]:18083/oauth/token' + grant_type: client_credentials + credential_placement: query-string + client_id: injected from secret reference + client_secret: injected from secret reference + maximum_response_bytes: 8192 + maximum_cache_seconds: 300 + failures: [http-400, http-401, http-429, http-500, timeout, invalid-json, missing-token, wrong-token-type, oversized-response] +request: + method: POST + path: /events/search + query: {} + headers: + content-type: application/json + authorization: Bearer token from credential bootstrap; exact value never snapshotted + body: + query: + type: and + clauses: + - eventType: birth + status: {type: exact, term: REGISTERED} + trackingId: {type: exact, term: TRACKING-CANARY-0001} + limit: 2 + offset: 0 + response_shape: [results, total] + result_shape: EventIndex including a country-configured declaration map + unexpected_request: fail test +selector_contracts: + identifier_only: {profile: civil-record-reference-v1, prepared_field: body.query.clauses.0.trackingId.term} + array_placement: The reviewed prepare/2 script constructs the fixed clauses array and places only the authorized record reference in the trackingId exact predicate. +cardinality: + zero: responses/no-match.json + one: responses/match.json + multiple: responses/ambiguous.json + maximum_results: 2 + page_traversal: prohibited + evidence_data_requests_after_response: 0 +extraction: + match_facts: [date_of_event] + missing_fact: responses/missing-fact.json + error_envelope: responses/error-envelope.json + inconsistent_cardinality: responses/inconsistent-cardinality.json + rule: total is provider-owned cardinality; total zero maps to no_match, one result with total one maps to match, and total above one maps to ambiguous without a second request or local comparison. + declaration_rule: The broader declaration is transient source data. The adapter does not compare child or parent fields and exposes only the declared date_of_event fact. + projection_rule: Rust removes ids, tracking ids, declarations, and every unrelated field before extract/2. +failures: + - http-401 + - http-403 + - http-429 + - http-500 + - timeout + - redirect + - invalid-json + - wrong-media-type + - oversized-response + - error-envelope +redaction_canaries: + committed_source_and_selector: [TRACKING-CANARY-0001, SOURCE-DATE-CANARY-DO-NOT-LOG, SOURCE-PARENT-CANARY-DO-NOT-LOG] + runtime_generated_secret_classes: [oauth-client-id, oauth-client-secret, oauth-access-token] +minimization: + acquisition: disclosure-minimized only; one bounded lookup returns a broader EventIndex and country-configured declaration + response_posture: record-transformed + persistence: prohibited + diagnostics: no declaration field, selector, source fact, credential, token, or response body +claim: Compatibility-shaped fixture only; not an emulator, connector certification, maintained vendor integration, or legal-parent matching implementation. +relationship_boundary: A zero-result Event Search response is no_match and can never be interpreted as a conclusive negative relationship fact. diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/ambiguous.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/ambiguous.json new file mode 100644 index 000000000..9b7d46915 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/ambiguous.json @@ -0,0 +1,21 @@ +{ + "results": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "trackingId": "TRACKING-CANARY-0001", + "type": "birth", + "status": "REGISTERED", + "dateOfEvent": "2000-02-29", + "declaration": {"child.name": {"firstname": "Synthetic", "surname": "One"}} + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "trackingId": "TRACKING-CANARY-0002", + "type": "birth", + "status": "REGISTERED", + "dateOfEvent": "2001-03-01", + "declaration": {"child.name": {"firstname": "Synthetic", "surname": "Two"}} + } + ], + "total": 2 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/error-envelope.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/error-envelope.json new file mode 100644 index 000000000..812345047 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/error-envelope.json @@ -0,0 +1,7 @@ +{ + "errors": [ + {"code": "SYNTHETIC-SOURCE-ERROR"} + ], + "results": [], + "total": 0 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/inconsistent-cardinality.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/inconsistent-cardinality.json new file mode 100644 index 000000000..c23f52426 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/inconsistent-cardinality.json @@ -0,0 +1,4 @@ +{ + "results": [], + "total": 1 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/match.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/match.json new file mode 100644 index 000000000..6b5561930 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/match.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "trackingId": "TRACKING-CANARY-0001", + "type": "birth", + "status": "REGISTERED", + "dateOfEvent": "2000-02-29", + "declaration": { + "child.name": {"firstname": "SOURCE-CHILD-CANARY-DO-NOT-LOG", "surname": "Example"}, + "mother.name": {"firstname": "SOURCE-PARENT-CANARY-DO-NOT-LOG", "surname": "Example"}, + "father.name": {"firstname": "Synthetic", "surname": "Parent"} + } + } + ], + "total": 1 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/missing-fact.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/missing-fact.json new file mode 100644 index 000000000..b564e2fcb --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/missing-fact.json @@ -0,0 +1,14 @@ +{ + "results": [ + { + "id": "00000000-0000-4000-8000-000000000003", + "trackingId": "TRACKING-CANARY-0003", + "type": "birth", + "status": "REGISTERED", + "declaration": { + "child.name": {"firstname": "Synthetic", "surname": "MissingDate"} + } + } + ], + "total": 1 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/no-match.json b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/no-match.json new file mode 100644 index 000000000..6d1830beb --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/responses/no-match.json @@ -0,0 +1,4 @@ +{ + "results": [], + "total": 0 +} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/adapter-parameters.schema.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..d0743705f --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,8 @@ +type: object +additionalProperties: false +required: [eventType, registeredStatus, resultLimit, resultOffset] +properties: + eventType: {const: birth} + registeredStatus: {const: REGISTERED} + resultLimit: {const: 2} + resultOffset: {const: 0} diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/facts.schema.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/facts.schema.yaml new file mode 100644 index 000000000..acfdcb86e --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [date_of_event] +properties: + date_of_event: {type: string, format: date} diff --git a/products/evidence/generated/evidence-request-v1.schema.json b/products/evidence/generated/evidence-request-v1.schema.json new file mode 100644 index 000000000..357e3258e --- /dev/null +++ b/products/evidence/generated/evidence-request-v1.schema.json @@ -0,0 +1,94 @@ +{ + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access.", + "$defs": { + "scalar-selector-value": { + "oneOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "selector": { + "additionalProperties": false, + "properties": { + "profile": { + "pattern": "^[a-z][a-z0-9._-]{0,127}$", + "type": "string" + }, + "values": { + "additionalProperties": { + "$ref": "#/$defs/scalar-selector-value" + }, + "maxProperties": 16, + "minProperties": 1, + "propertyNames": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "subject": { + "additionalProperties": false, + "properties": { + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "selector": { + "$ref": "#/$defs/selector" + } + }, + "required": [ + "role", + "selector" + ], + "type": "object" + } + }, + "$id": "https://registrystack.org/schemas/evidence/request-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "requirement": { + "format": "uri", + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "subjects": { + "items": { + "$ref": "#/$defs/subject" + }, + "maxItems": 8, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "requirement", + "purpose", + "subjects" + ], + "title": "Evidence request Version 1", + "type": "object" +} diff --git a/products/evidence/generated/evidence-v1.schema.json b/products/evidence/generated/evidence-v1.schema.json new file mode 100644 index 000000000..bfa4ef24e --- /dev/null +++ b/products/evidence/generated/evidence-v1.schema.json @@ -0,0 +1,247 @@ +{ + "$comment": "The selected concept declaration further closes value form, schema, codelist, precision, cardinality, structured fields, and uniqueness. Selector profiles and selector values never appear in Evidence.", + "$defs": { + "bucket": { + "additionalProperties": false, + "properties": { + "bucket": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + "form": { + "enum": [ + "date-bucket", + "time-bucket" + ] + }, + "scheme": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "form", + "scheme", + "bucket" + ], + "type": "object" + }, + "entity-reference": { + "additionalProperties": false, + "properties": { + "form": { + "const": "audience-scoped-entity-reference" + }, + "reference": { + "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "form", + "reference" + ], + "type": "object" + }, + "structured": { + "additionalProperties": false, + "properties": { + "fields": { + "maxProperties": 16, + "minProperties": 1, + "type": "object" + }, + "form": { + "const": "reviewed-structured-value" + }, + "schema": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "form", + "schema", + "fields" + ], + "type": "object" + }, + "subject-binding": { + "additionalProperties": false, + "properties": { + "binding": { + "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + } + }, + "required": [ + "role", + "binding" + ], + "type": "object" + }, + "supported-value": { + "additionalProperties": false, + "properties": { + "providesValueFor": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "value": { + "$ref": "#/$defs/value" + } + }, + "required": [ + "providesValueFor", + "value" + ], + "type": "object" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "$ref": "#/$defs/bucket" + }, + { + "$ref": "#/$defs/entity-reference" + }, + { + "$ref": "#/$defs/structured" + }, + { + "items": { + "anyOf": [ + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "$ref": "#/$defs/entity-reference" + } + ] + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + } + ] + } + }, + "$id": "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "audience": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, + "id": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "isConformantTo": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "issuedAt": { + "format": "date-time", + "type": "string" + }, + "issuedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "observedAt": { + "format": "date-time", + "type": "string" + }, + "providedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "schema": { + "const": "registry.assertion-evidence/v1" + }, + "subjects": { + "items": { + "$ref": "#/$defs/subject-binding" + }, + "maxItems": 8, + "minItems": 1, + "type": "array" + }, + "supportedValues": { + "items": { + "$ref": "#/$defs/supported-value" + }, + "maxItems": 16, + "minItems": 1, + "type": "array" + }, + "supportsRequirement": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "type": { + "const": "Evidence" + }, + "validUntil": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "type", + "supportsRequirement", + "isConformantTo", + "issuedBy", + "providedBy", + "issuedAt", + "observedAt", + "validUntil", + "purpose", + "audience", + "configurationRevision", + "subjects", + "supportedValues" + ], + "title": "Evidence assertion payload Version 1", + "type": "object" +} diff --git a/products/evidence/generated/flattened-jws-v1.schema.json b/products/evidence/generated/flattened-jws-v1.schema.json new file mode 100644 index 000000000..ae86ca959 --- /dev/null +++ b/products/evidence/generated/flattened-jws-v1.schema.json @@ -0,0 +1,29 @@ +{ + "$comment": "Flattened JWS JSON Serialization. The protected header has exactly alg=EdDSA, kid, typ=evidence+jws, and cty=application/evidence+json. The payload is the base64url encoding without padding of exact UTF-8 Evidence JSON bytes.", + "$id": "https://registrystack.org/schemas/evidence/flattened-jws-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "payload": { + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "protected": { + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "signature": { + "pattern": "^[A-Za-z0-9_-]{86}$", + "type": "string" + } + }, + "required": [ + "protected", + "payload", + "signature" + ], + "title": "Evidence flattened JWS response Version 1", + "type": "object" +} diff --git a/products/evidence/generated/jwks-v1.schema.json b/products/evidence/generated/jwks-v1.schema.json new file mode 100644 index 000000000..d47d1c18e --- /dev/null +++ b/products/evidence/generated/jwks-v1.schema.json @@ -0,0 +1,56 @@ +{ + "$comment": "Only the active and configured retired public keys are published. Key ids are unique and limited to 256 UTF-8 bytes by the runtime; JSON Schema maxLength is an additional code-point bound. Discovery is not a trust anchor; verifiers pin the governed provider and JWKS location.", + "$defs": { + "ed25519-public-jwk": { + "additionalProperties": false, + "properties": { + "alg": { + "const": "EdDSA" + }, + "crv": { + "const": "Ed25519" + }, + "kid": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string" + }, + "kty": { + "const": "OKP" + }, + "x": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "kty", + "kid", + "alg", + "crv", + "x" + ], + "type": "object" + } + }, + "$id": "https://registrystack.org/schemas/evidence/jwks-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "$ref": "#/$defs/ed25519-public-jwk" + }, + "maxItems": 33, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "keys" + ], + "title": "Evidence public JWKS Version 1", + "type": "object" +} diff --git a/products/evidence/generated/problem-v1.schema.json b/products/evidence/generated/problem-v1.schema.json new file mode 100644 index 000000000..bc46f22d8 --- /dev/null +++ b/products/evidence/generated/problem-v1.schema.json @@ -0,0 +1,199 @@ +{ + "$comment": "Problem members are a closed safe shape. No request, authority, source, script, supported-value, subject-binding, candidate, or credential detail is returned.", + "$id": "https://registrystack.org/schemas/evidence/problem-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "code": { + "const": "malformed_request" + }, + "status": { + "const": 400 + }, + "title": { + "const": "Request is not valid" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/malformed_request" + } + } + }, + { + "properties": { + "code": { + "const": "invalid_selector" + }, + "status": { + "const": 400 + }, + "title": { + "const": "Request is not valid" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/invalid_selector" + } + } + }, + { + "properties": { + "code": { + "const": "authentication_failed" + }, + "status": { + "const": 401 + }, + "title": { + "const": "Authentication failed" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/authentication_failed" + } + } + }, + { + "properties": { + "code": { + "const": "not_authorized" + }, + "status": { + "const": 403 + }, + "title": { + "const": "Request is not authorized" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/not_authorized" + } + } + }, + { + "properties": { + "code": { + "const": "evidence_not_available" + }, + "status": { + "const": 422 + }, + "title": { + "const": "Evidence could not be produced" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/evidence_not_available" + } + } + }, + { + "properties": { + "code": { + "const": "rate_limited" + }, + "status": { + "const": 429 + }, + "title": { + "const": "Request rate exceeded" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/rate_limited" + } + } + }, + { + "properties": { + "code": { + "const": "dependency_unavailable" + }, + "status": { + "const": 503 + }, + "title": { + "const": "Service temporarily unavailable" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/dependency_unavailable" + } + } + }, + { + "properties": { + "code": { + "const": "service_unavailable" + }, + "status": { + "const": 503 + }, + "title": { + "const": "Service temporarily unavailable" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/service_unavailable" + } + } + } + ], + "properties": { + "code": { + "enum": [ + "malformed_request", + "invalid_selector", + "authentication_failed", + "not_authorized", + "evidence_not_available", + "rate_limited", + "dependency_unavailable", + "service_unavailable" + ], + "type": "string" + }, + "operation": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + }, + "status": { + "enum": [ + 400, + 401, + 403, + 422, + 429, + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid", + "Authentication failed", + "Request is not authorized", + "Evidence could not be produced", + "Request rate exceeded", + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request", + "https://registrystack.org/problems/evidence/invalid_selector", + "https://registrystack.org/problems/evidence/authentication_failed", + "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/evidence_not_available", + "https://registrystack.org/problems/evidence/rate_limited", + "https://registrystack.org/problems/evidence/dependency_unavailable", + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "code", + "operation" + ], + "title": "Evidence public problem Version 1", + "type": "object" +} diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json new file mode 100644 index 000000000..2d55b856a --- /dev/null +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -0,0 +1,1421 @@ +{ + "components": { + "schemas": { + "BucketValue": { + "additionalProperties": false, + "properties": { + "bucket": { + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "type": "string" + }, + "form": { + "enum": [ + "date-bucket", + "time-bucket" + ], + "type": "string" + }, + "scheme": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "form", + "scheme", + "bucket" + ], + "type": "object" + }, + "Ed25519PublicJwk": { + "additionalProperties": false, + "properties": { + "alg": { + "enum": [ + "EdDSA" + ], + "type": "string" + }, + "crv": { + "enum": [ + "Ed25519" + ], + "type": "string" + }, + "kid": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string" + }, + "kty": { + "enum": [ + "OKP" + ], + "type": "string" + }, + "x": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "kty", + "kid", + "alg", + "crv", + "x" + ], + "type": "object" + }, + "EntityReferenceValue": { + "additionalProperties": false, + "properties": { + "form": { + "enum": [ + "audience-scoped-entity-reference" + ], + "type": "string" + }, + "reference": { + "pattern": "^urn:evidence:entity:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "form", + "reference" + ], + "type": "object" + }, + "Evidence": { + "additionalProperties": false, + "properties": { + "audience": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, + "id": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "isConformantTo": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "issuedAt": { + "format": "date-time", + "type": "string" + }, + "issuedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "observedAt": { + "format": "date-time", + "type": "string" + }, + "providedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "schema": { + "enum": [ + "registry.assertion-evidence/v1" + ], + "type": "string" + }, + "subjects": { + "items": { + "$ref": "#/components/schemas/SubjectBinding" + }, + "maxItems": 8, + "minItems": 1, + "type": "array" + }, + "supportedValues": { + "items": { + "$ref": "#/components/schemas/SupportedValue" + }, + "maxItems": 16, + "minItems": 1, + "type": "array" + }, + "supportsRequirement": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "type": { + "enum": [ + "Evidence" + ], + "type": "string" + }, + "validUntil": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "schema", + "id", + "type", + "supportsRequirement", + "isConformantTo", + "issuedBy", + "providedBy", + "issuedAt", + "observedAt", + "validUntil", + "purpose", + "audience", + "configurationRevision", + "subjects", + "supportedValues" + ], + "title": "Evidence assertion payload Version 1", + "type": "object" + }, + "EvidenceProtectedHeader": { + "additionalProperties": false, + "properties": { + "alg": { + "enum": [ + "EdDSA" + ], + "type": "string" + }, + "cty": { + "enum": [ + "application/evidence+json" + ], + "type": "string" + }, + "kid": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$", + "type": "string" + }, + "typ": { + "enum": [ + "evidence+jws" + ], + "type": "string" + } + }, + "required": [ + "alg", + "kid", + "typ", + "cty" + ], + "type": "object" + }, + "EvidenceRequest": { + "additionalProperties": false, + "properties": { + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "requirement": { + "format": "uri", + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "subjects": { + "items": { + "$ref": "#/components/schemas/EvidenceRequestSubject" + }, + "maxItems": 8, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "requirement", + "purpose", + "subjects" + ], + "title": "Evidence request Version 1", + "type": "object" + }, + "EvidenceRequestSelector": { + "additionalProperties": false, + "properties": { + "profile": { + "pattern": "^[a-z][a-z0-9._-]{0,127}$", + "type": "string" + }, + "values": { + "additionalProperties": { + "$ref": "#/components/schemas/SelectorValue" + }, + "maxProperties": 16, + "minProperties": 1, + "propertyNames": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "EvidenceRequestSubject": { + "additionalProperties": false, + "properties": { + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "selector": { + "$ref": "#/components/schemas/EvidenceRequestSelector" + } + }, + "required": [ + "role", + "selector" + ], + "type": "object" + }, + "FlattenedJws": { + "additionalProperties": false, + "properties": { + "payload": { + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string", + "x-decoded-schema": { + "$ref": "#/components/schemas/Evidence" + } + }, + "protected": { + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string", + "x-decoded-schema": { + "$ref": "#/components/schemas/EvidenceProtectedHeader" + } + }, + "signature": { + "pattern": "^[A-Za-z0-9_-]{86}$", + "type": "string" + } + }, + "required": [ + "protected", + "payload", + "signature" + ], + "title": "Evidence flattened JWS response Version 1", + "type": "object" + }, + "HealthStatus": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "ok" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "JwksDocument": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "$ref": "#/components/schemas/Ed25519PublicJwk" + }, + "maxItems": 33, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "keys" + ], + "title": "Evidence public JWKS Version 1", + "type": "object" + }, + "Problem": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "code": { + "enum": [ + "malformed_request" + ], + "type": "string" + }, + "status": { + "enum": [ + 400 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "invalid_selector" + ], + "type": "string" + }, + "status": { + "enum": [ + 400 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/invalid_selector" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "authentication_failed" + ], + "type": "string" + }, + "status": { + "enum": [ + 401 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Authentication failed" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/authentication_failed" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "not_authorized" + ], + "type": "string" + }, + "status": { + "enum": [ + 403 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not authorized" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/not_authorized" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "evidence_not_available" + ], + "type": "string" + }, + "status": { + "enum": [ + 422 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Evidence could not be produced" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/evidence_not_available" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "rate_limited" + ], + "type": "string" + }, + "status": { + "enum": [ + 429 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request rate exceeded" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/rate_limited" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "dependency_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/dependency_unavailable" + ], + "type": "string" + } + } + }, + { + "properties": { + "code": { + "enum": [ + "service_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + } + } + ], + "properties": { + "code": { + "enum": [ + "malformed_request", + "invalid_selector", + "authentication_failed", + "not_authorized", + "evidence_not_available", + "rate_limited", + "dependency_unavailable", + "service_unavailable" + ], + "type": "string" + }, + "operation": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + }, + "status": { + "enum": [ + 400, + 401, + 403, + 422, + 429, + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid", + "Authentication failed", + "Request is not authorized", + "Evidence could not be produced", + "Request rate exceeded", + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request", + "https://registrystack.org/problems/evidence/invalid_selector", + "https://registrystack.org/problems/evidence/authentication_failed", + "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/evidence_not_available", + "https://registrystack.org/problems/evidence/rate_limited", + "https://registrystack.org/problems/evidence/dependency_unavailable", + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "code", + "operation" + ], + "title": "Evidence public problem Version 1", + "type": "object" + }, + "PublicValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "$ref": "#/components/schemas/BucketValue" + }, + { + "$ref": "#/components/schemas/EntityReferenceValue" + }, + { + "$ref": "#/components/schemas/StructuredValue" + }, + { + "items": { + "anyOf": [ + { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + { + "$ref": "#/components/schemas/EntityReferenceValue" + } + ] + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + } + ] + }, + "ReadyStatus": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "ready" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SelectorValue": { + "oneOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "StructuredValue": { + "additionalProperties": false, + "properties": { + "fields": { + "maxProperties": 16, + "minProperties": 1, + "type": "object" + }, + "form": { + "enum": [ + "reviewed-structured-value" + ], + "type": "string" + }, + "schema": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "form", + "schema", + "fields" + ], + "type": "object" + }, + "SubjectBinding": { + "additionalProperties": false, + "properties": { + "binding": { + "pattern": "^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$", + "type": "string" + }, + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + } + }, + "required": [ + "role", + "binding" + ], + "type": "object" + }, + "SupportedValue": { + "additionalProperties": false, + "properties": { + "providesValueFor": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/PublicValue" + } + }, + "required": [ + "providesValueFor", + "value" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "Exactly one Authorization header containing one Bearer token is required.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Minimum-disclosure signed assertion service, Version 1.", + "title": "Registry Evidence API", + "version": "0.16.2" + }, + "openapi": "3.1.0", + "paths": { + "/.well-known/evidence/jwks.json": { + "get": { + "operationId": "getEvidenceJwks", + "responses": { + "200": { + "content": { + "application/jwk-set+json": { + "schema": { + "$ref": "#/components/schemas/JwksDocument" + } + } + }, + "description": "Evidence public verification keys", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "summary": "Publish the active and retained public verification keys" + } + }, + "/health": { + "get": { + "operationId": "getHealth", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthStatus" + } + } + }, + "description": "Process is live", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "summary": "Report process liveness without dependency access" + } + }, + "/ready": { + "get": { + "operationId": "getReadiness", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyStatus" + } + } + }, + "description": "Runtime is ready", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "service_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Runtime is not ready", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "summary": "Report fail-closed runtime readiness" + } + }, + "/v1/evidence": { + "post": { + "operationId": "createEvidence", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/jose+json": { + "schema": { + "$ref": "#/components/schemas/FlattenedJws" + } + } + }, + "description": "Signed Evidence as flattened JWS JSON Serialization", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "malformed_request" + ], + "type": "string" + }, + "status": { + "enum": [ + 400 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "invalid_selector" + ], + "type": "string" + }, + "status": { + "enum": [ + 400 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/invalid_selector" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + ] + } + } + }, + "description": "Malformed request or invalid selector", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "authentication_failed" + ], + "type": "string" + }, + "status": { + "enum": [ + 401 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Authentication failed" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/authentication_failed" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Authentication failed", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "WWW-Authenticate": { + "schema": { + "enum": [ + "Bearer" + ], + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "not_authorized" + ], + "type": "string" + }, + "status": { + "enum": [ + 403 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not authorized" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/not_authorized" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Request is not authorized", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "evidence_not_available" + ], + "type": "string" + }, + "status": { + "enum": [ + 422 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Evidence could not be produced" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/evidence_not_available" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Evidence could not be produced", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "rate_limited" + ], + "type": "string" + }, + "status": { + "enum": [ + 429 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request rate exceeded" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/rate_limited" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Request rate exceeded", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "Retry-After": { + "schema": { + "enum": [ + "1" + ], + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "dependency_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/dependency_unavailable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "service_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + ] + } + } + }, + "description": "Dependency or service temporarily unavailable", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Produce signed evidence for one authorized fixed requirement" + } + } + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai new file mode 100644 index 000000000..18f2e4a8e --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai @@ -0,0 +1,57 @@ +fn extract(source_response, parameters) { + if !source_response.contains("pager") || + !source_response.contains("trackedEntities") || + type_of(source_response["pager"]) != "map" || + type_of(source_response["trackedEntities"]) != "array" || + !source_response["pager"].contains("total") || + type_of(source_response["pager"]["total"]) != "i64" || + source_response["pager"]["total"] < 0 || + source_response["trackedEntities"].len > 2 { + throw("source_protocol_error"); + } + + let total = source_response["pager"]["total"]; + let records = source_response["trackedEntities"]; + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 { throw("source_protocol_error"); } + + let record = records[0]; + if !record.contains("trackedEntity") || + type_of(record["trackedEntity"]) != "string" || + !record.contains("attributes") || + type_of(record["attributes"]) != "array" { + throw("source_protocol_error"); + } + + let date_of_birth = (); + let matches = 0; + for attribute in record["attributes"] { + if type_of(attribute) != "map" || + !attribute.contains("attribute") || + !attribute.contains("value") { + throw("source_protocol_error"); + } + if attribute["attribute"] == parameters["dateOfBirthAttribute"] { + if type_of(attribute["value"]) != "string" { + throw("source_protocol_error"); + } + date_of_birth = attribute["value"]; + matches += 1; + } + } + if matches > 1 { throw("source_protocol_error"); } + + let facts = #{record_reference: record["trackedEntity"]}; + if matches == 1 { + facts["date_of_birth"] = date_of_birth; + } + + #{outcome: "match", facts: facts} +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai new file mode 100644 index 000000000..1721e4b1c --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "program", value: parameters["program"]}, + #{name: "orgUnits", value: parameters["organisationUnit"]}, + #{name: "trackedEntities", value: subject["values"]["record_reference"]}, + #{name: "fields", value: parameters["providerFields"]}, + #{name: "pageSize", value: parameters["pageSize"]}, + #{name: "page", value: parameters["page"]}, + #{name: "totalPages", value: parameters["totalPages"]} + ], + body: () + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai new file mode 100644 index 000000000..2232883b3 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai @@ -0,0 +1,18 @@ +fn derive(facts, selectors, evaluation_context) { + if facts["record_reference"] != + selectors["subject"]["values"]["record_reference"] { + throw("derivation_input_error"); + } + let date_of_birth = parse_date(required( + facts["date_of_birth"], + "required_fact_missing" + )); + let threshold = add_calendar_years( + date_of_birth, + evaluation_context["parameters"]["minimum_age_years"] + ); + [#{ + concept_id: "urn:gov:example:concept:adult-status", + value: compare_dates(evaluation_context["legal_local_date"], threshold) >= 0 + }] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml new file mode 100644 index 000000000..55c4aa4ed --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml @@ -0,0 +1,130 @@ +version: 1 +service: + providerId: urn:gov:example:evidence:dhis2 + trustDomain: urn:gov:example:trust-domain:social-protection +issuer: + id: urn:gov:example:issuer:population-authority +authentication: + kind: oidc-access-token + issuer: https://identity.gov.example + audiences: [registry-evidence] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.gov.example/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: evidence-signing-2026-01 + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +selectorProfiles: + tracked-entity-reference-v1: + maximumAggregateBytes: 64 + fields: + record_reference: {type: string, minimumBytes: 11, maximumBytes: 64} +sources: + population-tracker: + transport: http-json + baseUrl: https://dhis2.gov.example + posture: record-transformed + tlsTrustProfile: government-internal-pki + authentication: + kind: basic + usernameRef: secret:file/dhis2-username + passwordRef: secret:file/dhis2-password + request: + method: GET + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + prepareScript: adapters/prepare.rhai + adapterParameters: + program: Prg00000001 + organisationUnit: Org00000001 + providerFields: trackedEntity,attributes[attribute,value] + pageSize: "2" + page: "1" + totalPages: "true" + dateOfBirthAttribute: Dob00000001 + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + projection: + - /pager/total + - /trackedEntities/*/trackedEntity + - /trackedEntities/*/attributes/*/attribute + - /trackedEntities/*/attributes/*/value + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/extract.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + eligibility-caseworker-v1: + kind: statutory + requesterTags: [eligibility-caseworker] + grants: + - requirement: urn:gov:example:requirement:adult-status:v1 + purpose: benefit-eligibility + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: tracked-entity-reference-v1 + valueOrigin: request +requirements: + - id: urn:gov:example:requirement:adult-status:v1 + kind: criterion + source: population-tracker + purposes: [benefit-eligibility] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} + referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] + evidenceType: urn:gov:example:evidence-type:adult-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 86400 + derivation: + script: derivations/adult-status.rhai + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + parameters: {minimum_age_years: 18} + concepts: + - id: urn:gov:example:concept:adult-status + form: boolean + required: true + constraints: {} + fixtures: fixtures/cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:adult-status] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml new file mode 100644 index 000000000..6c4da5c84 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml @@ -0,0 +1,111 @@ +fixture: registry.evidence.reference.dhis2-adult-status/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + subject: + profile: tracked-entity-reference-v1 + values: {record_reference: Tei00000001} + derivationSelectorInputs: + subject: + profile: tracked-entity-reference-v1 + values: {record_reference: Tei00000001} + expectedRequestParts: + query: + - {name: program, value: Prg00000001} + - {name: orgUnits, value: Org00000001} + - {name: trackedEntities, value: Tei00000001} + - {name: fields, value: "trackedEntity,attributes[attribute,value]"} + - {name: pageSize, value: "2"} + - {name: page, value: "1"} + - {name: totalPages, value: "true"} + body: null + expectedTransport: + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} +cases: + - id: positive + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Dob00000001, value: "2000-01-01"} + - {attribute: Oth00000001, value: SOURCE-UNRELATED-CANARY} + expected: + lookup: match + derivationRuns: true + signed: true + facts: {record_reference: Tei00000001, date_of_birth: "2000-01-01"} + value: true + - id: negative-false-is-success + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [{attribute: Dob00000001, value: "2010-01-01"}] + expected: + lookup: match + derivationRuns: true + facts: {record_reference: Tei00000001, date_of_birth: "2010-01-01"} + value: false + signed: true + - id: boundary-on + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [{attribute: Dob00000001, value: "2008-08-02"}] + expected: + lookup: match + derivationRuns: true + signed: true + facts: {record_reference: Tei00000001, date_of_birth: "2008-08-02"} + value: true + - id: missing-fact + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [{attribute: Oth00000001, value: SOURCE-UNRELATED-CANARY}] + expected: {publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: no-match + response: {pager: {page: 1, pageSize: 2, total: 0}, trackedEntities: []} + expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: ambiguous + response: + pager: {page: 1, pageSize: 2, total: 2} + trackedEntities: + - {trackedEntity: Tei00000001, attributes: []} + - {trackedEntity: Tei00000002, attributes: []} + expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - {id: fractional-total, response: {pager: {total: 1.5}, trackedEntities: []}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} + - id: returned-subject-mismatch + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: + - trackedEntity: Tei00000002 + attributes: [{attribute: Dob00000001, value: "2000-01-01"}] + expected: + lookup: match + error: derivation_input_error + publicProblem: service_unavailable + derivationRuns: true + signed: false + - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + - id: hostile-reference + selectorOverrides: + subject: + values: {record_reference: "X&fields=*%0D%0AInjected:yes"} + expected: + sourceRequestCount: 1 + expectedTransport: + path: /api/tracker/trackedEntities + query: "program=Prg00000001&orgUnits=Org00000001&trackedEntities=X%26fields%3D%2A%250D%250AInjected%3Ayes&fields=trackedEntity%2Cattributes%5Battribute%2Cvalue%5D&pageSize=2&page=1&totalPages=true" + body: null + - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} +privacyExpectation: + evidenceContains: [urn:gov:example:concept:adult-status] + evidenceExcludes: [date_of_birth, record_reference, tracked-entity-reference-v1, Tei00000001, Tei00000002] + diagnosticsExclude: [Tei00000001, Tei00000002, "2000-01-01", SOURCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..38d39ed97 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,18 @@ +type: object +additionalProperties: false +required: + - program + - organisationUnit + - providerFields + - pageSize + - page + - totalPages + - dateOfBirthAttribute +properties: + program: {type: string, minLength: 1, maxLength: 128} + organisationUnit: {type: string, minLength: 1, maxLength: 128} + providerFields: {const: "trackedEntity,attributes[attribute,value]"} + pageSize: {const: "2"} + page: {const: "1"} + totalPages: {const: "true"} + dateOfBirthAttribute: {type: string, minLength: 1, maxLength: 128} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml new file mode 100644 index 000000000..ffbe79466 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [record_reference, date_of_birth] +properties: + record_reference: {type: string, minLength: 11, maxLength: 64} + date_of_birth: {type: string, format: date} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml new file mode 100644 index 000000000..0bb1dd4c2 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml @@ -0,0 +1,22 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: /run/secrets/registry-evidence +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: + government-internal-pki: + caBundleFile: /etc/registry-evidence/ca/government-internal.pem diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai new file mode 100644 index 000000000..61d6fb399 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai @@ -0,0 +1,43 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + !source_response.contains("results") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + type_of(source_response["results"]) != "array" || + source_response["results"].len > parameters["resultLimit"] { + throw("source_protocol_error"); + } + + let total = source_response["total"]; + let results = source_response["results"]; + if total == 0 { + if results.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if results.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if results.len != 1 { throw("source_protocol_error"); } + + let result = results[0]; + if !result.contains("type") || + !result.contains("status") || + result["type"] != parameters["eventType"] || + result["status"] != parameters["registeredStatus"] || + !result.contains("trackingId") || + type_of(result["trackingId"]) != "string" || + !result.contains(parameters["dateField"]) || + type_of(result[parameters["dateField"]]) != "string" { + throw("source_protocol_error"); + } + + #{ + outcome: "match", + facts: #{ + record_tracking_id: result["trackingId"], + date_of_birth: result[parameters["dateField"]] + } + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-event-prepare.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-event-prepare.rhai new file mode 100644 index 000000000..dbcbb93b4 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-event-prepare.rhai @@ -0,0 +1,18 @@ +fn prepare(selectors, parameters) { + let subject = selectors[parameters["selectorRole"]]; + #{ + query: [], + body: #{ + query: #{ + type: "and", + clauses: [#{ + eventType: parameters["eventType"], + status: #{type: "exact", term: parameters["registeredStatus"]}, + trackingId: #{type: "exact", term: subject["values"]["tracking_id"]} + }] + }, + limit: parameters["resultLimit"], + offset: parameters["resultOffset"] + } + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai new file mode 100644 index 000000000..7fffdd951 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai @@ -0,0 +1,63 @@ +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + !source_response.contains("results") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + type_of(source_response["results"]) != "array" || + source_response["results"].len > parameters["resultLimit"] { + throw("source_protocol_error"); + } + + let total = source_response["total"]; + let results = source_response["results"]; + if total == 0 { + if results.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if results.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if results.len != 1 { throw("source_protocol_error"); } + + let result = results[0]; + if !result.contains("type") || + !result.contains("status") || + result["type"] != parameters["eventType"] || + result["status"] != parameters["registeredStatus"] || + !result.contains("trackingId") || + type_of(result["trackingId"]) != "string" || + !result.contains("declaration") || + type_of(result["declaration"]) != "map" { + throw("source_protocol_error"); + } + + let parent_references = []; + for field in parameters["parentReferenceFields"] { + if result["declaration"].contains(field) { + let reference = result["declaration"][field]; + if type_of(reference) != "string" || reference == "" || + list_contains(parent_references, reference) { + throw("source_protocol_error"); + } + parent_references.push(reference); + } + } + + if parent_references.len < parameters["minimumParentReferences"] || + parent_references.len > parameters["maximumParentReferences"] { + throw("source_protocol_error"); + } + + #{ + outcome: "match", + facts: #{ + record_tracking_id: result["trackingId"], + parent_references: parent_references, + reference_namespace: parameters["parentReferenceNamespace"], + relationship_set_contract: parameters["relationshipSetContract"], + relationship_set_complete: true + } + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/adult-status.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/adult-status.rhai new file mode 100644 index 000000000..7c40ab498 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/adult-status.rhai @@ -0,0 +1,18 @@ +fn derive(facts, selectors, evaluation_context) { + if facts["record_tracking_id"] != + selectors["subject"]["values"]["tracking_id"] { + throw("derivation_input_error"); + } + let date_of_birth = parse_date(required( + facts["date_of_birth"], + "required_fact_missing" + )); + let threshold = add_calendar_years( + date_of_birth, + evaluation_context["parameters"]["minimum_age_years"] + ); + [#{ + concept_id: "urn:gov:example:concept:adult-status", + value: compare_dates(evaluation_context["legal_local_date"], threshold) >= 0 + }] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-references.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-references.rhai new file mode 100644 index 000000000..e7bfbbeaa --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-references.rhai @@ -0,0 +1,37 @@ +fn derive(facts, selectors, evaluation_context) { + if facts["record_tracking_id"] != + selectors["child"]["values"]["tracking_id"] { + throw("derivation_input_error"); + } + let parent_references = required( + facts["parent_references"], + "required_fact_missing" + ); + let complete = required( + facts["relationship_set_complete"], + "required_fact_missing" + ); + let namespace = required( + facts["reference_namespace"], + "required_fact_missing" + ); + let contract = required( + facts["relationship_set_contract"], + "required_fact_missing" + ); + if complete != true || + namespace != evaluation_context["parameters"]["reference_namespace"] || + contract != evaluation_context["parameters"]["relationship_set_contract"] { + throw("derivation_input_error"); + } + let seeds = []; + for reference in parent_references { + seeds.push(entity_reference_seed( + namespace + ":" + reference + )); + } + [#{ + concept_id: "urn:gov:example:concept:registered-parent-references", + value: seeds + }] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-relationship.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-relationship.rhai new file mode 100644 index 000000000..7e67227f3 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/derivations/registered-parent-relationship.rhai @@ -0,0 +1,40 @@ +fn derive(facts, selectors, evaluation_context) { + if evaluation_context["parameters"]["matching_policy"] != + "exact-opaque-reference-v1" { + throw("derivation_input_error"); + } + + let candidate = selectors["candidate-parent"]; + if facts["record_tracking_id"] != + selectors["child"]["values"]["tracking_id"] { + throw("derivation_input_error"); + } + let parent_references = required( + facts["parent_references"], + "required_fact_missing" + ); + let complete = required( + facts["relationship_set_complete"], + "required_fact_missing" + ); + let namespace = required( + facts["reference_namespace"], + "required_fact_missing" + ); + let contract = required( + facts["relationship_set_contract"], + "required_fact_missing" + ); + if complete != true || + namespace != evaluation_context["parameters"]["candidate_reference_namespace"] || + contract != evaluation_context["parameters"]["relationship_set_contract"] { + throw("derivation_input_error"); + } + [#{ + concept_id: "urn:gov:example:concept:registered-parent-relationship-confirmed", + value: list_contains( + parent_references, + candidate["values"]["person_reference"] + ) + }] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml new file mode 100644 index 000000000..ebd7e7ffb --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -0,0 +1,265 @@ +version: 1 +service: + providerId: urn:gov:example:evidence:opencrvs + trustDomain: urn:gov:example:trust-domain:civil-registration +issuer: + id: urn:gov:example:issuer:civil-registration-authority +authentication: + kind: oidc-access-token + issuer: https://identity.gov.example + audiences: [registry-evidence] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.gov.example/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: evidence-signing-2026-01 + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +selectorProfiles: + opencrvs-tracking-id-v1: + maximumAggregateBytes: 64 + fields: + tracking_id: {type: string, minimumBytes: 1, maximumBytes: 64} + civil-person-reference-v1: + maximumAggregateBytes: 128 + fields: + person_reference: {type: string, minimumBytes: 1, maximumBytes: 128} +sources: + registered-birth-date: + transport: http-json + baseUrl: https://events.opencrvs.gov.example + posture: record-transformed + authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://auth.opencrvs.gov.example/token + clientIdRef: secret:file/opencrvs-client-id + clientSecretRef: secret:file/opencrvs-client-secret + scope: recordsearch + credentialPlacement: query-string + maximumCacheSeconds: 300 + request: + method: POST + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + prepareScript: adapters/birth-event-prepare.rhai + adapterParameters: + selectorRole: subject + eventType: birth + registeredStatus: REGISTERED + resultLimit: 2 + resultOffset: 0 + dateField: dateOfEvent + adapterParametersSchema: schemas/birth-adult-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 12 + maximumCollectionItems: 32 + maximumStringBytes: 512 + maximumNormalizedBytes: 8192 + projection: + - /total + - /results/*/type + - /results/*/status + - /results/*/trackingId + - /results/*/dateOfEvent + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 262144 + concurrencyLimit: 8 + extractScript: adapters/birth-adult-extract.rhai + factSchema: schemas/birth-adult-facts.schema.yaml + registered-birth-parents: + transport: http-json + baseUrl: https://events.opencrvs.gov.example + posture: record-transformed + authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://auth.opencrvs.gov.example/token + clientIdRef: secret:file/opencrvs-client-id + clientSecretRef: secret:file/opencrvs-client-secret + scope: recordsearch + credentialPlacement: query-string + maximumCacheSeconds: 300 + request: + method: POST + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: child + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + prepareScript: adapters/birth-event-prepare.rhai + adapterParameters: + selectorRole: child + eventType: birth + registeredStatus: REGISTERED + resultLimit: 2 + resultOffset: 0 + parentReferenceFields: [mother.personReference, father.personReference] + minimumParentReferences: 1 + maximumParentReferences: 2 + parentReferenceNamespace: urn:gov:example:opencrvs:person + relationshipSetContract: urn:gov:example:opencrvs:registered-parent-set:v1 + relationshipSetComplete: true + absentConfiguredFieldMeansNoRegisteredParent: true + adapterParametersSchema: schemas/birth-parents-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 12 + maximumCollectionItems: 32 + maximumStringBytes: 512 + maximumNormalizedBytes: 8192 + projection: + - /total + - /results/*/type + - /results/*/status + - /results/*/trackingId + - /results/*/declaration/mother.personReference + - /results/*/declaration/father.personReference + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 262144 + concurrencyLimit: 8 + extractScript: adapters/birth-parents-extract.rhai + factSchema: schemas/birth-parents-facts.schema.yaml +authorityProfiles: + civil-registration-caseworker-v1: + kind: statutory + requesterTags: [civil-registration-caseworker] + grants: + - requirement: urn:gov:example:requirement:adult-status-from-birth:v1 + purpose: eligibility-assessment + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: opencrvs-tracking-id-v1 + valueOrigin: request + - requirement: urn:gov:example:requirement:registered-parent-relationship:v1 + purpose: family-relationship-verification + audienceFrom: authenticated-requester + subjects: + - role: child + selectorProfile: opencrvs-tracking-id-v1 + valueOrigin: request + - role: candidate-parent + selectorProfile: civil-person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference + - requirement: urn:gov:example:requirement:identify-registered-parents:v1 + purpose: family-case-record + audienceFrom: authenticated-requester + subjects: + - role: child + selectorProfile: opencrvs-tracking-id-v1 + valueOrigin: request +requirements: + - id: urn:gov:example:requirement:adult-status-from-birth:v1 + kind: criterion + source: registered-birth-date + purposes: [eligibility-assessment] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} + referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] + evidenceType: urn:gov:example:evidence-type:adult-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 86400 + derivation: + script: derivations/adult-status.rhai + selectorInputs: + - role: subject + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + parameters: {minimum_age_years: 18} + concepts: + - {id: urn:gov:example:concept:adult-status, form: boolean, required: true, constraints: {}} + fixtures: fixtures/adult-status-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:adult-status] + existenceDisclosure: collapse-unresolved + - id: urn:gov:example:requirement:registered-parent-relationship:v1 + kind: criterion + source: registered-birth-parents + purposes: [family-relationship-verification] + subjectRoles: + - {role: child, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} + - {role: candidate-parent, cardinality: one, selectorProfiles: [civil-person-reference-v1]} + referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] + evidenceType: urn:gov:example:evidence-type:registered-parent-relationship:v1 + validitySeconds: 86400 + derivation: + script: derivations/registered-parent-relationship.rhai + selectorInputs: + - role: child + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - role: candidate-parent + alternatives: + - {profile: civil-person-reference-v1, fields: [person_reference]} + parameters: + matching_policy: exact-opaque-reference-v1 + candidate_reference_namespace: urn:gov:example:opencrvs:person + relationship_set_contract: urn:gov:example:opencrvs:registered-parent-set:v1 + concepts: + - {id: urn:gov:example:concept:registered-parent-relationship-confirmed, form: boolean, required: true, constraints: {}} + fixtures: fixtures/registered-parent-relationship-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:registered-parent-relationship] + existenceDisclosure: collapse-unresolved + - id: urn:gov:example:requirement:identify-registered-parents:v1 + kind: information-requirement + source: registered-birth-parents + purposes: [family-case-record] + subjectRoles: + - {role: child, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} + referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] + evidenceType: urn:gov:example:evidence-type:registered-parent-references:v1 + validitySeconds: 86400 + derivation: + script: derivations/registered-parent-references.rhai + selectorInputs: + - role: child + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + parameters: + reference_namespace: urn:gov:example:opencrvs:person + relationship_set_contract: urn:gov:example:opencrvs:registered-parent-set:v1 + concepts: + - id: urn:gov:example:concept:registered-parent-references + form: entity-reference-list + required: true + constraints: {minimumItems: 1, maximumItems: 2, unique: true} + fixtures: fixtures/registered-parent-references-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:registered-parent-references] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml new file mode 100644 index 000000000..724b0b7fd --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml @@ -0,0 +1,66 @@ +fixture: registry.evidence.reference.opencrvs-adult-status/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + subject: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + derivationSelectorInputs: + subject: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + expectedRequestParts: + query: [] + body: + query: + type: and + clauses: + - eventType: birth + status: {type: exact, term: REGISTERED} + trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + limit: 2 + offset: 0 + expectedTransport: + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} +cases: + - id: positive + response: + total: 1 + results: + - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2000-01-01", declaration: {unrelated: SOURCE-UNRELATED-CANARY}} + expected: {lookup: match, value: true, derivationRuns: true, signed: true} + - id: negative-false-is-success + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2010-01-01"}] + expected: {lookup: match, value: false, derivationRuns: true, signed: true} + - id: boundary-on + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2008-08-02"}] + expected: {lookup: match, value: true, derivationRuns: true, signed: true} + - id: missing-fact + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + - id: ambiguous + response: + total: 2 + results: + - {type: birth, status: REGISTERED} + - {type: birth, status: REGISTERED} + expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - {id: fractional-total, response: {total: 0.5, results: []}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} + - {id: wrong-status, response: {total: 1, results: [{type: birth, status: DECLARED, dateOfEvent: "2000-01-01"}]}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} + - {id: returned-subject-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, dateOfEvent: "2000-01-01"}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} + - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} +privacyExpectation: + evidenceContains: [urn:gov:example:concept:adult-status] + evidenceExcludes: [date_of_birth, tracking_id, opencrvs-tracking-id-v1] + diagnosticsExclude: [TRACKING-SYNTHETIC-001, "2000-01-01", SOURCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml new file mode 100644 index 000000000..f15f8f83f --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml @@ -0,0 +1,78 @@ +fixture: registry.evidence.reference.opencrvs-registered-parent-references/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + child: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + derivationSelectorInputs: + child: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + expectedRequestParts: + query: [] + body: + query: + type: and + clauses: + - eventType: birth + status: {type: exact, term: REGISTERED} + trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + limit: 2 + offset: 0 + expectedTransport: + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} +cases: + - id: positive + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: {lookup: match, entityReferenceCount: 1, rawReferencesDisclosed: false, derivationRuns: true, signed: true} + - id: boundary-two-parents + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: + mother.personReference: PERSON-SYNTHETIC-A + father.personReference: PERSON-SYNTHETIC-B + unrelated.field: SOURCE-UNRELATED-CANARY + expected: {lookup: match, entityReferenceCount: 2, rawReferencesDisclosed: false, derivationRuns: true, signed: true} + - id: missing-parent-set + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {}}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + - id: ambiguous-child + response: + total: 2 + results: + - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: PERSON-SYNTHETIC-A}} + - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-002, declaration: {father.personReference: PERSON-SYNTHETIC-B}} + expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: negative-duplicate-parent-reference + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A, father.personReference: PERSON-SYNTHETIC-A} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + - {id: returned-child-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, declaration: {mother.personReference: PERSON-SYNTHETIC-A}}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} + - {id: anti-reconstruction, derivationMutation: return-raw-reference, expected: {outputGate: rejected, signed: false}} +privacyExpectation: + evidenceContains: [urn:gov:example:concept:registered-parent-references] + evidenceExcludes: [parent_references, tracking_id, person_reference, relationship_set_contract] + diagnosticsExclude: [TRACKING-SYNTHETIC-001, PERSON-SYNTHETIC-A, PERSON-SYNTHETIC-B, SOURCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml new file mode 100644 index 000000000..4e83740d7 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -0,0 +1,116 @@ +fixture: registry.evidence.reference.opencrvs-registered-parent-relationship/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + child: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + candidate-parent: + profile: civil-person-reference-v1 + values: {person_reference: PERSON-SYNTHETIC-A} + derivationSelectorInputs: + child: + profile: opencrvs-tracking-id-v1 + values: {tracking_id: TRACKING-SYNTHETIC-001} + candidate-parent: + profile: civil-person-reference-v1 + values: {person_reference: PERSON-SYNTHETIC-A} + verified_token_claims: + evidence_grant_id: synthetic-parentage-grant-001 + evidence_authority: civil-registration-caseworker-v1 + grant: + candidate_parent: {person_reference: PERSON-SYNTHETIC-A} + expectedRequestParts: + query: [] + body: + query: + type: and + clauses: + - eventType: birth + status: {type: exact, term: REGISTERED} + trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + limit: 2 + offset: 0 + expectedTransport: + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} +cases: + - id: positive + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: + mother.personReference: PERSON-SYNTHETIC-A + father.personReference: PERSON-SYNTHETIC-B + unrelated.field: SOURCE-UNRELATED-CANARY + expected: {lookup: match, value: true, derivationRuns: true, signed: true} + - id: negative-false-is-success + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {father.personReference: PERSON-SYNTHETIC-B} + expected: + lookup: match + derivationRuns: true + facts: + record_tracking_id: TRACKING-SYNTHETIC-001 + parent_references: [PERSON-SYNTHETIC-B] + reference_namespace: urn:gov:example:opencrvs:person + relationship_set_contract: urn:gov:example:opencrvs:registered-parent-set:v1 + relationship_set_complete: true + value: false + signed: true + - id: boundary-one-parent + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: {lookup: match, value: true, derivationRuns: true, signed: true} + - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + - id: ambiguous-child + response: + total: 2 + results: + - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: PERSON-SYNTHETIC-A}} + - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-002, declaration: {father.personReference: PERSON-SYNTHETIC-B}} + expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: missing-parent-set + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {unrelated.field: SOURCE-UNRELATED-CANARY}}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: duplicate-parent-reference + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A, father.personReference: PERSON-SYNTHETIC-A} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: wrong-parent-reference-type + response: + total: 1 + results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: 123}}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - {id: namespace-mismatch, derivationParameterMutation: {candidate_reference_namespace: urn:gov:example:other:person}, expected: {signed: false, error: derivation_input_error, derivationRuns: true}} + - {id: returned-child-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, declaration: {mother.personReference: PERSON-SYNTHETIC-A}}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} + - {id: swapped-roles, requestMutation: swap-subject-roles, expected: {rejectedBefore: source, sourceRequestCount: 0, signed: false}} + - {id: caller-candidate-substitution, requestMutation: supply-grant-derived-candidate, expected: {rejectedBefore: source, sourceRequestCount: 0, signed: false}} + - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} +privacyExpectation: + evidenceContains: [urn:gov:example:concept:registered-parent-relationship-confirmed] + evidenceExcludes: [parent_references, tracking_id, person_reference, relationship_set_contract] + diagnosticsExclude: [TRACKING-SYNTHETIC-001, PERSON-SYNTHETIC-A, PERSON-SYNTHETIC-B, SOURCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml new file mode 100644 index 000000000..950c04460 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [record_tracking_id, date_of_birth] +properties: + record_tracking_id: {type: string, minLength: 1, maxLength: 64} + date_of_birth: {type: string, format: date} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-parameters.schema.yaml new file mode 100644 index 000000000..3440c14fb --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-parameters.schema.yaml @@ -0,0 +1,10 @@ +type: object +additionalProperties: false +required: [selectorRole, eventType, registeredStatus, resultLimit, resultOffset, dateField] +properties: + selectorRole: {const: subject} + eventType: {const: birth} + registeredStatus: {const: REGISTERED} + resultLimit: {const: 2} + resultOffset: {const: 0} + dateField: {const: dateOfEvent} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml new file mode 100644 index 000000000..a6771e182 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml @@ -0,0 +1,19 @@ +type: object +additionalProperties: false +required: + - record_tracking_id + - parent_references + - reference_namespace + - relationship_set_contract + - relationship_set_complete +properties: + record_tracking_id: {type: string, minLength: 1, maxLength: 64} + parent_references: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {const: urn:gov:example:opencrvs:person} + relationship_set_contract: {const: urn:gov:example:opencrvs:registered-parent-set:v1} + relationship_set_complete: {const: true} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml new file mode 100644 index 000000000..71ec8d098 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml @@ -0,0 +1,33 @@ +type: object +additionalProperties: false +required: + - selectorRole + - eventType + - registeredStatus + - resultLimit + - resultOffset + - parentReferenceFields + - minimumParentReferences + - maximumParentReferences + - parentReferenceNamespace + - relationshipSetContract + - relationshipSetComplete + - absentConfiguredFieldMeansNoRegisteredParent +properties: + selectorRole: {const: child} + eventType: {const: birth} + registeredStatus: {const: REGISTERED} + resultLimit: {const: 2} + resultOffset: {const: 0} + parentReferenceFields: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + minimumParentReferences: {const: 1} + maximumParentReferences: {const: 2} + parentReferenceNamespace: {const: urn:gov:example:opencrvs:person} + relationshipSetContract: {const: urn:gov:example:opencrvs:registered-parent-set:v1} + relationshipSetComplete: {const: true} + absentConfiguredFieldMeansNoRegisteredParent: {const: true} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml new file mode 100644 index 000000000..c50586401 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml @@ -0,0 +1,20 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: /run/secrets/registry-evidence +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/expected-lookup-result.json b/products/evidence/reference/request-adapter/dhis2-tracker/expected-lookup-result.json new file mode 100644 index 000000000..b84ee6330 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/expected-lookup-result.json @@ -0,0 +1,6 @@ +{ + "outcome": "match", + "facts": { + "status_code": "SYNTHETIC-ACTIVE" + } +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/expected-request-parts.json b/products/evidence/reference/request-adapter/dhis2-tracker/expected-request-parts.json new file mode 100644 index 000000000..b7f4e5cf0 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/expected-request-parts.json @@ -0,0 +1,14 @@ +{ + "query": [ + {"name": "program", "value": "Prg00000001"}, + {"name": "orgUnits", "value": "Org00000001"}, + {"name": "fields", "value": "trackedEntity,attributes[attribute,value]"}, + {"name": "pageSize", "value": "2"}, + {"name": "page", "value": "1"}, + {"name": "totalPages", "value": "true"}, + {"name": "filter", "value": "Gvn00000001:EQ:Synthetic"}, + {"name": "filter", "value": "Fam00000001:EQ:Subject"}, + {"name": "filter", "value": "Dob00000001:EQ:2000-02-29"} + ], + "body": null +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai b/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai new file mode 100644 index 000000000..e068e1ec9 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai @@ -0,0 +1,82 @@ +// Extraction runs with fresh state and receives no selectors or request parts. + +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("pager") || + !source_response.contains("trackedEntities") { + throw("source_protocol_error"); + } + + let pager = source_response["pager"]; + let records = source_response["trackedEntities"]; + if len(pager) != 4 || + !pager.contains("page") || + !pager.contains("pageSize") || + !pager.contains("total") || + !pager.contains("pageCount") || + type_of(pager["page"]) != "i64" || + type_of(pager["pageSize"]) != "i64" || + type_of(pager["total"]) != "i64" || + type_of(pager["pageCount"]) != "i64" || + pager["page"] != 1 || + pager["pageSize"] != 2 || + pager["total"] < 0 || + pager["pageCount"] < 0 || + records.len > 2 { + throw("source_protocol_error"); + } + + let expected_page_count = + (pager["total"] + pager["pageSize"] - 1) / pager["pageSize"]; + if pager["pageCount"] != expected_page_count { + throw("source_protocol_error"); + } + + if pager["total"] == 0 { + if records.len != 0 { + throw("source_protocol_error"); + } + return #{outcome: "no_match"}; + } + + if pager["total"] > 1 { + if records.len != 2 { + throw("source_protocol_error"); + } + return #{outcome: "ambiguous"}; + } + + if pager["total"] != 1 || records.len != 1 { + throw("source_protocol_error"); + } + + let record = records[0]; + if len(record) != 2 || + !record.contains("trackedEntity") || + !record.contains("attributes") { + throw("source_protocol_error"); + } + + let found_status = false; + let status_code = (); + for attribute in record["attributes"] { + if len(attribute) != 2 || + !attribute.contains("attribute") || + !attribute.contains("value") { + throw("source_protocol_error"); + } + if attribute["attribute"] == parameters["statusAttribute"] { + if found_status { + throw("source_protocol_error"); + } + found_status = true; + status_code = attribute["value"]; + } + } + + if !found_status { + throw("source_protocol_error"); + } + + #{outcome: "match", facts: #{status_code: status_code}} +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/facts.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/facts.schema.yaml new file mode 100644 index 000000000..a46e863f8 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/facts.schema.yaml @@ -0,0 +1,8 @@ +type: object +additionalProperties: false +required: [status_code] +properties: + status_code: + type: string + minLength: 1 + maxLength: 64 diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/parameters.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/parameters.schema.yaml new file mode 100644 index 000000000..fa6c23c89 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/parameters.schema.yaml @@ -0,0 +1,27 @@ +type: object +additionalProperties: false +required: + - program + - organisationUnit + - projection + - pageSize + - page + - totalPages + - attributes + - statusAttribute +properties: + program: {type: string, minLength: 1, maxLength: 64} + organisationUnit: {type: string, minLength: 1, maxLength: 64} + projection: {type: string, minLength: 1, maxLength: 1024} + pageSize: {const: "2"} + page: {const: "1"} + totalPages: {const: "true"} + attributes: + type: object + additionalProperties: false + required: [given_name, family_name, birth_date] + properties: + given_name: {type: string, minLength: 1, maxLength: 64} + family_name: {type: string, minLength: 1, maxLength: 64} + birth_date: {type: string, minLength: 1, maxLength: 64} + statusAttribute: {type: string, minLength: 1, maxLength: 64} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/prepare-input.json b/products/evidence/reference/request-adapter/dhis2-tracker/prepare-input.json new file mode 100644 index 000000000..80f7845c7 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/prepare-input.json @@ -0,0 +1,26 @@ +{ + "selectors": { + "subject": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Synthetic", + "family_name": "Subject", + "birth_date": "2000-02-29" + } + } + }, + "parameters": { + "program": "Prg00000001", + "organisationUnit": "Org00000001", + "projection": "trackedEntity,attributes[attribute,value]", + "pageSize": "2", + "page": "1", + "totalPages": "true", + "attributes": { + "given_name": "Gvn00000001", + "family_name": "Fam00000001", + "birth_date": "Dob00000001" + }, + "statusAttribute": "Sta00000001" + } +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/prepare.rhai b/products/evidence/reference/request-adapter/dhis2-tracker/prepare.rhai new file mode 100644 index 000000000..bdeb02f9f --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/prepare.rhai @@ -0,0 +1,59 @@ +// Exploratory trusted request preparation. Rust supplies only authorized +// selectors and non-secret parameters from the immutable bundle. + +fn dhis2_filter_value(value) { + // DHIS2 uses slash escaping inside filter expressions. Rust performs URL + // encoding on the complete raw query value after preparation. + let escaped = value; + escaped.replace("/", "//"); + escaped.replace(":", "/:"); + escaped.replace(",", "/,"); + escaped +} + +fn exact_filter(attribute, value) { + attribute + ":EQ:" + dhis2_filter_value(value) +} + +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + let query = [ + #{name: "program", value: parameters["program"]}, + #{name: "orgUnits", value: parameters["organisationUnit"]}, + #{name: "fields", value: parameters["projection"]}, + #{name: "pageSize", value: parameters["pageSize"]}, + #{name: "page", value: parameters["page"]}, + #{name: "totalPages", value: parameters["totalPages"]} + ]; + + let values = subject["values"]; + if subject["profile"] == "tracked-entity-id-v1" { + query.push(#{name: "trackedEntities", value: values["record_reference"]}); + } else if subject["profile"] == "person-demographics-v1" { + query.push(#{ + name: "filter", + value: exact_filter( + parameters["attributes"]["given_name"], + values["given_name"] + ) + }); + query.push(#{ + name: "filter", + value: exact_filter( + parameters["attributes"]["family_name"], + values["family_name"] + ) + }); + query.push(#{ + name: "filter", + value: exact_filter( + parameters["attributes"]["birth_date"], + values["birth_date"] + ) + }); + } else { + throw("adapter_input_error"); + } + + #{query: query, body: ()} +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response-malformed-count.json b/products/evidence/reference/request-adapter/dhis2-tracker/response-malformed-count.json new file mode 100644 index 000000000..f6bf469e4 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response-malformed-count.json @@ -0,0 +1,16 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 0.5, + "pageCount": 1 + }, + "trackedEntities": [ + { + "trackedEntity": "Tes00000002", + "attributes": [ + {"attribute": "Sta00000001", "value": "SYNTHETIC-ACTIVE"} + ] + } + ] +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response-match.json b/products/evidence/reference/request-adapter/dhis2-tracker/response-match.json new file mode 100644 index 000000000..5d4991952 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response-match.json @@ -0,0 +1,16 @@ +{ + "pager": { + "page": 1, + "pageSize": 2, + "total": 1, + "pageCount": 1 + }, + "trackedEntities": [ + { + "trackedEntity": "Tes00000001", + "attributes": [ + {"attribute": "Sta00000001", "value": "SYNTHETIC-ACTIVE"} + ] + } + ] +} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml new file mode 100644 index 000000000..787e09750 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml @@ -0,0 +1,51 @@ +# Focused executable DHIS2 adapter fragment. For a deployable governed bundle +# and runtime binding, use deployment-projects/dhis2-adult-status/. +transport: http-json +baseUrl: https://tracker.example.invalid +posture: record-transformed +authentication: + kind: basic + usernameRef: secret:file/tracker-username + passwordRef: secret:file/tracker-password +request: + method: GET + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-id-v1, fields: [record_reference]} + - {profile: person-demographics-v1, fields: [given_name, family_name, birth_date]} + prepareScript: prepare.rhai + adapterParameters: + program: Prg00000001 + organisationUnit: Org00000001 + projection: trackedEntity,attributes[attribute,value] + pageSize: "2" + page: "1" + totalPages: "true" + attributes: + given_name: Gvn00000001 + family_name: Fam00000001 + birth_date: Dob00000001 + statusAttribute: Sta00000001 + adapterParametersSchema: parameters.schema.yaml + preparationLimits: + query: allowed + jsonBody: forbidden + maximumQueryPairs: 16 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 8192 + projection: + - /pager/total + - /trackedEntities/*/trackedEntity + - /trackedEntities/*/attributes/*/attribute + - /trackedEntities/*/attributes/*/value + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 4 +extractScript: extract.rhai +factSchema: facts.schema.yaml diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/expected-lookup-result.json b/products/evidence/reference/request-adapter/opencrvs-event-search/expected-lookup-result.json new file mode 100644 index 000000000..c5c071523 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/expected-lookup-result.json @@ -0,0 +1,10 @@ +{ + "outcome": "match", + "facts": { + "record_tracking_id": "EVT-SYNTHETIC-001", + "parent_references": ["PERSON-SYNTHETIC-A", "PERSON-SYNTHETIC-B"], + "reference_namespace": "urn:gov:example:opencrvs:person", + "relationship_set_contract": "urn:gov:example:opencrvs:registered-parent-set:v1", + "relationship_set_complete": true + } +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/expected-request-parts.json b/products/evidence/reference/request-adapter/opencrvs-event-search/expected-request-parts.json new file mode 100644 index 000000000..cf9b78ba7 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/expected-request-parts.json @@ -0,0 +1,17 @@ +{ + "query": [], + "body": { + "query": { + "type": "and", + "clauses": [ + { + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": {"type": "exact", "term": "EVT-SYNTHETIC-001"} + } + ] + }, + "limit": 2, + "offset": 0 + } +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai new file mode 100644 index 000000000..5c2332d92 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai @@ -0,0 +1,66 @@ +// Extraction validates one exact child-event result and returns only the +// configured complete parent-reference set. Relationship comparison belongs +// to the selector-aware requirement derivation. + +fn extract(source_response, parameters) { + if len(source_response) != 2 || + !source_response.contains("total") || + !source_response.contains("results") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 || + type_of(source_response["results"]) != "array" || + source_response["results"].len > parameters["resultLimit"] { + throw("source_protocol_error"); + } + + let total = source_response["total"]; + let results = source_response["results"]; + if total == 0 { + if results.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if results.len < 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if results.len != 1 { throw("source_protocol_error"); } + + let result = results[0]; + if !result.contains("type") || + !result.contains("status") || + result["type"] != parameters["eventType"] || + result["status"] != parameters["registeredStatus"] || + !result.contains("trackingId") || + type_of(result["trackingId"]) != "string" || + !result.contains("declaration") || + type_of(result["declaration"]) != "map" { + throw("source_protocol_error"); + } + + let parent_references = []; + for field in parameters["parentReferenceFields"] { + if result["declaration"].contains(field) { + let reference = result["declaration"][field]; + if type_of(reference) != "string" || reference == "" || + list_contains(parent_references, reference) { + throw("source_protocol_error"); + } + parent_references.push(reference); + } + } + if parent_references.len < parameters["minimumParentReferences"] || + parent_references.len > parameters["maximumParentReferences"] { + throw("source_protocol_error"); + } + + #{ + outcome: "match", + facts: #{ + record_tracking_id: result["trackingId"], + parent_references: parent_references, + reference_namespace: parameters["parentReferenceNamespace"], + relationship_set_contract: parameters["relationshipSetContract"], + relationship_set_complete: true + } + } +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/facts.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/facts.schema.yaml new file mode 100644 index 000000000..a6771e182 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/facts.schema.yaml @@ -0,0 +1,19 @@ +type: object +additionalProperties: false +required: + - record_tracking_id + - parent_references + - reference_namespace + - relationship_set_contract + - relationship_set_complete +properties: + record_tracking_id: {type: string, minLength: 1, maxLength: 64} + parent_references: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {const: urn:gov:example:opencrvs:person} + relationship_set_contract: {const: urn:gov:example:opencrvs:registered-parent-set:v1} + relationship_set_complete: {const: true} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/parameters.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/parameters.schema.yaml new file mode 100644 index 000000000..9fea49e97 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/parameters.schema.yaml @@ -0,0 +1,31 @@ +type: object +additionalProperties: false +required: + - eventType + - registeredStatus + - resultLimit + - resultOffset + - parentReferenceFields + - minimumParentReferences + - maximumParentReferences + - parentReferenceNamespace + - relationshipSetContract + - relationshipSetComplete + - absentConfiguredFieldMeansNoRegisteredParent +properties: + eventType: {const: birth} + registeredStatus: {const: REGISTERED} + resultLimit: {const: 2} + resultOffset: {const: 0} + parentReferenceFields: + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + minimumParentReferences: {const: 1} + maximumParentReferences: {const: 2} + parentReferenceNamespace: {const: urn:gov:example:opencrvs:person} + relationshipSetContract: {const: urn:gov:example:opencrvs:registered-parent-set:v1} + relationshipSetComplete: {const: true} + absentConfiguredFieldMeansNoRegisteredParent: {const: true} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/prepare-input.json b/products/evidence/reference/request-adapter/opencrvs-event-search/prepare-input.json new file mode 100644 index 000000000..0fd4800e1 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/prepare-input.json @@ -0,0 +1,24 @@ +{ + "selectors": { + "child": { + "profile": "opencrvs-tracking-id-v1", + "values": {"tracking_id": "EVT-SYNTHETIC-001"} + } + }, + "parameters": { + "eventType": "birth", + "registeredStatus": "REGISTERED", + "resultLimit": 2, + "resultOffset": 0, + "parentReferenceFields": [ + "mother.personReference", + "father.personReference" + ], + "minimumParentReferences": 1, + "maximumParentReferences": 2, + "parentReferenceNamespace": "urn:gov:example:opencrvs:person", + "relationshipSetContract": "urn:gov:example:opencrvs:registered-parent-set:v1", + "relationshipSetComplete": true, + "absentConfiguredFieldMeansNoRegisteredParent": true + } +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/prepare.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/prepare.rhai new file mode 100644 index 000000000..b27b08f7c --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/prepare.rhai @@ -0,0 +1,21 @@ +// Exploratory exact OpenCRVS child-event request preparation. It has no +// credentials, transport authority, or network access. + +fn prepare(selectors, parameters) { + let child = selectors["child"]; + #{ + query: [], + body: #{ + query: #{ + type: "and", + clauses: [#{ + eventType: parameters["eventType"], + status: #{type: "exact", term: parameters["registeredStatus"]}, + trackingId: #{type: "exact", term: child["values"]["tracking_id"]} + }] + }, + limit: parameters["resultLimit"], + offset: parameters["resultOffset"] + } + } +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response-malformed-count.json b/products/evidence/reference/request-adapter/opencrvs-event-search/response-malformed-count.json new file mode 100644 index 000000000..013dd570a --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response-malformed-count.json @@ -0,0 +1,10 @@ +{ + "results": [ + { + "id": "00000000-0000-4000-8000-000000000002", + "type": "birth", + "status": "REGISTERED" + } + ], + "total": 0.5 +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response-match.json b/products/evidence/reference/request-adapter/opencrvs-event-search/response-match.json new file mode 100644 index 000000000..1ae9c7a05 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response-match.json @@ -0,0 +1,17 @@ +{ + "results": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "trackingId": "EVT-SYNTHETIC-001", + "type": "birth", + "status": "REGISTERED", + "dateOfEvent": "2015-04-03", + "declaration": { + "mother.personReference": "PERSON-SYNTHETIC-A", + "father.personReference": "PERSON-SYNTHETIC-B", + "unrelated.field": "SOURCE-UNRELATED-CANARY" + } + } + ], + "total": 1 +} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml new file mode 100644 index 000000000..2f4c1d4e0 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml @@ -0,0 +1,59 @@ +# Focused executable OpenCRVS adapter fragment. For a deployable governed +# bundle and runtime binding, use deployment-projects/opencrvs-family-evidence/. +transport: http-json +baseUrl: https://events.opencrvs.example.invalid +posture: record-transformed +authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://auth.opencrvs.example.invalid/token + clientIdRef: secret:file/events-client-id + clientSecretRef: secret:file/events-client-secret + scope: recordsearch + # OpenCRVS token-bootstrap compatibility only. The evidence-data request + # still uses Authorization: Bearer. See the documented URL-log risk. + credentialPlacement: query-string + maximumCacheSeconds: 300 +request: + method: POST + path: /events/search + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: child + alternatives: + - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + prepareScript: prepare.rhai + adapterParameters: + eventType: birth + registeredStatus: REGISTERED + resultLimit: 2 + resultOffset: 0 + parentReferenceFields: [mother.personReference, father.personReference] + minimumParentReferences: 1 + maximumParentReferences: 2 + parentReferenceNamespace: urn:gov:example:opencrvs:person + relationshipSetContract: urn:gov:example:opencrvs:registered-parent-set:v1 + relationshipSetComplete: true + absentConfiguredFieldMeansNoRegisteredParent: true + adapterParametersSchema: parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 16 + maximumCollectionItems: 128 + maximumStringBytes: 1024 + maximumNormalizedBytes: 16384 + # Client-side allowlist, not provider-side EventIndex field selection. + projection: + - /total + - /results/*/type + - /results/*/status + - /results/*/trackingId + - /results/*/declaration/mother.personReference + - /results/*/declaration/father.personReference + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 262144 + concurrencyLimit: 4 +extractScript: extract.rhai +factSchema: facts.schema.yaml diff --git a/products/evidence/scripts/check-contracts.sh b/products/evidence/scripts/check-contracts.sh new file mode 100755 index 000000000..c36d833fb --- /dev/null +++ b/products/evidence/scripts/check-contracts.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +committed_root="$repository_root/products/evidence/generated" +temporary_root=$(mktemp -d) +trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM +generated_root="$temporary_root/generated" + +if [ ! -d "$committed_root" ]; then + echo "Evidence generated contract directory is missing: $committed_root" >&2 + exit 1 +fi + +cd "$repository_root" +CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked --quiet -p registry-evidence --test security_contract_traceability +CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + cargo run --locked --quiet -p registry-evidence --example evidence-contracts -- \ + --output "$generated_root" + +if ! diff -ru "$committed_root" "$generated_root"; then + echo 'Evidence generated contracts differ from the committed artifacts.' >&2 + echo 'Regenerate into a separate directory and review the complete contract diff.' >&2 + exit 1 +fi + +echo 'Evidence generated contracts reproduce exactly.' diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh new file mode 100755 index 000000000..8f5efdf23 --- /dev/null +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -0,0 +1,149 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +temporary_root=$(mktemp -d) +trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM +production_text="$temporary_root/production-rust.txt" + +: >"$production_text" +for source_file in $(rg --files "$repository_root/crates/registry-evidence/src" -g '*.rs' | sort); do + case "$source_file" in + *_tests.rs) continue ;; + esac + python3 - "$source_file" >>"$production_text" <<'PY' +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +masked = list(source) +i = 0 +block_depth = 0 +while i < len(source): + if block_depth: + if source.startswith("/*", i): + masked[i:i + 2] = " " + block_depth += 1 + i += 2 + elif source.startswith("*/", i): + masked[i:i + 2] = " " + block_depth -= 1 + i += 2 + else: + if source[i] != "\n": + masked[i] = " " + i += 1 + continue + if source.startswith("//", i): + end = source.find("\n", i) + end = len(source) if end < 0 else end + masked[i:end] = " " * (end - i) + i = end + continue + if source.startswith("/*", i): + masked[i:i + 2] = " " + block_depth = 1 + i += 2 + continue + raw = re.match(r'(?:b?r)(?P#{0,255})"', source[i:]) + if raw: + marker = '"' + raw.group("hashes") + start = i + i += raw.end() + end = source.find(marker, i) + i = len(source) if end < 0 else end + len(marker) + for position in range(start, i): + if source[position] != "\n": + masked[position] = " " + continue + prefix = 1 if source.startswith('"', i) else 2 if source.startswith('b"', i) else 0 + if prefix: + start = i + i += prefix + while i < len(source): + if source[i] == "\\": + i += 2 + elif source[i] == '"': + i += 1 + break + else: + i += 1 + for position in range(start, min(i, len(source))): + if source[position] != "\n": + masked[position] = " " + continue + char_prefix = 2 if source.startswith("b'", i) else 1 if source.startswith("'", i) else 0 + if char_prefix: + start = i + cursor = i + char_prefix + if cursor < len(source) and source[cursor] == "\\": + cursor += 2 + else: + cursor += 1 + if cursor < len(source) and source[cursor] == "'": + i = cursor + 1 + for position in range(start, i): + if source[position] != "\n": + masked[position] = " " + continue + i += 1 + +code = "".join(masked) +spans = [] +for match in re.finditer(r'#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]', code): + start = match.start() + cursor = match.end() + opener = None + while cursor < len(code): + if code[cursor] in "{;": + opener = code[cursor] + break + cursor += 1 + if opener is None or opener == ";": + end = min(cursor + 1, len(code)) + else: + depth = 0 + while cursor < len(code): + if code[cursor] == "{": + depth += 1 + elif code[cursor] == "}": + depth -= 1 + if depth == 0: + cursor += 1 + break + cursor += 1 + end = cursor + spans.append((start, end)) + +cursor = 0 +for start, end in spans: + if start < cursor: + continue + sys.stdout.write(source[cursor:start]) + cursor = end +sys.stdout.write(source[cursor:]) +PY +done + +if rg -n -i 'dhis2|opencrvs' \ + "$production_text" \ + "$repository_root/crates/registry-evidence/Cargo.toml" \ + "$repository_root/Cargo.toml"; then + echo 'Evidence production code or Cargo metadata contains a prohibited source-product name.' >&2 + exit 1 +fi + +if rg -n -i 'adult|age[_ -]?at|residence|licen[cs]e|parentage|legal[_ -]?parent|given_name|family_name|birth_date|national[_ -]?identifier' \ + "$production_text"; then + echo 'Evidence production Rust contains acceptance-case or jurisdiction-specific vocabulary.' >&2 + exit 1 +fi + +generated_root="$repository_root/products/evidence/generated" +contract_root="$repository_root/products/evidence/contracts" +if rg -n -i 'dhis2|opencrvs' "$generated_root" "$contract_root"; then + echo 'Evidence public configuration or generated contracts contain a prohibited source-product name.' >&2 + exit 1 +fi + +echo 'Evidence source-product and domain neutrality checks passed.' From ed7ccec379deb0b413641ccafb0aebed8e0c086d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 17:21:53 +0700 Subject: [PATCH 002/136] feat(evidence): add requester-scoped discovery Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/contracts.rs | 252 +++++++- crates/registry-evidence/src/lib.rs | 1 + crates/registry-evidence/src/model.rs | 94 +++ crates/registry-evidence/src/runtime.rs | 247 ++++++- crates/registry-evidence/src/runtime_tests.rs | 297 ++++++++- crates/registry-evidence/src/selector.rs | 51 ++ crates/registry-evidence/src/server.rs | 58 +- .../contracts/definitions.schema.yaml | 141 ++++ .../contracts/security-invariant-matrix.yaml | 5 + .../contracts/security-test-traceability.yaml | 7 + .../evidence-definitions-v1.schema.json | 322 ++++++++++ .../generated/registry-evidence.openapi.json | 607 ++++++++++++++++++ 12 files changed, 2064 insertions(+), 18 deletions(-) create mode 100644 products/evidence/contracts/definitions.schema.yaml create mode 100644 products/evidence/generated/evidence-definitions-v1.schema.json diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index dc92569e7..105ebc397 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -15,11 +15,14 @@ use schemars::JsonSchema; use serde_json::{json, Value}; use thiserror::Error; -use crate::model::{Evidence, EvidenceRequest, FlattenedJws, JwksDocument, ProblemBody}; +use crate::model::{ + Evidence, EvidenceDefinitions, EvidenceRequest, FlattenedJws, JwksDocument, ProblemBody, +}; pub const OPENAPI_FILE: &str = "registry-evidence.openapi.json"; pub const REQUEST_SCHEMA_FILE: &str = "evidence-request-v1.schema.json"; pub const EVIDENCE_SCHEMA_FILE: &str = "evidence-v1.schema.json"; +pub const DEFINITIONS_SCHEMA_FILE: &str = "evidence-definitions-v1.schema.json"; pub const JWS_SCHEMA_FILE: &str = "flattened-jws-v1.schema.json"; pub const PROBLEM_SCHEMA_FILE: &str = "problem-v1.schema.json"; pub const JWKS_SCHEMA_FILE: &str = "jwks-v1.schema.json"; @@ -28,6 +31,8 @@ const SCHEMA_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema"; const REQUEST_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/request-v1.json"; const EVIDENCE_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json"; +const DEFINITIONS_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/definitions-v1.json"; const JWS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/flattened-jws-v1.json"; const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; @@ -56,6 +61,8 @@ const PROBLEM_VARIANTS: [(&str, u16, &str); 8] = [ static REQUEST_VALIDATOR: OnceLock> = OnceLock::new(); static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); +static DEFINITIONS_VALIDATOR: OnceLock> = + OnceLock::new(); #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] #[error("built-in public contract schema failed to initialize")] @@ -79,19 +86,22 @@ pub enum ContractGenerationError { pub fn documents() -> Result, ContractGenerationError> { let request = request_schema(); let evidence = evidence_schema(); + let definitions = definitions_schema(); let jws = jws_schema(); let problem = problem_schema(); let jwks = jwks_schema(); assert_model_shape::("EvidenceRequest", &request, true)?; assert_model_shape::("Evidence", &evidence, true)?; + assert_model_shape::("EvidenceDefinitions", &definitions, true)?; assert_model_shape::("FlattenedJws", &jws, false)?; assert_model_shape::("ProblemBody", &problem, false)?; assert_model_shape::("JwksDocument", &jwks, false)?; - let openapi = openapi_document(&request, &evidence, &jws, &problem, &jwks); + let openapi = openapi_document(&request, &evidence, &definitions, &jws, &problem, &jwks); let values = [ (REQUEST_SCHEMA_FILE, request), (EVIDENCE_SCHEMA_FILE, evidence), + (DEFINITIONS_SCHEMA_FILE, definitions), (JWS_SCHEMA_FILE, jws), (PROBLEM_SCHEMA_FILE, problem), (JWKS_SCHEMA_FILE, jwks), @@ -129,6 +139,13 @@ pub(crate) fn evidence_contract_accepts(value: &Value) -> Result Result { + contract_validator(&DEFINITIONS_VALIDATOR, definitions_schema) + .map(|validator| validator.is_valid(value)) +} + fn contract_validator( cell: &'static OnceLock>, schema: fn() -> Value, @@ -252,6 +269,142 @@ fn request_schema() -> Value { }) } +fn definitions_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": DEFINITIONS_SCHEMA_ID, + "title": "Requester-scoped Evidence definitions Version 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", "configurationRevision", "issuedBy", "providedBy", "definitions" + ], + "properties": { + "schema": {"const": "registry.evidence-definitions/v1"}, + "configurationRevision": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, + "issuedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "providedBy": {"type": "string", "format": "uri", "maxLength": 512}, + "definitions": { + "type": "array", "maxItems": 16384, "uniqueItems": true, + "items": {"$ref": "#/$defs/definition"} + } + }, + "$defs": { + "definition": { + "type": "object", "additionalProperties": false, + "required": [ + "requirement", "kind", "evidenceType", "purpose", + "referenceFrameworks", "subjects", "concepts" + ], + "properties": { + "requirement": {"type": "string", "format": "uri", "maxLength": 512}, + "kind": {"enum": ["criterion", "information-requirement", "constraint"]}, + "evidenceType": {"type": "string", "format": "uri", "maxLength": 512}, + "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, + "referenceFrameworks": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"type": "string", "format": "uri", "maxLength": 512} + }, + "subjects": { + "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, + "items": {"$ref": "#/$defs/subject"} + }, + "concepts": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"$ref": "#/$defs/concept"} + } + } + }, + "subject": { + "type": "object", "additionalProperties": false, + "required": ["role", "cardinality", "selector"], + "properties": { + "role": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "cardinality": {"const": "one"}, + "selector": {"$ref": "#/$defs/selector"} + } + }, + "selector": { + "type": "object", "additionalProperties": false, + "required": ["profile", "valueOrigin", "fields"], + "properties": { + "profile": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,127}$"}, + "valueOrigin": {"enum": ["request", "authenticated-context", "authenticated-grant"]}, + "fields": { + "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, + "items": {"$ref": "#/$defs/selector-field"} + } + } + }, + "selector-field": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "minimumBytes", "maximumBytes"], + "properties": { + "type": {"const": "string"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "minimumBytes": {"type": "integer", "minimum": 1, "maximum": 8192}, + "maximumBytes": {"type": "integer", "minimum": 1, "maximum": 8192} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": {"const": "date"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "minimum", "maximum"], + "properties": { + "type": {"const": "integer"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "minimum": {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, + "maximum": {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name"], + "properties": { + "type": {"const": "boolean"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["type", "name", "scheme", "version", "maximumBytes"], + "properties": { + "type": {"const": "controlled-code"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$"}, + "scheme": {"type": "string", "format": "uri", "maxLength": 512}, + "version": {"type": "string", "minLength": 1, "maxLength": 128}, + "maximumBytes": {"type": "integer", "minimum": 1, "maximum": 8192} + } + } + ] + }, + "concept": { + "type": "object", "additionalProperties": false, + "required": ["id", "form"], + "properties": { + "id": {"type": "string", "format": "uri", "maxLength": 512}, + "form": {"enum": [ + "boolean", "controlled-code", "controlled-category", "bounded-integer", + "bounded-decimal", "date-bucket", "time-bucket", + "audience-scoped-entity-reference", "controlled-code-list", + "entity-reference-list", "reviewed-structured-value" + ]} + } + } + }, + "$comment": "The authenticated response contains only complete request shapes that match exactly one configured authority path. It never exposes source plans, scripts, credentials, requester tags, authority-profile identifiers, selector values, codelist values, or unrelated definitions." + }) +} + fn evidence_schema() -> Value { json!({ "$schema": SCHEMA_DIALECT, @@ -603,6 +756,7 @@ fn response_headers(extra: Option<(&str, Value)>) -> Value { fn openapi_document( request: &Value, evidence: &Value, + definitions: &Value, jws: &Value, problem: &Value, jwks: &Value, @@ -631,6 +785,18 @@ fn openapi_document( ("structured", "StructuredValue"), ], ); + insert_schema_family( + &mut schemas, + "EvidenceDefinitions", + definitions, + &[ + ("definition", "EvidenceDefinition"), + ("subject", "EvidenceDefinitionSubject"), + ("selector", "EvidenceDefinitionSelector"), + ("selector-field", "EvidenceSelectorField"), + ("concept", "EvidenceDefinitionConcept"), + ], + ); insert_schema_family(&mut schemas, "FlattenedJws", jws, &[]); if let Some(properties) = schemas .get_mut("FlattenedJws") @@ -741,6 +907,44 @@ fn openapi_document( } } }, + "/v1/evidence-definitions": { + "get": { + "operationId": "listEvidenceDefinitions", + "summary": "List the complete Evidence request shapes available to the authenticated caller", + "security": [{"bearerAuth": []}], + "responses": { + "200": { + "description": "Requester-scoped Evidence definitions", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/EvidenceDefinitions"}}} + }, + "400": { + "description": "Malformed discovery request", + "headers": response_headers(None), + "content": problem_content(&["malformed_request"]) + }, + "401": { + "description": "Authentication failed", + "headers": response_headers(Some(("WWW-Authenticate", json!({ + "schema": {"type": "string", "enum": ["Bearer"]} + })))), + "content": problem_content(&["authentication_failed"]) + }, + "429": { + "description": "Request rate exceeded", + "headers": response_headers(Some(("Retry-After", json!({ + "schema": {"type": "string", "enum": ["1"]} + })))), + "content": problem_content(&["rate_limited"]) + }, + "503": { + "description": "Service temporarily unavailable", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, "/health": { "get": { "operationId": "getHealth", @@ -805,6 +1009,7 @@ mod tests { for schema in [ request_schema(), evidence_schema(), + definitions_schema(), jws_schema(), problem_schema(), jwks_schema(), @@ -822,6 +1027,7 @@ mod tests { let document = openapi_document( &request_schema(), &evidence_schema(), + &definitions_schema(), &jws_schema(), &problem_schema(), &jwks_schema(), @@ -840,10 +1046,11 @@ mod tests { } #[test] - fn openapi_has_only_the_four_version_one_routes_and_exact_success_media() { + fn openapi_has_only_the_five_version_one_routes_and_exact_success_media() { let document = openapi_document( &request_schema(), &evidence_schema(), + &definitions_schema(), &jws_schema(), &problem_schema(), &jwks_schema(), @@ -855,7 +1062,8 @@ mod tests { "/.well-known/evidence/jwks.json", "/health", "/ready", - "/v1/evidence" + "/v1/evidence", + "/v1/evidence-definitions" ] ); assert!( @@ -863,6 +1071,11 @@ mod tests { ["application/jose+json"] .is_object() ); + assert!( + document["paths"]["/v1/evidence-definitions"]["get"]["responses"]["200"]["content"] + ["application/json"] + .is_object() + ); assert!( document["paths"]["/.well-known/evidence/jwks.json"]["get"]["responses"]["200"] ["content"]["application/jwk-set+json"] @@ -962,6 +1175,37 @@ mod tests { }] }), ), + ( + definitions_schema(), + json!({ + "schema": "registry.evidence-definitions/v1", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "definitions": [{ + "requirement": "urn:example:requirement:v1", + "kind": "criterion", + "evidenceType": "urn:example:evidence-type:v1", + "purpose": "casework", + "referenceFrameworks": ["urn:example:framework:v1"], + "subjects": [{ + "role": "subject", + "cardinality": "one", + "selector": { + "profile": "person-v1", + "valueOrigin": "request", + "fields": [{ + "type": "string", + "name": "record_reference", + "minimumBytes": 1, + "maximumBytes": 96 + }] + } + }], + "concepts": [{"id": "urn:example:concept", "form": "boolean"}] + }] + }), + ), ( jws_schema(), json!({ diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs index a14746316..1710c6f7d 100644 --- a/crates/registry-evidence/src/lib.rs +++ b/crates/registry-evidence/src/lib.rs @@ -27,6 +27,7 @@ pub mod verifier; mod runtime_tests; pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; +pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/v1"; pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index b3c850ac8..1231160fe 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -15,6 +15,86 @@ pub struct EvidenceRequest { pub subjects: Vec, } +/// Requester-scoped descriptions of the exact Evidence request shapes that +/// the authenticated caller can currently invoke. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitions { + pub schema: String, + pub configuration_revision: String, + pub issued_by: String, + pub provided_by: String, + pub definitions: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinition { + pub requirement: String, + pub kind: String, + pub evidence_type: String, + pub purpose: String, + pub reference_frameworks: Vec, + pub subjects: Vec, + pub concepts: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct EvidenceDefinitionSubject { + pub role: String, + pub cardinality: String, + pub selector: EvidenceDefinitionSelector, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitionSelector { + pub profile: String, + pub value_origin: String, + pub fields: Vec, +} + +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceDefinitionConcept { + pub id: String, + pub form: String, +} + +/// Public validation metadata for a selector field. Controlled-code +/// definitions expose their governed scheme identity, never the bundle path or +/// the configured list of supported values. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum EvidenceSelectorField { + String { + name: String, + #[serde(rename = "minimumBytes")] + minimum_bytes: u64, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, + Date { + name: String, + }, + Integer { + name: String, + minimum: i64, + maximum: i64, + }, + Boolean { + name: String, + }, + ControlledCode { + name: String, + scheme: String, + version: String, + #[serde(rename = "maximumBytes")] + maximum_bytes: u64, + }, +} + #[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] #[serde(deny_unknown_fields)] pub struct RequestedSubject { @@ -262,6 +342,12 @@ macro_rules! redacted_debug { redacted_debug!( EvidenceRequest, + EvidenceDefinitions, + EvidenceDefinition, + EvidenceDefinitionSubject, + EvidenceDefinitionSelector, + EvidenceDefinitionConcept, + EvidenceSelectorField, RequestedSubject, RequestedSelector, SelectorValue, @@ -406,9 +492,17 @@ mod tests { payload: "protected-payload-canary".to_owned(), signature: "protected-signature-canary".to_owned(), }; + let definitions = EvidenceDefinitions { + schema: "protected-discovery-schema-canary".to_owned(), + configuration_revision: "protected-discovery-revision-canary".to_owned(), + issued_by: "protected-discovery-issuer-canary".to_owned(), + provided_by: "protected-discovery-provider-canary".to_owned(), + definitions: Vec::new(), + }; for diagnostic in [ format!("{request:?}"), + format!("{definitions:?}"), format!( "{:?}", SelectorValue::String("protected-selector-canary".to_owned()) diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 0514c47b1..ffba1a751 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -1,6 +1,12 @@ //! Complete authenticated Evidence evaluation and fail-closed release pipeline. -use std::{collections::BTreeMap, path::Path, str, sync::Arc, time::Instant}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + str, + sync::Arc, + time::Instant, +}; use chrono::Utc; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; @@ -14,18 +20,29 @@ use crate::{ }, auth::{AuthenticatedContext, Authenticator}, bundle::{Bundle, DeploymentInputs}, - config::{AuthorityKind, RuntimeConfig, SelectorInput}, + config::{ + AuthorityKind, ConceptForm, RequirementKind, RuntimeConfig, SelectorField, SelectorInput, + SubjectCardinality, ValueOrigin, + }, + contracts::definitions_contract_accepts, kernel::{EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection}, - model::{EvidenceRequest, FlattenedJws, JwksDocument, SelectorValue, SubjectBinding}, + model::{ + EvidenceDefinition, EvidenceDefinitionConcept, EvidenceDefinitionSelector, + EvidenceDefinitionSubject, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, + FlattenedJws, JwksDocument, RequestedSelector, RequestedSubject, SelectorValue, + SubjectBinding, + }, problem::ProblemCode, rate_limit::{EvidenceRateLimiter, RateLimitConfig, RateLimitError}, secrets::{ProtectedSecret, SecretProvider, SecretResolver}, selector::{ - match_entitlement, resolve_selectors, validate_subject_binding_key, AuthorizationError, + match_entitlement, resolve_selectors, validate_entitlement_context, + validate_subject_binding_key, AuthorizationError, MatchedEntitlement, ResolvedAuthorization, ResolvedSelectorValue, }, signing::{jwks_document, EvidenceSigner}, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, + EVIDENCE_DEFINITIONS_SCHEMA_V1, }; const MAX_OPERATION_BYTES: usize = 128; @@ -277,6 +294,190 @@ impl EvidenceRuntime { true } + /// List only the complete request shapes that the authenticated caller can + /// currently invoke. Discovery performs no source access, credential + /// resolution, signing, or evidence-data audit. + pub async fn discover( + &self, + access_token: &str, + ) -> Result { + let context = self + .authenticator + .authenticate(access_token) + .await + .map_err(|_| failure(ProblemCode::AuthenticationFailed, "authentication"))?; + let rate_scope = rate_limit_scope(&self.bundle().config.service.trust_domain); + let request_limit_key = self + .audit + .pseudonym("request-rate", &rate_scope, context.principal().as_bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.rate_limiter + .check_request(&request_limit_key) + .await + .map_err(map_request_limit)?; + + let mut candidates = BTreeSet::new(); + for (_, authority) in self.bundle().config.authority_profiles.iter() { + for grant in &authority.grants { + let mut subjects = grant + .subjects + .iter() + .map(|subject| (subject.role.clone(), subject.selector_profile.clone())) + .collect::>(); + subjects.sort(); + candidates.insert((grant.requirement.clone(), grant.purpose.clone(), subjects)); + } + } + + let mut definitions = Vec::new(); + for (requirement, purpose, subjects) in candidates { + let request = EvidenceRequest { + requirement, + purpose, + subjects: subjects + .into_iter() + .map(|(role, profile)| RequestedSubject { + role, + selector: RequestedSelector { + profile, + values: None, + }, + }) + .collect(), + }; + let Ok(matched) = match_entitlement(self.bundle(), &request, &context) else { + continue; + }; + if validate_entitlement_context(self.bundle(), &context, &matched).is_err() { + continue; + } + definitions.push(self.discovery_definition(&request, &matched)?); + } + let response = EvidenceDefinitions { + schema: EVIDENCE_DEFINITIONS_SCHEMA_V1.to_owned(), + configuration_revision: self.bundle().revision().to_owned(), + issued_by: self.bundle().config.issuer.id.clone(), + provided_by: self.bundle().config.service.provider_id.clone(), + definitions, + }; + let contract_value = serde_json::to_value(&response) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "discovery-contract"))?; + if !definitions_contract_accepts(&contract_value) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "discovery-contract"))? + { + return Err(failure( + ProblemCode::ServiceUnavailable, + "discovery-contract", + )); + } + Ok(response) + } + + fn discovery_definition( + &self, + request: &EvidenceRequest, + matched: &MatchedEntitlement, + ) -> Result { + let requirement = self + .kernel + .requirement(&request.requirement) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "discovery-requirement"))?; + let subjects = requirement + .subject_roles + .iter() + .map(|role| { + let granted = matched + .subjects() + .iter() + .find(|subject| subject.role == role.role) + .ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "discovery-subject"))?; + let profile = self + .bundle() + .config + .selector_profiles + .get(&granted.selector_profile) + .ok_or_else(|| { + failure(ProblemCode::ServiceUnavailable, "discovery-selector") + })?; + let fields = profile + .fields + .iter() + .map(|(name, field)| self.discovery_selector_field(name, field)) + .collect::, RuntimeFailure>>()?; + Ok(EvidenceDefinitionSubject { + role: role.role.clone(), + cardinality: subject_cardinality_name(role.cardinality).to_owned(), + selector: EvidenceDefinitionSelector { + profile: granted.selector_profile.clone(), + value_origin: value_origin_name(granted.value_origin).to_owned(), + fields, + }, + }) + }) + .collect::, RuntimeFailure>>()?; + let concepts = requirement + .concepts + .iter() + .map(|concept| EvidenceDefinitionConcept { + id: concept.id.clone(), + form: concept_form_name(concept.form).to_owned(), + }) + .collect(); + + Ok(EvidenceDefinition { + requirement: requirement.id.clone(), + kind: requirement_kind_name(requirement.kind).to_owned(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + reference_frameworks: requirement.reference_frameworks.clone(), + subjects, + concepts, + }) + } + + fn discovery_selector_field( + &self, + name: &str, + field: &SelectorField, + ) -> Result { + Ok(match field { + SelectorField::String { + minimum_bytes, + maximum_bytes, + } => EvidenceSelectorField::String { + name: name.to_owned(), + minimum_bytes: *minimum_bytes, + maximum_bytes: *maximum_bytes, + }, + SelectorField::Date => EvidenceSelectorField::Date { + name: name.to_owned(), + }, + SelectorField::Integer { minimum, maximum } => EvidenceSelectorField::Integer { + name: name.to_owned(), + minimum: *minimum, + maximum: *maximum, + }, + SelectorField::Boolean => EvidenceSelectorField::Boolean { + name: name.to_owned(), + }, + SelectorField::ControlledCode { + codelist, + codelist_version, + maximum_bytes, + } => { + let list = self.bundle().codelist(codelist).ok_or_else(|| { + failure(ProblemCode::ServiceUnavailable, "discovery-codelist") + })?; + EvidenceSelectorField::ControlledCode { + name: name.to_owned(), + scheme: list.id().to_owned(), + version: codelist_version.clone(), + maximum_bytes: *maximum_bytes, + } + } + }) + } + /// Run the fixed authenticated path through signing and durable release audit. pub async fn evaluate( &self, @@ -967,6 +1168,44 @@ fn map_authority_kind(kind: AuthorityKind) -> AuditAuthorityKind { } } +fn requirement_kind_name(kind: RequirementKind) -> &'static str { + match kind { + RequirementKind::Criterion => "criterion", + RequirementKind::InformationRequirement => "information-requirement", + RequirementKind::Constraint => "constraint", + } +} + +fn subject_cardinality_name(cardinality: SubjectCardinality) -> &'static str { + match cardinality { + SubjectCardinality::One => "one", + } +} + +fn value_origin_name(origin: ValueOrigin) -> &'static str { + match origin { + ValueOrigin::AuthenticatedContext => "authenticated-context", + ValueOrigin::AuthenticatedGrant => "authenticated-grant", + ValueOrigin::Request => "request", + } +} + +fn concept_form_name(form: ConceptForm) -> &'static str { + match form { + ConceptForm::Boolean => "boolean", + ConceptForm::ControlledCode => "controlled-code", + ConceptForm::ControlledCategory => "controlled-category", + ConceptForm::BoundedInteger => "bounded-integer", + ConceptForm::BoundedDecimal => "bounded-decimal", + ConceptForm::DateBucket => "date-bucket", + ConceptForm::TimeBucket => "time-bucket", + ConceptForm::AudienceScopedEntityReference => "audience-scoped-entity-reference", + ConceptForm::ControlledCodeList => "controlled-code-list", + ConceptForm::EntityReferenceList => "entity-reference-list", + ConceptForm::ReviewedStructuredValue => "reviewed-structured-value", + } +} + fn audit_scope(trust_domain: &str, purpose: &str, audience: &str) -> String { format!( "v1:{}:{trust_domain}:{}:{purpose}:{}:{audience}", diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 440d9493a..bd2cf14e3 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -32,8 +32,8 @@ use wiremock::{ use crate::{ auth::{AuthenticationClaimsConfig, Authenticator}, model::{ - Evidence, EvidenceRequest, FlattenedJws, PublicValue, RequestedSelector, RequestedSubject, - SelectorValue, + Evidence, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, FlattenedJws, + PublicValue, RequestedSelector, RequestedSubject, SelectorValue, }, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeInitializationError}, @@ -92,7 +92,7 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { mount_adult_source(&fixture.server, None).await; let request = adult_request(); - let token = access_token(None); + let token = access_token(Some(parent_grant_claims())); let state_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/evidence/.first-curl"); fs::create_dir_all(&state_root).expect("first-curl state directory is created"); @@ -102,11 +102,14 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { fs::set_permissions(&state_root, fs::Permissions::from_mode(0o700)) .expect("first-curl state directory is owner-only"); } + let definitions_path = state_root.join("definitions.json"); let response_path = state_root.join("response.json"); - match fs::remove_file(&response_path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => panic!("stale first-curl response could not be removed: {error}"), + for stale in [&definitions_path, &response_path] { + match fs::remove_file(stale) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("stale first-curl output could not be removed: {error}"), + } } let listener = tokio::net::TcpListener::bind("127.0.0.1:18080") @@ -139,6 +142,42 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { .await }); + let definitions = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&definitions_path) { + if let Ok(definitions) = serde_json::from_slice::(&bytes) { + break definitions; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence discovery returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("curl discovery response arrives within three minutes"); + assert_eq!(definitions.definitions.len(), 4); + assert_eq!( + definitions.configuration_revision, + fixture.runtime.bundle().revision() + ); + let serialized_definitions = + serde_json::to_string(&definitions).expect("discovery response serializes"); + for prohibited in [ + "fixture-agency", + "statutory-caseworker-v1", + "source-a", + "adapters/", + "derivations/", + "codelists/", + "secret:", + ] { + assert!(!serialized_definitions.contains(prohibited)); + } + let serialized = tokio::time::timeout(Duration::from_secs(180), async { loop { if let Ok(bytes) = fs::read(&response_path) { @@ -179,7 +218,7 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { let audit = fs::read_to_string(&fixture.audit_path).expect("first-curl audit is readable"); assert_eq!(audit.lines().count(), 2); println!( - "PASS: Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." + "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." ); } @@ -258,9 +297,86 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda *fixture.runtime.jwks() ); - mount_success_sources(&fixture.server, false).await; let standard_token = access_token(None); let parent_token = access_token(Some(parent_grant_claims())); + let standard_discovery = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {standard_token}")) + .await; + standard_discovery.assert_status_ok(); + assert_eq!( + standard_discovery.header("content-type"), + "application/json" + ); + let standard_definitions = standard_discovery.json::(); + assert_eq!(standard_definitions.definitions.len(), 3); + assert_eq!( + standard_definitions.configuration_revision, + fixture.runtime.bundle().revision() + ); + assert!(standard_definitions + .definitions + .iter() + .all(|definition| !definition.requirement.contains("legal-parent"))); + + let parent_discovery = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {parent_token}")) + .await; + parent_discovery.assert_status_ok(); + let parent_definitions = parent_discovery.json::(); + assert_eq!(parent_definitions.definitions.len(), 4); + let adult_definition = parent_definitions + .definitions + .iter() + .find(|definition| definition.requirement.ends_with(":adult-status:v1")) + .expect("authorized adult definition is discoverable"); + assert!(adult_definition.subjects[0].selector.fields.iter().any( + |field| matches!(field, EvidenceSelectorField::Date { name } if name == "birth_date") + )); + let parent_definition = parent_definitions + .definitions + .iter() + .find(|definition| { + definition + .requirement + .ends_with(":legal-parent-relationship:v1") + }) + .expect("grant-backed relationship definition is discoverable"); + assert_eq!( + parent_definition.subjects[1].selector.value_origin, + "authenticated-grant" + ); + let serialized_discovery = + serde_json::to_string(&parent_definitions).expect("discovery serializes"); + for prohibited in [ + "fixture-agency", + "statutory-caseworker-v1", + "source-a", + "adapters/", + "derivations/", + "codelists/", + "secret:", + ] { + assert!( + !serialized_discovery.contains(prohibited), + "discovery exposed protected deployment material" + ); + } + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "metadata discovery must not create evidence-data audit records" + ); + + mount_success_sources(&fixture.server, false).await; let adult_request = adult_request(); let residence_request = residence_request(); let licence_request = licence_request(); @@ -346,6 +462,169 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda } } +#[tokio::test] +async fn discovery_requires_authentication_and_returns_no_unentitled_definitions() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let missing = http.get("/v1/evidence-definitions").await; + assert_eq!(missing.status_code(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!( + missing.json::()["code"], + json!("authentication_failed") + ); + + let filtered = http + .get("/v1/evidence-definitions?requirement=caller-selected") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .await; + assert_eq!(filtered.status_code(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(filtered.json::()["code"], json!("malformed_request")); + + let body_response = build_app(Arc::clone(&fixture.runtime)) + .oneshot( + HttpRequest::builder() + .uri("/v1/evidence-definitions") + .header("authorization", format!("Bearer {}", access_token(None))) + .body(Body::from("{}")) + .expect("discovery request is valid"), + ) + .await + .expect("discovery router responds"); + assert_eq!(body_response.status(), axum::http::StatusCode::BAD_REQUEST); + + let now = Utc::now().timestamp(); + let unentitled = signed_access_token(json!({ + "iss": TOKEN_ISSUER, + "aud": TOKEN_AUDIENCE, + "sub": "unentitled-discovery-principal", + "iat": now - 1, + "exp": now + 3600, + "evidence_tags": ["unentitled-agency"], + "evidence_audience": EVIDENCE_AUDIENCE + })); + let response = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {unentitled}")) + .await; + response.assert_status_ok(); + assert!(response + .json::() + .definitions + .is_empty()); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "denied discovery must not create evidence-data audit records" + ); +} + +#[tokio::test] +async fn discovery_omits_an_authority_shape_that_the_runtime_would_deny_as_ambiguous() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "authorityProfiles:\n statutory-caseworker-v1:", + r#"authorityProfiles: + overlapping-caseworker-v1: + kind: statutory + requesterTags: [fixture-agency] + grants: + - requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + subjects: + - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} + statutory-caseworker-v1:"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("overlapping runtime initializes for fail-closed request decisions"), + ); + let http = TestServer::new(build_app(runtime)); + + let response = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .await; + response.assert_status_ok(); + let definitions = response.json::(); + assert_eq!(definitions.definitions.len(), 2); + assert!(definitions + .definitions + .iter() + .all(|definition| !definition.requirement.ends_with(":adult-status:v1"))); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + assert!(fs::read_to_string(&prepared.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn discovery_uses_the_bounded_per_principal_request_budget() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + "rateLimits: {requestsPerPrincipalPerMinute: 1, burstPerPrincipal: 1, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10}", + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("rate-limited discovery runtime initializes"), + ); + let http = TestServer::new(build_app(runtime)); + let token = access_token(None); + + http.get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {token}")) + .await + .assert_status_ok(); + let limited = http + .get("/v1/evidence-definitions") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!( + limited.status_code(), + axum::http::StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!(limited.json::()["code"], json!("rate_limited")); + assert_eq!(limited.header("retry-after"), "1"); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); +} + #[tokio::test] async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture() { let fixture = acceptance_runtime().await; diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index 6b8ab69cc..4040748a8 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -292,6 +292,10 @@ impl MatchedEntitlement { pub fn authority_kind(&self) -> AuthorityKind { self.authority_kind } + + pub(crate) fn subjects(&self) -> &[GrantedSubject] { + &self.subjects + } } impl fmt::Debug for MatchedEntitlement { @@ -404,6 +408,53 @@ pub fn resolve_selectors( }) } +/// Confirm that selector values owned by the authenticated context or grant +/// are present and valid before advertising an entitlement. Request-owned +/// selector values are intentionally not inspected during discovery. +pub(crate) fn validate_entitlement_context( + bundle: &Bundle, + context: &AuthenticatedContext, + matched: &MatchedEntitlement, +) -> Result<(), AuthorizationError> { + let uses_authenticated_grant = matched + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + if uses_authenticated_grant + && (context.grant_id().is_none() + || context.grant_authority() != Some(matched.authority_profile())) + { + return Err(AuthorizationError::Unauthorized); + } + + for grant in &matched.subjects { + if grant.value_origin == ValueOrigin::Request { + continue; + } + let profile = bundle + .config + .selector_profiles + .get(&grant.selector_profile) + .ok_or(AuthorizationError::Unauthorized)?; + let claims = grant + .value_claims + .as_ref() + .ok_or(AuthorizationError::Unauthorized)?; + let values = claims + .iter() + .map(|(field, path)| { + let value = context + .claim_path(path) + .and_then(selector_value_from_claim) + .ok_or(AuthorizationError::Selector)?; + Ok((field.to_owned(), value)) + }) + .collect::>()?; + validate_values(bundle, profile, &values)?; + } + Ok(()) +} + /// Resolve exactly one complete entitlement and its complete selector set. /// /// Service code that applies the failed-selector budget uses diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 593e8525a..4d125dafb 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -60,7 +60,7 @@ struct ServerState { evaluation_time: Option>, } -/// Build the four-route Version 1 application from one immutable runtime. +/// Build the five-route Version 1 application from one immutable runtime. #[cfg(test)] pub(crate) fn build_app(runtime: Arc) -> Router { build_app_with_tracker(runtime).0 @@ -101,6 +101,7 @@ fn build_app_with_tracker_at( let routes = Router::new() .route("/v1/evidence", post(create_evidence)) + .route("/v1/evidence-definitions", get(discover_evidence)) .route("/health", get(health)) .route("/ready", get(ready)) .route("/.well-known/evidence/jwks.json", get(jwks)) @@ -343,6 +344,61 @@ async fn create_evidence( } } +async fn discover_evidence( + State(state): State>, + request: Request, +) -> Response { + let operation = operation_id(); + let started = Instant::now(); + let access_token = match bearer_token(request.headers()) { + Ok(token) => token.to_owned(), + Err(code) => return problem_response(code, &operation), + }; + if request.uri().query().is_some() || content_length_exceeds(request.headers(), 0) { + return problem_response(ProblemCode::MalformedRequest, &operation); + } + let admission_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + let _request_slot = match tokio::time::timeout( + admission_budget, + Arc::clone(&state.request_slots).acquire_owned(), + ) + .await + { + Ok(Ok(permit)) => permit, + Ok(Err(_)) | Err(_) => { + return problem_response(ProblemCode::ServiceUnavailable, &operation) + } + }; + let body_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + match tokio::time::timeout(body_budget, to_bytes(request.into_body(), 0)).await { + Ok(Ok(body)) if body.is_empty() => {} + Ok(Ok(_)) | Ok(Err(_)) => { + return problem_response(ProblemCode::MalformedRequest, &operation) + } + Err(_) => return problem_response(ProblemCode::ServiceUnavailable, &operation), + } + let discovery_budget = match remaining(state.request_timeout, started) { + Some(remaining) => remaining, + None => return problem_response(ProblemCode::ServiceUnavailable, &operation), + }; + match tokio::time::timeout(discovery_budget, state.runtime.discover(&access_token)).await { + Ok(Ok(definitions)) => { + match serialize_response(StatusCode::OK, JSON_MEDIA_TYPE, &definitions) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } + } + Ok(Err(failure)) => runtime_failure_response(failure, &operation), + Err(_) => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + async fn health() -> Response { static_json_response(StatusCode::OK, r#"{"status":"ok"}"#) } diff --git a/products/evidence/contracts/definitions.schema.yaml b/products/evidence/contracts/definitions.schema.yaml new file mode 100644 index 000000000..b480207b8 --- /dev/null +++ b/products/evidence/contracts/definitions.schema.yaml @@ -0,0 +1,141 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/definitions-v1.json +title: Requester-scoped Evidence definitions Version 1 +type: object +additionalProperties: false +required: [schema, configurationRevision, issuedBy, providedBy, definitions] +properties: + schema: {const: registry.evidence-definitions/v1} + configurationRevision: + type: string + pattern: '^sha256:[a-f0-9]{64}$' + issuedBy: {type: string, format: uri, maxLength: 512} + providedBy: {type: string, format: uri, maxLength: 512} + definitions: + type: array + maxItems: 16384 + uniqueItems: true + items: {$ref: '#/$defs/definition'} +$defs: + definition: + type: object + additionalProperties: false + required: + - requirement + - kind + - evidenceType + - purpose + - referenceFrameworks + - subjects + - concepts + properties: + requirement: {type: string, format: uri, maxLength: 512} + kind: {enum: [criterion, information-requirement, constraint]} + evidenceType: {type: string, format: uri, maxLength: 512} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + referenceFrameworks: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {type: string, format: uri, maxLength: 512} + subjects: + type: array + minItems: 1 + maxItems: 8 + uniqueItems: true + items: {$ref: '#/$defs/subject'} + concepts: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {$ref: '#/$defs/concept'} + subject: + type: object + additionalProperties: false + required: [role, cardinality, selector] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + cardinality: {const: one} + selector: {$ref: '#/$defs/selector'} + selector: + type: object + additionalProperties: false + required: [profile, valueOrigin, fields] + properties: + profile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} + valueOrigin: + enum: [request, authenticated-context, authenticated-grant] + fields: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + items: {$ref: '#/$defs/selector-field'} + selector-field: + oneOf: + - type: object + additionalProperties: false + required: [type, name, minimumBytes, maximumBytes] + properties: + type: {const: string} + name: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + minimumBytes: {type: integer, minimum: 1, maximum: 8192} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + - type: object + additionalProperties: false + required: [type, name] + properties: + type: {const: date} + name: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + - type: object + additionalProperties: false + required: [type, name, minimum, maximum] + properties: + type: {const: integer} + name: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + minimum: + {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + maximum: + {type: integer, minimum: -9007199254740991, maximum: 9007199254740991} + - type: object + additionalProperties: false + required: [type, name] + properties: + type: {const: boolean} + name: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + - type: object + additionalProperties: false + required: [type, name, scheme, version, maximumBytes] + properties: + type: {const: controlled-code} + name: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + scheme: {type: string, format: uri, maxLength: 512} + version: {type: string, minLength: 1, maxLength: 128} + maximumBytes: {type: integer, minimum: 1, maximum: 8192} + concept: + type: object + additionalProperties: false + required: [id, form] + properties: + id: {type: string, format: uri, maxLength: 512} + form: + enum: + - boolean + - controlled-code + - controlled-category + - bounded-integer + - bounded-decimal + - date-bucket + - time-bucket + - audience-scoped-entity-reference + - controlled-code-list + - entity-reference-list + - reviewed-structured-value +$comment: >- + The authenticated response contains only complete request shapes that match + exactly one configured authority path. It never exposes source plans, + scripts, credentials, requester tags, authority-profile identifiers, + selector values, codelist values, or unrelated definitions. Discovery + performs no provider request and grants no authority. diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index bdff69016..8047a542f 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -127,6 +127,11 @@ invariants: threat: Script injects envelope fields, subject identifiers, selector values, or unsupported claims. enforcement: Rhai returns ConceptValueSet only; core owns identifiers, subjects, metadata, decimal serialization, entity-reference HMAC projection, public projection, and JWS. negative_test: sec-script-cannot-construct-evidence + - id: V1-I26 + rule: Evidence-definition discovery is authenticated, requester-scoped, and never creates authority or exposes deployment internals. + threat: A public catalog, entitlement oracle, or overbroad metadata response reveals unavailable definitions, authority structure, selectors, source plans, or credentials. + enforcement: Rust authenticates and rate-limits discovery, projects only complete shapes matching exactly one authority path and valid token-owned selector material through a closed response allowlist, omits unentitled and ambiguous shapes, and performs no provider or evidence-data audit access. + negative_test: sec-discovery-requester-scoped cross_cutting: config_trust: threat: A missing, writable, or unreviewed bundle is treated as trusted configuration. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 04ee48fa2..6a0bafee7 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -79,6 +79,13 @@ entries: tests: - {file: crates/registry-evidence/src/rhai_runtime.rs, name: ambient_and_diagnostic_capabilities_are_unavailable} - {file: crates/registry-evidence/src/rhai_runtime.rs, name: derivation_decode_is_closed_and_retains_protected_types} + - id: sec-discovery-requester-scoped + tests: + - {file: crates/registry-evidence/src/model.rs, name: debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: real_router_serves_all_definitions_concurrently_without_crossing_boundaries} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_requires_authentication_and_returns_no_unentitled_definitions} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_omits_an_authority_shape_that_the_runtime_would_deny_as_ambiguous} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_uses_the_bounded_per_principal_request_budget} - id: sec-missing-or-writable-bundle-fails tests: [{file: crates/registry-evidence/src/bundle.rs, name: writable_bundle_and_unknown_files_fail_closed}] - id: sec-audit-order-and-failure diff --git a/products/evidence/generated/evidence-definitions-v1.schema.json b/products/evidence/generated/evidence-definitions-v1.schema.json new file mode 100644 index 000000000..a7ef13942 --- /dev/null +++ b/products/evidence/generated/evidence-definitions-v1.schema.json @@ -0,0 +1,322 @@ +{ + "$comment": "The authenticated response contains only complete request shapes that match exactly one configured authority path. It never exposes source plans, scripts, credentials, requester tags, authority-profile identifiers, selector values, codelist values, or unrelated definitions.", + "$defs": { + "concept": { + "additionalProperties": false, + "properties": { + "form": { + "enum": [ + "boolean", + "controlled-code", + "controlled-category", + "bounded-integer", + "bounded-decimal", + "date-bucket", + "time-bucket", + "audience-scoped-entity-reference", + "controlled-code-list", + "entity-reference-list", + "reviewed-structured-value" + ] + }, + "id": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "id", + "form" + ], + "type": "object" + }, + "definition": { + "additionalProperties": false, + "properties": { + "concepts": { + "items": { + "$ref": "#/$defs/concept" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "evidenceType": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "kind": { + "enum": [ + "criterion", + "information-requirement", + "constraint" + ] + }, + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "referenceFrameworks": { + "items": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "requirement": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "subjects": { + "items": { + "$ref": "#/$defs/subject" + }, + "maxItems": 8, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "requirement", + "kind", + "evidenceType", + "purpose", + "referenceFrameworks", + "subjects", + "concepts" + ], + "type": "object" + }, + "selector": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "$ref": "#/$defs/selector-field" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "profile": { + "pattern": "^[a-z][a-z0-9._-]{0,127}$", + "type": "string" + }, + "valueOrigin": { + "enum": [ + "request", + "authenticated-context", + "authenticated-grant" + ] + } + }, + "required": [ + "profile", + "valueOrigin", + "fields" + ], + "type": "object" + }, + "selector-field": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "maximumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "minimumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "const": "string" + } + }, + "required": [ + "type", + "name", + "minimumBytes", + "maximumBytes" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "const": "date" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "maximum": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "minimum": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "const": "integer" + } + }, + "required": [ + "type", + "name", + "minimum", + "maximum" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "const": "boolean" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "maximumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "scheme": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "type": { + "const": "controlled-code" + }, + "version": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "name", + "scheme", + "version", + "maximumBytes" + ], + "type": "object" + } + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "cardinality": { + "const": "one" + }, + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "selector": { + "$ref": "#/$defs/selector" + } + }, + "required": [ + "role", + "cardinality", + "selector" + ], + "type": "object" + } + }, + "$id": "https://registrystack.org/schemas/evidence/definitions-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, + "definitions": { + "items": { + "$ref": "#/$defs/definition" + }, + "maxItems": 16384, + "type": "array", + "uniqueItems": true + }, + "issuedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "providedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "schema": { + "const": "registry.evidence-definitions/v1" + } + }, + "required": [ + "schema", + "configurationRevision", + "issuedBy", + "providedBy", + "definitions" + ], + "title": "Requester-scoped Evidence definitions Version 1", + "type": "object" +} diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index 2d55b856a..8af541441 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -191,6 +191,201 @@ "title": "Evidence assertion payload Version 1", "type": "object" }, + "EvidenceDefinition": { + "additionalProperties": false, + "properties": { + "concepts": { + "items": { + "$ref": "#/components/schemas/EvidenceDefinitionConcept" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "evidenceType": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "kind": { + "enum": [ + "criterion", + "information-requirement", + "constraint" + ], + "type": "string" + }, + "purpose": { + "pattern": "^[a-z][a-z0-9._:-]{0,127}$", + "type": "string" + }, + "referenceFrameworks": { + "items": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "requirement": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "subjects": { + "items": { + "$ref": "#/components/schemas/EvidenceDefinitionSubject" + }, + "maxItems": 8, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "requirement", + "kind", + "evidenceType", + "purpose", + "referenceFrameworks", + "subjects", + "concepts" + ], + "type": "object" + }, + "EvidenceDefinitionConcept": { + "additionalProperties": false, + "properties": { + "form": { + "enum": [ + "boolean", + "controlled-code", + "controlled-category", + "bounded-integer", + "bounded-decimal", + "date-bucket", + "time-bucket", + "audience-scoped-entity-reference", + "controlled-code-list", + "entity-reference-list", + "reviewed-structured-value" + ], + "type": "string" + }, + "id": { + "format": "uri", + "maxLength": 512, + "type": "string" + } + }, + "required": [ + "id", + "form" + ], + "type": "object" + }, + "EvidenceDefinitionSelector": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/EvidenceSelectorField" + }, + "maxItems": 16, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "profile": { + "pattern": "^[a-z][a-z0-9._-]{0,127}$", + "type": "string" + }, + "valueOrigin": { + "enum": [ + "request", + "authenticated-context", + "authenticated-grant" + ], + "type": "string" + } + }, + "required": [ + "profile", + "valueOrigin", + "fields" + ], + "type": "object" + }, + "EvidenceDefinitionSubject": { + "additionalProperties": false, + "properties": { + "cardinality": { + "enum": [ + "one" + ], + "type": "string" + }, + "role": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "selector": { + "$ref": "#/components/schemas/EvidenceDefinitionSelector" + } + }, + "required": [ + "role", + "cardinality", + "selector" + ], + "type": "object" + }, + "EvidenceDefinitions": { + "additionalProperties": false, + "properties": { + "configurationRevision": { + "pattern": "^sha256:[a-f0-9]{64}$", + "type": "string" + }, + "definitions": { + "items": { + "$ref": "#/components/schemas/EvidenceDefinition" + }, + "maxItems": 16384, + "type": "array", + "uniqueItems": true + }, + "issuedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "providedBy": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "schema": { + "enum": [ + "registry.evidence-definitions/v1" + ], + "type": "string" + } + }, + "required": [ + "schema", + "configurationRevision", + "issuedBy", + "providedBy", + "definitions" + ], + "title": "Requester-scoped Evidence definitions Version 1", + "type": "object" + }, "EvidenceProtectedHeader": { "additionalProperties": false, "properties": { @@ -299,6 +494,152 @@ ], "type": "object" }, + "EvidenceSelectorField": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "maximumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "minimumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "enum": [ + "string" + ], + "type": "string" + } + }, + "required": [ + "type", + "name", + "minimumBytes", + "maximumBytes" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "enum": [ + "date" + ], + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "maximum": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "minimum": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "enum": [ + "integer" + ], + "type": "string" + } + }, + "required": [ + "type", + "name", + "minimum", + "maximum" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "type": { + "enum": [ + "boolean" + ], + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "maximumBytes": { + "maximum": 8192, + "minimum": 1, + "type": "integer" + }, + "name": { + "pattern": "^[a-z][a-z0-9._-]{0,63}$", + "type": "string" + }, + "scheme": { + "format": "uri", + "maxLength": 512, + "type": "string" + }, + "type": { + "enum": [ + "controlled-code" + ], + "type": "string" + }, + "version": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "name", + "scheme", + "version", + "maximumBytes" + ], + "type": "object" + } + ] + }, "FlattenedJws": { "additionalProperties": false, "properties": { @@ -1416,6 +1757,272 @@ ], "summary": "Produce signed evidence for one authorized fixed requirement" } + }, + "/v1/evidence-definitions": { + "get": { + "operationId": "listEvidenceDefinitions", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EvidenceDefinitions" + } + } + }, + "description": "Requester-scoped Evidence definitions", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "malformed_request" + ], + "type": "string" + }, + "status": { + "enum": [ + 400 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request is not valid" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/malformed_request" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Malformed discovery request", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "authentication_failed" + ], + "type": "string" + }, + "status": { + "enum": [ + 401 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Authentication failed" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/authentication_failed" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Authentication failed", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "WWW-Authenticate": { + "schema": { + "enum": [ + "Bearer" + ], + "type": "string" + } + } + } + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "rate_limited" + ], + "type": "string" + }, + "status": { + "enum": [ + 429 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Request rate exceeded" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/rate_limited" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Request rate exceeded", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "Retry-After": { + "schema": { + "enum": [ + "1" + ], + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "service_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Service temporarily unavailable", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the complete Evidence request shapes available to the authenticated caller" + } } } } From 99884bcb8b8c4e0fa54872ec24caa21f078db81d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 17:39:19 +0700 Subject: [PATCH 003/136] feat(mint): issue short-lived tokens to registered clients Deployments with many callers and no identity provider had no safe way to get a signed, expiring, audience-bound token to a resource server such as Evidence. Handing every client a key from one pooled JWK set does not work: key selection is by `kid`, which the signer chooses, so every key in the pool is equally authoritative for every claim. Any client could name any principal, any requester tags, and any evidence audience. Mint separates the two questions. A server-side client registry binds a client id to that client's own public keys and to the authority Mint will assert for it. The token endpoint selects the key set by the asserted client id before verifying the signature, then reads authority from the registry and never from the assertion payload. A client therefore holds its own key and signs for itself, but possession of a key no longer decides what may be said. Security-sensitive review notes: - RFC 7523 `private_key_jwt` only. Assertions are bound to this endpoint by audience, bounded in lifetime, and single use by `jti`. The replay record outlives the assertion by the same clock skew the freshness check tolerates, so there is no window where an assertion is still accepted but no longer recorded as spent. - Strict compact-JWS preflight with duplicate-JSON-member rejection runs before any cryptographic work, and every segment is size-bounded. - The replay cache fails closed when saturated rather than evicting a live entry, since eviction is the move an attacker would use to make room for a replay. - OAuth errors collapse to `invalid_client` so the endpoint cannot be used to probe which client ids are registered. - Issuer identity, signing keys, listener, and token policy are startup-only. Only the client registry reloads, on SIGHUP, keeping the previous registry if the new one fails to load. Caller lifecycle changes therefore never restart a resource server. - Registered client authority is redacted from Debug, and the signing key never appears in Debug output or in startup diagnostics. `tests/evidence_compatibility.rs` drives the real router over a real on-disk deployment and feeds the minted token to Evidence's own authenticator. The dependency runs one way only: Evidence does not depend on Mint. The CI shard inventory gains a `mint` shard. Evidence changes now also select it, because the compatibility test dev-depends on registry-evidence and must run when Evidence's auth surface moves. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 1 + .github/scripts/test_ci_changes.py | 7 +- Cargo.lock | 32 + Cargo.toml | 2 + crates/registry-mint/Cargo.toml | 46 ++ crates/registry-mint/README.md | 167 +++++ crates/registry-mint/src/assertion.rs | 638 ++++++++++++++++++ crates/registry-mint/src/clients.rs | 508 ++++++++++++++ crates/registry-mint/src/config.rs | 461 +++++++++++++ crates/registry-mint/src/error.rs | 150 ++++ crates/registry-mint/src/lib.rs | 56 ++ crates/registry-mint/src/main.rs | 137 ++++ crates/registry-mint/src/replay.rs | 116 ++++ crates/registry-mint/src/secretfile.rs | 116 ++++ crates/registry-mint/src/server.rs | 481 +++++++++++++ crates/registry-mint/src/token.rs | 455 +++++++++++++ .../tests/evidence_compatibility.rs | 368 ++++++++++ 17 files changed, 3739 insertions(+), 2 deletions(-) create mode 100644 crates/registry-mint/Cargo.toml create mode 100644 crates/registry-mint/README.md create mode 100644 crates/registry-mint/src/assertion.rs create mode 100644 crates/registry-mint/src/clients.rs create mode 100644 crates/registry-mint/src/config.rs create mode 100644 crates/registry-mint/src/error.rs create mode 100644 crates/registry-mint/src/lib.rs create mode 100644 crates/registry-mint/src/main.rs create mode 100644 crates/registry-mint/src/replay.rs create mode 100644 crates/registry-mint/src/secretfile.rs create mode 100644 crates/registry-mint/src/server.rs create mode 100644 crates/registry-mint/src/token.rs create mode 100644 crates/registry-mint/tests/evidence_compatibility.rs diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index e6d0e0e5f..a316af223 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -43,6 +43,7 @@ ), "relay": ("registry-relay",), "evidence": ("registry-evidence",), + "mint": ("registry-mint",), "developer-tools": ( "registry-config-report", "registry-language-server", diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index fc56b3385..4ef3a119f 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -85,7 +85,7 @@ def test_docs_only_change_skips_rust(self) -> None: self.assertTrue(outputs["docs"]) self.assertFalse(outputs["docs_archives"]) - def test_evidence_code_and_product_contracts_select_one_shard_and_drift_gate(self) -> None: + def test_evidence_code_and_product_contracts_select_its_shards_and_drift_gate(self) -> None: for path in ( "crates/registry-evidence/src/source.rs", "products/evidence/contracts/source-contract.yaml", @@ -96,9 +96,12 @@ def test_evidence_code_and_product_contracts_select_one_shard_and_drift_gate(sel outputs = classify(self.workspace, (path,)) self.assertTrue(outputs["evidence_contracts"]) self.assertIn("registry-evidence", outputs["rust_packages"]) + # registry-mint dev-depends on registry-evidence so its + # compatibility test proves Evidence accepts a minted token. + # Changing Evidence must therefore run the mint shard too. self.assertEqual( {entry["name"] for entry in outputs["rust_matrix"]["include"]}, - {"evidence"}, + {"evidence", "mint"}, ) def test_evidence_contract_gate_is_required_by_the_rust_aggregate(self) -> None: diff --git a/Cargo.lock b/Cargo.lock index 0607f0605..fbf3db49c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5431,6 +5431,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "registry-mint" +version = "0.16.2" +dependencies = [ + "async-trait", + "axum", + "axum-test", + "base64", + "clap", + "ed25519-dalek", + "http", + "jsonwebtoken", + "registry-evidence", + "registry-platform-canonical-json", + "registry-platform-crypto", + "registry-platform-oidc", + "rustix", + "serde", + "serde_json", + "serde_norway", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "ulid", + "url", + "zeroize", +] + [[package]] name = "registry-notary" version = "0.16.2" diff --git a/Cargo.toml b/Cargo.toml index fae995073..f93cad094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/registry-platform-testing", "crates/registry-manifest-core", "crates/registry-manifest-cli", + "crates/registry-mint", "crates/registry-notary-core", "crates/registry-notary-client", "crates/registry-notary-server", @@ -56,6 +57,7 @@ registry-config-report = { path = "crates/registry-config-report", version = "0. registry-evidence = { path = "crates/registry-evidence", version = "0.16.2" } registry-language-server = { path = "crates/registry-language-server", version = "0.16.2" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.16.2" } +registry-mint = { path = "crates/registry-mint", version = "0.16.2" } registry-notary-client = { path = "crates/registry-notary-client", version = "0.16.2" } registry-notary-core = { path = "crates/registry-notary-core", version = "0.16.2" } registry-relay = { path = "crates/registry-relay", version = "0.16.2" } diff --git a/crates/registry-mint/Cargo.toml b/crates/registry-mint/Cargo.toml new file mode 100644 index 000000000..ecf75df10 --- /dev/null +++ b/crates/registry-mint/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "registry-mint" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Minimal OAuth token issuer that authenticates clients by private key JWT." +repository.workspace = true +publish = false + +[[bin]] +name = "mint" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +clap.workspace = true +http.workspace = true +jsonwebtoken.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-crypto.workspace = true +registry-platform-oidc.workspace = true +rustix.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +ulid.workspace = true +url.workspace = true +zeroize.workspace = true + +[dev-dependencies] +axum-test.workspace = true +ed25519-dalek.workspace = true +registry-evidence.workspace = true +tempfile.workspace = true diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md new file mode 100644 index 000000000..5bebee4ae --- /dev/null +++ b/crates/registry-mint/README.md @@ -0,0 +1,167 @@ +# Registry Mint + +Mint issues short-lived access tokens to registered machine clients. It exists +so that a resource server such as Evidence can require signed, expiring, +audience-bound tokens without the deployment first having to stand up a general +purpose identity provider. + +Mint is not a product line. It is a small supporting service for deployments +that have many callers and no IdP. + +## Why a server, and not just a shared JWKS + +A resource server configured with a pooled JWK set can answer only one +question: *was this token signed by one of the trusted keys?* It cannot answer +the question that authorization actually depends on: *which caller signed it, +and what is that caller permitted to assert?* + +Key selection inside a JWK set is by `kid`, and `kid` is chosen by whoever +built the token. So in a pooled set every key is equally authoritative for +every claim. Any client holding any trusted key can mint a token naming any +principal, any requester tags, and any evidence audience. + +Mint closes that by splitting the two questions across two places: + +- The **client registry** (`clients/*.yaml`) binds a client id to *that + client's* public keys and to the authority Mint will assert for it. +- The **token endpoint** verifies an incoming client assertion against the + keys of the client it claims to be, selected *before* signature + verification, and then writes the authority from the registry, never from + the assertion payload. + +A client therefore holds its own key and signs for itself, but possession of a +key no longer decides what may be said. + +## Protocol + +Mint speaks the `client_credentials` grant with `private_key_jwt` client +authentication (RFC 7523). A client builds a short-lived JWT assertion signed +with its own private key, and posts it to the token endpoint: + +``` +POST /token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +&client_assertion= +``` + +The assertion must carry `iss` and `sub` equal to the client id, `aud` equal to +the configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside the +configured maximum lifetime. Every `jti` is single use: presenting the same +assertion twice is refused. + +The response is a signed `at+jwt` access token. Errors collapse to +`invalid_client` so that the endpoint cannot be used to probe which client ids +are registered. + +Endpoints: + +| Path | Purpose | +|---|---| +| `POST /token` | Issue an access token | +| `GET /.well-known/jwks.json` | Public keys for verifying minted tokens (path is configurable) | +| `GET /.well-known/oauth-authorization-server` | Metadata pointing at the above | +| `GET /health`, `GET /ready` | Liveness, and readiness (503 while no client is registered) | + +## Configuration + +One YAML document. Every path in it resolves relative to the document's own +directory. Everything here is startup-only: issuer identity, signing keys, +listener, and token policy are fixed for the life of the process. + +```yaml +version: 1 +issuer: https://mint.example.org +listener: + address: 127.0.0.1 + port: 8081 +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: secrets/signing.jwk + # Public JWKs of keys that no longer sign but may still have live tokens. + retiredPublicJwkFiles: [] +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.example.org/token + maximumLifetimeSeconds: 300 + algorithms: [EdDSA] +clients: + directory: clients +``` + +The `accessTokens.claims` names must match the resource server's +`authentication` block, because the resource server reads its principal, +requester tags, evidence audience, and grant pair from configurable claim +names. Access token lifetime is bounded to 60..=3600 seconds; a long-lived +bearer token is the thing Mint exists to avoid. + +The signing key file must be a private JWK and must be readable only by its +owner. Never commit it, print it, or pass it on a command line. + +## Registering a client + +One `*.yaml` file per client in `clients.directory`: + +```yaml +clientId: health-desk +principal: service:health-desk +evidenceAudience: https://evidence.example.org +requesterTags: [health-desk, region-north] +# Optional. Minted only for callers acting under a recorded authority. +grant: + id: grant-2026-014 + authority: ministry-of-health +keys: + - kty: OKP + crv: Ed25519 + kid: health-desk-2026-01 + x: "..." +``` + +Only public JWKs are accepted; a document carrying a private member is +rejected. The load is all-or-nothing, so one malformed registration fails the +whole load and a partially applied registry can never serve. + +The registry is the one reloadable part of Mint. `SIGHUP` reloads it in place, +keeping the previous registry if the new one does not load. Onboarding, +offboarding, and caller key rotation therefore never restart the resource +server. + +## Running + +```bash +mint check --config /etc/mint/mint.yaml +``` + +```bash +mint serve --config /etc/mint/mint.yaml +``` + +Both accept `MINT_CONFIG` in place of `--config`. `check` loads the +configuration, signing key, and client registry, then exits without opening a +socket. + +Mint serves plain HTTP and expects to sit behind TLS termination it does not +manage. + +## Verify a change + +```bash +cargo test --locked -p registry-mint +``` + +`tests/evidence_compatibility.rs` is the test that justifies the crate: it +drives the real router over a real on-disk deployment and feeds the minted +token to Evidence's own authenticator. The dependency runs one way only. +Evidence does not depend on Mint. diff --git a/crates/registry-mint/src/assertion.rs b/crates/registry-mint/src/assertion.rs new file mode 100644 index 000000000..c58db3671 --- /dev/null +++ b/crates/registry-mint/src/assertion.rs @@ -0,0 +1,638 @@ +//! RFC 7523 `private_key_jwt` client authentication. +//! +//! The single most important property in this module is that an assertion is +//! verified against **only the keys registered for the client it claims to be**. +//! +//! The alternative, pooling every client key into one JWK set, is what makes +//! distributing signing keys unsafe: key selection happens by `kid`, which the +//! signer chooses, and nothing downstream re-checks which key was used against +//! the claims that were signed. In a pooled set, client A signs with A's key, +//! writes `iss: client-b`, and verification succeeds. Selecting the key set by +//! the asserted client id *before* verifying removes that move entirely: A's +//! key simply is not in B's set, so the signature fails. +//! +//! Everything else here is bounding: strict structural preflight, an audience +//! bound to this endpoint, a bounded assertion lifetime, and single-use `jti`. + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_canonical_json::parse_json_strict; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::Value; + +use crate::{ + clients::{ClientRegistry, RegisteredClient}, + config::ClientAssertionConfig, + error::TokenError, + replay::{ReplayCache, ReplayError}, +}; + +/// Bounds chosen so a hostile caller cannot make Mint allocate before any +/// signature has been checked. +const MAX_ASSERTION_BYTES: usize = 16 * 1024; +const MAX_HEADER_BYTES: usize = 8 * 1024; +const MAX_CLAIMS_BYTES: usize = 8 * 1024; +const MAX_CLIENT_ID_BYTES: usize = 256; +const MAX_JTI_BYTES: usize = 256; + +/// Tolerance for clock difference between a caller and Mint. Applied to the +/// assertion's own `exp` and `nbf`, not to the tokens Mint issues. +const CLOCK_SKEW_SECONDS: i64 = 30; + +/// `JWT` is the conventional RFC 7523 assertion type. The explicit type is +/// accepted too, for callers that prefer unambiguous typing. +const ALLOWED_ASSERTION_TYP: [&str; 2] = ["JWT", "client-assertion+jwt"]; + +/// The parsed but *unverified* surface of an assertion, used only to decide +/// which client's keys to verify against. +struct AssertionPreflight { + claims: Value, +} + +/// Authenticates client assertions against a registry snapshot. +/// +/// One verifier is built per registered client at construction time, each bound +/// to that client's own static JWK set. +pub struct ClientAuthenticator { + registry: Arc, + verifiers: BTreeMap>, + maximum_lifetime_seconds: i64, + replay: Arc, +} + +impl std::fmt::Debug for ClientAuthenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClientAuthenticator") + .field("clients", &self.verifiers.len()) + .field("maximum_lifetime_seconds", &self.maximum_lifetime_seconds) + .finish_non_exhaustive() + } +} + +impl ClientAuthenticator { + /// Build one verifier per registered client. + /// + /// The `replay` cache is passed in rather than created here so that + /// reloading the registry never forgets spent assertion identifiers. + #[must_use] + pub fn new( + registry: Arc, + config: &ClientAssertionConfig, + replay: Arc, + ) -> Self { + let algorithms = config + .algorithms + .iter() + .map(|algorithm| algorithm.as_jsonwebtoken()) + .collect::>(); + let allowed_typ = ALLOWED_ASSERTION_TYP.map(ToOwned::to_owned).to_vec(); + + let mut verifiers = BTreeMap::new(); + for client_id in registry.client_ids() { + let client = registry + .get(client_id) + .expect("client id came from this registry"); + // The static set holds this client's public keys and nothing else. + let fetcher = Arc::new(JwksFetcher::new_static( + client.jwks().clone(), + JwksFetcherConfig::defaults(), + )); + let verifier_config = TokenVerifierConfig::access_token_profile( + // An assertion issues from the client itself. + client_id.to_owned(), + vec![config.audience.clone()], + algorithms.clone(), + allowed_typ.clone(), + ) + .with_leeway(Duration::from_secs(CLOCK_SKEW_SECONDS.unsigned_abs())); + verifiers.insert( + client_id.to_owned(), + Arc::new(TokenVerifier::new(verifier_config, fetcher)), + ); + } + + Self { + registry, + verifiers, + maximum_lifetime_seconds: config.maximum_lifetime_seconds as i64, + replay, + } + } + + #[must_use] + pub fn registry(&self) -> &Arc { + &self.registry + } + + /// Authenticate a client assertion and return the client it proves. + /// + /// The returned client is the registry entry, which is where all authority + /// is read from. Nothing from the assertion payload is carried forward. + pub async fn authenticate( + &self, + assertion: &str, + now: i64, + ) -> Result, TokenError> { + let preflight = preflight(assertion)?; + let client_id = asserted_client_id(&preflight.claims)?; + + // Selecting the key set before verifying is the whole point: an + // unknown client never reaches a signature check, and a known one is + // checked against its own keys only. + let client = self + .registry + .get(client_id) + .ok_or_else(|| TokenError::invalid_client("unknown client"))?; + let verifier = self + .verifiers + .get(client_id) + .ok_or_else(|| TokenError::server_error("registry and verifiers disagree"))?; + + let verified = verifier + .verify(assertion) + .await + .map_err(|_| TokenError::invalid_client("assertion signature or claims rejected"))?; + + // RFC 7523 section 3: for client authentication the subject is the + // client itself. Without this an assertion could name a different + // subject while still being signed by a legitimate client key. + let subject = verified + .claims + .sub + .as_deref() + .ok_or_else(|| TokenError::invalid_client("assertion has no subject"))?; + if subject != client_id { + return Err(TokenError::invalid_client( + "assertion subject does not match its issuer", + )); + } + + let issued_at = verified + .claims + .iat + .ok_or_else(|| TokenError::invalid_client("assertion has no issued-at"))?; + let expires_at = verified + .claims + .exp + .ok_or_else(|| TokenError::invalid_client("assertion has no expiry"))?; + // A long-lived assertion is a long-lived bearer credential. Bound it + // regardless of what the caller chose. + if expires_at <= issued_at + || expires_at.saturating_sub(issued_at) > self.maximum_lifetime_seconds + { + return Err(TokenError::invalid_client( + "assertion lifetime exceeds the configured maximum", + )); + } + // The verifier also checks expiry, but against its own read of the + // system clock. Freshness, the replay window, and the audit record must + // agree on one instant, so they are all decided against `now`. + if expires_at.saturating_add(CLOCK_SKEW_SECONDS) <= now { + return Err(TokenError::invalid_client("assertion has expired")); + } + if issued_at.saturating_sub(CLOCK_SKEW_SECONDS) > now { + return Err(TokenError::invalid_client("assertion is not yet issued")); + } + + let jti = verified + .claims + .extra + .get("jti") + .and_then(Value::as_str) + .ok_or_else(|| TokenError::invalid_client("assertion has no jti"))?; + if jti.is_empty() || jti.len() > MAX_JTI_BYTES { + return Err(TokenError::invalid_client("assertion jti is not bounded")); + } + // Namespaced by client so two clients choosing the same jti do not + // lock each other out. + let replay_key = format!("{client_id}\u{0}{jti}"); + // Remembered past `exp` by the same skew the freshness check tolerates. + // Forgetting it at `exp` would leave a window in which the assertion is + // still accepted but no longer recorded as spent. + self.replay + .remember( + &replay_key, + expires_at.saturating_add(CLOCK_SKEW_SECONDS), + now, + ) + .map_err(|error| match error { + ReplayError::AlreadyUsed => TokenError::invalid_client("assertion already used"), + ReplayError::Saturated => TokenError::server_error("replay cache saturated"), + ReplayError::Poisoned => TokenError::server_error("replay cache poisoned"), + })?; + + Ok(Arc::clone(client)) + } +} + +/// Structural validation performed before any allocation-heavy or +/// cryptographic work, mirroring the strictness Evidence applies to bearer +/// tokens. +fn preflight(assertion: &str) -> Result { + let malformed = || TokenError::invalid_client("assertion is malformed"); + + if assertion.is_empty() || assertion.len() > MAX_ASSERTION_BYTES { + return Err(malformed()); + } + let segments = assertion.split('.').collect::>(); + if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { + return Err(malformed()); + } + + let header = decode_segment(segments[0], MAX_HEADER_BYTES)?; + if !header.is_object() { + return Err(malformed()); + } + let claims = decode_segment(segments[1], MAX_CLAIMS_BYTES)?; + if !claims.is_object() { + return Err(malformed()); + } + // A present but undecodable or empty signature is malformed regardless of + // what the verifier would later say about it. + let signature = URL_SAFE_NO_PAD + .decode(segments[2]) + .map_err(|_| malformed())?; + if signature.is_empty() { + return Err(malformed()); + } + + Ok(AssertionPreflight { claims }) +} + +/// Decode one base64url segment into strictly parsed JSON. +/// +/// `parse_json_strict` rejects duplicate members, so a header or claim set that +/// says one thing to a lenient parser and another to a strict one cannot get +/// past this point. +fn decode_segment(segment: &str, maximum_bytes: usize) -> Result { + let malformed = || TokenError::invalid_client("assertion is malformed"); + if segment.len() > maximum_bytes { + return Err(malformed()); + } + let bytes = URL_SAFE_NO_PAD.decode(segment).map_err(|_| malformed())?; + if bytes.len() > maximum_bytes { + return Err(malformed()); + } + parse_json_strict(&bytes).map_err(|_| malformed()) +} + +fn asserted_client_id(claims: &Value) -> Result<&str, TokenError> { + let issuer = claims + .get("iss") + .and_then(Value::as_str) + .ok_or_else(|| TokenError::invalid_client("assertion has no issuer"))?; + if issuer.is_empty() || issuer.len() > MAX_CLIENT_ID_BYTES { + return Err(TokenError::invalid_client( + "assertion issuer is not bounded", + )); + } + Ok(issuer) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Algorithm; + use serde_json::json; + + // Deterministic per-seed Ed25519 keys so tests can hold several distinct + // client identities at once. + pub(crate) fn test_key(seed: u8) -> (registry_platform_crypto::PrivateJwk, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let private = registry_platform_crypto::PrivateJwk::parse( + &json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}) + .to_string(), + ) + .expect("test private JWK parses"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + (private, public) + } + + fn sign_assertion( + private: ®istry_platform_crypto::PrivateJwk, + typ: &str, + claims: &Value, + ) -> String { + let kid = private.kid.clone().expect("test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": typ, "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), private) + .expect("test key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) + } + + const AUDIENCE: &str = "https://mint.example.org/token"; + const NOW: i64 = 1_800_000_000; + + fn assertion_claims(client_id: &str, jti: &str) -> Value { + json!({ + "iss": client_id, + "sub": client_id, + "aud": AUDIENCE, + "iat": NOW, + "exp": NOW + 120, + "jti": jti, + }) + } + + fn registry_with(clients: &[(&str, &Value)]) -> Arc { + let directory = tempfile::tempdir().expect("temp dir"); + for (client_id, public) in clients { + let document = format!( + "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [tag-{client_id}]\nkeys: [{public}]\n" + ); + std::fs::write(directory.path().join(format!("{client_id}.yaml")), document) + .expect("write client registration"); + } + Arc::new(ClientRegistry::load(directory.path()).expect("registry loads")) + } + + fn authenticator(registry: Arc) -> ClientAuthenticator { + let config = ClientAssertionConfig { + audience: AUDIENCE.to_owned(), + maximum_lifetime_seconds: 300, + algorithms: vec![Algorithm::EdDSA], + replay_cache_entries: 256, + }; + ClientAuthenticator::new(registry, &config, Arc::new(ReplayCache::new(256))) + } + + #[tokio::test] + async fn a_valid_assertion_authenticates_its_client() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + let client = authenticator + .authenticate(&assertion, NOW) + .await + .expect("valid assertion authenticates"); + assert_eq!(client.client_id(), "client-a"); + assert_eq!(client.principal(), "urn:example:client-a"); + } + + /// The core security property. Client A holds a real, registered key. It + /// cannot use that key to speak as client B. + #[tokio::test] + async fn one_clients_key_cannot_sign_an_assertion_for_another_client() { + let (private_a, public_a) = test_key(1); + let (_private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + // A signs an assertion that claims to be B. + let forged = sign_assertion(&private_a, "JWT", &assertion_claims("client-b", "jti-1")); + + let error = authenticator + .authenticate(&forged, NOW) + .await + .expect_err("a forged assertion must be rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion signature or claims rejected") + ); + } + + /// Even naming its own kid does not help: the kid is looked up inside the + /// asserted client's key set, where A's key does not exist. + #[tokio::test] + async fn naming_a_foreign_kid_does_not_reach_another_clients_key_set() { + let (private_a, public_a) = test_key(1); + let (_private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + let mut claims = assertion_claims("client-b", "jti-1"); + claims["sub"] = json!("client-b"); + let forged = sign_assertion(&private_a, "JWT", &claims); + assert!(authenticator.authenticate(&forged, NOW).await.is_err()); + } + + #[tokio::test] + async fn an_unknown_client_is_rejected_before_any_signature_check() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-z", "jti-1")); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("unknown clients are rejected"); + assert_eq!(error, TokenError::invalid_client("unknown client")); + } + + #[tokio::test] + async fn the_subject_must_equal_the_issuer() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims["sub"] = json!("someone-else"); + let assertion = sign_assertion(&private, "JWT", &claims); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a mismatched subject is rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion subject does not match its issuer") + ); + } + + #[tokio::test] + async fn an_assertion_is_single_use() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a replayed assertion is rejected"); + assert_eq!(error, TokenError::invalid_client("assertion already used")); + } + + /// Freshness tolerates clock skew, so an assertion stays acceptable for a + /// short window past its own `exp`. The replay record has to outlive that + /// window: if it expired first, a captured assertion would become + /// replayable exactly as it was about to stop being useful. + #[tokio::test] + async fn an_assertion_stays_single_use_for_as_long_as_it_stays_acceptable() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + + // One second past `exp`, still inside the accepted skew window. + let error = authenticator + .authenticate(&assertion, NOW + 121) + .await + .expect_err("a replayed assertion is rejected while it is still accepted"); + assert_eq!(error, TokenError::invalid_client("assertion already used")); + + // Past the window the assertion is refused on freshness instead, so the + // replay record has no further work to do. + let error = authenticator + .authenticate(&assertion, NOW + 151) + .await + .expect_err("an assertion past the skew window is refused"); + assert_eq!(error, TokenError::invalid_client("assertion has expired")); + } + + #[tokio::test] + async fn two_clients_may_use_the_same_jti_value() { + let (private_a, public_a) = test_key(1); + let (private_b, public_b) = test_key(2); + let authenticator = authenticator(registry_with(&[ + ("client-a", &public_a), + ("client-b", &public_b), + ])); + + let from_a = sign_assertion(&private_a, "JWT", &assertion_claims("client-a", "shared")); + let from_b = sign_assertion(&private_b, "JWT", &assertion_claims("client-b", "shared")); + assert!(authenticator.authenticate(&from_a, NOW).await.is_ok()); + assert!(authenticator.authenticate(&from_b, NOW).await.is_ok()); + } + + #[tokio::test] + async fn an_assertion_for_another_audience_is_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims["aud"] = json!("https://another-service.example.org/token"); + let assertion = sign_assertion(&private, "JWT", &claims); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); + } + + #[tokio::test] + async fn expired_and_over_long_assertions_are_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + + let mut expired = assertion_claims("client-a", "jti-1"); + expired["iat"] = json!(NOW - 400); + expired["exp"] = json!(NOW - 300); + let assertion = sign_assertion(&private, "JWT", &expired); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an expired assertion is rejected"), + TokenError::invalid_client("assertion has expired") + ); + + let mut ahead = assertion_claims("client-a", "jti-3"); + ahead["iat"] = json!(NOW + 400); + ahead["exp"] = json!(NOW + 500); + let assertion = sign_assertion(&private, "JWT", &ahead); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an assertion issued in the future is rejected"), + TokenError::invalid_client("assertion is not yet issued") + ); + + let mut over_long = assertion_claims("client-a", "jti-2"); + over_long["exp"] = json!(NOW + 4_000); + let assertion = sign_assertion(&private, "JWT", &over_long); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an over-long assertion is rejected"); + assert_eq!( + error, + TokenError::invalid_client("assertion lifetime exceeds the configured maximum") + ); + } + + #[tokio::test] + async fn an_assertion_without_a_jti_is_rejected() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let mut claims = assertion_claims("client-a", "jti-1"); + claims.as_object_mut().expect("claims object").remove("jti"); + let assertion = sign_assertion(&private, "JWT", &claims); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a jti is required"); + assert_eq!(error, TokenError::invalid_client("assertion has no jti")); + } + + #[tokio::test] + async fn an_unsigned_or_malformed_assertion_never_reaches_the_registry() { + let authenticator = authenticator(registry_with(&[("client-a", &test_key(1).1)])); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT","kid":"key-1"}"#); + let claims = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&assertion_claims("client-a", "jti-1")).expect("claims")); + + for candidate in [ + String::new(), + "not-a-jwt".to_owned(), + "a.b".to_owned(), + "a.b.c.d".to_owned(), + format!("{header}.{claims}."), + format!("{header}..x"), + format!("{header}.{claims}.!!!"), + ] { + let error = authenticator + .authenticate(&candidate, NOW) + .await + .expect_err("malformed assertions are rejected"); + assert_eq!(error, TokenError::invalid_client("assertion is malformed")); + } + } + + #[tokio::test] + async fn duplicate_json_members_are_rejected_by_the_strict_preflight() { + let (private, _public) = test_key(1); + let public = test_key(1).1; + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + + // Two `iss` members: a lenient parser would take one, a strict parser + // refuses to guess. + let raw_claims = format!( + r#"{{"iss":"client-a","iss":"client-b","sub":"client-a","aud":"{AUDIENCE}","iat":{NOW},"exp":{},"jti":"jti-1"}}"#, + NOW + 120 + ); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT","kid":"key-1"}"#); + let claims = URL_SAFE_NO_PAD.encode(raw_claims.as_bytes()); + let signing_input = format!("{header}.{claims}"); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), &private) + .expect("test key signs"); + let assertion = format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("duplicate members are rejected"); + assert_eq!(error, TokenError::invalid_client("assertion is malformed")); + } + + #[tokio::test] + async fn an_access_token_type_is_not_accepted_as_a_client_assertion() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "at+jwt", &assertion_claims("client-a", "jti-1")); + + assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); + } +} diff --git a/crates/registry-mint/src/clients.rs b/crates/registry-mint/src/clients.rs new file mode 100644 index 000000000..79a674c79 --- /dev/null +++ b/crates/registry-mint/src/clients.rs @@ -0,0 +1,508 @@ +//! The client registry: the server-side binding from keys to authority. +//! +//! This module is the reason Mint exists. A JWKS answers only "was this signed +//! by a trusted key?" The registry answers the question that actually matters: +//! "*this specific client* holds *these specific keys*, and is permitted to act +//! as *this principal* with *these tags* for *this audience*." +//! +//! Two rules keep that binding meaningful: +//! +//! 1. A client's assertion is verified against that client's keys only, never +//! against a pooled key set. See [`crate::assertion`]. +//! 2. Authority is read from here, never from the assertion payload. +//! +//! The registry is reloadable so that onboarding, offboarding, and caller key +//! rotation never require restarting a resource server. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, fs, + path::Path, + sync::Arc, +}; + +use jsonwebtoken::{jwk::JwkSet, DecodingKey}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use thiserror::Error; +use url::Url; + +/// Evidence rejects principals longer than this, so a longer one could never +/// be used. +const MAX_PRINCIPAL_BYTES: usize = 512; +/// Evidence accepts at most this many requester tags. +const MAX_TAGS: usize = 32; +const MAX_KEYS_PER_CLIENT: usize = 8; +const MAX_CLIENT_FILE_BYTES: u64 = 256 * 1024; +const MAX_CLIENTS: usize = 4_096; + +/// JWK members that only ever appear in private keys. +const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"]; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ClientRegistryError { + #[error("the client registry directory is unavailable")] + DirectoryUnavailable, + #[error("a client registration file is unreadable")] + Unreadable, + #[error("client registration {0} is invalid: {1}")] + Invalid(String, &'static str), + #[error("client registration document {0} is malformed: {1}")] + Document(String, String), + #[error("client id {0} is registered more than once")] + Duplicate(String), + #[error("the client registry holds more than {MAX_CLIENTS} clients")] + TooManyClients, +} + +/// A grant reference minted into tokens for callers acting under a recorded +/// authority. Evidence requires the id and authority together or not at all. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Grant { + pub id: String, + pub authority: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClientDocument { + client_id: String, + principal: String, + evidence_audience: String, + requester_tags: Vec, + #[serde(default)] + grant: Option, + keys: Vec, +} + +/// One registered client: its public keys, and the authority Mint will assert +/// on its behalf. +#[derive(Clone)] +pub struct RegisteredClient { + client_id: String, + principal: String, + evidence_audience: String, + requester_tags: Vec, + grant: Option, + jwks: JwkSet, +} + +impl RegisteredClient { + #[must_use] + pub fn client_id(&self) -> &str { + &self.client_id + } + + #[must_use] + pub fn principal(&self) -> &str { + &self.principal + } + + #[must_use] + pub fn evidence_audience(&self) -> &str { + &self.evidence_audience + } + + #[must_use] + pub fn requester_tags(&self) -> &[String] { + &self.requester_tags + } + + #[must_use] + pub fn grant(&self) -> Option<&Grant> { + self.grant.as_ref() + } + + /// The public keys registered for this client, and nothing else. This is + /// the set an assertion from this client is verified against. + #[must_use] + pub fn jwks(&self) -> &JwkSet { + &self.jwks + } +} + +/// Authority data identifies real callers, so it is kept out of logs for the +/// same reason Evidence redacts its authenticated context. +impl fmt::Debug for RegisteredClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RegisteredClient") + .field("client_id", &"[redacted]") + .field("principal", &"[redacted]") + .field("evidence_audience", &"[redacted]") + .field( + "requester_tags", + &format_args!("[{} redacted]", self.requester_tags.len()), + ) + .field("grant", &self.grant.as_ref().map(|_| "[redacted]")) + .field("keys", &self.jwks.keys.len()) + .finish() + } +} + +/// An immutable snapshot of the registered clients. +#[derive(Debug, Default)] +pub struct ClientRegistry { + clients: BTreeMap>, +} + +impl ClientRegistry { + /// Load every `*.yaml` registration in `directory`. + /// + /// The load is all-or-nothing: one malformed registration fails the whole + /// load, so a partially applied registry can never serve. + pub fn load(directory: &Path) -> Result { + let entries = + fs::read_dir(directory).map_err(|_| ClientRegistryError::DirectoryUnavailable)?; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|_| ClientRegistryError::Unreadable)?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) == Some("yaml") { + paths.push(path); + } + } + paths.sort(); + + let mut clients = BTreeMap::new(); + for path in paths { + let client = load_client_file(&path)?; + if clients.contains_key(client.client_id()) { + return Err(ClientRegistryError::Duplicate( + client.client_id().to_owned(), + )); + } + clients.insert(client.client_id().to_owned(), Arc::new(client)); + } + if clients.len() > MAX_CLIENTS { + return Err(ClientRegistryError::TooManyClients); + } + Ok(Self { clients }) + } + + #[must_use] + pub fn get(&self, client_id: &str) -> Option<&Arc> { + self.clients.get(client_id) + } + + #[must_use] + pub fn len(&self) -> usize { + self.clients.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.clients.is_empty() + } + + #[must_use] + pub fn client_ids(&self) -> Vec<&str> { + self.clients.keys().map(String::as_str).collect() + } +} + +fn load_client_file(path: &Path) -> Result { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_owned(); + + let metadata = fs::symlink_metadata(path).map_err(|_| ClientRegistryError::Unreadable)?; + if !metadata.is_file() { + return Err(ClientRegistryError::Invalid( + name, + "registrations must be regular files", + )); + } + if metadata.len() > MAX_CLIENT_FILE_BYTES { + return Err(ClientRegistryError::Invalid( + name, + "registration is too large", + )); + } + + let text = fs::read_to_string(path).map_err(|_| ClientRegistryError::Unreadable)?; + let document: ClientDocument = serde_norway::from_str(&text) + .map_err(|error| ClientRegistryError::Document(name.clone(), error.to_string()))?; + build_client(&name, document) +} + +fn build_client( + name: &str, + document: ClientDocument, +) -> Result { + let invalid = |reason: &'static str| ClientRegistryError::Invalid(name.to_owned(), reason); + + if document.client_id.trim().is_empty() || document.client_id.len() > 256 { + return Err(invalid("client id must be 1..=256 bytes")); + } + if document.principal.trim().is_empty() || document.principal.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("principal must be 1..=512 bytes")); + } + if document.evidence_audience.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("evidence audience must be at most 512 bytes")); + } + // Evidence parses this claim as a URL and mixes it into the subject-binding + // MAC, so a value that fails to parse there must fail here. + Url::parse(&document.evidence_audience) + .map_err(|_| invalid("evidence audience must be a URL"))?; + + if document.requester_tags.is_empty() || document.requester_tags.len() > MAX_TAGS { + return Err(invalid("between 1 and 32 requester tags are required")); + } + for tag in &document.requester_tags { + if tag.trim().is_empty() || tag.len() > 256 { + return Err(invalid("requester tags must be 1..=256 bytes")); + } + } + let unique_tags = document.requester_tags.iter().collect::>(); + if unique_tags.len() != document.requester_tags.len() { + return Err(invalid("requester tags must be unique")); + } + + if let Some(grant) = &document.grant { + if grant.id.trim().is_empty() || grant.id.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("grant id must be 1..=512 bytes")); + } + if grant.authority.trim().is_empty() || grant.authority.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("grant authority must be 1..=512 bytes")); + } + } + + let jwks = build_public_jwks(document.keys, &invalid)?; + + Ok(RegisteredClient { + client_id: document.client_id, + principal: document.principal, + evidence_audience: document.evidence_audience, + requester_tags: document.requester_tags, + grant: document.grant, + jwks, + }) +} + +fn build_public_jwks( + keys: Vec, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result { + if keys.is_empty() || keys.len() > MAX_KEYS_PER_CLIENT { + return Err(invalid("between 1 and 8 keys are required")); + } + + let mut kids = BTreeSet::new(); + for key in &keys { + let object = key + .as_object() + .ok_or_else(|| invalid("keys must be objects"))?; + reject_private_material(object, invalid)?; + let kid = object + .get("kid") + .and_then(Value::as_str) + .ok_or_else(|| invalid("every key must carry a kid"))?; + if kid.trim().is_empty() || kid.len() > 256 { + return Err(invalid("key ids must be 1..=256 bytes")); + } + if !kids.insert(kid.to_owned()) { + return Err(invalid("key ids must be unique within a client")); + } + } + + let jwks: JwkSet = serde_json::from_value(Value::Object( + [("keys".to_owned(), Value::Array(keys))] + .into_iter() + .collect::>(), + )) + .map_err(|_| invalid("keys are not a valid JWK set"))?; + + // Prove at load time that every key is actually usable, so a broken + // registration fails at startup rather than at the first token request. + for jwk in &jwks.keys { + DecodingKey::from_jwk(jwk).map_err(|_| invalid("a key is not a usable public key"))?; + } + Ok(jwks) +} + +/// Reject anything carrying private key material. A client registration is +/// public data; a private member here would mean an operator pasted a signing +/// key into the registry. +fn reject_private_material( + object: &Map, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result<(), ClientRegistryError> { + if contains_private_material(object) { + return Err(invalid("client keys must not contain private key material")); + } + Ok(()) +} + +/// Whether a JWK object carries any member that only exists in a private key. +/// +/// Shared with the published JWKS so neither the registry nor the public key +/// set can ever carry private material. +#[must_use] +pub fn contains_private_material(object: &Map) -> bool { + PRIVATE_JWK_MEMBERS + .iter() + .any(|member| object.contains_key(*member)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLIENT_A: &str = r#" +clientId: client-a +principal: urn:example:client-a +evidenceAudience: https://client-a.example.org +requesterTags: [ministry-of-health] +keys: + - {kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo} +"#; + + fn registry_from(files: &[(&str, &str)]) -> Result { + let directory = tempfile::tempdir().expect("temp dir"); + for (name, contents) in files { + fs::write(directory.path().join(name), contents).expect("write client"); + } + ClientRegistry::load(directory.path()) + } + + fn load_one(contents: &str) -> Result { + registry_from(&[("client.yaml", contents)]) + } + + fn registry_error(files: &[(&str, &str)]) -> ClientRegistryError { + registry_from(files).expect_err("the registration must be rejected") + } + + fn load_error(contents: &str) -> ClientRegistryError { + registry_error(&[("client.yaml", contents)]) + } + + fn invalid(reason: &'static str) -> ClientRegistryError { + ClientRegistryError::Invalid("client.yaml".to_owned(), reason) + } + + #[test] + fn a_valid_registration_binds_keys_to_authority() { + let registry = load_one(CLIENT_A).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + + assert_eq!(client.principal(), "urn:example:client-a"); + assert_eq!(client.evidence_audience(), "https://client-a.example.org"); + assert_eq!(client.requester_tags(), ["ministry-of-health"]); + assert_eq!(client.grant(), None); + assert_eq!(client.jwks().keys.len(), 1); + assert_eq!(registry.len(), 1); + } + + #[test] + fn private_key_material_is_rejected() { + for member in PRIVATE_JWK_MEMBERS { + let text = CLIENT_A.replace( + "alg: EdDSA,", + &format!("alg: EdDSA, {member}: nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A,"), + ); + assert_eq!( + load_error(&text), + invalid("client keys must not contain private key material"), + "member {member} must be rejected" + ); + } + } + + #[test] + fn keys_require_unique_non_empty_kids() { + let text = CLIENT_A.replace("kid: client-a-2026-01, ", ""); + assert_eq!(load_error(&text), invalid("every key must carry a kid")); + + let duplicated = format!( + "{CLIENT_A} - {{kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo}}\n" + ); + assert_eq!( + load_error(&duplicated), + invalid("key ids must be unique within a client") + ); + } + + #[test] + fn evidence_audience_must_parse_as_a_url() { + let text = CLIENT_A.replace("https://client-a.example.org", "not-a-url"); + assert_eq!( + load_error(&text), + invalid("evidence audience must be a URL") + ); + } + + #[test] + fn requester_tags_are_required_bounded_and_unique() { + let text = CLIENT_A.replace("[ministry-of-health]", "[]"); + assert_eq!( + load_error(&text), + invalid("between 1 and 32 requester tags are required") + ); + + let text = CLIENT_A.replace("[ministry-of-health]", "[a, a]"); + assert_eq!(load_error(&text), invalid("requester tags must be unique")); + } + + #[test] + fn duplicate_client_ids_across_files_fail_the_whole_load() { + assert_eq!( + registry_error(&[("a.yaml", CLIENT_A), ("b.yaml", CLIENT_A)]), + ClientRegistryError::Duplicate("client-a".to_owned()) + ); + } + + #[test] + fn unknown_registration_fields_are_rejected() { + let text = CLIENT_A.replace("clientId: client-a", "clientId: client-a\nunexpected: true"); + assert!(matches!( + load_one(&text), + Err(ClientRegistryError::Document(_, _)) + )); + } + + #[test] + fn a_grant_requires_both_an_id_and_an_authority() { + let text = format!("{CLIENT_A}grant: {{id: grant-1, authority: statute-7}}\n"); + let registry = load_one(&text).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + assert_eq!( + client.grant(), + Some(&Grant { + id: "grant-1".to_owned(), + authority: "statute-7".to_owned() + }) + ); + + let text = format!("{CLIENT_A}grant: {{id: grant-1}}\n"); + assert!(matches!( + load_one(&text), + Err(ClientRegistryError::Document(_, _)) + )); + } + + #[test] + fn debug_output_redacts_client_authority() { + let registry = load_one(CLIENT_A).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let rendered = format!("{client:?}"); + assert!(!rendered.contains("urn:example:client-a")); + assert!(!rendered.contains("ministry-of-health")); + assert!(!rendered.contains("client-a.example.org")); + } + + #[test] + fn non_yaml_files_are_ignored() { + let registry = registry_from(&[ + ("client.yaml", CLIENT_A), + ("notes.txt", "not a registration"), + ("client.yaml.bak", "also not a registration"), + ]) + .expect("registry loads"); + assert_eq!(registry.len(), 1); + } +} diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs new file mode 100644 index 000000000..ef21f823c --- /dev/null +++ b/crates/registry-mint/src/config.rs @@ -0,0 +1,461 @@ +//! Startup-only Mint configuration. +//! +//! Everything in this file is fixed for the lifetime of the serving process: +//! issuer identity, signing keys, listener, and token policy. The one part of +//! Mint's state that is intentionally reloadable is the client registry, which +//! lives in [`crate::clients`]. + +use std::{ + collections::BTreeSet, + net::IpAddr, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; +use thiserror::Error; +use url::Url; + +/// Supported signature algorithms, shared by minted tokens and accepted client +/// assertions. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] +pub enum Algorithm { + EdDSA, + ES256, + RS256, +} + +impl Algorithm { + #[must_use] + pub fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm { + match self { + Self::EdDSA => jsonwebtoken::Algorithm::EdDSA, + Self::ES256 => jsonwebtoken::Algorithm::ES256, + Self::RS256 => jsonwebtoken::Algorithm::RS256, + } + } + + #[must_use] + pub fn as_header_value(self) -> &'static str { + match self { + Self::EdDSA => "EdDSA", + Self::ES256 => "ES256", + Self::RS256 => "RS256", + } + } +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ConfigError { + #[error("the configuration file is unavailable")] + Unavailable, + #[error("the configuration document is invalid: {0}")] + Document(String), + #[error("configuration is invalid: {0}")] + Invalid(&'static str), +} + +fn default_jwks_path() -> String { + "/.well-known/jwks.json".to_owned() +} + +fn default_maximum_request_bytes() -> u32 { + 16 * 1024 +} + +fn default_request_timeout_milliseconds() -> u64 { + 5_000 +} + +fn default_assertion_lifetime_seconds() -> u64 { + 300 +} + +fn default_replay_cache_entries() -> usize { + 8_192 +} + +fn default_principal_claim() -> String { + "sub".to_owned() +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + pub address: String, + pub port: u16, + #[serde(default = "default_maximum_request_bytes")] + pub maximum_request_bytes: u32, + #[serde(default = "default_request_timeout_milliseconds")] + pub request_timeout_milliseconds: u64, +} + +impl ListenerConfig { + pub fn bind_address(&self) -> Result { + self.address + .parse() + .map_err(|_| ConfigError::Invalid("listener address is not an IP address")) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SigningConfig { + pub algorithm: Algorithm, + pub active_key_id: String, + /// Path to the private JWK, resolved relative to the configuration file. + pub active_key_file: PathBuf, + /// Public JWKs of keys that no longer sign but may still have live tokens. + #[serde(default)] + pub retired_public_jwk_files: Vec, + #[serde(default = "default_jwks_path")] + pub jwks_path: String, +} + +/// Names of the claims Mint writes into minted access tokens. +/// +/// These must match the resource server's `authentication` block. Evidence, for +/// example, reads its principal, requester tags, evidence audience, and grant +/// pair from configurable claim names. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClaimNames { + #[serde(default = "default_principal_claim")] + pub principal: String, + pub requester_tags: String, + pub evidence_audience: String, + pub grant_id: String, + pub grant_authority: String, +} + +impl ClaimNames { + fn validate(&self) -> Result<(), ConfigError> { + let names = [ + self.principal.as_str(), + self.requester_tags.as_str(), + self.evidence_audience.as_str(), + self.grant_id.as_str(), + self.grant_authority.as_str(), + ]; + for name in names { + if name.trim().is_empty() || name.len() > 128 { + return Err(ConfigError::Invalid("claim names must be 1..=128 bytes")); + } + } + // A duplicated name would make one claim silently overwrite another, + // so authority could be smuggled through the wrong field. + let unique = names.iter().collect::>(); + if unique.len() != names.len() { + return Err(ConfigError::Invalid("claim names must be distinct")); + } + // These are written by Mint itself and must not be overridable. + for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { + if names.iter().skip(1).any(|name| *name == reserved) { + return Err(ConfigError::Invalid( + "authority claim names must not shadow registered JWT claims", + )); + } + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccessTokenConfig { + /// The `aud` written into minted tokens. Matches the resource server's + /// configured audiences. + pub audiences: Vec, + pub lifetime_seconds: u64, + pub claims: ClaimNames, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientAssertionConfig { + /// The value clients must set as the assertion `aud`. Binding assertions to + /// this endpoint stops one presented to another service being replayed here. + pub audience: String, + #[serde(default = "default_assertion_lifetime_seconds")] + pub maximum_lifetime_seconds: u64, + pub algorithms: Vec, + #[serde(default = "default_replay_cache_entries")] + pub replay_cache_entries: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientsConfig { + /// Directory of per-client registration files, relative to the config file. + pub directory: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MintConfig { + pub version: u32, + pub issuer: String, + pub listener: ListenerConfig, + pub signing: SigningConfig, + pub access_tokens: AccessTokenConfig, + pub client_assertion: ClientAssertionConfig, + pub clients: ClientsConfig, +} + +impl MintConfig { + /// Load and validate a configuration document, resolving every path + /// relative to the document's own directory. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|_| ConfigError::Unavailable)?; + let mut config: Self = serde_norway::from_str(&text) + .map_err(|error| ConfigError::Document(error.to_string()))?; + let root = path + .parent() + .ok_or(ConfigError::Invalid("configuration path has no parent"))?; + config.resolve_paths(root); + config.validate()?; + Ok(config) + } + + fn resolve_paths(&mut self, root: &Path) { + let resolve = |path: &Path| -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } + }; + self.signing.active_key_file = resolve(&self.signing.active_key_file); + self.signing.retired_public_jwk_files = self + .signing + .retired_public_jwk_files + .iter() + .map(|path| resolve(path)) + .collect(); + self.clients.directory = resolve(&self.clients.directory); + } + + fn validate(&self) -> Result<(), ConfigError> { + if self.version != 1 { + return Err(ConfigError::Invalid( + "only configuration version 1 is supported", + )); + } + validate_https_issuer(&self.issuer)?; + self.listener.bind_address()?; + + if self.signing.active_key_id.trim().is_empty() || self.signing.active_key_id.len() > 256 { + return Err(ConfigError::Invalid("active key id must be 1..=256 bytes")); + } + if !self.signing.jwks_path.starts_with('/') { + return Err(ConfigError::Invalid("jwks path must be absolute")); + } + + if self.access_tokens.audiences.is_empty() || self.access_tokens.audiences.len() > 16 { + return Err(ConfigError::Invalid( + "between 1 and 16 audiences are required", + )); + } + for audience in &self.access_tokens.audiences { + if audience.trim().is_empty() || audience.len() > 512 { + return Err(ConfigError::Invalid("audiences must be 1..=512 bytes")); + } + } + // A long-lived bearer token is the thing Mint exists to avoid, and a + // token shorter than the verifier's clock skew is unusable. + if !(60..=3600).contains(&self.access_tokens.lifetime_seconds) { + return Err(ConfigError::Invalid( + "access token lifetime must be 60..=3600 seconds", + )); + } + self.access_tokens.claims.validate()?; + + let assertion_audience = Url::parse(&self.client_assertion.audience) + .map_err(|_| ConfigError::Invalid("client assertion audience must be a URL"))?; + if !assertion_audience.has_host() { + return Err(ConfigError::Invalid( + "client assertion audience must have a host", + )); + } + if !(30..=600).contains(&self.client_assertion.maximum_lifetime_seconds) { + return Err(ConfigError::Invalid( + "client assertion lifetime must be 30..=600 seconds", + )); + } + if self.client_assertion.algorithms.is_empty() { + return Err(ConfigError::Invalid( + "at least one client assertion algorithm is required", + )); + } + if self.client_assertion.replay_cache_entries < 256 { + return Err(ConfigError::Invalid( + "the replay cache must hold at least 256 entries", + )); + } + Ok(()) + } +} + +/// Require an issuer that is `https`, has a host, and carries no credentials, +/// query, or fragment. Resource servers compare this string exactly, so any +/// variable part of it would weaken the comparison. +pub fn validate_https_issuer(issuer: &str) -> Result<(), ConfigError> { + let url = + Url::parse(issuer).map_err(|_| ConfigError::Invalid("issuer must be an absolute URL"))?; + if url.scheme() != "https" { + return Err(ConfigError::Invalid("issuer must use https")); + } + if !url.has_host() { + return Err(ConfigError::Invalid("issuer must have a host")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ConfigError::Invalid("issuer must not carry credentials")); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(ConfigError::Invalid( + "issuer must not carry a query or fragment", + )); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::fs; + + pub(crate) const VALID: &str = r#" +version: 1 +issuer: https://mint.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.example.org/token + algorithms: [EdDSA] +clients: + directory: clients +"#; + + fn load_from(text: &str) -> Result { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("mint.yaml"); + fs::write(&path, text).expect("write config"); + MintConfig::load(&path) + } + + fn load_error(text: &str) -> ConfigError { + load_from(text).expect_err("the document must be rejected") + } + + /// A valid configuration for tests in other modules that need one but do + /// not exercise loading itself. + pub(crate) fn sample_config() -> MintConfig { + load_from(VALID).expect("the sample configuration is valid") + } + + #[test] + fn a_valid_document_loads_and_resolves_paths_against_the_config_directory() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("mint.yaml"); + fs::write(&path, VALID).expect("write config"); + let config = MintConfig::load(&path).expect("valid config loads"); + + assert_eq!(config.issuer, "https://mint.example.org"); + assert_eq!( + config.signing.active_key_file, + directory.path().join("secrets/signing.jwk") + ); + assert_eq!(config.clients.directory, directory.path().join("clients")); + assert_eq!(config.signing.jwks_path, "/.well-known/jwks.json"); + assert_eq!(config.client_assertion.maximum_lifetime_seconds, 300); + } + + #[test] + fn unknown_fields_are_rejected() { + let text = VALID.replace("version: 1", "version: 1\nunexpected: true"); + assert!(matches!(load_from(&text), Err(ConfigError::Document(_)))); + } + + #[test] + fn issuers_must_be_https_hosts_without_credentials_or_query() { + for issuer in [ + "http://mint.example.org", + "https://user:pass@mint.example.org", + "https://mint.example.org?tenant=a", + "https://mint.example.org#frag", + "mint.example.org", + "https://", + ] { + let text = VALID.replace("https://mint.example.org\n", &format!("{issuer}\n")); + assert!( + matches!(load_from(&text), Err(ConfigError::Invalid(_))), + "issuer {issuer} must be rejected" + ); + } + } + + #[test] + fn access_token_lifetime_is_bounded_on_both_sides() { + for lifetime in ["1", "59", "3601", "86400"] { + let text = VALID.replace( + "lifetimeSeconds: 300", + &format!("lifetimeSeconds: {lifetime}"), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("access token lifetime must be 60..=3600 seconds"), + "lifetime {lifetime} must be rejected" + ); + } + } + + #[test] + fn duplicate_claim_names_are_rejected() { + let text = VALID.replace("grantId: evidence_grant_id", "grantId: evidence_tags"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("claim names must be distinct") + ); + } + + #[test] + fn authority_claims_cannot_shadow_registered_jwt_claims() { + for reserved in ["iss", "aud", "exp", "jti", "client_id"] { + let text = VALID.replace( + "requesterTags: evidence_tags", + &format!("requesterTags: {reserved}"), + ); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "claim {reserved} must be rejected" + ); + } + } + + #[test] + fn version_must_be_one_and_audiences_must_be_present() { + let text = VALID.replace("version: 1", "version: 2"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("only configuration version 1 is supported") + ); + + let text = VALID.replace("audiences: [evidence]", "audiences: []"); + assert_eq!( + load_error(&text), + ConfigError::Invalid("between 1 and 16 audiences are required") + ); + } +} diff --git a/crates/registry-mint/src/error.rs b/crates/registry-mint/src/error.rs new file mode 100644 index 000000000..8208d0896 --- /dev/null +++ b/crates/registry-mint/src/error.rs @@ -0,0 +1,150 @@ +//! OAuth 2.0 token endpoint errors. +//! +//! The public error code is deliberately coarse. Whether a client is unknown, +//! presented a bad signature, replayed a `jti`, or sent an expired assertion, +//! the caller sees `invalid_client`. Distinguishing those cases on the wire +//! would turn the token endpoint into an oracle for probing the client +//! registry. The specific reason is retained for operator logs only. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; + +/// RFC 6749 section 5.2 error codes, restricted to the ones Mint can return. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum TokenErrorCode { + InvalidRequest, + InvalidClient, + UnsupportedGrantType, + ServerError, +} + +impl TokenErrorCode { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InvalidClient => "invalid_client", + Self::UnsupportedGrantType => "unsupported_grant_type", + Self::ServerError => "server_error", + } + } + + #[must_use] + pub fn status(self) -> StatusCode { + match self { + Self::InvalidRequest | Self::UnsupportedGrantType => StatusCode::BAD_REQUEST, + Self::InvalidClient => StatusCode::UNAUTHORIZED, + Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +#[derive(Debug, Serialize)] +struct TokenErrorBody { + error: &'static str, +} + +/// A token endpoint failure carrying a public code and a private reason. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct TokenError { + code: TokenErrorCode, + reason: &'static str, +} + +impl TokenError { + #[must_use] + pub fn new(code: TokenErrorCode, reason: &'static str) -> Self { + Self { code, reason } + } + + #[must_use] + pub fn invalid_request(reason: &'static str) -> Self { + Self::new(TokenErrorCode::InvalidRequest, reason) + } + + /// Every client authentication failure collapses to this variant. + #[must_use] + pub fn invalid_client(reason: &'static str) -> Self { + Self::new(TokenErrorCode::InvalidClient, reason) + } + + #[must_use] + pub fn unsupported_grant_type(reason: &'static str) -> Self { + Self::new(TokenErrorCode::UnsupportedGrantType, reason) + } + + #[must_use] + pub fn server_error(reason: &'static str) -> Self { + Self::new(TokenErrorCode::ServerError, reason) + } + + #[must_use] + pub fn code(&self) -> TokenErrorCode { + self.code + } + + /// Operator-facing detail. Never sent to the caller. + #[must_use] + pub fn reason(&self) -> &'static str { + self.reason + } +} + +impl IntoResponse for TokenError { + fn into_response(self) -> Response { + tracing::warn!( + target: "registry_mint::token", + error = self.code.as_str(), + reason = self.reason, + "token request rejected" + ); + let mut response = ( + self.code.status(), + Json(TokenErrorBody { + error: self.code.as_str(), + }), + ) + .into_response(); + if self.code == TokenErrorCode::InvalidClient { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + axum::http::HeaderValue::from_static("Bearer error=\"invalid_client\""), + ); + } + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_authentication_failures_share_one_public_code() { + for reason in [ + "unknown client", + "signature did not verify", + "assertion replayed", + "assertion expired", + ] { + let error = TokenError::invalid_client(reason); + assert_eq!(error.code().as_str(), "invalid_client"); + assert_eq!(error.code().status(), StatusCode::UNAUTHORIZED); + } + } + + #[test] + fn public_codes_match_the_oauth_registry() { + assert_eq!(TokenErrorCode::InvalidRequest.as_str(), "invalid_request"); + assert_eq!(TokenErrorCode::InvalidClient.as_str(), "invalid_client"); + assert_eq!( + TokenErrorCode::UnsupportedGrantType.as_str(), + "unsupported_grant_type" + ); + assert_eq!(TokenErrorCode::ServerError.as_str(), "server_error"); + } +} diff --git a/crates/registry-mint/src/lib.rs b/crates/registry-mint/src/lib.rs new file mode 100644 index 000000000..0870b4be2 --- /dev/null +++ b/crates/registry-mint/src/lib.rs @@ -0,0 +1,56 @@ +//! Registry Mint: a minimal OAuth 2.0 token issuer for RegistryStack services. +//! +//! Mint exists to answer one question that a JWKS alone cannot answer: *which +//! principal signed this token, and what is that principal allowed to assert?* +//! +//! A resource server such as Evidence verifies an access token by selecting a +//! key from a JWKS using the token's own `kid` header, then reading the +//! authority claims out of the payload. Nothing in that flow binds a key to a +//! permitted claim set, so every key published in a JWKS is equally +//! authoritative for every claim. Distributing signing keys directly to callers +//! therefore makes each caller an issuer able to speak as any other. +//! +//! Mint keeps that binding server-side. Callers hold their own private keys and +//! authenticate with an RFC 7523 `private_key_jwt` client assertion. Mint +//! verifies that assertion against **only the keys registered for the asserted +//! client**, then mints an access token whose authority claims are read from +//! the server-side client registry and never from the assertion. A caller can +//! prove who it is; it cannot choose what it is allowed to say. +//! +//! # Trust split +//! +//! - Issuer identity, signing keys, listener, and token policy are startup-only +//! and immutable for the process lifetime. +//! - The client registry is reloadable, so onboarding, offboarding, and key +//! rotation for callers never require restarting a resource server. +//! +//! That split is the point of running Mint as a separate process: Evidence +//! keeps its immutable governed bundle while the caller population changes. +//! +//! # Naming +//! +//! "Issuer" here means OAuth token issuance. It is unrelated to the verifiable +//! credential issuance performed by Registry Notary. + +#[cfg(not(unix))] +compile_error!( + "registry-mint requires a Unix target for the owner-only signing key file guarantees" +); + +pub mod assertion; +pub mod clients; +pub mod config; +pub mod error; +pub mod replay; +pub mod secretfile; +pub mod server; +pub mod token; + +/// RFC 7523 client assertion type for `private_key_jwt` authentication. +pub const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +/// The only grant type Mint issues tokens for. +pub const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; + +/// Media type of the minted access tokens. +pub const ACCESS_TOKEN_TYP: &str = "at+jwt"; diff --git a/crates/registry-mint/src/main.rs b/crates/registry-mint/src/main.rs new file mode 100644 index 000000000..83f41f383 --- /dev/null +++ b/crates/registry-mint/src/main.rs @@ -0,0 +1,137 @@ +//! The `mint` binary. +//! +//! Two subcommands: `check` validates a deployment without opening a socket, +//! and `serve` runs the token endpoint. `SIGHUP` reloads the client registry in +//! place so onboarding a caller never restarts the service. + +use std::{ + path::{Path, PathBuf}, + process::ExitCode, + sync::Arc, +}; + +use clap::{Parser, Subcommand}; +use registry_mint::{ + config::MintConfig, + server::{serve, MintService}, +}; + +#[derive(Debug, Parser)] +#[command(name = "mint", about = "Registry Stack token issuer", version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Load the configuration, signing key, and client registry, then exit. + Check { + #[arg(long, env = "MINT_CONFIG")] + config: PathBuf, + }, + /// Serve the token endpoint until terminated. + Serve { + #[arg(long, env = "MINT_CONFIG")] + config: PathBuf, + }, +} + +fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .json() + .init(); + + let cli = Cli::parse(); + match run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + // Startup failures name the failing stage, never the key material + // or the file contents that produced them. + tracing::error!(target: "registry_mint", "{message}"); + ExitCode::FAILURE + } + } +} + +fn run(cli: Cli) -> Result<(), String> { + match cli.command { + Command::Check { config } => { + let service = load(&config)?; + tracing::info!( + target: "registry_mint", + issuer = service.issuer(), + clients = service.client_count(), + "configuration is valid" + ); + Ok(()) + } + Command::Serve { config } => { + let service = Arc::new(load(&config)?); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("the async runtime could not start: {error}"))?; + runtime.block_on(async move { + let reloads = Arc::clone(&service); + tokio::spawn(async move { reload_on_hangup(reloads).await }); + serve(service, shutdown_signal()) + .await + .map_err(|error| format!("the listener failed: {error}")) + }) + } + } +} + +fn load(config: &Path) -> Result { + let config = MintConfig::load(config) + .map_err(|error| format!("the configuration could not be loaded: {error}"))?; + MintService::load(config).map_err(|error| format!("the service could not start: {error}")) +} + +/// Reload the client registry on every `SIGHUP`, keeping the previous registry +/// when the new one does not load. +async fn reload_on_hangup(service: Arc) { + let mut hangup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) { + Ok(hangup) => hangup, + Err(error) => { + tracing::error!(target: "registry_mint", "the hangup handler could not be installed: {error}"); + return; + } + }; + while hangup.recv().await.is_some() { + match service.reload_clients() { + Ok(clients) => { + tracing::info!(target: "registry_mint", clients, "client registry reloaded"); + } + Err(error) => { + tracing::error!( + target: "registry_mint", + "the client registry was not reloaded and the previous one is still in use: {error}" + ); + } + } + } +} + +async fn shutdown_signal() { + let interrupt = async { + let _ = tokio::signal::ctrl_c().await; + }; + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut terminate) => { + terminate.recv().await; + } + Err(_) => std::future::pending::<()>().await, + } + }; + tokio::select! { + () = interrupt => {} + () = terminate => {} + } +} diff --git a/crates/registry-mint/src/replay.rs b/crates/registry-mint/src/replay.rs new file mode 100644 index 000000000..931d89e6c --- /dev/null +++ b/crates/registry-mint/src/replay.rs @@ -0,0 +1,116 @@ +//! Single-use enforcement for client assertion `jti` values. +//! +//! A captured client assertion is a bearer credential until it expires. The +//! cache remembers every accepted `jti` until its own expiry, so a captured +//! assertion buys an attacker nothing. + +use std::{collections::HashMap, sync::Mutex}; + +use thiserror::Error; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ReplayError { + #[error("the assertion identifier has already been used")] + AlreadyUsed, + #[error("the replay cache is saturated")] + Saturated, + #[error("the replay cache is poisoned")] + Poisoned, +} + +/// A bounded set of assertion identifiers that have already been spent. +#[derive(Debug)] +pub struct ReplayCache { + capacity: usize, + entries: Mutex>, +} + +impl ReplayCache { + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity, + entries: Mutex::new(HashMap::new()), + } + } + + /// Record `jti` as spent until `expires_at`. + /// + /// Saturation fails closed rather than evicting a live entry. Evicting the + /// oldest entry would let a caller flush the cache with fresh assertions + /// and then replay the one it evicted, which is precisely what this cache + /// exists to prevent. Only clients that already passed signature + /// verification can reach this code, so the failure is bounded to + /// authenticated misbehaviour and is visible to operators. + pub fn remember(&self, jti: &str, expires_at: i64, now: i64) -> Result<(), ReplayError> { + let mut entries = self.entries.lock().map_err(|_| ReplayError::Poisoned)?; + entries.retain(|_, entry_expiry| *entry_expiry > now); + if entries.contains_key(jti) { + return Err(ReplayError::AlreadyUsed); + } + if entries.len() >= self.capacity { + return Err(ReplayError::Saturated); + } + entries.insert(jti.to_owned(), expires_at); + Ok(()) + } + + #[must_use] + pub fn len(&self) -> usize { + self.entries + .lock() + .map(|entries| entries.len()) + .unwrap_or(0) + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_identifier_is_accepted_once_and_then_refused() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!( + cache.remember("jti-1", 100, 0), + Err(ReplayError::AlreadyUsed) + ); + } + + #[test] + fn distinct_identifiers_do_not_collide() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); + assert_eq!(cache.len(), 2); + } + + #[test] + fn entries_are_pruned_once_their_own_expiry_passes() { + let cache = ReplayCache::new(16); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + // At 101 the assertion is expired anyway, so forgetting it is safe and + // the slot is reclaimed. + assert_eq!(cache.remember("jti-2", 200, 101), Ok(())); + assert_eq!(cache.len(), 1); + } + + #[test] + fn saturation_fails_closed_instead_of_evicting_a_live_entry() { + let cache = ReplayCache::new(2); + assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); + assert_eq!(cache.remember("jti-3", 100, 0), Err(ReplayError::Saturated)); + // The entry an attacker would have wanted evicted is still remembered. + assert_eq!( + cache.remember("jti-1", 100, 0), + Err(ReplayError::AlreadyUsed) + ); + } +} diff --git a/crates/registry-mint/src/secretfile.rs b/crates/registry-mint/src/secretfile.rs new file mode 100644 index 000000000..9e7b9d83e --- /dev/null +++ b/crates/registry-mint/src/secretfile.rs @@ -0,0 +1,116 @@ +//! Bounded, owner-only reads of private key material. +//! +//! Mint holds exactly one secret: its access-token signing key. Client +//! registrations carry public keys only, so this module is deliberately small +//! and is the single place private material enters the process. + +use std::{fs, os::unix::fs::MetadataExt, path::Path}; + +use thiserror::Error; +use zeroize::Zeroizing; + +/// Upper bound on a signing key file, generous for any supported JWK. +pub const MAX_SECRET_BYTES: u64 = 64 * 1024; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SecretFileError { + #[error("the secret file is unavailable")] + Unavailable, + #[error("the secret file is not a regular, single-link, owner-only file")] + Unsafe, + #[error("the secret file is too large")] + TooLarge, + #[error("the secret file could not be read")] + Read, + #[error("the secret file is not valid UTF-8")] + InvalidValue, +} + +/// Read a secret file that must be a regular file, owned by the running user, +/// unreadable by group and other, and reachable without traversing a symlink. +/// +/// `symlink_metadata` is used rather than `metadata` so a symlink fails the +/// regular-file check instead of being silently followed to its target. The +/// link count is pinned to one so a hard link created by another user cannot +/// alias the same inode under weaker permissions. +pub fn read_owner_only(path: &Path) -> Result, SecretFileError> { + let metadata = fs::symlink_metadata(path).map_err(|_| SecretFileError::Unavailable)?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(SecretFileError::Unsafe); + } + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(SecretFileError::Unsafe); + } + if metadata.mode() & 0o077 != 0 { + return Err(SecretFileError::Unsafe); + } + if metadata.len() > MAX_SECRET_BYTES { + return Err(SecretFileError::TooLarge); + } + let bytes = Zeroizing::new(fs::read(path).map_err(|_| SecretFileError::Read)?); + let text = std::str::from_utf8(&bytes).map_err(|_| SecretFileError::InvalidValue)?; + Ok(Zeroizing::new(text.trim().to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, os::unix::fs::PermissionsExt}; + + fn write_key(directory: &Path, name: &str, mode: u32) -> std::path::PathBuf { + let path = directory.join(name); + let mut file = fs::File::create(&path).expect("create key file"); + file.write_all(b" key-material ").expect("write key file"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("set mode"); + path + } + + #[test] + fn owner_only_files_are_read_and_trimmed() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = write_key(directory.path(), "signing.jwk", 0o600); + let value = read_owner_only(&path).expect("owner-only file reads"); + assert_eq!(&*value, "key-material"); + } + + #[test] + fn group_or_world_readable_files_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + for mode in [0o640, 0o604, 0o644, 0o660] { + let path = write_key(directory.path(), &format!("key-{mode:o}.jwk"), mode); + assert_eq!( + read_owner_only(&path), + Err(SecretFileError::Unsafe), + "mode {mode:o} must be rejected" + ); + } + } + + #[test] + fn symlinked_and_hard_linked_secrets_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + let target = write_key(directory.path(), "target.jwk", 0o600); + + let symlink = directory.path().join("symlink.jwk"); + std::os::unix::fs::symlink(&target, &symlink).expect("create symlink"); + assert_eq!(read_owner_only(&symlink), Err(SecretFileError::Unsafe)); + + let hard_link = directory.path().join("hard.jwk"); + fs::hard_link(&target, &hard_link).expect("create hard link"); + assert_eq!(read_owner_only(&hard_link), Err(SecretFileError::Unsafe)); + assert_eq!(read_owner_only(&target), Err(SecretFileError::Unsafe)); + } + + #[test] + fn directories_and_missing_paths_are_rejected() { + let directory = tempfile::tempdir().expect("temp dir"); + assert_eq!( + read_owner_only(directory.path()), + Err(SecretFileError::Unsafe) + ); + assert_eq!( + read_owner_only(&directory.path().join("absent.jwk")), + Err(SecretFileError::Unavailable) + ); + } +} diff --git a/crates/registry-mint/src/server.rs b/crates/registry-mint/src/server.rs new file mode 100644 index 000000000..2fd245cc9 --- /dev/null +++ b/crates/registry-mint/src/server.rs @@ -0,0 +1,481 @@ +//! The Mint HTTP boundary. +//! +//! Four routes: the token endpoint, the published key set, authorization server +//! metadata, and the two liveness probes. Everything a caller sends is treated +//! as an unauthenticated claim about identity until the client assertion has +//! been verified against that client's own registered keys. +//! +//! The service holds two kinds of state with deliberately different lifetimes. +//! Issuer identity, signing keys, listener, and token policy are startup-only: +//! changing them means restarting. The client registry is reloadable, so +//! onboarding or removing a caller never restarts a resource server. + +use std::{ + future::{Future, IntoFuture}, + io, + net::SocketAddr, + sync::{Arc, RwLock}, + time::Duration, +}; + +use axum::{ + body::{to_bytes, Body}, + extract::State, + http::{ + header::{CACHE_CONTROL, CONTENT_TYPE, PRAGMA}, + HeaderMap, HeaderValue, Request, StatusCode, + }, + middleware::{from_fn, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use serde_json::{json, Value}; +use thiserror::Error; +use tokio::net::TcpListener; + +use crate::{ + assertion::ClientAuthenticator, + clients::{ClientRegistry, ClientRegistryError}, + config::MintConfig, + error::TokenError, + replay::ReplayCache, + token::{MinterError, TokenMinter}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, +}; + +const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; +const JSON_MEDIA_TYPE: &str = "application/json"; +const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; +const METADATA_PATH: &str = "/.well-known/oauth-authorization-server"; +const TOKEN_PATH: &str = "/token"; + +#[derive(Debug, Error)] +pub enum ServiceError { + #[error("the signing key could not be loaded: {0}")] + Minter(#[from] MinterError), + #[error("the client registry could not be loaded: {0}")] + Registry(#[from] ClientRegistryError), +} + +/// The whole serving state: an immutable minter over a reloadable registry. +pub struct MintService { + config: MintConfig, + minter: TokenMinter, + /// Swapped wholesale on reload. Readers clone the `Arc` and release the + /// lock before any await, so a reload never blocks in-flight requests. + authenticator: RwLock>, + /// Owned by the service rather than the authenticator so that reloading the + /// registry never forgets which assertion identifiers were already spent. + replay: Arc, + metadata: Value, +} + +impl std::fmt::Debug for MintService { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MintService") + .field("issuer", &self.config.issuer) + .field("clients", &self.client_count()) + .finish_non_exhaustive() + } +} + +impl MintService { + /// Load the signing key and the client registry described by `config`. + pub fn load(config: MintConfig) -> Result { + let minter = TokenMinter::new(&config)?; + let registry = Arc::new(ClientRegistry::load(&config.clients.directory)?); + let replay = Arc::new(ReplayCache::new( + config.client_assertion.replay_cache_entries, + )); + let authenticator = + ClientAuthenticator::new(registry, &config.client_assertion, Arc::clone(&replay)); + let metadata = build_metadata(&config); + Ok(Self { + config, + minter, + authenticator: RwLock::new(Arc::new(authenticator)), + replay, + metadata, + }) + } + + /// Re-read the client registry directory and swap it in atomically. + /// + /// A failed reload leaves the previous registry in place: a malformed file + /// dropped into the directory must not silently revoke every caller. + pub fn reload_clients(&self) -> Result { + let registry = Arc::new(ClientRegistry::load(&self.config.clients.directory)?); + let count = registry.len(); + let authenticator = Arc::new(ClientAuthenticator::new( + registry, + &self.config.client_assertion, + Arc::clone(&self.replay), + )); + *self + .authenticator + .write() + .expect("the client registry lock is never poisoned") = authenticator; + Ok(count) + } + + #[must_use] + pub fn client_count(&self) -> usize { + self.authenticator().registry().len() + } + + #[must_use] + pub fn issuer(&self) -> &str { + &self.config.issuer + } + + #[must_use] + pub fn jwks(&self) -> &Value { + self.minter.jwks() + } + + fn authenticator(&self) -> Arc { + Arc::clone( + &self + .authenticator + .read() + .expect("the client registry lock is never poisoned"), + ) + } + + /// Authenticate a token request and mint the authority its registry entry + /// carries. Nothing is read from the assertion payload. + async fn issue(&self, request: &TokenRequest, now: i64) -> Result { + if request.grant_type != GRANT_TYPE_CLIENT_CREDENTIALS { + return Err(TokenError::unsupported_grant_type( + "grant type is not supported", + )); + } + if request.client_assertion_type != CLIENT_ASSERTION_TYPE { + return Err(TokenError::invalid_request( + "client assertion type is not supported", + )); + } + + // Cloned out of the lock so a concurrent reload cannot block here. + let authenticator = self.authenticator(); + let client = authenticator + .authenticate(&request.client_assertion, now) + .await?; + let token = self.minter.mint(&client, now).await?; + + serde_json::to_vec(&token) + .map(|body| json_response(StatusCode::OK, JSON_MEDIA_TYPE, body)) + .map_err(|_| TokenError::server_error("the token response could not be serialized")) + } +} + +fn build_metadata(config: &MintConfig) -> Value { + let issuer = config.issuer.trim_end_matches('/'); + let algorithms = { + let mut algorithms = config + .client_assertion + .algorithms + .iter() + .map(|algorithm| algorithm.as_header_value()) + .collect::>(); + algorithms.sort_unstable(); + algorithms.dedup(); + algorithms + }; + json!({ + "issuer": config.issuer, + "token_endpoint": format!("{issuer}{TOKEN_PATH}"), + "jwks_uri": format!("{issuer}{}", config.signing.jwks_path), + "grant_types_supported": [GRANT_TYPE_CLIENT_CREDENTIALS], + "token_endpoint_auth_methods_supported": ["private_key_jwt"], + "token_endpoint_auth_signing_alg_values_supported": algorithms, + // Mint has no authorization endpoint: there is no user to redirect. + "response_types_supported": [], + }) +} + +/// The three parameters Mint reads from a token request. +/// +/// RFC 6749 section 3.1 requires unrecognized parameters to be ignored and +/// forbids any parameter appearing more than once, so this is parsed by hand +/// rather than through a permissive form deserializer. +#[derive(Debug)] +struct TokenRequest { + grant_type: String, + client_assertion_type: String, + client_assertion: String, +} + +fn parse_token_request(body: &[u8]) -> Result { + let mut grant_type = None; + let mut client_assertion_type = None; + let mut client_assertion = None; + + for (name, value) in url::form_urlencoded::parse(body) { + let slot = match name.as_ref() { + "grant_type" => &mut grant_type, + "client_assertion_type" => &mut client_assertion_type, + "client_assertion" => &mut client_assertion, + // Ignored by RFC 6749 section 3.1. + _ => continue, + }; + // A repeated parameter leaves which value was authenticated ambiguous. + if slot.is_some() { + return Err(TokenError::invalid_request( + "a request parameter was repeated", + )); + } + *slot = Some(value.into_owned()); + } + + Ok(TokenRequest { + grant_type: grant_type + .ok_or_else(|| TokenError::invalid_request("grant_type is missing"))?, + client_assertion_type: client_assertion_type + .ok_or_else(|| TokenError::invalid_request("client_assertion_type is missing"))?, + client_assertion: client_assertion + .ok_or_else(|| TokenError::invalid_request("client_assertion is missing"))?, + }) +} + +/// Build the router over an already loaded service. +pub fn build_app(service: Arc) -> Router { + let jwks_path = service.config.signing.jwks_path.clone(); + let routes = Router::new() + .route(TOKEN_PATH, post(token)) + .route(&jwks_path, get(jwks)) + .route(METADATA_PATH, get(metadata)) + .route("/health", get(health)) + .route("/ready", get(ready)) + .fallback(unknown_route) + .method_not_allowed_fallback(unknown_route) + .with_state(service); + routes.layer(from_fn(add_no_store)) +} + +/// Bind the configured listener and serve until `shutdown` resolves. +pub async fn serve(service: Arc, shutdown: F) -> io::Result<()> +where + F: Future + Send + 'static, +{ + let bind_ip = service + .config + .listener + .bind_address() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let address = SocketAddr::new(bind_ip, service.config.listener.port); + let listener = TcpListener::bind(address).await?; + tracing::info!( + target: "registry_mint::server", + issuer = %service.config.issuer, + clients = service.client_count(), + "mint listening" + ); + let app = build_app(service); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .into_future() + .await +} + +async fn token(State(service): State>, request: Request) -> Response { + if !has_exact_content_type(request.headers(), FORM_MEDIA_TYPE) { + return TokenError::invalid_request("content type must be form encoded").into_response(); + } + + let maximum_bytes = service.config.listener.maximum_request_bytes as usize; + let timeout = Duration::from_millis(service.config.listener.request_timeout_milliseconds); + let body = + match tokio::time::timeout(timeout, to_bytes(request.into_body(), maximum_bytes)).await { + Ok(Ok(body)) => body, + Ok(Err(_)) => { + return TokenError::invalid_request("the request body could not be read") + .into_response() + } + Err(_) => { + return TokenError::invalid_request("the request body timed out").into_response(); + } + }; + + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let parsed = match parse_token_request(&body) { + Ok(parsed) => parsed, + Err(error) => return error.into_response(), + }; + match service.issue(&parsed, now).await { + Ok(response) => response, + Err(error) => error.into_response(), + } +} + +async fn jwks(State(service): State>) -> Response { + match serde_json::to_vec(service.jwks()) { + Ok(body) => json_response(StatusCode::OK, JWKS_MEDIA_TYPE, body), + Err(_) => TokenError::server_error("the key set could not be serialized").into_response(), + } +} + +async fn metadata(State(service): State>) -> Response { + match serde_json::to_vec(&service.metadata) { + Ok(body) => json_response(StatusCode::OK, JSON_MEDIA_TYPE, body), + Err(_) => TokenError::server_error("the metadata could not be serialized").into_response(), + } +} + +async fn health() -> Response { + json_response( + StatusCode::OK, + JSON_MEDIA_TYPE, + br#"{"status":"ok"}"#.to_vec(), + ) +} + +async fn ready(State(service): State>) -> Response { + // A Mint with no registered clients is running but cannot serve anybody, + // so it reports live but not ready rather than failing every request. + if service.client_count() == 0 { + return json_response( + StatusCode::SERVICE_UNAVAILABLE, + JSON_MEDIA_TYPE, + br#"{"status":"no clients registered"}"#.to_vec(), + ); + } + json_response( + StatusCode::OK, + JSON_MEDIA_TYPE, + br#"{"status":"ready"}"#.to_vec(), + ) +} + +async fn unknown_route() -> Response { + TokenError::invalid_request("no such route").into_response() +} + +/// RFC 6749 section 5.1 requires both headers on token responses. Applying them +/// to every route keeps the key set and metadata out of shared caches too. +async fn add_no_store(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert(PRAGMA, HeaderValue::from_static("no-cache")); + headers.insert( + http::header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +fn json_response(status: StatusCode, media_type: &'static str, body: Vec) -> Response { + ( + status, + [(CONTENT_TYPE, HeaderValue::from_static(media_type))], + body, + ) + .into_response() +} + +fn has_exact_content_type(headers: &HeaderMap, expected: &str) -> bool { + let Some(value) = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + // Only a bare type or one carrying the redundant charset is accepted; a + // multipart or otherwise decorated type is not this endpoint's input. + let (media_type, parameters) = match value.split_once(';') { + Some((media_type, parameters)) => (media_type, Some(parameters)), + None => (value, None), + }; + if !media_type.trim().eq_ignore_ascii_case(expected) { + return false; + } + match parameters { + None => true, + Some(parameters) => parameters.trim().eq_ignore_ascii_case("charset=utf-8"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_repeated_parameter_is_rejected() { + let error = parse_token_request(b"grant_type=a&grant_type=b") + .expect_err("a repeated parameter must be rejected"); + assert_eq!( + error, + TokenError::invalid_request("a request parameter was repeated") + ); + } + + #[test] + fn unrecognized_parameters_are_ignored() { + let request = parse_token_request( + b"grant_type=client_credentials&scope=anything&client_assertion_type=t&client_assertion=a", + ) + .expect("unrecognized parameters must be ignored"); + assert_eq!(request.grant_type, "client_credentials"); + assert_eq!(request.client_assertion_type, "t"); + assert_eq!(request.client_assertion, "a"); + } + + #[test] + fn each_required_parameter_is_required() { + for body in [ + &b"client_assertion_type=t&client_assertion=a"[..], + &b"grant_type=g&client_assertion=a"[..], + &b"grant_type=g&client_assertion_type=t"[..], + ] { + let error = + parse_token_request(body).expect_err("a missing parameter must be rejected"); + assert_eq!(error.code(), crate::error::TokenErrorCode::InvalidRequest); + } + } + + #[test] + fn content_type_must_be_the_form_media_type() { + let mut headers = HeaderMap::new(); + for (value, expected) in [ + ("application/x-www-form-urlencoded", true), + ("application/x-www-form-urlencoded; charset=utf-8", true), + ("application/x-www-form-urlencoded; charset=UTF-8", true), + ("application/json", false), + ("multipart/form-data; boundary=x", false), + ("application/x-www-form-urlencoded; boundary=x", false), + ] { + headers.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("header")); + assert_eq!( + has_exact_content_type(&headers, FORM_MEDIA_TYPE), + expected, + "{value}" + ); + } + } + + #[test] + fn metadata_describes_the_endpoints_a_client_needs() { + let config = crate::config::tests::sample_config(); + let metadata = build_metadata(&config); + assert_eq!(metadata["issuer"], json!("https://mint.example.org")); + assert_eq!( + metadata["token_endpoint"], + json!("https://mint.example.org/token") + ); + assert_eq!( + metadata["jwks_uri"], + json!("https://mint.example.org/.well-known/jwks.json") + ); + assert_eq!( + metadata["token_endpoint_auth_methods_supported"], + json!(["private_key_jwt"]) + ); + assert_eq!( + metadata["grant_types_supported"], + json!(["client_credentials"]) + ); + } +} diff --git a/crates/registry-mint/src/token.rs b/crates/registry-mint/src/token.rs new file mode 100644 index 000000000..310a029ea --- /dev/null +++ b/crates/registry-mint/src/token.rs @@ -0,0 +1,455 @@ +//! Access token minting. +//! +//! Every authority claim written here is read from the server-side client +//! registry. Nothing is copied from the client assertion. That is what makes a +//! caller's private key a proof of *identity* rather than a licence to assert +//! whatever it likes: the caller chooses which registry entry it authenticates +//! as, and the registry chooses what that entry may say. + +use std::path::Path; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use thiserror::Error; + +use crate::{ + clients::{contains_private_material, RegisteredClient}, + config::{Algorithm, ClaimNames, MintConfig}, + error::TokenError, + secretfile::{self, SecretFileError}, + ACCESS_TOKEN_TYP, +}; + +#[derive(Debug, Error)] +pub enum MinterError { + #[error("the signing key file could not be read: {0}")] + SigningKeyFile(#[from] SecretFileError), + #[error("the signing key is invalid: {0}")] + SigningKey(&'static str), + #[error("a retired public key is invalid: {0}")] + RetiredKey(&'static str), +} + +/// A minted access token and the lifetime the caller should assume. +#[derive(Debug, Serialize)] +pub struct MintedToken { + pub access_token: String, + pub token_type: &'static str, + pub expires_in: u64, +} + +/// Signs access tokens with the configured active key. +pub struct TokenMinter { + issuer: String, + audience: Value, + lifetime_seconds: i64, + claims: ClaimNames, + algorithm: Algorithm, + signer: LocalJwkSigner, + jwks: Value, +} + +impl std::fmt::Debug for TokenMinter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TokenMinter") + .field("issuer", &self.issuer) + .field("algorithm", &self.algorithm) + .field("key_id", &self.signer.key_id()) + .finish_non_exhaustive() + } +} + +impl TokenMinter { + /// Load the active signing key and build the published JWK set. + pub fn new(config: &MintConfig) -> Result { + let key_text = secretfile::read_owner_only(&config.signing.active_key_file)?; + let private = PrivateJwk::parse(&key_text) + .map_err(|_| MinterError::SigningKey("not a private JWK"))?; + + // A mismatch here would publish one key id and sign with another, so + // verifiers would fail to find the key that actually signed. + if private.kid.as_deref() != Some(config.signing.active_key_id.as_str()) { + return Err(MinterError::SigningKey( + "key id does not match the configured active key id", + )); + } + let signer = + LocalJwkSigner::new(private).map_err(|_| MinterError::SigningKey("is not usable"))?; + if signer.public_jwk().alg.as_deref() != Some(config.signing.algorithm.as_header_value()) { + return Err(MinterError::SigningKey( + "algorithm does not match the configured signing algorithm", + )); + } + + let jwks = build_jwks(&signer, &config.signing.retired_public_jwk_files)?; + + let audience = if config.access_tokens.audiences.len() == 1 { + Value::String(config.access_tokens.audiences[0].clone()) + } else { + Value::Array( + config + .access_tokens + .audiences + .iter() + .map(|audience| Value::String(audience.clone())) + .collect(), + ) + }; + + Ok(Self { + issuer: config.issuer.clone(), + audience, + lifetime_seconds: config.access_tokens.lifetime_seconds as i64, + claims: config.access_tokens.claims.clone(), + algorithm: config.signing.algorithm, + signer, + jwks, + }) + } + + /// The public key set resource servers fetch to verify minted tokens. + #[must_use] + pub fn jwks(&self) -> &Value { + &self.jwks + } + + #[must_use] + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Mint an access token carrying the registry's authority for `client`. + pub async fn mint( + &self, + client: &RegisteredClient, + now: i64, + ) -> Result { + let expires_at = now + self.lifetime_seconds; + let mut claims = Map::new(); + claims.insert("iss".to_owned(), Value::String(self.issuer.clone())); + claims.insert("aud".to_owned(), self.audience.clone()); + claims.insert("iat".to_owned(), json!(now)); + claims.insert("nbf".to_owned(), json!(now)); + claims.insert("exp".to_owned(), json!(expires_at)); + claims.insert( + "jti".to_owned(), + Value::String(ulid::Ulid::new().to_string()), + ); + // `client_id` records which registration authenticated; the principal + // is what the resource server acts on. They are allowed to differ. + claims.insert( + "client_id".to_owned(), + Value::String(client.client_id().to_owned()), + ); + + // `sub` always carries the principal so the token is meaningful to a + // standard OAuth consumer, even when the resource server reads the + // principal from a differently named claim. + claims.insert( + "sub".to_owned(), + Value::String(client.principal().to_owned()), + ); + claims.insert( + self.claims.principal.clone(), + Value::String(client.principal().to_owned()), + ); + claims.insert( + self.claims.requester_tags.clone(), + Value::Array( + client + .requester_tags() + .iter() + .map(|tag| Value::String(tag.clone())) + .collect(), + ), + ); + claims.insert( + self.claims.evidence_audience.clone(), + Value::String(client.evidence_audience().to_owned()), + ); + // Evidence requires the grant id and authority together or not at all, + // which the registry already guarantees by construction. + if let Some(grant) = client.grant() { + claims.insert( + self.claims.grant_id.clone(), + Value::String(grant.id.clone()), + ); + claims.insert( + self.claims.grant_authority.clone(), + Value::String(grant.authority.clone()), + ); + } + + let header = json!({ + "alg": self.algorithm.as_header_value(), + "typ": ACCESS_TOKEN_TYP, + "kid": self.signer.key_id(), + }); + let signing_input = format!( + "{}.{}", + encode_json(&header)?, + encode_json(&Value::Object(claims))? + ); + let signature = self + .signer + .sign(signing_input.as_bytes()) + .await + .map_err(|_| TokenError::server_error("the access token could not be signed"))?; + + Ok(MintedToken { + access_token: format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)), + token_type: "Bearer", + expires_in: self.lifetime_seconds as u64, + }) + } +} + +fn encode_json(value: &Value) -> Result { + let bytes = serde_json::to_vec(value) + .map_err(|_| TokenError::server_error("a token component could not be serialized"))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +/// Publish the active public key plus any retired public keys whose tokens may +/// still be in flight. +fn build_jwks( + signer: &LocalJwkSigner, + retired: &[std::path::PathBuf], +) -> Result { + let active = serde_json::to_value(signer.public_jwk()) + .map_err(|_| MinterError::SigningKey("public key could not be serialized"))?; + let mut keys = vec![active]; + for path in retired { + keys.push(load_retired_public_key(path)?); + } + Ok(json!({ "keys": keys })) +} + +fn load_retired_public_key(path: &Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|_| MinterError::RetiredKey("is unreadable"))?; + let value: Value = + serde_json::from_str(&text).map_err(|_| MinterError::RetiredKey("is not JSON"))?; + let object = value + .as_object() + .ok_or(MinterError::RetiredKey("is not a JSON object"))?; + // The whole point of the published set is that it is public. + if contains_private_material(object) { + return Err(MinterError::RetiredKey("contains private key material")); + } + if !object.get("kid").is_some_and(Value::is_string) { + return Err(MinterError::RetiredKey("has no key id")); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clients::ClientRegistry; + use std::{fs, os::unix::fs::PermissionsExt}; + + const NOW: i64 = 1_800_000_000; + + fn ed25519_key(seed: u8, kid: &str) -> (String, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let private = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + (private.to_string(), public) + } + + struct Fixture { + _directory: tempfile::TempDir, + minter: TokenMinter, + registry: ClientRegistry, + } + + fn fixture(grant: Option<&str>) -> Fixture { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir_all(root.join("clients")).expect("client dir"); + + let (private, _public) = ed25519_key(9, "mint-2026-01"); + let key_path = root.join("signing.jwk"); + fs::write(&key_path, private).expect("write signing key"); + fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + + let grant_line = grant + .map(|value| format!("grant: {value}\n")) + .unwrap_or_default(); + fs::write( + root.join("clients/client-a.yaml"), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n", ed25519_key(1, "client-a-1").1), + ) + .expect("write client"); + + let config_path = root.join("mint.yaml"); + fs::write( + &config_path, + r#" +version: 1 +issuer: https://mint.example.org +listener: {address: 127.0.0.1, port: 8081} +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: signing.jwk +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.example.org/token + algorithms: [EdDSA] +clients: + directory: clients +"#, + ) + .expect("write config"); + + let config = MintConfig::load(&config_path).expect("config loads"); + let registry = ClientRegistry::load(&config.clients.directory).expect("registry loads"); + let minter = TokenMinter::new(&config).expect("minter builds"); + Fixture { + _directory: directory, + minter, + registry, + } + } + + fn decode_claims(token: &str) -> Value { + let segment = token.split('.').nth(1).expect("token has a claims segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("claims decode")) + .expect("claims parse") + } + + fn decode_header(token: &str) -> Value { + let segment = token.split('.').next().expect("token has a header segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("header decode")) + .expect("header parse") + } + + #[tokio::test] + async fn minted_claims_come_from_the_registry() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture.minter.mint(client, NOW).await.expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert_eq!(claims["iss"], json!("https://mint.example.org")); + assert_eq!(claims["aud"], json!("evidence")); + assert_eq!(claims["sub"], json!("urn:example:client-a")); + assert_eq!(claims["client_id"], json!("client-a")); + assert_eq!( + claims["evidence_tags"], + json!(["ministry-of-health", "tier-one"]) + ); + assert_eq!( + claims["evidence_audience"], + json!("https://client-a.example.org") + ); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!(claims["nbf"], json!(NOW)); + assert_eq!(claims["exp"], json!(NOW + 300)); + assert_eq!(minted.expires_in, 300); + assert_eq!(minted.token_type, "Bearer"); + } + + #[tokio::test] + async fn the_header_names_the_active_key_and_access_token_type() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture.minter.mint(client, NOW).await.expect("token mints"); + + assert_eq!( + decode_header(&minted.access_token), + json!({"alg": "EdDSA", "typ": "at+jwt", "kid": "mint-2026-01"}) + ); + } + + #[tokio::test] + async fn a_grant_is_minted_as_a_matched_pair_or_not_at_all() { + let without = fixture(None); + let client = without.registry.get("client-a").expect("client registered"); + let claims = decode_claims( + &without + .minter + .mint(client, NOW) + .await + .expect("token mints") + .access_token, + ); + assert!(claims.get("evidence_grant_id").is_none()); + assert!(claims.get("evidence_authority").is_none()); + + let with = fixture(Some("{id: grant-1, authority: statute-7}")); + let client = with.registry.get("client-a").expect("client registered"); + let claims = decode_claims( + &with + .minter + .mint(client, NOW) + .await + .expect("token mints") + .access_token, + ); + assert_eq!(claims["evidence_grant_id"], json!("grant-1")); + assert_eq!(claims["evidence_authority"], json!("statute-7")); + } + + #[tokio::test] + async fn every_token_carries_a_distinct_identifier() { + let fixture = fixture(None); + let client = fixture.registry.get("client-a").expect("client registered"); + let first = fixture.minter.mint(client, NOW).await.expect("token mints"); + let second = fixture.minter.mint(client, NOW).await.expect("token mints"); + + let first_jti = decode_claims(&first.access_token)["jti"].clone(); + let second_jti = decode_claims(&second.access_token)["jti"].clone(); + assert_ne!(first_jti, second_jti); + } + + #[test] + fn the_published_key_set_carries_public_material_only() { + let fixture = fixture(None); + let rendered = serde_json::to_string(fixture.minter.jwks()).expect("jwks serializes"); + for member in [ + "\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"", "\"k\"", + ] { + assert!( + !rendered.contains(member), + "the published key set must not contain {member}" + ); + } + assert!(rendered.contains("mint-2026-01")); + } + + #[test] + fn debug_output_never_reveals_the_signing_key() { + let fixture = fixture(None); + let rendered = format!("{:?}", fixture.minter); + + // Useful for operators: which key is live, and under what identity. + assert!(rendered.contains("mint-2026-01")); + assert!(rendered.contains("https://mint.example.org")); + + // The private scalar of the fixture's signing key, verbatim. Debug is + // the easiest place for key material to escape into a log line. + let private_scalar = URL_SAFE_NO_PAD.encode([9u8; 32]); + assert!( + !rendered.contains(&private_scalar), + "the debug output must never carry private key material" + ); + } +} diff --git a/crates/registry-mint/tests/evidence_compatibility.rs b/crates/registry-mint/tests/evidence_compatibility.rs new file mode 100644 index 000000000..f06ea61e4 --- /dev/null +++ b/crates/registry-mint/tests/evidence_compatibility.rs @@ -0,0 +1,368 @@ +//! End-to-end proof that a token Mint issues is one Evidence accepts. +//! +//! This is the test that justifies the crate existing. It drives the real Mint +//! router over a real deployment on disk, and feeds the resulting access token +//! to the real Evidence authenticator. Nothing here stubs a boundary: if the +//! two products ever disagree about claim names, algorithms, token type, +//! issuer, or audience, this fails. + +use std::{fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; + +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence::auth::{AuthenticationClaimsConfig, Authenticator}; +use registry_mint::{ + config::MintConfig, + server::{build_app, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, +}; +use registry_platform_crypto::PrivateJwk; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; + +const ISSUER: &str = "https://mint.example.org"; +const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; +const EVIDENCE_AUDIENCE: &str = "evidence.example.org"; + +/// The claim names shared by the two configuration documents. Evidence reads +/// exactly these, and Mint writes exactly these. +const PRINCIPAL_CLAIM: &str = "sub"; +const REQUESTER_TAGS_CLAIM: &str = "evidence_tags"; +const EVIDENCE_AUDIENCE_CLAIM: &str = "evidence_audience"; +const GRANT_ID_CLAIM: &str = "evidence_grant_id"; +const GRANT_AUTHORITY_CLAIM: &str = "evidence_authority"; + +/// Deterministic Ed25519 material, so a test can hold several distinct +/// identities and know which one signed what. +fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + let private_document = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); + (private, public, private_document) +} + +struct Deployment { + /// Held so the directory outlives the service that reads from it. + _directory: tempfile::TempDir, + service: Arc, +} + +/// Write a complete Mint deployment to disk and load it exactly as the binary +/// would, including the owner-only permission requirement on the signing key. +fn deployment() -> Deployment { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, _, signing_document) = key_pair(9); + let signing_path = root.join("secrets/signing.jwk"); + fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); + fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) + .expect("restrict signing key"); + + let (_, health_public, _) = key_pair(1); + write_client( + root, + "health-ministry", + &health_public, + Some(("grant-7", "statute-12")), + ); + let (_, statistics_public, _) = key_pair(2); + write_client(root, "statistics-office", &statistics_public, None); + + let config_path = root.join("mint.yaml"); + fs::write( + &config_path, + format!( + r#" +version: 1 +issuer: {ISSUER} +listener: {{address: 127.0.0.1, port: 0}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [{EVIDENCE_AUDIENCE}] + lifetimeSeconds: 300 + claims: + principal: {PRINCIPAL_CLAIM} + requesterTags: {REQUESTER_TAGS_CLAIM} + evidenceAudience: {EVIDENCE_AUDIENCE_CLAIM} + grantId: {GRANT_ID_CLAIM} + grantAuthority: {GRANT_AUTHORITY_CLAIM} +clientAssertion: + audience: {ASSERTION_AUDIENCE} + algorithms: [EdDSA] +clients: + directory: clients +"# + ), + ) + .expect("write config"); + + let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); + let service = Arc::new(MintService::load(config).expect("the deployment loads")); + Deployment { + _directory: directory, + service, + } +} + +fn write_client(root: &Path, client_id: &str, public: &Value, grant: Option<(&str, &str)>) { + let mut document = format!( + "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [{client_id}]\nkeys: [{public}]\n" + ); + if let Some((id, authority)) = grant { + document.push_str(&format!("grant: {{id: {id}, authority: {authority}}}\n")); + } + fs::write(root.join(format!("clients/{client_id}.yaml")), document) + .expect("write client registration"); +} + +fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { + let kid = private.kid.clone().expect("the test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = + registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + +fn assertion_claims(client_id: &str, jti: &str) -> Value { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + json!({ + "iss": client_id, + "sub": client_id, + "aud": ASSERTION_AUDIENCE, + "iat": now, + "exp": now + 120, + "jti": jti, + }) +} + +fn token_form(assertion: &str) -> Vec<(String, String)> { + vec![ + ( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + ), + ( + "client_assertion_type".to_owned(), + CLIENT_ASSERTION_TYPE.to_owned(), + ), + ("client_assertion".to_owned(), assertion.to_owned()), + ] +} + +/// Build the Evidence authenticator the way `Authenticator::from_config` does, +/// but over the key set Mint actually published rather than an HTTPS fetch. +fn evidence_authenticator(jwks: &Value) -> Authenticator { + let key_set: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); + let verifier_config = TokenVerifierConfig::access_token_profile( + ISSUER.to_owned(), + vec![EVIDENCE_AUDIENCE.to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ); + let fetcher = Arc::new(JwksFetcher::new_static( + key_set, + JwksFetcherConfig::defaults(), + )); + Authenticator::new( + Arc::new(TokenVerifier::new(verifier_config, fetcher)), + AuthenticationClaimsConfig { + principal_claim: PRINCIPAL_CLAIM.to_owned(), + requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), + evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), + grant_id_claim: GRANT_ID_CLAIM.to_owned(), + grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), + actor_claim: None, + }, + ) +} + +#[tokio::test] +async fn a_client_signing_with_its_own_key_receives_a_token_evidence_accepts() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let jwks = http.get("/.well-known/jwks.json").await; + jwks.assert_status_ok(); + assert_eq!(jwks.header("content-type"), "application/jwk-set+json"); + let published = jwks.json::(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-1")); + let response = http.post("/token").form(&token_form(&assertion)).await; + response.assert_status_ok(); + // RFC 6749 section 5.1: a token response must never be cached. + assert_eq!(response.header("cache-control"), "no-store"); + let body = response.json::(); + assert_eq!(body["token_type"], json!("Bearer")); + assert_eq!(body["expires_in"], json!(300)); + let access_token = body["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned(); + + let context = evidence_authenticator(&published) + .authenticate(&access_token) + .await + .expect("Evidence accepts a token Mint issued"); + + // Every one of these came from the server-side registry, not the assertion. + assert_eq!(context.principal(), "urn:example:health-ministry"); + assert_eq!(context.requester_tags(), ["health-ministry"]); + assert_eq!( + context.evidence_audience(), + "https://health-ministry.example.org" + ); + assert_eq!(context.grant_id(), Some("grant-7")); + assert_eq!(context.grant_authority(), Some("statute-12")); + assert_eq!(context.actor(), None); +} + +#[tokio::test] +async fn a_registered_client_cannot_borrow_another_clients_authority() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + // The health ministry's own key, but claiming to be the statistics office. + let (health_private, _, _) = key_pair(1); + let forged = sign_assertion( + &health_private, + &assertion_claims("statistics-office", "jti-forged"), + ); + let response = http.post("/token").form(&token_form(&forged)).await; + + assert_eq!(response.status_code(), 401); + // The public error never distinguishes an unknown client from a bad + // signature, so it cannot be used to enumerate the registry. + assert_eq!(response.json::(), json!({"error": "invalid_client"})); + + let unknown = sign_assertion( + &health_private, + &assertion_claims("no-such-client", "jti-x"), + ); + let response = http.post("/token").form(&token_form(&unknown)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); +} + +#[tokio::test] +async fn an_assertion_cannot_be_presented_twice() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-once")); + + let first = http.post("/token").form(&token_form(&assertion)).await; + first.assert_status_ok(); + + let replayed = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(replayed.status_code(), 401); + assert_eq!(replayed.json::(), json!({"error": "invalid_client"})); +} + +#[tokio::test] +async fn a_client_without_a_registered_grant_receives_no_grant_claims() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let published = http.get("/.well-known/jwks.json").await.json::(); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-2")); + let response = http.post("/token").form(&token_form(&assertion)).await; + response.assert_status_ok(); + let access_token = response.json::()["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned(); + + let context = evidence_authenticator(&published) + .authenticate(&access_token) + .await + .expect("Evidence accepts the token"); + assert_eq!(context.principal(), "urn:example:statistics-office"); + assert_eq!(context.grant_id(), None); + assert_eq!(context.grant_authority(), None); +} + +#[tokio::test] +async fn the_published_metadata_points_at_the_endpoints_that_exist() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let metadata = http.get("/.well-known/oauth-authorization-server").await; + metadata.assert_status_ok(); + let document = metadata.json::(); + assert_eq!(document["issuer"], json!(ISSUER)); + assert_eq!(document["token_endpoint"], json!(ASSERTION_AUDIENCE)); + assert_eq!( + document["jwks_uri"], + json!(format!("{ISSUER}/.well-known/jwks.json")) + ); + assert_eq!( + document["token_endpoint_auth_methods_supported"], + json!(["private_key_jwt"]) + ); + + // The metadata must describe routes this router actually serves. + http.get("/.well-known/jwks.json").await.assert_status_ok(); + let ready = http.get("/ready").await; + ready.assert_status_ok(); +} + +#[tokio::test] +async fn the_token_endpoint_refuses_anything_but_the_supported_grant() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-grant")); + + let mut form = token_form(&assertion); + form[0].1 = "password".to_owned(); + let response = http.post("/token").form(&form).await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "unsupported_grant_type"}) + ); + + let mut form = token_form(&assertion); + form[1].1 = "urn:example:something-else".to_owned(); + let response = http.post("/token").form(&form).await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "invalid_request"}) + ); + + // A bearer-style secret is not an accepted authentication method. + let response = http + .post("/token") + .form(&vec![( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + )]) + .await; + assert_eq!(response.status_code(), 400); + assert_eq!( + response.json::(), + json!({"error": "invalid_request"}) + ); +} From 00b4604e5014cbe47d69d427e6c7d4c97d8e906f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:04:17 +0700 Subject: [PATCH 004/136] wip(evidence): version one closeout in progress Accepted additions and must-close blockers landed so far: required request nonce end to end, governed response formats with strict Accept negotiation and the unsigned envelope, serialize-before-release-audit ordering, format-aware audit, strict verifier expectations, subject role order by declaration, Rhai containment/indexing/numeric/required fixes, transport pinning, existence-channel collapse, JWKS parity, operation exhaustion, acceptance traceability rows 1-62 with checker, contract and documentation reconciliation. Work in progress: bundle diagnostics, SIGTERM lifecycle and audit rotation procedure, reserved-header aliases, and the evidence verify subcommand are mid-implementation; independent reviews and the final same-revision gates have not run. Includes the separately authored evidence-definitions discovery endpoint that was staged in this worktree. Not a completion claim. Signed-off-by: Jeremi Joslin --- AGENTS.md | 66 +- crates/registry-evidence/README.md | 43 + crates/registry-evidence/src/audit.rs | 58 +- crates/registry-evidence/src/bundle.rs | 625 ++++--- crates/registry-evidence/src/config.rs | 639 ++++++- crates/registry-evidence/src/contracts.rs | 154 +- crates/registry-evidence/src/kernel.rs | 31 +- crates/registry-evidence/src/lib.rs | 3 + crates/registry-evidence/src/main.rs | 395 ++++- crates/registry-evidence/src/model.rs | 115 ++ crates/registry-evidence/src/problem.rs | 9 + crates/registry-evidence/src/rhai_runtime.rs | 921 +++++++++- crates/registry-evidence/src/runtime.rs | 268 ++- crates/registry-evidence/src/runtime_tests.rs | 831 ++++++++- crates/registry-evidence/src/selector.rs | 42 +- crates/registry-evidence/src/server.rs | 192 +- crates/registry-evidence/src/source.rs | 160 +- crates/registry-evidence/src/verifier.rs | 484 ++++- crates/registry-evidence/tests/cli.rs | 722 +++++++- .../tests/deployment_projects.rs | 31 +- .../tests/security_contract_traceability.rs | 273 ++- .../tests/selector_conformance.rs | 40 +- .../tests/source_contracts.rs | 139 +- products/evidence/AGENTS.md | 163 ++ products/evidence/CONCEPT.md | 1560 +++++++++++++++++ products/evidence/FIRST-CURL-TEST.md | 217 +++ products/evidence/IMPLEMENTATION.md | 890 ++++++++++ products/evidence/OPERATOR-CONTRACT.md | 463 +++++ products/evidence/README.md | 146 ++ products/evidence/SOURCE-TESTING.md | 401 +++++ products/evidence/contracts/README.md | 93 + .../acceptance-test-traceability.yaml | 417 +++++ .../contracts/audit-event.schema.yaml | 15 +- .../evidence/contracts/bundle.schema.yaml | 12 + .../contracts/cccev-field-mapping.yaml | 3 + .../evidence/contracts/evidence.schema.yaml | 9 +- products/evidence/contracts/jws-profile.yaml | 35 +- .../evidence/contracts/primitive-library.yaml | 10 +- .../evidence/contracts/problem-contract.yaml | 6 +- .../evidence/contracts/request.schema.yaml | 13 +- products/evidence/contracts/rhai-abi.yaml | 9 +- .../contracts/security-invariant-matrix.yaml | 59 +- .../contracts/security-test-traceability.yaml | 56 +- .../evidence/contracts/source-contract.yaml | 41 + .../contracts/verification-policy.schema.yaml | 118 ++ .../acceptance/adult-status/evidence.yaml | 3 + .../acceptance/all-definitions/evidence.yaml | 9 + .../legal-parent-relationship-cases.yaml | 2 +- .../legal-parent-relationship/evidence.yaml | 3 + .../fixtures/cases.yaml | 2 +- .../professional-licence/evidence.yaml | 3 + .../acceptance/residence-region/evidence.yaml | 3 + .../fixtures/conformance/audit-events.yaml | 27 + .../conformance/golden/adult-evidence.json | 1 + .../conformance/golden/adult-request.json | 1 + .../conformance/golden/licence-evidence.json | 1 + .../conformance/golden/licence-request.json | 1 + .../golden/relationship-evidence.json | 1 + .../golden/relationship-request.json | 1 + .../golden/residence-evidence.json | 1 + .../conformance/golden/residence-request.json | 1 + .../conformance/selectors/evidence.yaml | 8 + .../supported-values/evidence.yaml | 3 + .../generated/evidence-request-v1.schema.json | 7 +- .../evidence-unsigned-envelope-v1.schema.json | 32 + .../generated/evidence-v1.schema.json | 5 + .../evidence/generated/problem-v1.schema.json | 20 + .../generated/registry-evidence.openapi.json | 221 ++- .../reference/request-adapter/ADAPTER-API.md | 675 +++++++ .../reference/request-adapter/README.md | 526 ++++++ .../deployment-projects/CONFIG.md | 570 ++++++ .../deployment-projects/FIXTURES.md | 198 +++ .../deployment-projects/README.md | 88 + .../dhis2-adult-status/README.md | 59 + .../dhis2-adult-status/bundle/evidence.yaml | 3 + .../bundle/fixtures/cases.yaml | 2 +- .../opencrvs-family-evidence/README.md | 122 ++ .../bundle/evidence.yaml | 5 + 78 files changed, 12857 insertions(+), 724 deletions(-) create mode 100644 crates/registry-evidence/README.md create mode 100644 products/evidence/AGENTS.md create mode 100644 products/evidence/CONCEPT.md create mode 100644 products/evidence/FIRST-CURL-TEST.md create mode 100644 products/evidence/IMPLEMENTATION.md create mode 100644 products/evidence/OPERATOR-CONTRACT.md create mode 100644 products/evidence/README.md create mode 100644 products/evidence/SOURCE-TESTING.md create mode 100644 products/evidence/contracts/README.md create mode 100644 products/evidence/contracts/acceptance-test-traceability.yaml create mode 100644 products/evidence/contracts/verification-policy.schema.yaml create mode 100644 products/evidence/generated/evidence-unsigned-envelope-v1.schema.json create mode 100644 products/evidence/reference/request-adapter/ADAPTER-API.md create mode 100644 products/evidence/reference/request-adapter/README.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/CONFIG.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/README.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md diff --git a/AGENTS.md b/AGENTS.md index 0c669b6b8..555b58512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,14 @@ This is the Registry Stack monorepo: registry-facing services over data institutions already hold. Pre-1.0; APIs and deployment contracts may change. -Two runtime patterns anchor everything: +Three independent runtime patterns are relevant: - **Registry Relay** exposes protected, scoped, read-only HTTP APIs over existing sources. - **Registry Notary** certifies evidence: claim evaluation, credential issuance, disclosure policy, audit provenance. +- **Evidence** is a separate minimum-disclosure assertion service. It is not a + Notary mode or rewrite and does not inherit the Notary product model. Registry Manifest describes sources portably; Relay is its consumer in code (Notary does not depend on the manifest crates). `registry-platform-*` crates @@ -20,6 +22,7 @@ are shared primitives. `registryctl` is adopter tooling. |---|---| | `crates/registry-relay` | Protected read APIs (Relay) | | `crates/registry-notary*` | Evidence gateway: server, core, client, source adapters, worker harness (Notary) | +| `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | | `crates/registry-platform-*` | Shared primitives: audit, authcommon, cache, config, crypto, httpsec, httputil, oid4vci, oidc, ops, pdp, replay, sdjwt, sts, testing | | `crates/registryctl` | Adopter tooling | @@ -28,6 +31,50 @@ are shared primitives. `registryctl` is adopter tooling. | `release/` | Release manifests, schemas, notes, validation and conformance tooling, and the release source-model proof | | `external/` | Notes on inputs that intentionally stay out of this tree (e.g. Crosswalk stays a pinned git dependency) | +## Evidence product boundary + +Evidence work must remain independent from `registry-notary*`. Do not copy or +depend on Notary product abstractions merely because both products use the word +evidence. In particular, Evidence version one does not inherit credential +issuance, OID4VCI, SD-JWT, PDP, replay, federation, worker, or document +subsystems. + +The implementation is one `registry-evidence` crate and one `evidence` binary. +It may reuse narrowly applicable `registry-platform-*` +primitives such as audit, crypto, OIDC, HTTP security, and testing. It must not +depend on `registry-notary*`. + +Evidence configuration and scripts are trusted, startup-only deployment +artifacts. Rust owns authentication, authorization, fixed source execution, +bounded script execution, output validation, evidence construction, signing, +and audit. Rhai owns bounded request preparation, source extraction, and +requirement-specific derivation, using only deterministic, bounded, +domain-neutral primitives supplied by Rust. +Adult status, residence region, professional licence status, and legal-parent +relationship are coequal full-path Evidence acceptance definitions. None may +become a Rust domain type, built-in operation, special route, or implementation +phase. + +Evidence implementation changes require its approved Version 1 contracts, +schedule, and Definition of Done to remain aligned in tracked product material. +Do not call the product implemented when only one assertion case or a subset of +that DoD passes. Stop before the approved concept's non-goals and future +profiles. + +Evidence source compatibility is proven with sanitized local mocks in ordinary +tests. Public demo checks are opt-in, ignored, read-only local tests after the +mock suite passes. Credentials, tokens, live responses, demo-subject +identifiers, and human login or two-factor details must not be committed, +logged, placed in snapshots, or passed on command lines. + +DHIS2 and OpenCRVS names and behavior are test-only. Evidence production code, +dependencies, Cargo features, public configuration schemas, routes, and CLI +options must remain source-product neutral. + +The `changing-notary-endpoints` skill and Notary-specific OpenAPI commands do +not apply to Evidence. Use the Evidence-specific guidance and verification +commands rather than extending Notary guidance by analogy. + The adopter demo is maintained separately in [`registrystack/solmara-lab`](https://github.com/registrystack/solmara-lab). @@ -48,11 +95,18 @@ Root CI's `rust` job runs `cargo fmt --check`, `cargo check --locked --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --locked --workspace`, the full `cargo deny check` (advisories included; unresolvable RUSTSEC advisories carry scoped ignores in -`deny.toml` with review triggers), and the OpenAPI drift checks for both -products (`just openapi-check` from `products/notary`, `just openapi-contract` -from `crates/registry-relay`). cargo-deny needs v0.19+ to parse this +`deny.toml` with review triggers), and the Notary and Relay OpenAPI drift checks +(`just openapi-check` from `products/notary`, `just openapi-contract` from +`crates/registry-relay`). cargo-deny needs v0.19+ to parse this `deny.toml`; CI pins 0.19.8. +Evidence-specific contracts and source neutrality: + +```bash +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` + Release source checks: ```bash @@ -68,8 +122,8 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. ## Rules that bite - Every commit needs a DCO sign-off: `git commit -s`. -- Commit subjects: imperative mood; `fix(notary):` / `feat(relay):` style - prefixes are the norm for product-scoped changes. +- Commit subjects: imperative mood; `fix(notary):`, `feat(relay):`, and + `feat(evidence):` style prefixes are the norm for product-scoped changes. - History may be rewritten during review (session commits get squashed). In durable docs, cite only commits reachable from pushed `main`, and prefer stable facts plus dates over commit SHAs. diff --git a/crates/registry-evidence/README.md b/crates/registry-evidence/README.md new file mode 100644 index 000000000..8b63841b7 --- /dev/null +++ b/crates/registry-evidence/README.md @@ -0,0 +1,43 @@ +# registry-evidence + +`registry-evidence` is the single-crate Evidence Version 1 runtime. It loads +one immutable operator-controlled bundle, evaluates fixed requirements through +bounded Rhai extraction and derivation, and returns minimum-disclosure +assertion evidence. + +The `evidence` binary takes a runtime file and one subcommand: + +```text +evidence --runtime check +evidence --runtime evaluate --fixture +evidence --runtime serve +``` + +`check` validates and compiles the complete immutable bundle. `evaluate` runs +one bundle-owned fixture without source or credential access. `serve` starts +the native HTTP service: + +```text +POST /v1/evidence +GET /v1/evidence-definitions +GET /health +GET /ready +GET /.well-known/evidence/jwks.json +``` + +`POST /v1/evidence` requires a `requestNonce`: the canonical unpadded base64url +encoding of exactly 32 random bytes, freshly generated per request. The runtime +echoes it into the Evidence payload and covers it by the signature. It is never +stored, never uniqueness-checked, and never reaches authorization, rate limits, +Rhai, source requests, logs, metrics, traces, or audit. + +Signed flattened JWS (`application/jose+json`) is the mandatory default and the +only later-verifiable format. The exact +`application/vnd.registrystack.evidence-unsigned+json` selects a self-identifying +unsigned envelope, and only when both the bundle and the one complete matched +grant permit that format. Signing failure never falls back to unsigned output. + +The normative product contracts and verification commands live under +`products/evidence/`. Evidence is independent from Registry Notary and has no +credential, replay, policy-engine, document, federation, worker, or OOTS +subsystem. diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 2a68f1c2f..65c315bc0 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -44,6 +44,14 @@ pub enum AuditDecision { SigningFailure, } +/// Closed non-secret response-protection mode resolved with authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ResponseProtection { + Signed, + Unsigned, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum AuthorityKind { @@ -87,6 +95,7 @@ pub struct EvidenceAuditEvent { pub actor_pseudonym: Option, pub authority: AuditAuthority, pub subjects: Vec, + pub response_protection: ResponseProtection, #[serde(skip_serializing_if = "Option::is_none")] pub source_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -104,6 +113,7 @@ pub struct EvidenceAuditEvent { } impl EvidenceAuditEvent { + #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] pub fn new( operation: String, @@ -114,6 +124,7 @@ impl EvidenceAuditEvent { requester_pseudonym: String, authority: AuditAuthority, subjects: Vec, + response_protection: ResponseProtection, decision: AuditDecision, duration_milliseconds: u64, ) -> Self { @@ -130,6 +141,7 @@ impl EvidenceAuditEvent { actor_pseudonym: None, authority, subjects, + response_protection, source_id: None, adapter_id: None, decision, @@ -142,17 +154,19 @@ impl EvidenceAuditEvent { } pub fn validate_phase_fields(&self) -> Result<(), EvidenceAuditError> { - let any_release_field = self.disclosed_concepts.is_some() - || self.evidence_id.is_some() - || self.signing_key_id.is_some(); - let all_release_fields = self.disclosed_concepts.is_some() - && self.evidence_id.is_some() - && self.signing_key_id.is_some(); + let any_release_field = self.disclosed_concepts.is_some() || self.evidence_id.is_some(); + let all_release_fields = self.disclosed_concepts.is_some() && self.evidence_id.is_some(); if (self.phase == AuditPhase::DisclosureRelease && !all_release_fields) || (self.phase != AuditPhase::DisclosureRelease && any_release_field) { return Err(EvidenceAuditError::InvalidEvent); } + // A signing key identity exists exactly for signed disclosure release. + let signing_key_required = self.phase == AuditPhase::DisclosureRelease + && self.response_protection == ResponseProtection::Signed; + if self.signing_key_id.is_some() != signing_key_required { + return Err(EvidenceAuditError::InvalidEvent); + } if self.subjects.is_empty() || self.subjects.len() > 8 || !(16..=128).contains(&self.operation.len()) @@ -764,6 +778,7 @@ mod tests { .expect("pseudonym builds"), ), }], + ResponseProtection::Signed, AuditDecision::Authorized, 5, ) @@ -808,6 +823,7 @@ mod tests { .to_owned(), ), }], + response_protection: ResponseProtection::Signed, source_id: Some("source-a".to_owned()), adapter_id: Some("adapter-a".to_owned()), decision: AuditDecision::Authorized, @@ -842,6 +858,31 @@ mod tests { fixture["disclosure_release"] ); + let mut unsigned_release = release.clone(); + unsigned_release.event_id = "urn:example:fixture:audit:release-002".to_owned(); + unsigned_release.occurred_at = "2026-08-02T00:00:02Z".to_owned(); + unsigned_release.response_protection = ResponseProtection::Unsigned; + unsigned_release.signing_key_id = None; + unsigned_release + .validate_phase_fields() + .expect("fixture unsigned release event satisfies native phase rules"); + assert_eq!( + serde_json::to_value(&unsigned_release).expect("unsigned release event serializes"), + fixture["unsigned_disclosure_release"] + ); + unsigned_release.signing_key_id = Some("fixture-key-2026-01".to_owned()); + assert!(matches!( + unsigned_release.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + + let mut signed_release_without_key = release.clone(); + signed_release_without_key.signing_key_id = None; + assert!(matches!( + signed_release_without_key.validate_phase_fields(), + Err(EvidenceAuditError::InvalidEvent) + )); + let mut release_fields_on_access = access; release_fields_on_access.disclosed_concepts = release.disclosed_concepts.clone(); release_fields_on_access.evidence_id = release.evidence_id.clone(); @@ -878,7 +919,10 @@ mod tests { "credential-token-or-private-key", "candidate-count-score-hint-or-comparison", "release-fields-on-access-event", - "missing-release-fields-on-release-event" + "missing-release-fields-on-release-event", + "signing-key-on-unsigned-release-event", + "missing-signing-key-on-signed-release-event", + "request-nonce-in-any-event" ]) ); } diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index c97318502..56d933a6f 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -18,7 +18,8 @@ use thiserror::Error; use url::Url; use crate::config::{ - ArtifactPath, ConceptForm, EvidenceConfig, OrderedMap, RuntimeConfig, SelectorField, + ArtifactPath, ConceptForm, EvidenceConfig, OrderedMap, RuntimeConfig, SchemaFault, + SelectorField, }; pub const MAX_BUNDLE_FILES: usize = 1_024; @@ -28,6 +29,7 @@ pub const MAX_SCRIPT_BYTES: u64 = 64 * 1024; pub const MAX_PUBLIC_JWK_BYTES: u64 = 64 * 1024; const CONFIG_FILE: &str = "evidence.yaml"; +const RUNTIME_FILE: &str = "runtime.yaml"; const REVISION_DOMAIN: &[u8] = b"registry.evidence.bundle-revision/v1\0"; const RUNTIME_REVISION_DOMAIN: &[u8] = b"registry.evidence.runtime-revision/v1\0"; const MAX_CA_BUNDLE_BYTES: u64 = 1024 * 1024; @@ -50,16 +52,132 @@ pub enum BundleError { UnsupportedEntry, #[error("the Evidence deployment bundle contains a prohibited path")] InvalidPath, - #[error("the Evidence deployment bundle contains an unknown or unreferenced file")] - UnknownFile, + #[error("the Evidence deployment bundle artifact closure is invalid: {0}")] + UnknownFile(ArtifactFault), #[error("the Evidence deployment bundle exceeds a Version 1 size bound")] TooLarge, #[error("the Evidence deployment configuration is invalid: {0}")] - Config(#[from] crate::config::ConfigError), + Config(ArtifactFault), #[error("an Evidence bundle artifact is invalid: {0}")] - InvalidArtifact(&'static str), - #[error("an Evidence Rhai script is invalid")] - InvalidScript, + InvalidArtifact(ArtifactFault), + #[error("an Evidence Rhai script is invalid: {0}")] + InvalidScript(ArtifactFault), +} + +impl BundleError { + /// The value-free diagnostic, when this failure identifies one artifact. + /// + /// The remaining variants describe the deployment directory itself and are + /// already specific enough to act on without naming a file. + pub fn artifact_fault(&self) -> Option<&ArtifactFault> { + match self { + Self::Config(fault) + | Self::InvalidArtifact(fault) + | Self::InvalidScript(fault) + | Self::UnknownFile(fault) => Some(fault), + _ => None, + } + } + + /// Name the artifact being loaded when the failure did not already know it. + /// + /// Loaders raise their causes where the cause is known and the artifact is + /// not, so the enclosing per-artifact loop binds the name on the way out. + fn in_artifact(self, artifact: &str) -> Self { + match self { + Self::Config(fault) => Self::Config(fault.bind(artifact)), + Self::InvalidArtifact(fault) => Self::InvalidArtifact(fault.bind(artifact)), + Self::InvalidScript(fault) => Self::InvalidScript(fault.bind(artifact)), + other => other, + } + } +} + +/// A value-free deployment diagnostic bound to one bundle-relative artifact. +/// +/// The artifact name comes from the reviewed bundle layout or from the +/// operator's runtime file name. It is never taken from document content, and +/// the fault it carries is value-free by construction. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ArtifactFault { + artifact: String, + fault: SchemaFault, +} + +impl ArtifactFault { + /// A diagnostic whose artifact is already known. + pub fn new(artifact: impl Into, fault: SchemaFault) -> Self { + Self { + artifact: artifact.into(), + fault, + } + } + + /// A cause raised before the artifact being loaded is in scope. + fn unbound(cause: &'static str) -> Self { + Self { + artifact: String::new(), + fault: SchemaFault::because(cause), + } + } + + /// The bundle-relative artifact, empty when no loader claimed the failure. + pub fn artifact(&self) -> &str { + &self.artifact + } + + pub fn fault(&self) -> &SchemaFault { + &self.fault + } + + fn bind(mut self, artifact: &str) -> Self { + if self.artifact.is_empty() { + self.artifact = artifact.to_owned(); + } + self + } +} + +impl fmt::Display for ArtifactFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.artifact.is_empty() { + return fmt::Display::fmt(&self.fault, formatter); + } + write!(formatter, "artifact {}: {}", self.artifact, self.fault) + } +} + +/// An artifact fault whose artifact the caller binds later. +fn invalid_artifact(cause: &'static str) -> BundleError { + BundleError::InvalidArtifact(ArtifactFault::unbound(cause)) +} + +/// A script fault whose artifact the caller binds later. +fn invalid_script(cause: &'static str) -> BundleError { + BundleError::InvalidScript(ArtifactFault::unbound(cause)) +} + +/// A closure fault naming the artifact when the name is safe to print. +/// +/// Closure names come from the reviewed configuration or from the bundle +/// directory listing. A directory listing is operator input, so a name is +/// quoted only when it matches the reviewed artifact grammar; anything else is +/// reported without a name rather than echoed. +fn unknown_file(candidate: &str, cause: &'static str) -> BundleError { + if safe_artifact_name(candidate) { + BundleError::UnknownFile(ArtifactFault::new(candidate, SchemaFault::because(cause))) + } else { + BundleError::UnknownFile(ArtifactFault::unbound(cause)) + } +} + +/// The reviewed bundle-relative artifact grammar, as a printable-name test. +fn safe_artifact_name(candidate: &str) -> bool { + !candidate.is_empty() + && candidate.len() <= 128 + && candidate + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) } #[derive(Debug, Clone)] @@ -140,7 +258,7 @@ pub struct RuntimeDocument { impl RuntimeDocument { pub fn load(path: impl AsRef) -> Result { let path = path.as_ref(); - if path.file_name().and_then(|name| name.to_str()) != Some("runtime.yaml") { + if path.file_name().and_then(|name| name.to_str()) != Some(RUNTIME_FILE) { return Err(BundleError::InvalidPath); } let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; @@ -155,7 +273,9 @@ impl RuntimeDocument { crate::config::MAX_CONFIG_BYTES as u64, filesystem_read_only, )?; - let config = RuntimeConfig::parse_yaml(&bytes)?; + let config = RuntimeConfig::parse_yaml(&bytes).map_err(|error| { + BundleError::Config(ArtifactFault::new(RUNTIME_FILE, error.fault())) + })?; validate_secret_root(Path::new(&config.secret_providers.file.root))?; let mut ca_bundles = BTreeMap::new(); @@ -174,7 +294,7 @@ impl RuntimeDocument { MAX_CA_BUNDLE_BYTES, ca_filesystem_read_only, )?; - validate_ca_bundle(&ca_bytes)?; + validate_ca_bundle(&ca_bytes).map_err(|error| error.in_artifact(RUNTIME_FILE))?; ca_bundles.insert(profile.to_owned(), ca_bytes); } let revision = compute_runtime_revision(&bytes, &ca_bundles)?; @@ -223,7 +343,8 @@ impl Bundle { let root = root.as_ref(); let files = capture_bundle_files(root)?; let config_bytes = files.get(CONFIG_FILE).ok_or(BundleError::Unavailable)?; - let config = EvidenceConfig::parse_yaml(config_bytes)?; + let config = EvidenceConfig::parse_yaml(config_bytes) + .map_err(|error| BundleError::Config(ArtifactFault::new(CONFIG_FILE, error.fault())))?; validate_file_closure(&config, &files)?; let scripts = load_scripts(&config, &files)?; @@ -354,7 +475,10 @@ fn collect_paths( return Err(BundleError::InvalidPath); } } else if relative != CONFIG_FILE { - return Err(BundleError::UnknownFile); + return Err(unknown_file( + &relative, + "bundle root contains a file other than the configuration", + )); } } else if !ALLOWED_DIRECTORIES.contains(&top) { return Err(BundleError::InvalidPath); @@ -544,10 +668,19 @@ fn validate_file_closure( } expected.extend(reviewed_schema_paths(config, files)?); expected.extend(reviewed_bucket_codelist_paths(config, files)?); - if files.keys().map(String::as_str).collect::>() - != expected.iter().map(String::as_str).collect() - { - return Err(BundleError::UnknownFile); + let present: BTreeSet<&str> = files.keys().map(String::as_str).collect(); + let referenced: BTreeSet<&str> = expected.iter().map(String::as_str).collect(); + if let Some(missing) = referenced.difference(&present).next() { + return Err(unknown_file( + missing, + "the configuration references an artifact the bundle does not contain", + )); + } + if let Some(unreferenced) = present.difference(&referenced).next() { + return Err(unknown_file( + unreferenced, + "the bundle contains an artifact the configuration does not reference", + )); } Ok(()) } @@ -589,7 +722,7 @@ fn reviewed_bucket_codelist_paths( }) .collect::>(); if matches.len() != 1 { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "bucket scheme codelist is missing or ambiguous", )); } @@ -623,7 +756,7 @@ fn reviewed_schema_paths( }) .collect::>(); if matches.len() != 1 { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "reviewed structured schema identifier is missing or ambiguous", )); } @@ -658,37 +791,48 @@ fn load_scripts( } let mut scripts = BTreeMap::new(); for (path, (entrypoint, arity)) in expected { - let bytes = files - .get(path) - .ok_or(BundleError::InvalidArtifact("missing script"))?; - let source = std::str::from_utf8(bytes) - .map_err(|_| BundleError::InvalidArtifact("script is not UTF-8"))? - .to_owned(); - reject_prohibited_script_capabilities(&source)?; - let mut engine = Engine::new(); - engine.set_max_expr_depths(64, 64); - engine.set_max_call_levels(32); - engine.set_max_operations(100_000); - engine.set_max_array_size(256); - engine.set_max_map_size(256); - engine.set_max_string_size(16_384); - engine.set_max_modules(0); - let ast = engine - .compile(&source) - .map_err(|_| BundleError::InvalidScript)?; - let entrypoint_functions = ast - .iter_functions() - .filter(|function| function.name == entrypoint) - .map(|function| function.params.len()) - .collect::>(); - if entrypoint_functions != [arity] { - return Err(BundleError::InvalidScript); - } - scripts.insert(path.to_owned(), CompiledScript { source, ast }); + let script = compile_script(path, entrypoint, arity, files) + .map_err(|error| error.in_artifact(path))?; + scripts.insert(path.to_owned(), script); } Ok(scripts) } +fn compile_script( + path: &str, + entrypoint: &'static str, + arity: usize, + files: &BTreeMap>, +) -> Result { + let bytes = files.get(path).ok_or(invalid_artifact("missing script"))?; + let source = std::str::from_utf8(bytes) + .map_err(|_| invalid_artifact("script is not UTF-8"))? + .to_owned(); + reject_prohibited_script_capabilities(&source)?; + let mut engine = Engine::new(); + engine.set_max_expr_depths(64, 64); + engine.set_max_call_levels(32); + engine.set_max_operations(100_000); + engine.set_max_array_size(256); + engine.set_max_map_size(256); + engine.set_max_string_size(16_384); + engine.set_max_modules(0); + let ast = engine + .compile(&source) + .map_err(|_| invalid_script("script does not compile"))?; + let entrypoint_functions = ast + .iter_functions() + .filter(|function| function.name == entrypoint) + .map(|function| function.params.len()) + .collect::>(); + if entrypoint_functions != [arity] { + return Err(invalid_script( + "script does not declare exactly one entrypoint with the required arity", + )); + } + Ok(CompiledScript { source, ast }) +} + fn insert_script_contract<'a>( scripts: &mut BTreeMap<&'a str, (&'static str, usize)>, path: &'a str, @@ -698,7 +842,7 @@ fn insert_script_contract<'a>( .insert(path, contract) .is_some_and(|existing| existing != contract) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "one script path is assigned incompatible entry points", )); } @@ -751,13 +895,13 @@ fn reject_prohibited_script_capabilities(source: &str) -> Result<(), BundleError identifier.push(character); } else if !identifier.is_empty() { if PROHIBITED.contains(&identifier.as_str()) { - return Err(BundleError::InvalidScript); + return Err(invalid_script("script uses a prohibited capability")); } identifier.clear(); } } if PROHIBITED.contains(&identifier.as_str()) { - return Err(BundleError::InvalidScript); + return Err(invalid_script("script uses a prohibited capability")); } Ok(()) } @@ -785,91 +929,96 @@ fn load_fact_schemas( paths.extend(reviewed_schema_paths(config, files)?); let mut schemas = BTreeMap::new(); for path in paths { - let bytes = files - .get(&path) - .ok_or(BundleError::InvalidArtifact("missing fact schema"))?; - let text = std::str::from_utf8(bytes) - .map_err(|_| BundleError::InvalidArtifact("fact schema is not UTF-8"))?; - let schema: JsonValue = serde_norway::from_str(text) - .map_err(|_| BundleError::InvalidArtifact("fact schema YAML is invalid"))?; - validate_closed_schema(&schema, parameter_paths.contains(path.as_str()))?; - JSONSchema::options() - .with_draft(Draft::Draft202012) - .should_validate_formats(true) - .compile(&schema) - .map_err(|_| BundleError::InvalidArtifact("fact schema is not valid JSON Schema"))?; + let is_parameter_schema = parameter_paths.contains(path.as_str()); + let schema = load_fact_schema(&path, is_parameter_schema, files) + .map_err(|error| error.in_artifact(&path))?; schemas.insert(path, schema); } for (_, source) in config.sources.iter() { - let schema = schemas - .get(source.request.adapter_parameters_schema.as_str()) - .ok_or(BundleError::InvalidArtifact( - "missing adapter-parameter schema", - ))?; - let compiled = JSONSchema::options() - .with_draft(Draft::Draft202012) - .should_validate_formats(true) - .compile(schema) - .map_err(|_| { - BundleError::InvalidArtifact("adapter-parameter schema is not valid JSON Schema") - })?; - let parameters = - serde_json::to_value(&source.request.adapter_parameters).map_err(|_| { - BundleError::InvalidArtifact("adapter parameters are not JSON-compatible") - })?; - if !compiled.is_valid(¶meters) { - return Err(BundleError::InvalidArtifact( - "adapter parameters do not satisfy their closed schema", - )); - } + let schema_path = source.request.adapter_parameters_schema.as_str(); + validate_adapter_parameters(source, &schemas) + .map_err(|error| error.in_artifact(schema_path))?; } Ok(schemas) } +fn load_fact_schema( + path: &str, + is_parameter_schema: bool, + files: &BTreeMap>, +) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("missing fact schema"))?; + let text = + std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fact schema is not UTF-8"))?; + let schema: JsonValue = serde_norway::from_str(text) + .map_err(|_| invalid_artifact("fact schema YAML is invalid"))?; + validate_closed_schema(&schema, is_parameter_schema)?; + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(&schema) + .map_err(|_| invalid_artifact("fact schema is not valid JSON Schema"))?; + Ok(schema) +} + +fn validate_adapter_parameters( + source: &crate::config::SourceConfig, + schemas: &BTreeMap, +) -> Result<(), BundleError> { + let schema = schemas + .get(source.request.adapter_parameters_schema.as_str()) + .ok_or(invalid_artifact("missing adapter-parameter schema"))?; + let compiled = JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .compile(schema) + .map_err(|_| invalid_artifact("adapter-parameter schema is not valid JSON Schema"))?; + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .map_err(|_| invalid_artifact("adapter parameters are not JSON-compatible"))?; + if !compiled.is_valid(¶meters) { + return Err(invalid_artifact( + "adapter parameters do not satisfy their closed schema", + )); + } + Ok(()) +} + fn validate_closed_schema(schema: &JsonValue, allow_empty_root: bool) -> Result<(), BundleError> { - let root = schema.as_object().ok_or(BundleError::InvalidArtifact( - "fact schema must be an object", - ))?; + let root = schema + .as_object() + .ok_or(invalid_artifact("fact schema must be an object"))?; if root.get("type").and_then(JsonValue::as_str) != Some("object") || root .get("additionalProperties") .and_then(JsonValue::as_bool) != Some(false) { - return Err(BundleError::InvalidArtifact( - "fact schema must close the root object", - )); + return Err(invalid_artifact("fact schema must close the root object")); } let properties = root .get("properties") .and_then(JsonValue::as_object) - .ok_or(BundleError::InvalidArtifact( - "fact schema must declare properties", - ))?; + .ok_or(invalid_artifact("fact schema must declare properties"))?; if (!allow_empty_root && properties.is_empty()) || properties.len() > 64 { - return Err(BundleError::InvalidArtifact( - "fact schema property count is invalid", - )); + return Err(invalid_artifact("fact schema property count is invalid")); } - let required = - root.get("required") - .and_then(JsonValue::as_array) - .ok_or(BundleError::InvalidArtifact( - "fact schema must declare required fields", - ))?; + let required = root + .get("required") + .and_then(JsonValue::as_array) + .ok_or(invalid_artifact("fact schema must declare required fields"))?; let required = required .iter() .map(JsonValue::as_str) .collect::>>() - .ok_or(BundleError::InvalidArtifact( - "fact schema required fields are invalid", - ))?; + .ok_or(invalid_artifact("fact schema required fields are invalid"))?; if required.len() != properties.len() || properties .keys() .any(|property| !required.contains(property.as_str())) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "fact schema must require its exact closed field set", )); } @@ -877,9 +1026,9 @@ fn validate_closed_schema(schema: &JsonValue, allow_empty_root: bool) -> Result< } fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { - let object = node.as_object().ok_or(BundleError::InvalidArtifact( - "every schema node must be a typed object", - ))?; + let object = node + .as_object() + .ok_or(invalid_artifact("every schema node must be a typed object"))?; let Some(value_type) = object.get("type").and_then(JsonValue::as_str) else { if object .keys() @@ -888,7 +1037,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { { return Ok(()); } - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "every schema node must declare one type or one bounded const", )); }; @@ -926,13 +1075,13 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { ][..], "boolean" => &["$schema", "$id", "type", "enum", "const"][..], _ => { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema node type is outside the closed Version 1 subset", )); } }; if object.keys().any(|key| !allowed.contains(&key.as_str())) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema node uses a keyword outside the closed Version 1 subset", )); } @@ -944,15 +1093,13 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .and_then(JsonValue::as_bool) != Some(false) { - return Err(BundleError::InvalidArtifact( - "nested schema objects must be closed", - )); + return Err(invalid_artifact("nested schema objects must be closed")); } let properties = object .get("properties") .and_then(JsonValue::as_object) .filter(|properties| !properties.is_empty() && properties.len() <= 64) - .ok_or(BundleError::InvalidArtifact( + .ok_or(invalid_artifact( "schema objects must declare bounded properties", ))?; let required = object @@ -964,7 +1111,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .map(JsonValue::as_str) .collect::>>() }) - .ok_or(BundleError::InvalidArtifact( + .ok_or(invalid_artifact( "schema objects must declare required properties", ))?; if required.len() != properties.len() @@ -972,7 +1119,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .keys() .any(|property| !required.contains(property.as_str())) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema objects must require their exact property set", )); } @@ -985,37 +1132,32 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .get("uniqueItems") .is_some_and(|value| value.as_bool() != Some(true)) { - return Err(BundleError::InvalidArtifact( - "schema array uniqueness flag is invalid", - )); + return Err(invalid_artifact("schema array uniqueness flag is invalid")); } if let Some(value) = object.get("const") { if !value.is_array() || !schema_const_is_bounded(value) { - return Err(BundleError::InvalidArtifact( - "schema array const is invalid", - )); + return Err(invalid_artifact("schema array const is invalid")); } } - let maximum = object.get("maxItems").and_then(JsonValue::as_u64).ok_or( - BundleError::InvalidArtifact("schema arrays must be bounded"), - )?; + let maximum = object + .get("maxItems") + .and_then(JsonValue::as_u64) + .ok_or(invalid_artifact("schema arrays must be bounded"))?; if maximum == 0 || maximum > 256 { - return Err(BundleError::InvalidArtifact( - "schema array bound is invalid", - )); + return Err(invalid_artifact("schema array bound is invalid")); } if object .get("minItems") .and_then(JsonValue::as_u64) .is_some_and(|minimum| minimum > maximum) { - return Err(BundleError::InvalidArtifact( - "schema array bounds are invalid", - )); + return Err(invalid_artifact("schema array bounds are invalid")); } - validate_schema_node(object.get("items").ok_or(BundleError::InvalidArtifact( - "schema arrays must close their item type", - ))?)?; + validate_schema_node( + object + .get("items") + .ok_or(invalid_artifact("schema arrays must close their item type"))?, + )?; } "string" => { if object @@ -1023,7 +1165,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .and_then(JsonValue::as_str) .is_some_and(|format| !matches!(format, "date" | "date-time")) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema string format is outside the closed Version 1 subset", )); } @@ -1048,7 +1190,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .and_then(JsonValue::as_str) .is_some_and(|value| value.len() <= 65_536); if !bounded && !formatted && !enumerated && !constant { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema strings must be bounded, formatted, or enumerated", )); } @@ -1071,16 +1213,14 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { && !enumerated && !constant { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "schema integers must be bounded or enumerated", )); } } "boolean" => { if object.get("const").is_some_and(|value| !value.is_boolean()) { - return Err(BundleError::InvalidArtifact( - "schema boolean const is invalid", - )); + return Err(invalid_artifact("schema boolean const is invalid")); } if object.get("enum").is_some_and(|value| { value.as_array().is_none_or(|values| { @@ -1089,9 +1229,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { || values.iter().any(|value| !value.is_boolean()) }) }) { - return Err(BundleError::InvalidArtifact( - "schema boolean enumeration is invalid", - )); + return Err(invalid_artifact("schema boolean enumeration is invalid")); } } _ => unreachable!("type was closed above"), @@ -1144,19 +1282,22 @@ fn load_codelists( paths.extend(reviewed_bucket_codelist_paths(config, files)?); let mut codelists = BTreeMap::new(); for path in paths { - let bytes = files - .get(&path) - .ok_or(BundleError::InvalidArtifact("missing codelist"))?; - let text = std::str::from_utf8(bytes) - .map_err(|_| BundleError::InvalidArtifact("codelist is not UTF-8"))?; - let document: CodelistDocument = serde_norway::from_str(text) - .map_err(|_| BundleError::InvalidArtifact("codelist YAML is invalid"))?; - let codelist = document.validate()?; + let codelist = load_codelist(&path, files).map_err(|error| error.in_artifact(&path))?; codelists.insert(path, codelist); } Ok(codelists) } +fn load_codelist(path: &str, files: &BTreeMap>) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("missing codelist"))?; + let text = std::str::from_utf8(bytes).map_err(|_| invalid_artifact("codelist is not UTF-8"))?; + let document: CodelistDocument = + serde_norway::from_str(text).map_err(|_| invalid_artifact("codelist YAML is invalid"))?; + document.validate() +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum CodelistDocument { @@ -1206,18 +1347,14 @@ impl MappingCodelistDocument { fn validate(self) -> Result { validate_codelist_header(&self.id, &self.version)?; if self.entries.is_empty() || self.entries.len() > 4_096 { - return Err(BundleError::InvalidArtifact( - "codelist entry count is invalid", - )); + return Err(invalid_artifact("codelist entry count is invalid")); } validate_code_collection(&self.allowed_outputs)?; for (input, output) in &self.entries { validate_code(input)?; validate_code(output)?; if !self.allowed_outputs.contains(output) { - return Err(BundleError::InvalidArtifact( - "codelist mapping output is not allowed", - )); + return Err(invalid_artifact("codelist mapping output is not allowed")); } } Ok(Codelist::Mapping { @@ -1236,22 +1373,20 @@ fn validate_codelist_header(id: &str, version: &str) -> Result<(), BundleError> || version.len() > 128 || version.contains('\0') { - return Err(BundleError::InvalidArtifact("codelist identity is invalid")); + return Err(invalid_artifact("codelist identity is invalid")); } Ok(()) } fn validate_code_collection(codes: &[String]) -> Result<(), BundleError> { if codes.is_empty() || codes.len() > 4_096 { - return Err(BundleError::InvalidArtifact( - "codelist code count is invalid", - )); + return Err(invalid_artifact("codelist code count is invalid")); } let mut seen = BTreeSet::new(); for code in codes { validate_code(code)?; if !seen.insert(code.as_str()) { - return Err(BundleError::InvalidArtifact("codelist code is duplicated")); + return Err(invalid_artifact("codelist code is duplicated")); } } Ok(()) @@ -1266,7 +1401,7 @@ fn validate_code(code: &str) -> Result<(), BundleError> { .iter() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) { - return Err(BundleError::InvalidArtifact("codelist code is invalid")); + return Err(invalid_artifact("codelist code is invalid")); } Ok(()) } @@ -1285,11 +1420,9 @@ fn validate_codelist_references( { let loaded = codelists .get(codelist.as_str()) - .ok_or(BundleError::InvalidArtifact("selector codelist is missing"))?; + .ok_or(invalid_artifact("selector codelist is missing"))?; if loaded.version() != codelist_version { - return Err(BundleError::InvalidArtifact( - "selector codelist version mismatch", - )); + return Err(invalid_artifact("selector codelist version mismatch")); } } } @@ -1307,9 +1440,7 @@ fn validate_codelist_references( .filter(|codelist| codelist.id() == identifier && codelist.version() == version) .count(); if matches != 1 { - return Err(BundleError::InvalidArtifact( - "bucket scheme codelist identity mismatch", - )); + return Err(invalid_artifact("bucket scheme codelist identity mismatch")); } continue; } @@ -1322,11 +1453,9 @@ fn validate_codelist_references( let version = concept_constraint_string(&concept.constraints, version_key)?; let loaded = codelists .get(path) - .ok_or(BundleError::InvalidArtifact("concept codelist is missing"))?; + .ok_or(invalid_artifact("concept codelist is missing"))?; if loaded.version() != version { - return Err(BundleError::InvalidArtifact( - "concept codelist version mismatch", - )); + return Err(invalid_artifact("concept codelist version mismatch")); } } } @@ -1343,36 +1472,37 @@ fn load_fixtures( if fixtures.contains_key(path) { continue; } - let bytes = files - .get(path) - .ok_or(BundleError::InvalidArtifact("fixture file is missing"))?; - let text = std::str::from_utf8(bytes) - .map_err(|_| BundleError::InvalidArtifact("fixture file is not UTF-8"))?; - let fixture: YamlValue = serde_norway::from_str(text) - .map_err(|_| BundleError::InvalidArtifact("fixture YAML is invalid"))?; - validate_fixture_coverage(&fixture)?; + let fixture = load_fixture(path, files).map_err(|error| error.in_artifact(path))?; fixtures.insert(path.to_owned(), fixture); } Ok(fixtures) } +fn load_fixture(path: &str, files: &BTreeMap>) -> Result { + let bytes = files + .get(path) + .ok_or(invalid_artifact("fixture file is missing"))?; + let text = + std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fixture file is not UTF-8"))?; + let fixture: YamlValue = + serde_norway::from_str(text).map_err(|_| invalid_artifact("fixture YAML is invalid"))?; + validate_fixture_coverage(&fixture)?; + Ok(fixture) +} + fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { - let root = fixture.as_mapping().ok_or(BundleError::InvalidArtifact( - "fixture root must be a mapping", - ))?; + let root = fixture + .as_mapping() + .ok_or(invalid_artifact("fixture root must be a mapping"))?; if root.get("synthetic_only").and_then(YamlValue::as_bool) != Some(true) { - return Err(BundleError::InvalidArtifact( - "fixtures must be synthetic-only", - )); + return Err(invalid_artifact("fixtures must be synthetic-only")); } let cases = root .get("cases") .and_then(YamlValue::as_sequence) - .ok_or(BundleError::InvalidArtifact("fixture cases are missing"))?; + .ok_or(invalid_artifact("fixture cases are missing"))?; if cases.is_empty() || cases.len() > 256 { - return Err(BundleError::InvalidArtifact( - "fixture case count is invalid", - )); + return Err(invalid_artifact("fixture case count is invalid")); } let mut ids = BTreeSet::new(); let mut categories = FixtureCategories::default(); @@ -1381,18 +1511,14 @@ fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> { .as_mapping() .and_then(|mapping| mapping.get("id")) .and_then(YamlValue::as_str) - .ok_or(BundleError::InvalidArtifact("fixture case id is missing"))?; + .ok_or(invalid_artifact("fixture case id is missing"))?; if id.is_empty() || id.len() > 128 || !ids.insert(id) { - return Err(BundleError::InvalidArtifact( - "fixture case id is invalid or duplicated", - )); + return Err(invalid_artifact("fixture case id is invalid or duplicated")); } categories.observe(id); } if !categories.complete() { - return Err(BundleError::InvalidArtifact( - "fixture category coverage is incomplete", - )); + return Err(invalid_artifact("fixture category coverage is incomplete")); } Ok(()) } @@ -1439,17 +1565,18 @@ fn load_retired_public_jwks( ) -> Result, BundleError> { let mut keys = BTreeMap::new(); for path in &config.signing.retired_public_jwk_files { - let bytes = files - .get(path.as_str()) - .ok_or(BundleError::InvalidArtifact( - "retired public JWK is missing", - ))?; - let object = parse_strict_json_object(bytes)?; - let kid = validate_public_jwk(&object, &config.signing.active_key_id)?; + let path = path.as_str(); + let load = || -> Result<(String, JsonMap), BundleError> { + let bytes = files + .get(path) + .ok_or(invalid_artifact("retired public JWK is missing"))?; + let object = parse_strict_json_object(bytes)?; + let kid = validate_public_jwk(&object, &config.signing.active_key_id)?; + Ok((kid, object)) + }; + let (kid, object) = load().map_err(|error| error.in_artifact(path))?; if keys.insert(kid, JsonValue::Object(object)).is_some() { - return Err(BundleError::InvalidArtifact( - "retired public JWK kid is duplicated", - )); + return Err(invalid_artifact("retired public JWK kid is duplicated").in_artifact(path)); } } Ok(keys) @@ -1489,10 +1616,10 @@ fn parse_strict_json_object(bytes: &[u8]) -> Result, let mut deserializer = serde_json::Deserializer::from_slice(bytes); let object = StrictObject::deserialize(&mut deserializer) - .map_err(|_| BundleError::InvalidArtifact("public JWK JSON is invalid"))?; + .map_err(|_| invalid_artifact("public JWK JSON is invalid"))?; deserializer .end() - .map_err(|_| BundleError::InvalidArtifact("public JWK has trailing data"))?; + .map_err(|_| invalid_artifact("public JWK has trailing data"))?; Ok(object.0) } @@ -1509,7 +1636,7 @@ fn validate_public_jwk( .get("use") .is_some_and(|value| value.as_str() != Some("sig")) { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "retired JWK is not an allowed public EdDSA key", )); } @@ -1522,29 +1649,25 @@ fn validate_public_jwk( && !kid.chars().any(char::is_control) && *kid != active_key_id }) - .ok_or(BundleError::InvalidArtifact("retired JWK kid is invalid"))?; + .ok_or(invalid_artifact("retired JWK kid is invalid"))?; let x = object .get("x") .and_then(JsonValue::as_str) - .ok_or(BundleError::InvalidArtifact( - "retired JWK public coordinate is missing", - ))?; + .ok_or(invalid_artifact("retired JWK public coordinate is missing"))?; let decoded = URL_SAFE_NO_PAD .decode(x) - .map_err(|_| BundleError::InvalidArtifact("retired JWK public coordinate is invalid"))?; + .map_err(|_| invalid_artifact("retired JWK public coordinate is invalid"))?; if decoded.len() != 32 { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "retired JWK public coordinate has the wrong size", )); } if let Some(operations) = object.get("key_ops") { - let operations = operations.as_array().ok_or(BundleError::InvalidArtifact( - "retired JWK key_ops is invalid", - ))?; + let operations = operations + .as_array() + .ok_or(invalid_artifact("retired JWK key_ops is invalid"))?; if operations.len() != 1 || operations[0].as_str() != Some("verify") { - return Err(BundleError::InvalidArtifact( - "retired JWK key_ops is not verify-only", - )); + return Err(invalid_artifact("retired JWK key_ops is not verify-only")); } } Ok(kid.to_owned()) @@ -1561,9 +1684,7 @@ fn concept_constraint_string<'a>( constraints .get(key) .and_then(YamlValue::as_str) - .ok_or(BundleError::InvalidArtifact( - "concept codelist constraint is invalid", - )) + .ok_or(invalid_artifact("concept codelist constraint is invalid")) } fn validate_runtime_bindings( @@ -1581,7 +1702,7 @@ fn validate_runtime_bindings( .keys() .collect::>(); if required != configured { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "runtime TLS trust profiles must exactly bind bundle source profiles", )); } @@ -1609,7 +1730,7 @@ fn validate_secret_root(path: &Path) -> Result<(), BundleError> { fn validate_ca_bundle(bytes: &[u8]) -> Result<(), BundleError> { let text = std::str::from_utf8(bytes) - .map_err(|_| BundleError::InvalidArtifact("TLS CA bundle is not UTF-8 PEM"))?; + .map_err(|_| invalid_artifact("TLS CA bundle is not UTF-8 PEM"))?; let mut in_certificate = false; let mut encoded = String::new(); let mut certificates = 0_usize; @@ -1622,11 +1743,9 @@ fn validate_ca_bundle(bytes: &[u8]) -> Result<(), BundleError> { "-----END CERTIFICATE-----" if in_certificate => { let der = base64::engine::general_purpose::STANDARD .decode(encoded.as_bytes()) - .map_err(|_| BundleError::InvalidArtifact("TLS CA bundle PEM is invalid"))?; + .map_err(|_| invalid_artifact("TLS CA bundle PEM is invalid"))?; if der.len() < 4 || der.first() != Some(&0x30) { - return Err(BundleError::InvalidArtifact( - "TLS CA bundle certificate is invalid", - )); + return Err(invalid_artifact("TLS CA bundle certificate is invalid")); } certificates = certificates.checked_add(1).ok_or(BundleError::TooLarge)?; if certificates > 64 { @@ -1645,14 +1764,14 @@ fn validate_ca_bundle(bytes: &[u8]) -> Result<(), BundleError> { encoded.push_str(line); } _ => { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "TLS CA bundle contains non-certificate PEM data", )); } } } if in_certificate || certificates == 0 { - return Err(BundleError::InvalidArtifact( + return Err(invalid_artifact( "TLS CA bundle contains no complete certificate", )); } @@ -1928,12 +2047,14 @@ mod tests { fs::write(directory.path().join("schemas/duplicate.yaml"), schema) .expect("write duplicate schema"); set_tree_mode(directory.path(), 0o555, 0o444); - assert!(matches!( - Bundle::load(directory.path()), - Err(BundleError::InvalidArtifact( + let ambiguous = Bundle::load(directory.path()).expect_err("duplicate schema is rejected"); + assert!(matches!(ambiguous, BundleError::InvalidArtifact(_))); + assert_eq!( + ambiguous.artifact_fault().map(ArtifactFault::fault), + Some(&SchemaFault::because( "reviewed structured schema identifier is missing or ambiguous" )) - )); + ); } #[cfg(unix)] @@ -1954,11 +2075,29 @@ mod tests { ) .expect("write unknown artifact"); set_tree_mode(unknown.path(), 0o555, 0o444); - assert!(matches!( - Bundle::load(unknown.path()), - Err(BundleError::UnknownFile) - )); + let unreferenced = Bundle::load(unknown.path()).expect_err("unknown artifact is rejected"); + assert!(matches!(unreferenced, BundleError::UnknownFile(_))); + let fault = unreferenced.artifact_fault().expect("closure names a file"); + assert_eq!(fault.artifact(), "fixtures/unreferenced.yaml"); + assert_eq!( + fault.fault().cause(), + "the bundle contains an artifact the configuration does not reference" + ); set_tree_mode(unknown.path(), 0o755, 0o444); + + let missing = tempfile::tempdir().expect("temporary bundle"); + copy_acceptance_bundle("adult-status", missing.path()); + fs::remove_file(missing.path().join("derivations/adult-status.rhai")) + .expect("remove referenced derivation"); + set_tree_mode(missing.path(), 0o555, 0o444); + let absent = Bundle::load(missing.path()).expect_err("missing artifact is rejected"); + let fault = absent.artifact_fault().expect("closure names a file"); + assert_eq!(fault.artifact(), "derivations/adult-status.rhai"); + assert_eq!( + fault.fault().cause(), + "the configuration references an artifact the bundle does not contain" + ); + set_tree_mode(missing.path(), 0o755, 0o444); } #[cfg(unix)] diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 02a16ec3c..ceea97345 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -22,14 +22,214 @@ pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; #[derive(Debug, Error, Clone, Eq, PartialEq)] pub enum ConfigError { - #[error("configuration YAML does not match the Evidence Version 1 schema")] - InvalidYaml, + #[error("configuration YAML does not match the Evidence Version 1 schema: {0}")] + InvalidYaml(SchemaFault), #[error("configuration exceeds the Evidence Version 1 size limit")] TooLarge, #[error("configuration violates the Evidence Version 1 contract: {0}")] Invalid(&'static str), } +impl ConfigError { + /// The value-free diagnostic for this failure. + /// + /// Deployment tooling reports this instead of the error itself so that + /// every configuration failure carries the same safe shape. + pub fn fault(&self) -> SchemaFault { + match self { + Self::InvalidYaml(fault) => fault.clone(), + Self::TooLarge => SchemaFault::because("document exceeds the Version 1 size limit"), + Self::Invalid(cause) => SchemaFault::because(cause), + } + } +} + +/// A one-based text position inside a deployment artifact. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct TextLocation { + pub line: usize, + pub column: usize, +} + +impl fmt::Display for TextLocation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "line {} column {}", self.line, self.column) + } +} + +/// A value-free reason one deployment document was rejected. +/// +/// Only three things are kept: a schema path built from mapping keys and +/// sequence indices, a text location, and one static cause. The decoder's own +/// message is classified and then discarded, because it quotes scalars, and a +/// deployment scalar can be a selector value, a secret reference, or a source +/// identifier. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SchemaFault { + location: Option, + path: Option, + cause: &'static str, +} + +/// The longest schema path a diagnostic will carry. +const MAX_SCHEMA_PATH_BYTES: usize = 256; + +/// Decoder message prefixes mapped to their value-free cause. +/// +/// The prefixes are the fixed leading text of `serde` and `serde_norway` +/// messages. Everything after a prefix can quote document content and is +/// never read. +const DECODE_CAUSES: [(&str, &str); 13] = [ + ("unknown field", "unknown field"), + ("missing field", "required field is missing"), + ("duplicate field", "duplicate field"), + ("duplicate entry", "duplicate mapping key"), + ("invalid type", "field has the wrong type"), + ("invalid value", "field value is not accepted"), + ("invalid length", "field has the wrong length"), + ( + "unknown variant", + "field value is not one of the accepted variants", + ), + ( + "data did not match any variant", + "field value is not one of the accepted variants", + ), + ( + "EOF while parsing", + "document ends before a value is complete", + ), + ("recursion limit exceeded", "document nests too deeply"), + ( + "repetition limit exceeded", + "document repeats an alias too often", + ), + ( + "deserializing from YAML containing more than one document", + "document contains more than one YAML document", + ), +]; + +impl SchemaFault { + /// A fault that names only its cause. + pub fn because(cause: &'static str) -> Self { + Self { + location: None, + path: None, + cause, + } + } + + pub fn cause(&self) -> &'static str { + self.cause + } + + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + + pub fn location(&self) -> Option { + self.location + } + + /// Reduce a decoder error to a location, a safe schema path, and a cause. + fn from_yaml_error(error: &serde_norway::Error, fallback: &'static str) -> Self { + let rendered = error.to_string(); + let (path, message) = split_schema_path(&rendered); + Self { + location: error.location().map(|location| TextLocation { + line: location.line(), + column: location.column(), + }), + path, + cause: classify_decode_cause(message, fallback), + } + } +} + +impl fmt::Display for SchemaFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.cause)?; + if let Some(path) = &self.path { + write!(formatter, " at {path}")?; + } + if let Some(location) = self.location { + write!(formatter, " ({location})")?; + } + Ok(()) + } +} + +/// Split a rendered decoder error into its schema path and its message. +/// +/// `serde_norway` prefixes a message with the path to the offending value when +/// it knows one. The candidate prefix is accepted only when it matches the +/// path grammar, which no message text can satisfy, so a message that merely +/// contains a colon never becomes a path. +fn split_schema_path(rendered: &str) -> (Option, &str) { + match rendered.split_once(": ") { + Some((candidate, message)) if is_safe_schema_path(candidate) => { + (Some(candidate.to_owned()), message) + } + _ => (None, rendered), + } +} + +/// Accept only paths built from mapping keys and numeric sequence indices. +/// +/// Mapping keys are structural names in a reviewed bundle, never values, and +/// this grammar admits no whitespace, quoting, or punctuation that a quoted +/// scalar would carry. +fn is_safe_schema_path(candidate: &str) -> bool { + if candidate.is_empty() || candidate.len() > MAX_SCHEMA_PATH_BYTES { + return false; + } + let mut index_digits: Option = None; + for character in candidate.chars() { + match index_digits { + Some(digits) => match character { + '0'..='9' => index_digits = Some(digits + 1), + ']' if digits > 0 => index_digits = None, + _ => return false, + }, + None => match character { + '[' => index_digits = Some(0), + '.' | '-' | '_' | '?' => {} + _ if character.is_ascii_alphanumeric() => {} + _ => return false, + }, + } + } + index_digits.is_none() +} + +/// Map a decoder message to one static cause, reading only its fixed prefix. +fn classify_decode_cause(message: &str, fallback: &'static str) -> &'static str { + DECODE_CAUSES + .iter() + .find(|(prefix, _)| message.starts_with(prefix)) + .map_or(fallback, |(_, cause)| cause) +} + +/// Decode one YAML document into a closed typed schema. +/// +/// The bytes are parsed as untyped YAML first so that a syntax failure is +/// reported as a syntax failure rather than as a schema mismatch. +fn decode_yaml(text: &str) -> Result { + if let Err(error) = serde_norway::from_str::(text) { + return Err(ConfigError::InvalidYaml(SchemaFault::from_yaml_error( + &error, + "document is not well-formed YAML", + ))); + } + serde_norway::from_str(text).map_err(|error| { + ConfigError::InvalidYaml(SchemaFault::from_yaml_error( + &error, + "document does not match the closed schema", + )) + }) +} + /// A mapping that rejects duplicate keys and preserves declaration order. /// /// Selector declaration order is part of canonical selector encoding, so a @@ -144,6 +344,11 @@ pub struct EvidenceConfig { pub subject_binding: SubjectBindingConfig, pub rate_limits: RateLimitConfig, pub signing: SigningConfig, + /// Closed enabled response formats for the whole immutable bundle. Signed + /// flattened JWS is mandatory and the default; unsigned JSON must be + /// enabled here and permitted by the complete matched grant. + #[serde(default = "default_response_formats")] + pub response_formats: Vec, pub selector_profiles: OrderedMap, pub sources: OrderedMap, pub authority_profiles: OrderedMap, @@ -157,8 +362,9 @@ impl EvidenceConfig { if bytes.len() > MAX_CONFIG_BYTES { return Err(ConfigError::TooLarge); } - let text = std::str::from_utf8(bytes).map_err(|_| ConfigError::InvalidYaml)?; - let config: Self = serde_norway::from_str(text).map_err(|_| ConfigError::InvalidYaml)?; + let text = std::str::from_utf8(bytes) + .map_err(|_| ConfigError::InvalidYaml(SchemaFault::because("document is not UTF-8")))?; + let config: Self = decode_yaml(text)?; config.validate()?; Ok(config) } @@ -175,6 +381,7 @@ impl EvidenceConfig { self.subject_binding.validate()?; self.rate_limits.validate()?; self.signing.validate()?; + validate_response_formats(&self.response_formats, "bundle response formats")?; validate_named_map(&self.selector_profiles, 1, 128, |profile| { profile.validate() })?; @@ -500,8 +707,9 @@ impl RuntimeConfig { if bytes.len() > MAX_CONFIG_BYTES { return Err(ConfigError::TooLarge); } - let text = std::str::from_utf8(bytes).map_err(|_| ConfigError::InvalidYaml)?; - let config: Self = serde_norway::from_str(text).map_err(|_| ConfigError::InvalidYaml)?; + let text = std::str::from_utf8(bytes) + .map_err(|_| ConfigError::InvalidYaml(SchemaFault::because("document is not UTF-8")))?; + let config: Self = decode_yaml(text)?; config.validate()?; Ok(config) } @@ -1514,6 +1722,12 @@ pub struct AuthorityGrant { pub requirement: String, pub purpose: String, pub audience_from: AudienceFrom, + /// Closed response formats this complete grant permits. Selection through + /// the API creates no permission; the bundle formats and this grant must + /// both allow the requested format. Formats are never unioned across + /// grants. + #[serde(default = "default_response_formats")] + pub response_formats: Vec, pub subjects: Vec, } @@ -1521,10 +1735,47 @@ impl AuthorityGrant { fn validate(&self) -> Result<(), ConfigError> { validate_uri(&self.requirement)?; validate_purpose(&self.purpose)?; + validate_response_formats(&self.response_formats, "authority grant response formats")?; validate_len(self.subjects.len(), 1, 8, "authority grant subjects") } } +/// Closed Version 1 response-format vocabulary. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ResponseFormat { + SignedJws, + UnsignedJson, +} + +fn default_response_formats() -> Vec { + vec![ResponseFormat::SignedJws] +} + +fn validate_response_formats( + formats: &[ResponseFormat], + description: &'static str, +) -> Result<(), ConfigError> { + validate_len(formats.len(), 1, 2, description)?; + let mut seen = BTreeSet::new(); + for format in formats { + if !seen.insert(format_discriminant(*format)) { + return invalid("response formats must be unique"); + } + } + if !formats.contains(&ResponseFormat::SignedJws) { + return invalid("signed JWS must remain an enabled response format"); + } + Ok(()) +} + +fn format_discriminant(format: ResponseFormat) -> u8 { + match format { + ResponseFormat::SignedJws => 0, + ResponseFormat::UnsignedJson => 1, + } +} + #[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum AudienceFrom { @@ -1961,7 +2212,7 @@ fn validate_configurable_header_name(name: &str) -> Result<(), ConfigError> { if name.is_empty() || name.len() > 64 || !name.bytes().all(is_http_token_byte) - || is_reserved_header_name(&name.to_ascii_lowercase()) + || is_reserved_header_name(name) { return invalid("configured header name is prohibited"); } @@ -1989,40 +2240,139 @@ fn is_http_token_byte(byte: u8) -> bool { ) } -fn is_reserved_header_name(name: &str) -> bool { - matches!( - name, - "authorization" - | "proxy-authorization" - | "host" - | "cookie" - | "set-cookie" - | "content-length" - | "content-type" - | "transfer-encoding" - | "expect" - | "connection" - | "keep-alive" - | "te" - | "trailer" - | "upgrade" - | "proxy-connection" - | "forwarded" - | "via" - | "x-real-ip" - | "traceparent" - | "tracestate" - | "baggage" - | "x-request-id" - | "x-correlation-id" - | "x-amzn-trace-id" - | "x-original-url" - | "x-rewrite-url" - | "x-http-method-override" - | "x-original-method" - ) || name.starts_with("x-forwarded-") - || name.starts_with("proxy-") - || name.starts_with("x-b3-") +/// The complete closed set of header names no bundle may configure. +/// +/// Authentication, host and routing, cookie, framing, hop-by-hop, forwarding, +/// proxy, and tracing headers are owned by Rust or by the operator's network +/// path. A bundle that could set them could redirect a source request, forge a +/// client identity, or smuggle a second request past the reviewed contract. +const RESERVED_HEADER_NAMES: [&str; 38] = [ + "authorization", + "proxy-authorization", + "www-authenticate", + "proxy-authenticate", + "host", + "cookie", + "set-cookie", + "content-length", + "content-type", + "transfer-encoding", + "expect", + "connection", + "keep-alive", + "te", + "trailer", + "upgrade", + "proxy-connection", + "forwarded", + "via", + "x-real-ip", + "x-client-ip", + "x-cluster-client-ip", + "true-client-ip", + "cf-connecting-ip", + "fastly-client-ip", + "x-appengine-user-ip", + "x-azure-clientip", + "traceparent", + "tracestate", + "baggage", + "b3", + "x-cloud-trace-context", + "x-request-id", + "x-correlation-id", + "x-amzn-trace-id", + "x-original-url", + "x-rewrite-url", + "x-original-method", +]; + +/// The complete closed set of reserved header-name prefix families. +/// +/// A prefix family is denied before any exact name so that a new vendor +/// forwarding or tracing member cannot be configured before this contract +/// learns its exact name. +const RESERVED_HEADER_PREFIXES: [&str; 7] = [ + "x-forwarded-", + "proxy-", + "sec-", + "x-b3-", + "x-envoy-", + "x-datadog-", + "x-http-method", +]; + +/// Representative reserved names, case variants, and prefix-family members. +/// +/// Both the startup configuration contract and the source plan compiler are +/// tested against this one list, which is how their shared classifier is +/// proven to be a single closed deny set rather than two drifting copies. +pub const RESERVED_HEADER_CONTRACT_CASES: [&str; 51] = [ + "Authorization", + "authorization", + "AUTHORIZATION", + "Proxy-Authorization", + "Proxy-Authenticate", + "WWW-Authenticate", + "Host", + "Cookie", + "Set-Cookie", + "Content-Length", + "Content-Type", + "Transfer-Encoding", + "Expect", + "Connection", + "Keep-Alive", + "TE", + "Trailer", + "Upgrade", + "Proxy-Connection", + "Forwarded", + "Via", + "X-Real-IP", + "X-Client-IP", + "x-client-ip", + "X-Cluster-Client-IP", + "True-Client-IP", + "true-client-ip", + "CF-Connecting-IP", + "cf-connecting-ip", + "Fastly-Client-IP", + "X-Appengine-User-IP", + "X-Azure-ClientIP", + "TraceParent", + "Tracestate", + "Baggage", + "b3", + "B3", + "X-Cloud-Trace-Context", + "X-Request-ID", + "X-Correlation-ID", + "X-Amzn-Trace-ID", + "X-Original-URL", + "X-Rewrite-URL", + "X-HTTP-Method-Override", + "X-Original-Method", + "X-Forwarded-For", + "X-Forwarded-Proto", + "X-B3-TraceId", + "X-Envoy-External-Address", + "X-Datadog-Trace-Id", + "Sec-Fetch-Mode", +]; + +/// The one closed reserved-header classifier. +/// +/// `name` may be in any ASCII case. Configuration validation rejects a +/// reserved name at startup and source plan compilation rejects it again +/// before any credential is resolved, so both call sites share this function +/// rather than duplicating the deny set. +pub(crate) fn is_reserved_header_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + RESERVED_HEADER_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) + || RESERVED_HEADER_NAMES.contains(&name.as_str()) } fn validate_path_template( @@ -2790,6 +3140,124 @@ mod tests { serde_json::to_value(value).expect("bundle instance converts to JSON") } + /// Canary scalars planted in a malformed document. + /// + /// A diagnostic that ever reproduces one of these has leaked a deployment + /// value, which is exactly what the safe-diagnostic contract forbids. + const CANARY_VALUES: [&str; 4] = [ + "s3cr3t-selector-value", + "urn:gov:example:canary:subject:9910", + "secret:file/canary-private-key", + "https://canary.internal.example", + ]; + + #[test] + fn decode_failures_report_a_safe_path_a_location_and_a_value_free_cause() { + let reference = include_str!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml"); + let unknown_nested = reference.replacen( + " timeoutMilliseconds: 3000", + " timeoutMilliseconds: 3000\n surprise: s3cr3t-selector-value", + 1, + ); + assert_ne!(unknown_nested, reference, "nested mutation applies"); + let cases: [(&str, String, &str, Option<&str>, bool); 6] = [ + ( + "malformed YAML", + format!("version: 1\nbroken: [{}\n", CANARY_VALUES[0]), + "document is not well-formed YAML", + None, + true, + ), + ( + "unknown top-level field", + format!("version: 1\nbogusField: {}\n", CANARY_VALUES[1]), + "unknown field", + None, + true, + ), + ( + "unknown nested field", + unknown_nested, + "unknown field", + Some("sources.registered-birth-date.request"), + true, + ), + ( + "wrong type", + format!("version: {}\n", CANARY_VALUES[2]), + "field has the wrong type", + Some("version"), + true, + ), + ( + "missing field", + "version: 1\n".to_owned(), + "required field is missing", + None, + true, + ), + ( + "more than one document", + format!("version: 1\n---\nversion: {}\n", CANARY_VALUES[3]), + "document contains more than one YAML document", + None, + false, + ), + ]; + for (label, document, expected_cause, expected_path, expects_location) in cases { + let error = EvidenceConfig::parse_yaml(document.as_bytes()) + .err() + .unwrap_or_else(|| panic!("{label} was accepted")); + let ConfigError::InvalidYaml(fault) = &error else { + panic!("{label} was not reported as a decode failure: {error}"); + }; + assert_eq!(fault.cause(), expected_cause, "{label} cause"); + assert_eq!(fault.path(), expected_path, "{label} path"); + assert_eq!( + fault.location().is_some(), + expects_location, + "{label} location presence" + ); + let rendered = error.to_string(); + for canary in CANARY_VALUES { + assert!( + !rendered.contains(canary), + "{label} diagnostic leaked a document value: {rendered}" + ); + } + } + } + + #[test] + fn schema_paths_are_accepted_only_when_they_carry_no_document_value() { + for safe in [ + "version", + "sources.registered-birth-date.request", + "sources.a.request.fixedHeaders[0].name", + "requirements[12].concepts[3].id", + "sources.a.?", + ] { + assert!(is_safe_schema_path(safe), "{safe} is a schema path"); + } + for unsafe_candidate in [ + "", + "invalid type", + "invalid value", + "sources.urn:gov:example", + "sources.\"quoted key\"", + "sources.a[]", + "sources.a[x]", + "sources.a[0", + "sources.a/b", + &"a".repeat(MAX_SCHEMA_PATH_BYTES + 1), + ] { + assert!( + !is_safe_schema_path(unsafe_candidate), + "{unsafe_candidate} is not a schema path" + ); + } + } + #[test] fn exact_secret_reference_grammars_are_closed() { for valid in ["secret:file/a", "secret:file/source-token_v2.json"] { @@ -2834,6 +3302,64 @@ mod tests { } } + #[test] + fn requirement_validity_cannot_exceed_the_signing_maximum() { + // Startup validation is the enforcement point: runtime construction + // derives validUntil from the validated requirement validity, so no + // constructed assertion can exceed the bundle signing maximum and no + // redundant signing-time check exists. + let yaml = include_str!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + ); + assert!(EvidenceConfig::parse_yaml(yaml.as_bytes()).is_ok()); + let shrunk_maximum = yaml.replace( + "maximumAssertionValiditySeconds: 86400", + "maximumAssertionValiditySeconds: 3600", + ); + assert!(matches!( + EvidenceConfig::parse_yaml(shrunk_maximum.as_bytes()), + Err(ConfigError::Invalid( + "requirement validity exceeds signing maximum validity" + )) + )); + } + + #[test] + fn response_formats_are_closed_unique_and_keep_signed_mandatory() { + let yaml = include_str!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + ); + for (from, to) in [ + // The bundle cannot drop the mandatory signed format. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [unsigned-json]", + ), + // Formats must be unique. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, signed-jws]", + ), + // A grant cannot drop the mandatory signed format either. + ( + " responseFormats: [signed-jws, unsigned-json]\n subjects:\n - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}", + " responseFormats: [unsigned-json]\n subjects:\n - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}", + ), + // The vocabulary is closed. + ( + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, jws-detached]", + ), + ] { + let mutated = yaml.replace(from, to); + assert_ne!(mutated, yaml, "{to}"); + assert!( + EvidenceConfig::parse_yaml(mutated.as_bytes()).is_err(), + "{to}" + ); + } + } + #[test] fn bundle_contract_accepts_every_complete_version_one_bundle() { let validator = bundle_contract_validator(); @@ -3224,10 +3750,19 @@ outboundTls: ] { let mut candidate = valid.to_vec(); candidate.extend_from_slice(format!("{governed_key}: {{}}\n").as_bytes()); + let rejection = RuntimeConfig::parse_yaml(&candidate) + .expect_err("runtime accepted governed bundle key {governed_key}"); + let ConfigError::InvalidYaml(fault) = &rejection else { + panic!("runtime accepted governed bundle key {governed_key}: {rejection}"); + }; assert_eq!( - RuntimeConfig::parse_yaml(&candidate), - Err(ConfigError::InvalidYaml), - "runtime accepted governed bundle key {governed_key}" + fault.cause(), + "unknown field", + "governed bundle key {governed_key} was rejected for the wrong reason" + ); + assert!( + fault.location().is_some(), + "governed bundle key {governed_key} was rejected without a location" ); assert!( !validator.is_valid(&bundle_contract_instance(&candidate)), @@ -3248,25 +3783,13 @@ outboundTls: "/records/prefix-{record_reference}", "/records/{missing}", "/records/../{record_reference}", + "/records/{record_reference}/{record_reference}", ] { assert!(validate_path_template(invalid_template, &bindings).is_err()); } assert!(validate_configurable_header_name("X-API-Version").is_ok()); - for forbidden in [ - "Authorization", - "Host", - "Content-Length", - "Expect", - "Cookie", - "Proxy-Authorization", - "X-Forwarded-For", - "X-Original-URL", - "X-Rewrite-URL", - "X-HTTP-Method-Override", - "X-Original-Method", - "TraceParent", - ] { + for forbidden in RESERVED_HEADER_CONTRACT_CASES { assert!( validate_configurable_header_name(forbidden).is_err(), "{forbidden}" diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index 105ebc397..68bb5722f 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -17,6 +17,7 @@ use thiserror::Error; use crate::model::{ Evidence, EvidenceDefinitions, EvidenceRequest, FlattenedJws, JwksDocument, ProblemBody, + UnsignedEvidenceEnvelope, }; pub const OPENAPI_FILE: &str = "registry-evidence.openapi.json"; @@ -24,6 +25,7 @@ pub const REQUEST_SCHEMA_FILE: &str = "evidence-request-v1.schema.json"; pub const EVIDENCE_SCHEMA_FILE: &str = "evidence-v1.schema.json"; pub const DEFINITIONS_SCHEMA_FILE: &str = "evidence-definitions-v1.schema.json"; pub const JWS_SCHEMA_FILE: &str = "flattened-jws-v1.schema.json"; +pub const UNSIGNED_ENVELOPE_SCHEMA_FILE: &str = "evidence-unsigned-envelope-v1.schema.json"; pub const PROBLEM_SCHEMA_FILE: &str = "problem-v1.schema.json"; pub const JWKS_SCHEMA_FILE: &str = "jwks-v1.schema.json"; @@ -34,13 +36,21 @@ const EVIDENCE_SCHEMA_ID: &str = const DEFINITIONS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/definitions-v1.json"; const JWS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/flattened-jws-v1.json"; +const UNSIGNED_ENVELOPE_SCHEMA_ID: &str = + "https://registrystack.org/schemas/evidence/unsigned-envelope-v1.json"; const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; -const PROBLEM_VARIANTS: [(&str, u16, &str); 8] = [ +const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; +const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ ("malformed_request", 400, "Request is not valid"), ("invalid_selector", 400, "Request is not valid"), ("authentication_failed", 401, "Authentication failed"), ("not_authorized", 403, "Request is not authorized"), + ( + "response_format_not_acceptable", + 406, + "Requested response format is not acceptable", + ), ( "evidence_not_available", 422, @@ -88,21 +98,32 @@ pub fn documents() -> Result, ContractGenerationE let evidence = evidence_schema(); let definitions = definitions_schema(); let jws = jws_schema(); + let unsigned = unsigned_envelope_schema(); let problem = problem_schema(); let jwks = jwks_schema(); assert_model_shape::("EvidenceRequest", &request, true)?; assert_model_shape::("Evidence", &evidence, true)?; assert_model_shape::("EvidenceDefinitions", &definitions, true)?; assert_model_shape::("FlattenedJws", &jws, false)?; + assert_model_shape::("UnsignedEvidenceEnvelope", &unsigned, false)?; assert_model_shape::("ProblemBody", &problem, false)?; assert_model_shape::("JwksDocument", &jwks, false)?; - let openapi = openapi_document(&request, &evidence, &definitions, &jws, &problem, &jwks); + let openapi = openapi_document( + &request, + &evidence, + &definitions, + &jws, + &unsigned, + &problem, + &jwks, + ); let values = [ (REQUEST_SCHEMA_FILE, request), (EVIDENCE_SCHEMA_FILE, evidence), (DEFINITIONS_SCHEMA_FILE, definitions), (JWS_SCHEMA_FILE, jws), + (UNSIGNED_ENVELOPE_SCHEMA_FILE, unsigned), (PROBLEM_SCHEMA_FILE, problem), (JWKS_SCHEMA_FILE, jwks), (OPENAPI_FILE, openapi), @@ -227,8 +248,9 @@ fn request_schema() -> Value { "title": "Evidence request Version 1", "type": "object", "additionalProperties": false, - "required": ["requirement", "purpose", "subjects"], + "required": ["requestNonce", "requirement", "purpose", "subjects"], "properties": { + "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, "requirement": {"type": "string", "format": "uri", "minLength": 1, "maxLength": 512}, "purpose": {"type": "string", "pattern": "^[a-z][a-z0-9._:-]{0,127}$"}, "subjects": { @@ -265,7 +287,7 @@ fn request_schema() -> Value { ] } }, - "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access." + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it." }) } @@ -413,12 +435,13 @@ fn evidence_schema() -> Value { "type": "object", "additionalProperties": false, "required": [ - "schema", "id", "type", "supportsRequirement", "isConformantTo", + "schema", "requestNonce", "id", "type", "supportsRequirement", "isConformantTo", "issuedBy", "providedBy", "issuedAt", "observedAt", "validUntil", "purpose", "audience", "configurationRevision", "subjects", "supportedValues" ], "properties": { "schema": {"const": "registry.assertion-evidence/v1"}, + "requestNonce": {"type": "string", "pattern": REQUEST_NONCE_PATTERN}, "id": {"type": "string", "format": "uri", "maxLength": 512}, "type": {"const": "Evidence"}, "supportsRequirement": {"type": "string", "format": "uri", "maxLength": 512}, @@ -522,6 +545,25 @@ fn jws_schema() -> Value { }) } +fn unsigned_envelope_schema() -> Value { + json!({ + "$schema": SCHEMA_DIALECT, + "$id": UNSIGNED_ENVELOPE_SCHEMA_ID, + "title": "Evidence unsigned response envelope Version 1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "type", "integrityProtection", "warning", "evidence"], + "properties": { + "schema": {"const": "registry.unsigned-evidence-envelope/v1"}, + "type": {"const": "UnsignedEvidenceEnvelope"}, + "integrityProtection": {"const": "none"}, + "warning": {"const": "not-cryptographically-verifiable"}, + "evidence": {"$ref": EVIDENCE_SCHEMA_ID} + }, + "$comment": "Transport-authenticated convenience representation selected only by its exact vendor media type when the immutable bundle and the complete matched grant permit it. Once separated from its HTTPS exchange it provides no issuer-authenticity, integrity, non-repudiation, or later-verification property. The nested evidence is the same closed core object that would be JWS encoded. There is no protected, payload, or signature member, so the strict JWS verifier rejects this representation; tooling that parses it must return an explicitly unverified result." + }) +} + fn problem_schema() -> Value { let mut schema = json!({ "$schema": SCHEMA_DIALECT, @@ -538,6 +580,7 @@ fn problem_schema() -> Value { "https://registrystack.org/problems/evidence/invalid_selector", "https://registrystack.org/problems/evidence/authentication_failed", "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/response_format_not_acceptable", "https://registrystack.org/problems/evidence/evidence_not_available", "https://registrystack.org/problems/evidence/rate_limited", "https://registrystack.org/problems/evidence/dependency_unavailable", @@ -546,11 +589,13 @@ fn problem_schema() -> Value { }, "title": {"type": "string", "enum": [ "Request is not valid", "Authentication failed", "Request is not authorized", + "Requested response format is not acceptable", "Evidence could not be produced", "Request rate exceeded", "Service temporarily unavailable" ]}, - "status": {"type": "integer", "enum": [400, 401, 403, 422, 429, 503]}, + "status": {"type": "integer", "enum": [400, 401, 403, 406, 422, 429, 503]}, "code": {"type": "string", "enum": [ "malformed_request", "invalid_selector", "authentication_failed", "not_authorized", + "response_format_not_acceptable", "evidence_not_available", "rate_limited", "dependency_unavailable", "service_unavailable" ]}, "operation": {"type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"} @@ -753,11 +798,29 @@ fn response_headers(extra: Option<(&str, Value)>) -> Value { Value::Object(headers) } +/// Headers for every `/v1/evidence` response, which varies on `Accept`. +fn evidence_response_headers(extra: Option<(&str, Value)>) -> Value { + let mut headers = response_headers(extra); + headers + .as_object_mut() + .expect("response headers are an object") + .insert( + "Vary".to_string(), + json!({ + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": {"type": "string", "enum": ["Accept"]} + }), + ); + headers +} + +#[allow(clippy::too_many_arguments)] fn openapi_document( request: &Value, evidence: &Value, definitions: &Value, jws: &Value, + unsigned: &Value, problem: &Value, jwks: &Value, ) -> Value { @@ -798,6 +861,20 @@ fn openapi_document( ], ); insert_schema_family(&mut schemas, "FlattenedJws", jws, &[]); + insert_schema_family(&mut schemas, "UnsignedEvidenceEnvelope", unsigned, &[]); + if let Some(reference) = schemas + .get_mut("UnsignedEvidenceEnvelope") + .and_then(Value::as_object_mut) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("evidence")) + .and_then(Value::as_object_mut) + { + reference.insert( + "$ref".to_string(), + Value::String("#/components/schemas/Evidence".to_string()), + ); + } if let Some(properties) = schemas .get_mut("FlattenedJws") .and_then(Value::as_object_mut) @@ -858,7 +935,8 @@ fn openapi_document( "/v1/evidence": { "post": { "operationId": "createEvidence", - "summary": "Produce signed evidence for one authorized fixed requirement", + "summary": "Produce evidence for one authorized fixed requirement", + "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", "security": [{"bearerAuth": []}], "requestBody": { "required": true, @@ -866,42 +944,50 @@ fn openapi_document( }, "responses": { "200": { - "description": "Signed Evidence as flattened JWS JSON Serialization", - "headers": response_headers(None), - "content": {"application/jose+json": {"schema": {"$ref": "#/components/schemas/FlattenedJws"}}} + "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized self-identifying unsigned envelope", + "headers": evidence_response_headers(None), + "content": { + "application/jose+json": {"schema": {"$ref": "#/components/schemas/FlattenedJws"}}, + "application/vnd.registrystack.evidence-unsigned+json": {"schema": {"$ref": "#/components/schemas/UnsignedEvidenceEnvelope"}} + } }, "400": { "description": "Malformed request or invalid selector", - "headers": response_headers(None), + "headers": evidence_response_headers(None), "content": problem_content(&["malformed_request", "invalid_selector"]) }, "401": { "description": "Authentication failed", - "headers": response_headers(Some(("WWW-Authenticate", json!({ + "headers": evidence_response_headers(Some(("WWW-Authenticate", json!({ "schema": {"type": "string", "enum": ["Bearer"]} })))), "content": problem_content(&["authentication_failed"]) }, "403": { - "description": "Request is not authorized", - "headers": response_headers(None), + "description": "Request is not authorized, including a recognized response format the bundle or matched grant does not permit", + "headers": evidence_response_headers(None), "content": problem_content(&["not_authorized"]) }, + "406": { + "description": "Media negotiation is outside the closed Accept matrix", + "headers": evidence_response_headers(None), + "content": problem_content(&["response_format_not_acceptable"]) + }, "422": { "description": "Evidence could not be produced", - "headers": response_headers(None), + "headers": evidence_response_headers(None), "content": problem_content(&["evidence_not_available"]) }, "429": { "description": "Request rate exceeded", - "headers": response_headers(Some(("Retry-After", json!({ + "headers": evidence_response_headers(Some(("Retry-After", json!({ "schema": {"type": "string", "enum": ["1"]} })))), "content": problem_content(&["rate_limited"]) }, "503": { "description": "Dependency or service temporarily unavailable", - "headers": response_headers(None), + "headers": evidence_response_headers(None), "content": problem_content(&["dependency_unavailable", "service_unavailable"]) }, } @@ -1020,6 +1106,16 @@ mod tests { .compile(&schema) .expect("generated schema compiles"); } + + // The unsigned envelope references the Evidence payload schema by its + // canonical identifier, so that document is registered for offline + // compilation. + JSONSchema::options() + .with_draft(Draft::Draft202012) + .should_validate_formats(true) + .with_document(EVIDENCE_SCHEMA_ID.to_string(), evidence_schema()) + .compile(&unsigned_envelope_schema()) + .expect("generated unsigned envelope schema compiles"); } #[test] @@ -1029,6 +1125,7 @@ mod tests { &evidence_schema(), &definitions_schema(), &jws_schema(), + &unsigned_envelope_schema(), &problem_schema(), &jwks_schema(), ); @@ -1052,6 +1149,7 @@ mod tests { &evidence_schema(), &definitions_schema(), &jws_schema(), + &unsigned_envelope_schema(), &problem_schema(), &jwks_schema(), ); @@ -1071,6 +1169,26 @@ mod tests { ["application/jose+json"] .is_object() ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/vnd.registrystack.evidence-unsigned+json"] + .is_object() + ); + assert_eq!( + document["paths"]["/v1/evidence"]["post"]["responses"]["406"]["content"] + ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], + json!(["response_format_not_acceptable"]) + ); + for response in document["paths"]["/v1/evidence"]["post"]["responses"] + .as_object() + .expect("evidence responses are an object") + .values() + { + assert_eq!( + response["headers"]["Vary"]["schema"]["enum"], + json!(["Accept"]) + ); + } assert!( document["paths"]["/v1/evidence-definitions"]["get"]["responses"]["200"]["content"] ["application/json"] @@ -1141,6 +1259,7 @@ mod tests { ( request_schema(), json!({ + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "requirement": "urn:example:requirement:v1", "purpose": "casework", "subjects": [{ @@ -1153,6 +1272,7 @@ mod tests { evidence_schema(), json!({ "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "id": "urn:ulid:01K1EXAMPLE0000000000000000", "type": "Evidence", "supportsRequirement": "urn:example:requirement:v1", diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs index d5712c7d9..587718ac4 100644 --- a/crates/registry-evidence/src/kernel.rs +++ b/crates/registry-evidence/src/kernel.rs @@ -53,6 +53,11 @@ pub enum KernelError { Requirement, #[error("the Evidence extraction failed")] Extraction, + /// A uniquely resolved record reached derivation with missing, mistyped, + /// or inconsistent inputs. Publicly this collapses with the unresolved + /// lookup classes so callers cannot learn that a record exists. + #[error("the Evidence derivation inputs are unresolved")] + DerivationInput, #[error("the Evidence request preparation failed")] Preparation, #[error("the source response violates its fixed protocol contract")] @@ -111,6 +116,9 @@ pub struct ValueProjection<'a> { /// Core-owned envelope inputs supplied by the authenticated release pipeline. pub struct EvidenceConstruction<'a> { pub evidence_id: &'a str, + /// Exact caller nonce to echo. It is copied verbatim into the payload and + /// is not part of the subject binding, audit, or any diagnostic surface. + pub request_nonce: &'a str, pub purpose: &'a str, pub audience: &'a str, pub issued_at: DateTime, @@ -402,6 +410,7 @@ impl OfflineKernel { .derive(script, facts, selectors, evaluation_context) .map_err(|error| match error { RhaiRuntimeError::Unavailable => KernelError::Extraction, + RhaiRuntimeError::DerivationInput => KernelError::DerivationInput, RhaiRuntimeError::SourceProtocol => KernelError::Script, _ => KernelError::Script, })?; @@ -489,6 +498,7 @@ impl OfflineKernel { Ok(Evidence { schema: crate::EVIDENCE_SCHEMA_V1.to_owned(), + request_nonce: input.request_nonce.to_owned(), id: input.evidence_id.to_owned(), evidence_type_name: EvidenceObjectType::Evidence, supports_requirement: requirement.id.clone(), @@ -899,6 +909,7 @@ fn validate_evidence_inputs( if input.evidence_id.is_empty() || input.evidence_id.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES || url::Url::parse(input.evidence_id).is_err() + || !crate::model::request_nonce_is_canonical(input.request_nonce) || input.audience.is_empty() || input.audience.len() > MAXIMUM_EVIDENCE_IDENTIFIER_BYTES || url::Url::parse(input.audience).is_err() @@ -1529,6 +1540,7 @@ mod tests { .expect("values validate"); let construction = || EvidenceConstruction { evidence_id: "urn:ulid:01K1EXAMPLE0000000000000000", + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE, purpose: &requirement.purposes[0], audience: AUDIENCE, issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), @@ -2051,6 +2063,7 @@ mod tests { values, EvidenceConstruction { evidence_id: "urn:ulid:01K1SUPPORTEDVALUES0000000000", + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE, purpose: "conformance", audience: AUDIENCE, issued_at: "2026-08-02T00:00:01Z".parse().expect("time"), @@ -2073,17 +2086,13 @@ mod tests { let verified = verify_flattened_jws( &serialized, &jwks, - &EvidenceVerificationPolicy { - issued_by: evidence.issued_by.clone(), - provided_by: evidence.provided_by.clone(), - requirement: evidence.supports_requirement.clone(), - evidence_type: evidence.is_conformant_to.clone(), - purpose: evidence.purpose.clone(), - audience: evidence.audience.clone(), - configuration_revision: evidence.configuration_revision.clone(), - now: "2026-08-02T12:00:00Z".parse().expect("time"), - clock_skew: StdDuration::from_secs(30), - }, + &EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + &evidence.request_nonce, + StdDuration::from_secs(48 * 60 * 60), + "2026-08-02T12:00:00Z".parse().expect("time"), + StdDuration::from_secs(30), + ), ) .expect("signed Evidence verifies"); assert_eq!( diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs index 1710c6f7d..ea55d5305 100644 --- a/crates/registry-evidence/src/lib.rs +++ b/crates/registry-evidence/src/lib.rs @@ -28,6 +28,9 @@ mod runtime_tests; pub const EVIDENCE_SCHEMA_V1: &str = "registry.assertion-evidence/v1"; pub const EVIDENCE_DEFINITIONS_SCHEMA_V1: &str = "registry.evidence-definitions/v1"; +pub const EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1: &str = "registry.unsigned-evidence-envelope/v1"; pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; +pub const EVIDENCE_UNSIGNED_MEDIA_TYPE: &str = + "application/vnd.registrystack.evidence-unsigned+json"; diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 0719ed659..9ab514450 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -2,26 +2,29 @@ use std::{ collections::BTreeMap, - fmt, + fmt, fs, path::{Component, Path, PathBuf}, process::ExitCode, str::FromStr, sync::Arc, }; -use chrono::{DateTime, NaiveDate, TimeZone, Utc}; +use chrono::{DateTime, NaiveDate, SecondsFormat, TimeZone, Utc}; use chrono_tz::Tz; use clap::{Parser, Subcommand}; use ed25519_dalek::SigningKey; use rand_core::OsRng; use registry_evidence::{ - bundle::{Bundle, BundleError, DeploymentInputs, RuntimeDocument}, + bundle::{ArtifactFault, Bundle, BundleError, DeploymentInputs, RuntimeDocument}, config::{ConfigError, EvidenceConfig, OutboundTlsConfig, SelectorInput}, kernel::{ EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValidatedValues, ValueProjection, }, - model::{LookupResult, PublicValue, ScalarOrEntityReference, SelectorValue, SubjectBinding}, + model::{ + JwksDocument, LookupResult, PublicValue, ScalarOrEntityReference, SelectorValue, + SubjectBinding, + }, problem::ProblemCode, rhai_runtime::{DerivedConceptValue, DerivedValue, RequestParts}, runtime::{source_failure_problem, EvidenceRuntime, RuntimeInitializationError}, @@ -35,9 +38,13 @@ use registry_evidence::{ source::{ project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, }, - verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, + verifier::{ + verify_flattened_jws, verify_flattened_jws_report, EvidenceVerificationPolicy, + ExpectedOutput, ExpectedSubject, ExpectedValueForm, VerificationError, + }, }; -use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use registry_platform_crypto::{parse_json_strict, LocalJwkSigner, PrivateJwk}; +use serde::Deserialize; use serde_json::{Map as JsonMap, Value}; use zeroize::Zeroizing; @@ -75,6 +82,21 @@ enum Command { }, /// Start the native Evidence HTTP service. Serve, + /// Re-verify one stored signed response offline against a pinned key set. + Verify { + /// Stored flattened JWS JSON response file. + #[arg(long)] + jws: PathBuf, + /// Pinned trusted JWKS document. This file is the complete trust set. + #[arg(long)] + jwks: PathBuf, + /// Relying-procedure verification policy document. + #[arg(long)] + policy: PathBuf, + /// Verification instant as strict RFC 3339 UTC; system time by default. + #[arg(long)] + at: Option, + }, } #[derive(Debug, PartialEq, Eq)] @@ -88,6 +110,34 @@ impl fmt::Display for CliError { impl std::error::Error for CliError {} +/// A command failure: a fixed operator message, or one artifact diagnostic. +/// +/// The diagnostic names a bundle-relative artifact, a schema path, and a text +/// location so an operator can find the defect. It carries no document value, +/// which is what keeps a failed `check` safe to paste into a ticket. +#[derive(Debug, PartialEq, Eq)] +enum CommandError { + Cli(CliError), + Deployment(&'static str, ArtifactFault), +} + +impl fmt::Display for CommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cli(error) => fmt::Display::fmt(error, formatter), + Self::Deployment(message, fault) => write!(formatter, "{message}: {fault}"), + } + } +} + +impl std::error::Error for CommandError {} + +impl From for CommandError { + fn from(error: CliError) -> Self { + Self::Cli(error) + } +} + #[derive(Debug, Default, PartialEq, Eq)] struct FixtureSummary { evaluated_cases: usize, @@ -96,7 +146,7 @@ struct FixtureSummary { #[tokio::main] async fn main() -> ExitCode { match run(Cli::parse()).await { - Ok(()) => ExitCode::SUCCESS, + Ok(code) => code, Err(error) => { eprintln!("evidence: {error}"); ExitCode::FAILURE @@ -104,7 +154,7 @@ async fn main() -> ExitCode { } } -async fn run(cli: Cli) -> Result<(), CliError> { +async fn run(cli: Cli) -> Result { match cli.command { Command::Check => { let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; @@ -119,7 +169,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { runtime.revision(), bundle.config.requirements.len() ); - Ok(()) + Ok(ExitCode::SUCCESS) } Command::Evaluate { fixture } => { let deployment = DeploymentInputs::load(&cli.runtime).map_err(deployment_load_error)?; @@ -133,7 +183,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { "Evidence fixture passed ({} evaluated cases)", summary.evaluated_cases ); - Ok(()) + Ok(ExitCode::SUCCESS) } Command::Serve => { let runtime = Arc::new( @@ -143,22 +193,39 @@ async fn run(cli: Cli) -> Result<(), CliError> { ); server::serve(runtime, shutdown_signal()) .await - .map_err(|_| CliError("service failed")) + .map_err(|_| CommandError::Cli(CliError("service failed")))?; + Ok(ExitCode::SUCCESS) } + Command::Verify { + jws, + jwks, + policy, + at, + } => Ok(verify_stored_response(&jws, &jwks, &policy, at.as_deref())?), } } -fn deployment_load_error(error: BundleError) -> CliError { - match error { - BundleError::Unavailable => CliError("deployment input is unavailable"), - BundleError::NotImmutable => CliError("deployment input is not immutable"), - BundleError::UnsupportedEntry => CliError("deployment contains an unsupported entry"), - BundleError::InvalidPath => CliError("deployment contains an invalid path binding"), - BundleError::UnknownFile => CliError("deployment artifact closure is invalid"), - BundleError::TooLarge => CliError("deployment exceeds a Version 1 size bound"), - BundleError::Config(_) => CliError("deployment configuration is invalid"), - BundleError::InvalidArtifact(_) => CliError("deployment artifact is invalid"), - BundleError::InvalidScript => CliError("deployment script is invalid"), +/// Report a startup failure with the artifact diagnostic it carries. +/// +/// The failure class stays a fixed operator message. When the loader knew +/// which artifact failed, the value-free diagnostic is appended so that +/// `evidence check` names a file, a schema path, and a text location instead +/// of only a class. Public HTTP problems are unaffected and stay generic. +fn deployment_load_error(error: BundleError) -> CommandError { + let message = match &error { + BundleError::Unavailable => "deployment input is unavailable", + BundleError::NotImmutable => "deployment input is not immutable", + BundleError::UnsupportedEntry => "deployment contains an unsupported entry", + BundleError::InvalidPath => "deployment contains an invalid path binding", + BundleError::UnknownFile(_) => "deployment artifact closure is invalid", + BundleError::TooLarge => "deployment exceeds a Version 1 size bound", + BundleError::Config(_) => "deployment configuration is invalid", + BundleError::InvalidArtifact(_) => "deployment artifact is invalid", + BundleError::InvalidScript(_) => "deployment script is invalid", + }; + match error.artifact_fault() { + Some(fault) => CommandError::Deployment(message, fault.clone()), + None => CommandError::Cli(CliError(message)), } } @@ -213,8 +280,256 @@ fn compile_source_plans_with_runtime( Ok(plans) } +/// Resolve on the first operator stop signal. +/// +/// A service manager and a container runtime both stop a process with +/// SIGTERM, and an interactive operator uses Ctrl-C. Both resolve here, so the +/// same drain runs either way: the server stops accepting, finishes its +/// in-flight evaluations, and closes the audit chain before the process exits. async fn shutdown_signal() { - let _ = tokio::signal::ctrl_c().await; + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + match signal(SignalKind::terminate()) { + Ok(mut terminate) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + } + Err(error) => { + eprintln!( + "evidence: SIGTERM handler unavailable ({error}); stopping on Ctrl-C only" + ); + let _ = tokio::signal::ctrl_c().await; + } + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +/// Longest accepted verification input. +/// +/// A stored response, a pinned key set, and a relying-procedure policy are all +/// small documents, so a larger file is refused before it is read rather than +/// pulled into memory because a path was mistyped. +const MAX_VERIFY_INPUT_BYTES: u64 = 1024 * 1024; + +/// Exit status for a response that is authentic but no longer current. +const NOT_CURRENT_EXIT_CODE: u8 = 3; + +/// The one class reported for an input document that cannot be read or parsed. +const VERIFY_MALFORMED: CliError = CliError("stored response verification failed (malformed)"); + +/// One relying-procedure verification policy document. +/// +/// Every expectation is required. An optional expectation would silently skip +/// a comparison, so the document is closed and complete; only the clock skew +/// may be omitted, and omitting it means zero tolerance. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VerificationPolicyDocument { + issued_by: String, + provided_by: String, + requirement: String, + evidence_type: String, + purpose: String, + audience: String, + configuration_revision: String, + /// The exact nonce from the independently retained original request. + request_nonce: String, + expected_subjects: Vec, + expected_outputs: Vec, + maximum_assertion_lifetime_seconds: u64, + #[serde(default)] + clock_skew_seconds: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedSubjectDocument { + role: String, + binding: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedOutputDocument { + concept: String, + form: ExpectedFormDocument, +} + +/// The closed expected value-form vocabulary, as written in a policy document. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum ExpectedFormDocument { + Boolean, + Integer, + String, + DateBucket, + TimeBucket, + EntityReference, + Structured, + List(ExpectedListDocument), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedListDocument { + minimum_items: usize, + maximum_items: usize, +} + +impl VerificationPolicyDocument { + fn into_policy(self, now: DateTime) -> EvidenceVerificationPolicy { + EvidenceVerificationPolicy { + issued_by: self.issued_by, + provided_by: self.provided_by, + requirement: self.requirement, + evidence_type: self.evidence_type, + purpose: self.purpose, + audience: self.audience, + configuration_revision: self.configuration_revision, + request_nonce: self.request_nonce, + expected_subjects: self + .expected_subjects + .into_iter() + .map(|subject| ExpectedSubject { + role: subject.role, + binding: subject.binding, + }) + .collect(), + expected_outputs: self + .expected_outputs + .into_iter() + .map(|output| ExpectedOutput { + concept: output.concept, + form: expected_value_form(output.form), + }) + .collect(), + maximum_assertion_lifetime: std::time::Duration::from_secs( + self.maximum_assertion_lifetime_seconds, + ), + now, + clock_skew: std::time::Duration::from_secs(self.clock_skew_seconds), + } + } +} + +fn expected_value_form(document: ExpectedFormDocument) -> ExpectedValueForm { + match document { + ExpectedFormDocument::Boolean => ExpectedValueForm::Boolean, + ExpectedFormDocument::Integer => ExpectedValueForm::Integer, + ExpectedFormDocument::String => ExpectedValueForm::String, + ExpectedFormDocument::DateBucket => ExpectedValueForm::DateBucket, + ExpectedFormDocument::TimeBucket => ExpectedValueForm::TimeBucket, + ExpectedFormDocument::EntityReference => ExpectedValueForm::EntityReference, + ExpectedFormDocument::Structured => ExpectedValueForm::Structured, + ExpectedFormDocument::List(bounds) => ExpectedValueForm::List { + minimum_items: bounds.minimum_items, + maximum_items: bounds.maximum_items, + }, + } +} + +/// Re-verify one stored signed response offline. +/// +/// The pinned key set file is the complete trust set: this command opens no +/// socket, resolves no metadata, and fetches no key. Every expectation comes +/// from the operator's policy document, which belongs to independently +/// retained trusted state such as the original request and an accepted +/// original transaction. The printed lines are the verification instant, the +/// authenticity answer, and, for an authentic response, current usability. +/// A failure reports only its closed class, so re-verification never becomes +/// an oracle for which hidden comparison failed. +fn verify_stored_response( + jws_path: &Path, + jwks_path: &Path, + policy_path: &Path, + at: Option<&str>, +) -> Result { + let instant = verification_instant(at)?; + println!( + "verified-at: {}", + instant.to_rfc3339_opts(SecondsFormat::Secs, true) + ); + + let stored = read_verification_input(jws_path)?; + let trusted: JwksDocument = serde_json::from_value( + parse_json_strict(&read_verification_input(jwks_path)?).map_err(|_| VERIFY_MALFORMED)?, + ) + .map_err(|_| VERIFY_MALFORMED)?; + let document: VerificationPolicyDocument = + serde_norway::from_slice(&read_verification_input(policy_path)?) + .map_err(|_| VERIFY_MALFORMED)?; + let policy = document.into_policy(instant); + + match verify_flattened_jws_report(&stored, &trusted, &policy) { + Ok(report) => { + println!("authentic: yes"); + if !report.currently_valid { + println!("currently-valid: no"); + return Ok(ExitCode::from(NOT_CURRENT_EXIT_CODE)); + } + println!("currently-valid: yes"); + // Inspection output for the operator who already holds the stored + // response. It appears only once the trusted key signed the exact + // payload, every expectation held, and the assertion is current. + let inspected = serde_json::to_string_pretty(&report.evidence) + .map_err(|_| verification_error_class(VerificationError::Payload))?; + println!("{inspected}"); + Ok(ExitCode::SUCCESS) + } + Err(error) => { + println!("authentic: no"); + Err(verification_error_class(error)) + } + } +} + +/// Resolve the verification instant from `--at`, or from system time. +/// +/// `--at` is strict RFC 3339 at zero offset, so an operator cannot silently +/// re-verify against a local wall clock and read the result as UTC. +fn verification_instant(at: Option<&str>) -> Result, CliError> { + const NOT_UTC: CliError = CliError("verification instant is not strict RFC 3339 UTC"); + + let Some(text) = at else { + return Ok(Utc::now()); + }; + let parsed = DateTime::parse_from_rfc3339(text).map_err(|_| NOT_UTC)?; + if parsed.offset().local_minus_utc() != 0 { + return Err(NOT_UTC); + } + Ok(parsed.with_timezone(&Utc)) +} + +/// Read one bounded verification input file. +fn read_verification_input(path: &Path) -> Result, CliError> { + let metadata = fs::metadata(path).map_err(|_| VERIFY_MALFORMED)?; + if !metadata.is_file() || metadata.len() > MAX_VERIFY_INPUT_BYTES { + return Err(VERIFY_MALFORMED); + } + fs::read(path).map_err(|_| VERIFY_MALFORMED) +} + +/// Report one verification failure as its closed class and nothing more. +fn verification_error_class(error: VerificationError) -> CliError { + match error { + VerificationError::MalformedJws => VERIFY_MALFORMED, + VerificationError::ProtectedHeader => { + CliError("stored response verification failed (protected-header)") + } + VerificationError::Key => CliError("stored response verification failed (key)"), + VerificationError::Signature => CliError("stored response verification failed (signature)"), + VerificationError::Payload => CliError("stored response verification failed (payload)"), + VerificationError::Policy => CliError("stored response verification failed (policy)"), + VerificationError::Time => CliError("stored response verification failed (time)"), + } } async fn evaluate_fixture( @@ -863,6 +1178,7 @@ async fn sign_and_verify_fixture_evidence( values, EvidenceConstruction { evidence_id: &evidence_id, + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, purpose: &resolved.purpose, audience: OFFLINE_AUDIENCE, issued_at, @@ -877,21 +1193,25 @@ async fn sign_and_verify_fixture_evidence( .map_err(|_| CliError("fixture evidence signing failed"))?; let jwks = jwks_document(signer.public_jwk(), []) .map_err(|_| CliError("fixture verification key construction failed"))?; + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + std::time::Duration::from_secs(31_536_000), + issued_at, + std::time::Duration::ZERO, + ); + policy.issued_by = bundle.config.issuer.id.clone(); + policy.provided_by = bundle.config.service.provider_id.clone(); + policy.requirement = requirement.id.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = resolved.purpose.clone(); + policy.audience = OFFLINE_AUDIENCE.to_owned(); + policy.configuration_revision = bundle.revision().to_owned(); let verified = verify_flattened_jws( &serde_json::to_vec(&signed) .map_err(|_| CliError("fixture signed evidence is not representable"))?, &jwks, - &EvidenceVerificationPolicy { - issued_by: bundle.config.issuer.id.clone(), - provided_by: bundle.config.service.provider_id.clone(), - requirement: requirement.id.clone(), - evidence_type: requirement.evidence_type.clone(), - purpose: resolved.purpose.clone(), - audience: OFFLINE_AUDIENCE.to_owned(), - configuration_revision: bundle.revision().to_owned(), - now: issued_at, - clock_skew: std::time::Duration::ZERO, - }, + &policy, ) .map_err(|_| CliError("fixture signed evidence verification failed"))?; serde_json::to_value(verified) @@ -987,6 +1307,10 @@ fn validate_reference_error( let (internal, public_problem) = match error { KernelError::Preparation => ("adapter_input_error", "service_unavailable"), KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + // Derivation-input inconsistency over a uniquely found record + // collapses publicly with the unresolved classes; the internal + // category stays a value-free operator diagnostic. + KernelError::DerivationInput => ("derivation_input_error", "evidence_not_available"), KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), _ => ("service_unavailable", "service_unavailable"), @@ -1507,7 +1831,8 @@ fn validate_case_outcome( (problem, &outcome), ( "evidence_not_available", - Err(registry_evidence::kernel::KernelError::Extraction) + Err(registry_evidence::kernel::KernelError::Extraction + | registry_evidence::kernel::KernelError::DerivationInput) ) | ( "dependency_unavailable", Err(registry_evidence::kernel::KernelError::SourceProtocol) diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index 1231160fe..3c8f03795 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -1,13 +1,45 @@ use std::{collections::BTreeMap, fmt}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use schemars::JsonSchema; use serde::{de, Deserialize, Deserializer, Serialize}; use serde_json::{Number, Value}; use utoipa::ToSchema; +/// Exact encoded length of the required caller-generated request nonce: the +/// canonical unpadded base64url form of 32 random bytes. +pub const REQUEST_NONCE_ENCODED_LENGTH: usize = 43; +const REQUEST_NONCE_DECODED_LENGTH: usize = 32; + +/// Deterministic canonical nonce for offline fixture evaluation and internal +/// non-released request shapes. Real callers generate a fresh random value +/// for every request. +pub const OFFLINE_EVALUATION_REQUEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +/// Accept only the canonical 43-character unpadded base64url encoding of +/// exactly 32 bytes. Padding, wrong length, non-alphabet bytes, and a +/// noncanonical final symbol all fail. +pub fn request_nonce_is_canonical(nonce: &str) -> bool { + if nonce.len() != REQUEST_NONCE_ENCODED_LENGTH + || !nonce + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return false; + } + match URL_SAFE_NO_PAD.decode(nonce) { + Ok(decoded) => decoded.len() == REQUEST_NONCE_DECODED_LENGTH, + Err(_) => false, + } +} + #[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EvidenceRequest { + /// Uninterpreted caller-generated correlation nonce. It is echoed into the + /// Evidence payload and never reaches authorization, rate limits, Rhai, + /// source requests, logs, metrics, traces, or native audit. + pub request_nonce: String, pub requirement: String, pub purpose: String, /// Unordered role set encoded as an array. Roles are resolved by name and @@ -122,6 +154,9 @@ pub enum SelectorValue { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Evidence { pub schema: String, + /// Exact echo of the caller's request nonce for request-response + /// correlation. The runtime does not store it or reject reuse. + pub request_nonce: String, pub id: String, #[serde(rename = "type")] pub evidence_type_name: EvidenceObjectType, @@ -301,6 +336,37 @@ pub struct FlattenedJws { pub signature: String, } +/// Self-identifying unsigned response envelope. It deliberately does not +/// serialize as the signed Evidence payload by itself and carries no JWS +/// member, so the strict JWS verifier rejects it. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnsignedEvidenceEnvelope { + pub schema: String, + #[serde(rename = "type")] + pub envelope_type: UnsignedEnvelopeType, + pub integrity_protection: UnsignedIntegrityProtection, + pub warning: UnsignedEnvelopeWarning, + pub evidence: Evidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +pub enum UnsignedEnvelopeType { + UnsignedEvidenceEnvelope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedIntegrityProtection { + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum UnsignedEnvelopeWarning { + NotCryptographicallyVerifiable, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] #[serde(deny_unknown_fields)] pub struct JwksDocument { @@ -360,6 +426,7 @@ redacted_debug!( EntityReferenceValue, StructuredValue, FlattenedJws, + UnsignedEvidenceEnvelope, LookupResult, ); @@ -390,6 +457,7 @@ mod tests { #[test] fn request_rejects_query_material_and_unknown_fields() { let input = serde_json::json!({ + "requestNonce": "A".repeat(43), "requirement": "urn:example:requirement:v1", "purpose": "casework", "subjects": [{ @@ -401,6 +469,7 @@ mod tests { assert!(serde_json::from_value::(input).is_err()); let caller_grant_reference = serde_json::json!({ + "requestNonce": "A".repeat(43), "requirement": "urn:example:requirement:v1", "purpose": "casework", "grantId": "caller-selected-grant", @@ -411,12 +480,47 @@ mod tests { }] }); assert!(serde_json::from_value::(caller_grant_reference).is_err()); + + let missing_nonce = serde_json::json!({ + "requirement": "urn:example:requirement:v1", + "purpose": "casework", + "subjects": [{ + "role": "subject", + "selector": {"profile": "profile-v1", "values": {"opaque": "value"}} + }] + }); + assert!(serde_json::from_value::(missing_nonce).is_err()); + } + + #[test] + fn request_nonce_canonicality_is_exact() { + assert!(request_nonce_is_canonical( + "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I" + )); + assert!(request_nonce_is_canonical(&"A".repeat(43))); + + let noncanonical_final_symbol = format!("{}B", "A".repeat(42)); + for invalid in [ + "", + "short", + &"A".repeat(42), + &"A".repeat(44), + &format!("{}=", "A".repeat(42)), + &format!("{}+", "A".repeat(42)), + &format!("{}/", "A".repeat(42)), + &format!("{} ", "A".repeat(42)), + &format!("{}\u{e9}", "A".repeat(42)), + noncanonical_final_symbol.as_str(), + ] { + assert!(!request_nonce_is_canonical(invalid), "{invalid:?}"); + } } #[test] fn evidence_has_no_selector_echo_field() { let serialized = serde_json::to_value(Evidence { schema: crate::EVIDENCE_SCHEMA_V1.to_string(), + request_nonce: "A".repeat(43), id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), evidence_type_name: EvidenceObjectType::Evidence, supports_requirement: "urn:example:requirement:v1".to_string(), @@ -447,6 +551,7 @@ mod tests { #[test] fn debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads() { let request = EvidenceRequest { + request_nonce: "protected-request-nonce-canary".to_owned(), requirement: "urn:example:protected-requirement-canary".to_owned(), purpose: "protected-purpose-canary".to_owned(), subjects: vec![RequestedSubject { @@ -462,6 +567,7 @@ mod tests { }; let evidence = Evidence { schema: "protected-schema-canary".to_owned(), + request_nonce: "protected-request-nonce-canary".to_owned(), id: "protected-evidence-id-canary".to_owned(), evidence_type_name: EvidenceObjectType::Evidence, supports_requirement: "protected-requirement-canary".to_owned(), @@ -500,9 +606,18 @@ mod tests { definitions: Vec::new(), }; + let unsigned_envelope = UnsignedEvidenceEnvelope { + schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: UnsignedIntegrityProtection::None, + warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence: evidence.clone(), + }; + for diagnostic in [ format!("{request:?}"), format!("{definitions:?}"), + format!("{unsigned_envelope:?}"), format!( "{:?}", SelectorValue::String("protected-selector-canary".to_owned()) diff --git a/crates/registry-evidence/src/problem.rs b/crates/registry-evidence/src/problem.rs index 11f6c1761..de907f2ed 100644 --- a/crates/registry-evidence/src/problem.rs +++ b/crates/registry-evidence/src/problem.rs @@ -12,6 +12,7 @@ pub enum ProblemCode { InvalidSelector, AuthenticationFailed, NotAuthorized, + ResponseFormatNotAcceptable, EvidenceNotAvailable, RateLimited, DependencyUnavailable, @@ -25,6 +26,7 @@ impl ProblemCode { Self::InvalidSelector => "invalid_selector", Self::AuthenticationFailed => "authentication_failed", Self::NotAuthorized => "not_authorized", + Self::ResponseFormatNotAcceptable => "response_format_not_acceptable", Self::EvidenceNotAvailable => "evidence_not_available", Self::RateLimited => "rate_limited", Self::DependencyUnavailable => "dependency_unavailable", @@ -37,6 +39,7 @@ impl ProblemCode { Self::MalformedRequest | Self::InvalidSelector => StatusCode::BAD_REQUEST, Self::AuthenticationFailed => StatusCode::UNAUTHORIZED, Self::NotAuthorized => StatusCode::FORBIDDEN, + Self::ResponseFormatNotAcceptable => StatusCode::NOT_ACCEPTABLE, Self::EvidenceNotAvailable => StatusCode::UNPROCESSABLE_ENTITY, Self::RateLimited => StatusCode::TOO_MANY_REQUESTS, Self::DependencyUnavailable | Self::ServiceUnavailable => { @@ -50,6 +53,7 @@ impl ProblemCode { Self::MalformedRequest | Self::InvalidSelector => "Request is not valid", Self::AuthenticationFailed => "Authentication failed", Self::NotAuthorized => "Request is not authorized", + Self::ResponseFormatNotAcceptable => "Requested response format is not acceptable", Self::EvidenceNotAvailable => "Evidence could not be produced", Self::RateLimited => "Request rate exceeded", Self::DependencyUnavailable | Self::ServiceUnavailable => { @@ -101,6 +105,11 @@ mod tests { "Authentication failed", ), (ProblemCode::NotAuthorized, 403, "Request is not authorized"), + ( + ProblemCode::ResponseFormatNotAcceptable, + 406, + "Requested response format is not acceptable", + ), ( ProblemCode::EvidenceNotAvailable, 422, diff --git a/crates/registry-evidence/src/rhai_runtime.rs b/crates/registry-evidence/src/rhai_runtime.rs index 69bcb69d7..5755f788b 100644 --- a/crates/registry-evidence/src/rhai_runtime.rs +++ b/crates/registry-evidence/src/rhai_runtime.rs @@ -42,7 +42,14 @@ pub const MAXIMUM_JSON_BODY_DEPTH: usize = 32; const MAXIMUM_BUCKETS: usize = 64; const MAXIMUM_ENTITY_REFERENCE_ITEMS: usize = 64; const MAXIMUM_REQUIRED_CODE_BYTES: usize = 64; - +/// One past the largest signed 64-bit integer, as the exclusive magnitude bound for any +/// ordinary floating-point number that crosses a runtime boundary. +const INTEGER_MAGNITUDE_LIMIT: f64 = 9_223_372_036_854_775_808.0; +/// Host-owned wrapper applied to every index operand by the startup source review. +const INDEX_GUARD_FUNCTION: &str = "__evidence_index"; + +/// Host-private marker for a `required` value that was absent. Scripts cannot name, +/// construct, catch, or observe it, and it carries no script-supplied text. #[derive(Clone, Copy, Debug)] struct RequiredUnavailable; @@ -564,6 +571,9 @@ impl RhaiRuntime { .collect(), ); validate_json_bound(&facts_value, MAXIMUM_RESULT_BYTES)?; + if !json_numbers_are_supported(&facts_value) { + return Err(RhaiRuntimeError::InputBound); + } let facts = rhai::serde::to_dynamic(facts_value).map_err(|_| RhaiRuntimeError::InputBound)?; validate_adapter_object(selectors)?; @@ -591,10 +601,10 @@ impl RhaiRuntime { if source.len() > MAXIMUM_RESULT_BYTES { return Err(RhaiRuntimeError::InputBound); } - validate_script_source(source)?; + let guarded = guarded_script_source(source)?; let ast = self .engine - .compile(source) + .compile(&guarded) .map_err(|_| RhaiRuntimeError::Compilation)?; let mut names = BTreeSet::new(); let mut entry_points = 0usize; @@ -651,7 +661,9 @@ fn register_language_essentials(engine: &mut Engine) { }) .register_fn("push", bounded_array_push) .register_fn("replace", literal_string_replace) - .register_fn("parse_integer", parse_integer); + .register_fn("parse_integer", parse_integer) + .register_fn(INDEX_GUARD_FUNCTION, guard_index) + .register_fn(INDEX_GUARD_FUNCTION, guard_index_key); } fn register_evidence_primitives(engine: &mut Engine) { @@ -816,34 +828,36 @@ fn codelist_lookup(handle: CodelistHandle, code: &str) -> Dynamic { } fn list_contains(values: Array, needle: Dynamic) -> Result> { - if values.len() > MAXIMUM_ARRAY_ITEMS { - return Err(primitive_error("collection_out_of_bounds")); - } let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; - for value in values { - let value = scalar_value(&value).ok_or_else(|| primitive_error("invalid_scalar"))?; - if value == needle { - return Ok(true); - } - } - Ok(false) + let values = bounded_scalar_values(&values)?; + Ok(values.contains(&needle)) } fn set_contains(values: Array, needle: Dynamic) -> Result> { - if values.len() > MAXIMUM_ARRAY_ITEMS { - return Err(primitive_error("collection_out_of_bounds")); - } let needle = scalar_value(&needle).ok_or_else(|| primitive_error("invalid_scalar"))?; + let values = bounded_scalar_values(&values)?; let mut unique = BTreeSet::new(); for value in values { - let value = scalar_value(&value).ok_or_else(|| primitive_error("invalid_scalar"))?; - if !unique.insert(value.clone()) { + if !unique.insert(value) { return Err(primitive_error("set_contains_duplicate")); } } Ok(unique.contains(&needle)) } +/// Validates the whole bounded collection before any containment answer exists, so a +/// value that violates the declared `array` input fails even when an earlier +/// item already matches. +fn bounded_scalar_values(values: &Array) -> Result, Box> { + if values.len() > MAXIMUM_ARRAY_ITEMS { + return Err(primitive_error("collection_out_of_bounds")); + } + values + .iter() + .map(|value| scalar_value(value).ok_or_else(|| primitive_error("invalid_scalar"))) + .collect() +} + fn bounded_array_push(array: &mut Array, value: Dynamic) -> Result<(), Box> { if array.len() >= MAXIMUM_ARRAY_ITEMS { return Err(primitive_error("collection_out_of_bounds")); @@ -901,6 +915,14 @@ fn parse_integer(value: &str) -> Result> { .map_err(|_| primitive_error("invalid_integer")) } +/// Returns the value, or terminates the invocation with the host-private unavailable +/// signal. +/// +/// The second argument is validated as a safe shape and then deliberately discarded. +/// Shape validation cannot prove that a code is a reviewed bundle literal rather than a +/// value derived from protected source data, so carrying it into any observable failure +/// would open a disclosure channel. Every unavailable termination therefore collapses to +/// the same value-free class in public problems, audit, and service logs. fn required(value: Dynamic, error_code: &str) -> Result> { if !is_safe_error_code(error_code) { return Err(primitive_error("invalid_required_error_code")); @@ -987,7 +1009,7 @@ where "ambiguous" if has_exact_keys(&map, &["outcome"]) => Ok(LookupResult::Ambiguous), "match" if has_exact_keys(&map, &["outcome", "facts"]) => { let facts_dynamic = map.get("facts").ok_or(RhaiRuntimeError::ExtractionResult)?; - if !dynamic_is_json(facts_dynamic) { + if !dynamic_is_json(facts_dynamic, FloatAdmission::AdapterSurface) { return Err(RhaiRuntimeError::ExtractionResult); } let facts: Value = rhai::serde::from_dynamic(facts_dynamic) @@ -1085,7 +1107,7 @@ fn decode_derived_value(value: Dynamic) -> Result(&value) @@ -1095,7 +1117,32 @@ fn decode_derived_value(value: Dynamic) -> Result Result<(), RhaiRuntimeError> { +/// Reviews the reviewed-language surface and returns the source with every index +/// operand routed through the host-owned index guard. The raw engine remains the +/// capability boundary; this lexical review is an enforceable language contract over +/// governed bundle scripts and is not claimed as a sandbox perimeter. +fn guarded_script_source(source: &str) -> Result { + validate_top_level_functions(source)?; + let mut insertions = review_script_bytes(source)?; + insertions.sort_by_key(|(offset, _)| *offset); + let mut guarded = String::with_capacity(source.len()); + let mut copied = 0usize; + for (offset, guard) in insertions { + guarded.push_str(&source[copied..offset]); + match guard { + IndexGuard::Open => { + guarded.push_str(INDEX_GUARD_FUNCTION); + guarded.push('('); + } + IndexGuard::Close => guarded.push(')'), + } + copied = offset; + } + guarded.push_str(&source[copied..]); + Ok(guarded) +} + +fn validate_top_level_functions(source: &str) -> Result<(), RhaiRuntimeError> { let mut cursor = ScriptCursor::new(source); while cursor.skip_trivia()? { let _private = cursor.consume_word("private"); @@ -1112,11 +1159,19 @@ fn validate_script_source(source: &str) -> Result<(), RhaiRuntimeError> { cursor.skip_trivia()?; cursor.consume_balanced(b'{', b'}')?; } + Ok(()) +} +/// Walks the script the way Rhai 1.25.1 tokenizes it, rejects the forbidden constructs, +/// and reports where the index guard must wrap an index operand. +fn review_script_bytes(source: &str) -> Result, RhaiRuntimeError> { let mut cursor = ScriptCursor::new(source); + let mut insertions = Vec::new(); + let mut braces = Vec::new(); let mut previous_significant = None; + let mut previous_ends_value = false; while cursor.index < cursor.bytes.len() { - if cursor.skip_noise()? { + if cursor.skip_comment()? { continue; } let byte = cursor.bytes[cursor.index]; @@ -1124,46 +1179,155 @@ fn validate_script_source(source: &str) -> Result<(), RhaiRuntimeError> { cursor.index += 1; continue; } - if is_identifier_start(byte) { - let start = cursor.index; - cursor.index += 1; - while cursor.index < cursor.bytes.len() - && is_identifier_continue(cursor.bytes[cursor.index]) - { + match byte { + b'"' | b'\'' => { + cursor.skip_quoted()?; + previous_significant = Some(byte); + previous_ends_value = true; + } + b'`' => return Err(RhaiRuntimeError::Compilation), + b'#' => { + cursor.consume_map_literal_start()?; + braces.push(BraceKind::MapLiteral); + previous_significant = Some(b'{'); + previous_ends_value = false; + } + b'{' => { + if expects_operand(previous_significant) { + return Err(RhaiRuntimeError::Compilation); + } cursor.index += 1; + braces.push(BraceKind::Block); + previous_significant = Some(b'{'); + previous_ends_value = false; } - let word = &source[start..cursor.index]; - if word == "Fn" - || matches!(word, "call" | "curry") && previous_significant == Some(b'.') - { - return Err(RhaiRuntimeError::Compilation); + b'}' => { + let kind = braces.pop().ok_or(RhaiRuntimeError::Compilation)?; + cursor.index += 1; + previous_significant = Some(b'}'); + previous_ends_value = kind == BraceKind::MapLiteral; } - previous_significant = word.as_bytes().last().copied(); - continue; - } - if byte == b'[' { - let mut lookahead = ScriptCursor { - source, - bytes: cursor.bytes, - index: cursor.index + 1, - }; - lookahead.skip_trivia()?; - if lookahead.bytes.get(lookahead.index) == Some(&b'-') { - lookahead.index += 1; - lookahead.skip_trivia()?; - if lookahead - .bytes - .get(lookahead.index) - .is_some_and(u8::is_ascii_digit) + b'[' if previous_ends_value => { + let close = cursor.matching_bracket()?; + cursor.index += 1; + if cursor.negative_literal_follows()? { + return Err(RhaiRuntimeError::Compilation); + } + insertions.push((cursor.index, IndexGuard::Open)); + insertions.push((close, IndexGuard::Close)); + previous_significant = Some(b'['); + previous_ends_value = false; + } + _ if is_identifier_start(byte) => { + let word = cursor.take_identifier(); + if word == "Fn" + || word == INDEX_GUARD_FUNCTION + || matches!(word, "call" | "curry") && previous_significant == Some(b'.') + || word == "if" && expects_operand(previous_significant) { return Err(RhaiRuntimeError::Compilation); } + previous_significant = word.as_bytes().last().copied(); + previous_ends_value = !keyword_precedes_value(word); + } + _ => { + cursor.index += 1; + previous_significant = Some(byte); + previous_ends_value = matches!(byte, b')' | b']') || byte.is_ascii_digit(); } } - previous_significant = Some(byte); - cursor.index += 1; } - Ok(()) + if !braces.is_empty() { + return Err(RhaiRuntimeError::Compilation); + } + Ok(insertions) +} + +/// Whether the previous significant byte leaves an expression waiting for an operand. +/// +/// Rhai indexes a block or an `if` chain that sits in operand position, so `}` there ends +/// a value while `}` at statement position does not. A byte scanner cannot tell those two +/// closing braces apart, and a missing guard would restore negative indexing. Both forms +/// are therefore outside the reviewed language, which keeps every remaining `[` exactly +/// classifiable. +fn expects_operand(previous_significant: Option) -> bool { + matches!( + previous_significant, + Some( + b'=' | b'(' + | b',' + | b':' + | b'[' + | b'+' + | b'-' + | b'*' + | b'/' + | b'%' + | b'<' + | b'>' + | b'!' + | b'&' + | b'|' + | b'^' + | b'?' + ) + ) +} + +/// Reserved words after which `[` opens an array literal instead of indexing a value. +fn keyword_precedes_value(word: &str) -> bool { + matches!( + word, + "as" | "break" + | "case" + | "catch" + | "const" + | "continue" + | "do" + | "else" + | "export" + | "fn" + | "for" + | "if" + | "import" + | "in" + | "let" + | "loop" + | "private" + | "return" + | "switch" + | "throw" + | "try" + | "until" + | "while" + ) +} + +#[derive(Clone, Copy)] +enum IndexGuard { + Open, + Close, +} + +/// Whether a closing brace ends a map literal, which is a value, or a block, which is +/// not. The distinction decides whether a following `[` indexes or opens an array. +#[derive(Clone, Copy, PartialEq, Eq)] +enum BraceKind { + Block, + MapLiteral, +} + +/// Rejects a negative array index however it was computed, because Rhai counts a +/// negative index from the end of the array. +fn guard_index(index: INT) -> Result> { + if index < 0 { + return Err(primitive_error("negative_index")); + } + Ok(index) +} + +fn guard_index_key(key: ImmutableString) -> ImmutableString { + key } struct ScriptCursor<'a> { @@ -1206,10 +1370,90 @@ impl<'a> ScriptCursor<'a> { Ok(true) } Some(b'`') => Err(RhaiRuntimeError::Compilation), + Some(b'#') => { + self.check_map_literal_start()?; + Ok(false) + } _ => Ok(false), } } + /// Rhai 1.25.1 reads `#"..."#` and `##"..."##` as raw strings, whose bodies may + /// contain quotes. Only `#{` stays inside the reviewed language, so every other `#` + /// fails rather than letting this scanner and the Rhai tokenizer disagree about + /// where a string ends. + fn check_map_literal_start(&self) -> Result<(), RhaiRuntimeError> { + if self.bytes.get(self.index + 1) != Some(&b'{') { + return Err(RhaiRuntimeError::Compilation); + } + Ok(()) + } + + fn consume_map_literal_start(&mut self) -> Result<(), RhaiRuntimeError> { + self.check_map_literal_start()?; + self.index += 2; + Ok(()) + } + + /// Position of the `]` that closes the bracket at the cursor. + fn matching_bracket(&self) -> Result { + let mut cursor = ScriptCursor { + source: self.source, + bytes: self.bytes, + index: self.index, + }; + let mut depth = 0usize; + while cursor.index < cursor.bytes.len() { + if cursor.skip_noise()? { + continue; + } + match cursor.bytes[cursor.index] { + b'[' => depth += 1, + b']' => { + depth -= 1; + if depth == 0 { + return Ok(cursor.index); + } + } + _ => {} + } + cursor.index += 1; + } + Err(RhaiRuntimeError::Compilation) + } + + /// Whether the expression at the cursor opens with a negative numeric literal. + fn negative_literal_follows(&self) -> Result { + let mut lookahead = ScriptCursor { + source: self.source, + bytes: self.bytes, + index: self.index, + }; + lookahead.skip_trivia()?; + if lookahead.bytes.get(lookahead.index) != Some(&b'-') { + return Ok(false); + } + lookahead.index += 1; + lookahead.skip_trivia()?; + Ok(lookahead + .bytes + .get(lookahead.index) + .is_some_and(u8::is_ascii_digit)) + } + + fn take_identifier(&mut self) -> &'a str { + let start = self.index; + self.index += 1; + while self + .bytes + .get(self.index) + .is_some_and(|byte| is_identifier_continue(*byte)) + { + self.index += 1; + } + &self.source[start..self.index] + } + fn skip_comment(&mut self) -> Result { if self.bytes.get(self.index..self.index + 2) == Some(b"//") { self.index += 2; @@ -1465,7 +1709,7 @@ fn decode_request_parts( let body = if map["body"].is_unit() { None } else { - if !dynamic_is_json(&map["body"]) { + if !dynamic_is_json(&map["body"], FloatAdmission::AdapterSurface) { return Err(RhaiRuntimeError::PreparationResult); } let body = rhai::serde::from_dynamic::(&map["body"]) @@ -1578,29 +1822,54 @@ fn json_value_within_limits( ) } +/// Numeric admission for JSON decoded into Rhai. An integer token outside the signed +/// 64-bit range fails here instead of reaching a script as a precision-losing float; a +/// provider identifier beyond that range must be represented as a string. fn json_numbers_are_supported(value: &Value) -> bool { match value { - Value::Number(value) => { - value.is_i64() || value.is_f64() && value.as_f64().is_some_and(f64::is_finite) - } + Value::Number(value) => value.is_i64() || value.as_f64().is_some_and(is_supported_float), Value::Array(values) => values.iter().all(json_numbers_are_supported), Value::Object(values) => values.values().all(json_numbers_are_supported), _ => true, } } -fn dynamic_is_json(value: &Dynamic) -> bool { +/// An ordinary float is carried only when it is finite and its magnitude stays inside the +/// signed 64-bit integer range, which keeps every admitted number distinguishable from a +/// silently truncated large integer token. +fn is_supported_float(value: f64) -> bool { + value.is_finite() && value.abs() < INTEGER_MAGNITUDE_LIMIT +} + +/// Whether ordinary Rhai floats may appear in a decoded value. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FloatAdmission { + /// Request preparation and source extraction carry provider-shaped JSON, which may + /// contain finite ordinary floats. + AdapterSurface, + /// Public derived values use the declared integer or exact Decimal forms only. + Rejected, +} + +fn dynamic_is_json(value: &Dynamic, floats: FloatAdmission) -> bool { if value.is_unit() || value.is_bool() || value.is_int() || value.is_string() { return true; } if value.is_float() { - return value.as_float().is_ok_and(f64::is_finite); + return floats == FloatAdmission::AdapterSurface + && value.as_float().is_ok_and(is_supported_float); } if value.is_array() { - return value.clone_cast::().iter().all(dynamic_is_json); + return value + .clone_cast::() + .iter() + .all(|value| dynamic_is_json(value, floats)); } if value.is_map() { - return value.clone_cast::().values().all(dynamic_is_json); + return value + .clone_cast::() + .values() + .all(|value| dynamic_is_json(value, floats)); } false } @@ -2402,10 +2671,11 @@ mod tests { [#{ concept_id: "surface", value: [ - 1.5 + 2.0, 1 + 2.5, 2.5 + 1, - 5.0 % 2.0, 2.0 ** 3.0, + 1.5 + 2.0 == 3.5, 1 + 2.5 == 3.5, 2.5 + 1 == 3.5, + 5.0 % 2.0 == 1.0, 2.0 ** 3.0 == 8.0, 1.5 < 2.0, 1 < 2.0, 2.0 >= 1, "ab" + "cd", "abc" - "b", text, + type_of(1.5), type_of(1), type_of(parse_date("2026-08-02")), type_of(parse_instant("2026-08-02T00:00:00Z")), type_of(evaluation_context.legal_local_time), @@ -2425,20 +2695,91 @@ mod tests { &script, &BTreeMap::new(), &json!({}), - context(json!({}), BTreeMap::from([("codes".to_string(), codelist)])), + context( + json!({}), + BTreeMap::from([("codes".to_string(), codelist.clone())]), + ), ) .expect("derives"); assert!(matches!( &values[0].value, DerivedValue::Json(value) if value == &json!([ - 3.5, 3.5, 3.5, 1.0, 8.0, + true, true, true, true, true, true, true, true, "abcd", "ac", "source-adapter", + "f64", "i64", "Date", "Instant", "LegalLocalTime", "Decimal", "EntityReferenceSeed", "CodelistHandle" ]) )); + + // The float arithmetic surface still exists inside a script, but an ordinary + // float result is not a public derived value. + let float_output = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"surface\", value: 1.5 + 2.0 }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &float_output, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::from([("codes".to_string(), codelist)])) + ), + Err(RhaiRuntimeError::DerivationResult) + )); + } + + #[test] + fn operation_exhaustion_terminates_a_hostile_script_with_a_value_free_error() { + let runtime = runtime(); + // Unbounded loop syntax is disabled, so exhaustion is driven by + // nesting bounded iteration over the largest admissible fact array: + // 256 * 256 iterations exceeds the 100,000-operation ceiling. + let script = runtime + .compile_derivation( + r#" + fn derive(facts, selectors, evaluation_context) { + let total = 0; + for outer in facts.items { + for inner in facts.items { + total += 1; + } + } + [#{ concept_id: "count", value: total }] + } + "#, + ) + .expect("compiles"); + // A small input proves the script itself is well-formed, so the + // failure below is the operation ceiling and nothing else. + let small = BTreeMap::from([("items".to_string(), json!([1, 2, 3, 4]))]); + let values = runtime + .derive( + &script, + &small, + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("the bounded variant derives"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!(16))); + + let items = (0..256).collect::>(); + let facts = BTreeMap::from([("items".to_string(), json!(items))]); + let error = runtime + .derive( + &script, + &facts, + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect_err("operation exhaustion terminates the invocation"); + assert!(matches!(error, RhaiRuntimeError::Invocation)); + let diagnostic = format!("{error} {error:?}"); + assert!(!diagnostic.contains("operations")); + assert!(!diagnostic.contains("256")); } #[test] @@ -2901,6 +3242,454 @@ mod tests { } } + #[test] + fn list_contains_validates_every_element_before_any_answer() { + for unsupported in [ + Dynamic::UNIT, + Dynamic::from(Map::new()), + Dynamic::from(Array::new()), + Dynamic::from(1.5_f64), + Dynamic::from(EntityReferenceSeed::new("seed").expect("seed")), + ] { + assert!( + list_contains( + vec![Dynamic::from("A"), unsupported.clone()], + Dynamic::from("A") + ) + .is_err(), + "a match before an invalid element answered instead of failing" + ); + assert!(list_contains( + vec![unsupported.clone(), Dynamic::from("A")], + Dynamic::from("A") + ) + .is_err()); + assert!( + set_contains(vec![Dynamic::from("A"), unsupported], Dynamic::from("A")).is_err() + ); + } + + assert!(list_contains( + vec![Dynamic::from("A"), Dynamic::from("A")], + Dynamic::from("A") + ) + .expect("duplicates are containment, not a set")); + + let mixed = vec![ + Dynamic::from(1_i64), + Dynamic::from("1"), + Dynamic::from(true), + ]; + for needle in [ + Dynamic::from(1_i64), + Dynamic::from("1"), + Dynamic::from(true), + ] { + assert!(list_contains(mixed.clone(), needle).expect("valid scalars")); + } + for absent in [ + Dynamic::from(2_i64), + Dynamic::from("true"), + Dynamic::from(false), + ] { + assert!(!list_contains(mixed.clone(), absent).expect("valid scalars")); + } + + let decimals = vec![Dynamic::from(Decimal::parse("1.25").expect("decimal"))]; + assert!(list_contains( + decimals.clone(), + Dynamic::from(Decimal::parse("1.25").expect("decimal")) + ) + .expect("exact decimal comparison")); + assert!(!list_contains( + decimals.clone(), + Dynamic::from(Decimal::parse("1.26").expect("decimal")) + ) + .expect("exact decimal comparison")); + assert!(!list_contains( + vec![Dynamic::from(integer_to_decimal(1))], + Dynamic::from(1_i64) + ) + .expect("decimal and integer stay distinct")); + + assert!(list_contains( + vec![Dynamic::from("A"); MAXIMUM_ARRAY_ITEMS + 1], + Dynamic::from("A") + ) + .is_err()); + assert!(list_contains(vec![Dynamic::from("A")], Dynamic::UNIT).is_err()); + } + + #[test] + fn negative_array_indexes_fail_instead_of_selecting_from_the_end() { + let runtime = runtime(); + let facts = || BTreeMap::from([("values".to_string(), json!(["first", "last"]))]); + let derivation = |index: &str| { + format!( + r#"fn derive(facts, selectors, evaluation_context) {{ + let position = {index}; + [#{{ concept_id: "x", value: facts.values[position] }}] + }}"# + ) + }; + + let computed_negative = runtime + .compile_derivation(&derivation( + "compare_dates(evaluation_context.legal_local_date, parse_date(\"2100-01-01\"))", + )) + .expect("computed index compiles"); + assert!( + matches!( + runtime.derive( + &computed_negative, + &facts(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + ), + "a computed negative index selected from the end" + ); + + let computed_forward = runtime + .compile_derivation(&derivation( + "compare_dates(parse_date(\"2100-01-01\"), evaluation_context.legal_local_date)", + )) + .expect("computed index compiles"); + let values = runtime + .derive( + &computed_forward, + &facts(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("a non-negative computed index still resolves"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!("last"))); + + let computed_key = runtime + .compile_extraction( + r#"fn extract(source_response, parameters) { + let field = parameters.field; + #{ outcome: "match", facts: #{ code: source_response["record"][field] } } + }"#, + ) + .expect("computed map key compiles"); + assert_eq!( + runtime.extract( + &computed_key, + &json!({"record": {"code": "A"}}), + &json!({"field": "code"}), + &|_: &Value| true, + ), + Ok(LookupResult::Match(BTreeMap::from([( + "code".to_string(), + json!("A") + )]))) + ); + + assert_eq!( + runtime + .compile_derivation(&derivation("0")) + .and_then(|script| runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: facts.values[-1] }] }", + ) + .map(|_| script)) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + assert!( + runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + let offsets = [-1, 0]; + [#{ concept_id: "x", value: list_contains(offsets, 0) }] + }"#, + ) + .is_ok(), + "a negative number inside an array literal is not an index" + ); + } + + #[test] + fn script_scanner_agrees_with_rhai_string_comment_and_pointer_tokenization() { + let runtime = runtime(); + for rejected in [ + // Rhai reads #"A"B"# as one raw string; a scanner that treats every quote as a + // plain delimiter desynchronizes and hides the code between two raw strings. + "let a = #\"A\"B\"#; parameters.call(); let c = #\"D\"E\"#;", + "let a = #\"raw\"#;", + "let a = ##\"raw \"# still raw\"##;", + "let a = `interpolated ${parameters}`;", + "let a = Fn(\"helper\");", + "parameters.call();", + "parameters.curry(1);", + "let a = parameters.values[-1];", + "let a = parameters.values[ - 1];", + "let a = 1; /* unterminated", + "let a = __evidence_index(1);", + // Rhai indexes a block or an if chain in operand position, so its closing + // brace ends a value that a byte scanner cannot distinguish from the closing + // brace of a statement block. + "let v = [10, 20]; let a = if true { v } else { v }[-1];", + "let a = if true { 1 } else { 2 };", + "let a = { 1 };", + ] { + let source = format!( + "fn prepare(selectors, parameters) {{ {rejected} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_err(), + "accepted: {rejected}" + ); + } + for accepted in [ + "// Fn(\"helper\") and values[-1]\n", + "/* Fn(\"helper\") and values[-1] */", + "let a = \"values[-1] Fn\";", + "let a = 'x';", + "let a = [-1, 0];", + "let a = #{ inner: [1] }[\"inner\"][0];", + // A statement block is followed by an array literal, not by an index. + "if true { let b = 1; }\n [-1, 0];", + "for x in [1, 2] { let b = x; }", + "if true { let b = 1; } else if false { let b = 2; }", + ] { + let source = format!( + "fn prepare(selectors, parameters) {{ {accepted} #{{ query: [], body: () }} }}" + ); + assert!( + runtime.compile_preparation(&source).is_ok(), + "rejected: {accepted}" + ); + } + assert_eq!( + runtime + .compile_preparation( + "let leaked = 1; fn prepare(selectors, parameters) { #{ query: [], body: () } }", + ) + .unwrap_err(), + RhaiRuntimeError::Compilation + ); + + // Only index operands are rewritten, and every one of them is. + assert_eq!( + guarded_script_source( + "fn f(a) { let b = a[\"k\"][0]; let c = [1, 2]; let d = #{ k: [1] }[\"k\"]; }" + ) + .expect("reviewed"), + "fn f(a) { let b = a[__evidence_index(\"k\")][__evidence_index(0)]; \ + let c = [1, 2]; let d = #{ k: [1] }[__evidence_index(\"k\")]; }" + ); + assert_eq!( + guarded_script_source("fn f(a) { let b = a[a[\"i\"]]; }").expect("reviewed"), + "fn f(a) { let b = a[__evidence_index(a[__evidence_index(\"i\")])]; }" + ); + } + + #[test] + fn public_derived_values_reject_ordinary_floats() { + let runtime = runtime(); + for float in [ + "1.5", + "1.0", + "[1.5]", + "#{ ratio: 1.5 }", + "0.0 / 0.0", + "1.5 + 2.0", + ] { + let source = + format!("fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: {float} }}] }}"); + let script = runtime.compile_derivation(&source).expect("compiles"); + assert!( + matches!( + runtime.derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::DerivationResult) + ), + "ordinary float accepted at the derivation output gate: {float}" + ); + } + + let declared = runtime + .compile_derivation( + r#"fn derive(facts, selectors, evaluation_context) { + [ + #{ concept_id: "integer", value: 2 }, + #{ concept_id: "exact", value: decimal("1.25") } + ] + }"#, + ) + .expect("compiles"); + let values = runtime + .derive( + &declared, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect("declared numeric forms remain available"); + assert!(matches!(&values[0].value, DerivedValue::Json(value) if value == &json!(2))); + assert!( + matches!(&values[1].value, DerivedValue::Decimal(value) if value.canonical() == "1.25") + ); + + let extraction = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"match\", facts: #{ ratio: source_response.ratio } } }", + ) + .expect("compiles"); + assert_eq!( + runtime.extract( + &extraction, + &json!({"ratio": 1.25}), + &json!({}), + &|_: &Value| true + ), + Ok(LookupResult::Match(BTreeMap::from([( + "ratio".to_string(), + json!(1.25) + )]))) + ); + + let limits = request_limits( + RequestPartRequirement::Optional, + RequestPartRequirement::Optional, + ); + let preparation = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: #{ ratio: 1.25 } } }", + ) + .expect("compiles"); + assert_eq!( + runtime + .prepare(&preparation, &json!({}), &json!({}), &limits) + .expect("finite adapter floats remain available") + .body, + Some(json!({"ratio": 1.25})) + ); + let non_finite = runtime + .compile_preparation( + "fn prepare(selectors, parameters) { #{ query: [], body: #{ ratio: 0.0 / 0.0 } } }", + ) + .expect("compiles"); + assert_eq!( + runtime.prepare(&non_finite, &json!({}), &json!({}), &limits), + Err(RhaiRuntimeError::PreparationResult) + ); + } + + #[test] + fn out_of_range_json_integer_tokens_fail_before_rhai() { + let runtime = runtime(); + let script = runtime + .compile_extraction( + "fn extract(source_response, parameters) { #{ outcome: \"no_match\" } }", + ) + .expect("compiles"); + let response = |token: &str| { + serde_json::from_str::(&format!("{{\"value\": {token}}}")).expect("parses") + }; + for accepted in [ + "0", + "9223372036854775807", + "-9223372036854775808", + "1.25", + "1e3", + ] { + assert_eq!( + runtime.extract(&script, &response(accepted), &json!({}), &|_: &Value| true), + Ok(LookupResult::NoMatch), + "{accepted}" + ); + } + for rejected in [ + "9223372036854775808", + "18446744073709551615", + "99999999999999999999999", + "1e30", + ] { + assert_eq!( + runtime.extract(&script, &response(rejected), &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::InputBound), + "{rejected}" + ); + } + assert_eq!( + runtime.extract( + &script, + &json!({"value": "99999999999999999999999"}), + &json!({}), + &|_: &Value| true + ), + Ok(LookupResult::NoMatch), + "an identifier outside the signed 64-bit range must arrive as a string" + ); + + let derivation = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: true }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &derivation, + &BTreeMap::from([( + "value".to_string(), + response("99999999999999999999999")["value"].clone() + )]), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::InputBound) + )); + } + + #[test] + fn required_unavailability_carries_no_supplied_code_into_any_surface() { + let runtime = runtime(); + for code in ["required_fact_missing", "other_missing_input_9"] { + let source = format!( + "fn derive(facts, selectors, evaluation_context) {{ [#{{ concept_id: \"x\", value: required(facts.absent, \"{code}\") }}] }}" + ); + let script = runtime.compile_derivation(&source).expect("compiles"); + let error = runtime + .derive( + &script, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()), + ) + .expect_err("unavailable"); + assert_eq!(error, RhaiRuntimeError::Unavailable); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(code), "supplied code reached a surface"); + } + + // A code that fails review validation stops before the unavailable signal. + let unsafe_code = runtime + .compile_derivation( + "fn derive(facts, selectors, evaluation_context) { [#{ concept_id: \"x\", value: required(facts.absent, \"Protected Value\") }] }", + ) + .expect("compiles"); + assert!(matches!( + runtime.derive( + &unsafe_code, + &BTreeMap::new(), + &json!({}), + context(json!({}), BTreeMap::new()) + ), + Err(RhaiRuntimeError::Invocation) + )); + assert!(required(Dynamic::from(1_i64), "Protected Value").is_err()); + assert!(required(Dynamic::from(1_i64), "").is_err()); + assert!(required(Dynamic::from(1_i64), "safe_code").is_ok()); + } + fn boundary(minimum: &str, maximum: &str, code: &str) -> Dynamic { let mut map = Map::new(); map.insert( diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index ffba1a751..31e3870c4 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -17,20 +17,22 @@ use crate::{ audit::{ AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind as AuditAuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, + ResponseProtection, }, auth::{AuthenticatedContext, Authenticator}, bundle::{Bundle, DeploymentInputs}, config::{ - AuthorityKind, ConceptForm, RequirementKind, RuntimeConfig, SelectorField, SelectorInput, - SubjectCardinality, ValueOrigin, + AuthorityKind, ConceptForm, RequirementKind, ResponseFormat, RuntimeConfig, SelectorField, + SelectorInput, SubjectCardinality, ValueOrigin, }, contracts::definitions_contract_accepts, kernel::{EvidenceConstruction, KernelError, KernelOutcome, OfflineKernel, ValueProjection}, model::{ - EvidenceDefinition, EvidenceDefinitionConcept, EvidenceDefinitionSelector, - EvidenceDefinitionSubject, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, - FlattenedJws, JwksDocument, RequestedSelector, RequestedSubject, SelectorValue, - SubjectBinding, + request_nonce_is_canonical, EvidenceDefinition, EvidenceDefinitionConcept, + EvidenceDefinitionSelector, EvidenceDefinitionSubject, EvidenceDefinitions, + EvidenceRequest, EvidenceSelectorField, FlattenedJws, JwksDocument, RequestedSelector, + RequestedSubject, SelectorValue, SubjectBinding, UnsignedEnvelopeType, + UnsignedEnvelopeWarning, UnsignedEvidenceEnvelope, UnsignedIntegrityProtection, }, problem::ProblemCode, rate_limit::{EvidenceRateLimiter, RateLimitConfig, RateLimitError}, @@ -42,7 +44,8 @@ use crate::{ }, signing::{jwks_document, EvidenceSigner}, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, - EVIDENCE_DEFINITIONS_SCHEMA_V1, + EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, + EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const MAX_OPERATION_BYTES: usize = 128; @@ -98,6 +101,44 @@ impl std::fmt::Display for RuntimeFailure { impl std::error::Error for RuntimeFailure {} +/// One released response: the exact final immutable bytes that were serialized +/// before the durable disclosure-release audit event, plus their exact media +/// type. The HTTP boundary returns these bytes unchanged. +pub struct ReleasedEvidence { + format: ResponseFormat, + media_type: &'static str, + bytes: Vec, +} + +impl ReleasedEvidence { + pub fn format(&self) -> ResponseFormat { + self.format + } + + pub fn media_type(&self) -> &'static str { + self.media_type + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn into_bytes(self) -> Vec { + self.bytes + } +} + +impl std::fmt::Debug for ReleasedEvidence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ReleasedEvidence") + .field("format", &self.format) + .field("media_type", &self.media_type) + .field("bytes", &"") + .finish() + } +} + /// All runtime state is derived from one captured immutable bundle revision. pub struct EvidenceRuntime { kernel: OfflineKernel, @@ -331,7 +372,11 @@ impl EvidenceRuntime { let mut definitions = Vec::new(); for (requirement, purpose, subjects) in candidates { + // Discovery only probes the authorization boundary; this internal + // request shape is never evaluated or released, so it carries the + // fixed non-random placeholder nonce. let request = EvidenceRequest { + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), requirement, purpose, subjects: subjects @@ -478,14 +523,40 @@ impl EvidenceRuntime { }) } - /// Run the fixed authenticated path through signing and durable release audit. + /// Run the fixed authenticated signed-default path and return the JWS. + /// + /// This convenience wrapper deserializes the exact released bytes; the + /// HTTP boundary uses [`EvidenceRuntime::evaluate_with_format`] so the + /// bytes serialized before release audit are the bytes returned. pub async fn evaluate( &self, operation: &str, access_token: &str, request: &EvidenceRequest, ) -> Result { - self.evaluate_at(operation, access_token, request, None) + let released = self + .evaluate_at( + operation, + access_token, + request, + ResponseFormat::SignedJws, + None, + ) + .await?; + serde_json::from_slice(released.bytes()) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")) + } + + /// Run the fixed authenticated path for one explicitly resolved response + /// format through serialization and durable release audit. + pub async fn evaluate_with_format( + &self, + operation: &str, + access_token: &str, + request: &EvidenceRequest, + format: ResponseFormat, + ) -> Result { + self.evaluate_at(operation, access_token, request, format, None) .await } @@ -495,10 +566,17 @@ impl EvidenceRuntime { operation: &str, access_token: &str, request: &EvidenceRequest, + format: ResponseFormat, evaluation_time: chrono::DateTime, - ) -> Result { - self.evaluate_at(operation, access_token, request, Some(evaluation_time)) - .await + ) -> Result { + self.evaluate_at( + operation, + access_token, + request, + format, + Some(evaluation_time), + ) + .await } async fn evaluate_at( @@ -506,14 +584,20 @@ impl EvidenceRuntime { operation: &str, access_token: &str, request: &EvidenceRequest, + format: ResponseFormat, evaluation_time: Option>, - ) -> Result { + ) -> Result { if operation.len() < 16 || operation.len() > MAX_OPERATION_BYTES || operation.bytes().any(|byte| byte.is_ascii_whitespace()) { return Err(failure(ProblemCode::ServiceUnavailable, "operation-id")); } + // The nonce is validated before authentication and never used again + // until evidence construction echoes it. + if !request_nonce_is_canonical(&request.request_nonce) { + return Err(failure(ProblemCode::MalformedRequest, "request-nonce")); + } let started = Instant::now(); let context = self .authenticator @@ -541,6 +625,14 @@ impl EvidenceRuntime { .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; let matched = match_entitlement(self.bundle(), request, &context).map_err(map_authority)?; + // The immutable bundle and the one complete matched grant must both + // permit the requested format. API selection creates no permission, + // and the denial does not reveal which layer withheld it. + if !self.bundle().config.response_formats.contains(&format) + || !matched.permits_response_format(format) + { + return Err(failure(ProblemCode::NotAuthorized, "response-format")); + } let selector_limit_input = canonical_pair( context.principal().as_bytes(), matched.authority_profile().as_bytes(), @@ -567,7 +659,8 @@ impl EvidenceRuntime { } }; - let material = self.audit_material(&scope, requester_pseudonym, &context, &resolved)?; + let material = + self.audit_material(&scope, requester_pseudonym, &context, &resolved, format)?; let (source_id, adapter_id) = self.source_identity(&request.requirement)?; let mut access_event = material.event( operation, @@ -681,7 +774,9 @@ impl EvidenceRuntime { let category = kernel_failure_category(error); let problem = kernel_failure_problem(error); let decision = match error { - KernelError::Extraction => AuditDecision::FactMissing, + KernelError::Extraction | KernelError::DerivationInput => { + AuditDecision::FactMissing + } KernelError::SourceProtocol => AuditDecision::DependencyFailure, _ => AuditDecision::EvaluationFailure, }; @@ -722,6 +817,7 @@ impl EvidenceRuntime { values, EvidenceConstruction { evidence_id: &evidence_id, + request_nonce: &request.request_nonce, purpose: &request.purpose, audience: context.evidence_audience(), issued_at, @@ -752,20 +848,94 @@ impl EvidenceRuntime { .iter() .map(|value| value.provides_value_for.clone()) .collect::>(); - let signed = match self.signer.sign_json(&evidence).await { - Ok(signed) => signed, - Err(_) => { - self.append_failure( - &material, - operation, - AuditDecision::SigningFailure, - "signing", - &source_id, - &adapter_id, - started, + + // Serialize the final immutable response bytes before the durable + // disclosure-release audit; the released bytes are exactly these. A + // signed-path failure never downgrades to unsigned output. + let (bytes, media_type, signing_key_id) = match format { + ResponseFormat::SignedJws => { + let signed = match self.signer.sign_json(&evidence).await { + Ok(signed) => signed, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + }; + let bytes = serde_json::to_vec(&signed) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")); + let bytes = match bytes { + Ok(bytes) => bytes, + Err(error) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "release-serialization", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + ( + bytes, + EVIDENCE_JWS_MEDIA_TYPE, + Some(self.signer.key_id().to_owned()), ) - .await?; - return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + ResponseFormat::UnsignedJson => { + // No signing operation runs, but the ordinary signing + // dependency must still be ready for the deployment. + if !self.signer.ready() { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + let envelope = UnsignedEvidenceEnvelope { + schema: EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: UnsignedIntegrityProtection::None, + warning: UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence, + }; + let bytes = serde_json::to_vec(&envelope) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-serialization")); + let bytes = match bytes { + Ok(bytes) => bytes, + Err(error) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "release-serialization", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(error); + } + }; + (bytes, EVIDENCE_UNSIGNED_MEDIA_TYPE, None) } }; @@ -779,12 +949,16 @@ impl EvidenceRuntime { release.adapter_id = Some(adapter_id); release.disclosed_concepts = Some(disclosed_concepts); release.evidence_id = Some(evidence_id); - release.signing_key_id = Some(self.signer.key_id().to_owned()); + release.signing_key_id = signing_key_id; self.audit .append(release) .await .map_err(|_| failure(ProblemCode::ServiceUnavailable, "release-audit"))?; - Ok(signed) + Ok(ReleasedEvidence { + format, + media_type, + bytes, + }) } fn source_identity(&self, requirement_id: &str) -> Result<(String, String), RuntimeFailure> { @@ -812,6 +986,7 @@ impl EvidenceRuntime { requester_pseudonym: String, context: &AuthenticatedContext, resolved: &ResolvedAuthorization, + format: ResponseFormat, ) -> Result { let actor_pseudonym = context .actor() @@ -853,6 +1028,7 @@ impl EvidenceRuntime { grant_pseudonym, }, subjects, + response_protection: map_response_protection(format), }) } @@ -918,6 +1094,7 @@ struct AuditMaterial { actor_pseudonym: Option, authority: AuditAuthority, subjects: Vec, + response_protection: ResponseProtection, } impl AuditMaterial { @@ -937,6 +1114,7 @@ impl AuditMaterial { self.requester_pseudonym.clone(), self.authority.clone(), self.subjects.clone(), + self.response_protection, decision, duration_milliseconds, ); @@ -945,6 +1123,13 @@ impl AuditMaterial { } } +fn map_response_protection(format: ResponseFormat) -> ResponseProtection { + match format { + ResponseFormat::SignedJws => ResponseProtection::Signed, + ResponseFormat::UnsignedJson => ResponseProtection::Unsigned, + } +} + fn source_selectors( resolved: &ResolvedAuthorization, inputs: &[SelectorInput], @@ -1138,6 +1323,7 @@ fn kernel_failure_category(error: KernelError) -> &'static str { match error { KernelError::Preparation => "request-preparation", KernelError::Extraction => "fact-unavailable", + KernelError::DerivationInput => "derivation-input", KernelError::SourceProtocol => "source-protocol", KernelError::Script => "script-failure", KernelError::Output => "output-gate", @@ -1145,10 +1331,14 @@ fn kernel_failure_category(error: KernelError) -> &'static str { } } +/// Map a closed kernel failure to its public problem class. The unresolved +/// classes, including derivation-input inconsistency over a uniquely found +/// record, collapse to one public shape so status codes cannot become an +/// existence oracle. Native audit keeps only a value-free category. fn kernel_failure_problem(error: KernelError) -> ProblemCode { match error { KernelError::Preparation => ProblemCode::ServiceUnavailable, - KernelError::Extraction => ProblemCode::EvidenceNotAvailable, + KernelError::Extraction | KernelError::DerivationInput => ProblemCode::EvidenceNotAvailable, KernelError::SourceProtocol => ProblemCode::DependencyUnavailable, KernelError::Script | KernelError::Output @@ -1304,4 +1494,20 @@ mod tests { ); } } + + #[test] + fn derivation_input_inconsistency_collapses_with_the_unresolved_classes() { + assert_eq!( + kernel_failure_problem(KernelError::DerivationInput), + kernel_failure_problem(KernelError::Extraction) + ); + assert_eq!( + kernel_failure_problem(KernelError::DerivationInput), + ProblemCode::EvidenceNotAvailable + ); + assert_ne!( + kernel_failure_category(KernelError::DerivationInput), + kernel_failure_category(KernelError::Extraction) + ); + } } diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index bd2cf14e3..550b9641c 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -31,15 +31,18 @@ use wiremock::{ use crate::{ auth::{AuthenticationClaimsConfig, Authenticator}, + config::ResponseFormat, + contracts::evidence_contract_accepts, model::{ Evidence, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, FlattenedJws, - PublicValue, RequestedSelector, RequestedSubject, SelectorValue, + PublicValue, RequestedSelector, RequestedSubject, SelectorValue, UnsignedEvidenceEnvelope, }, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeInitializationError}, server::{build_app, build_app_at_for_test, serve_listener_for_test}, signing::EvidenceSigner, verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, + EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-auth-key"}"#; @@ -89,7 +92,28 @@ struct UnavailableReadinessSigner { #[ignore = "operator-driven local curl checkpoint"] async fn first_curl_exercises_and_verifies_the_evidence_server() { let fixture = acceptance_runtime().await; - mount_adult_source(&fixture.server, None).await; + // The documented optional unsigned curl may run before the signed one, so + // the deterministic source accepts one or two identical lookups. + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .expect(1..=2) + .mount(&fixture.server) + .await; let request = adult_request(); let token = access_token(Some(parent_grant_claims())); @@ -104,7 +128,8 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { } let definitions_path = state_root.join("definitions.json"); let response_path = state_root.join("response.json"); - for stale in [&definitions_path, &response_path] { + let unsigned_path = state_root.join("response-unsigned.json"); + for stale in [&definitions_path, &response_path, &unsigned_path] { match fs::remove_file(stale) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -199,7 +224,7 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { let evidence = verify_flattened_jws( &serialized, fixture.runtime.jwks(), - &verification_policy(&fixture.runtime, &request), + &verification_policy(&fixture.runtime, &request, &serialized), ) .expect("curl response JWS verifies against the running Evidence JWKS"); assert_eq!( @@ -215,11 +240,41 @@ async fn first_curl_exercises_and_verifies_the_evidence_server() { .await .expect("first-curl server task joins") .expect("first-curl server stops cleanly"); + + // The optional unsigned leg, when the operator ran it, produced its own + // self-identifying envelope and its own pair of durable audit events. + let unsigned_leg_ran = match fs::read(&unsigned_path) { + Ok(bytes) => { + let envelope: UnsignedEvidenceEnvelope = serde_json::from_slice(&bytes) + .expect("unsigned first-curl output parses as the closed envelope"); + assert_eq!(envelope.evidence.request_nonce, request.request_nonce); + assert!( + verify_flattened_jws( + &bytes, + fixture.runtime.jwks(), + &verification_policy(&fixture.runtime, &request, &serialized), + ) + .is_err(), + "the strict JWS verifier must reject the unsigned envelope" + ); + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => panic!("unsigned first-curl output could not be read: {error}"), + }; let audit = fs::read_to_string(&fixture.audit_path).expect("first-curl audit is readable"); - assert_eq!(audit.lines().count(), 2); - println!( - "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." - ); + let expected_audit_lines = if unsigned_leg_ran { 4 } else { 2 }; + assert_eq!(audit.lines().count(), expected_audit_lines); + assert!(!audit.contains(&request.request_nonce)); + if unsigned_leg_ran { + println!( + "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200 in both formats, the JWS verified, the unsigned envelope was self-identifying and rejected by the JWS verifier, adult-status was true, minimization held, and all four audit events were durable." + ); + } else { + println!( + "PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable." + ); + } } #[async_trait] @@ -437,7 +492,7 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda let evidence = verify_flattened_jws( &serialized, fixture.runtime.jwks(), - &verification_policy(&fixture.runtime, request), + &verification_policy(&fixture.runtime, request, &serialized), ) .expect("router response verifies"); assert_eq!( @@ -697,7 +752,7 @@ async fn serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture let evidence = verify_flattened_jws( &serialized, fixture.runtime.jwks(), - &verification_policy(&fixture.runtime, &request), + &verification_policy(&fixture.runtime, &request, &serialized), ) .expect("captured-revision assertion verifies"); assert_eq!(evidence.configuration_revision, captured_revision); @@ -1017,6 +1072,701 @@ async fn signing_failure_is_transient_audited_and_never_releases_unsigned_eviden } } +#[tokio::test] +async fn configured_jwks_path_is_mechanically_the_served_route() { + let fixture = acceptance_runtime().await; + let configured_path = fixture.runtime.bundle().config.signing.jwks_path.clone(); + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let response = http.get(&configured_path).await; + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jwk-set+json"); + let keys = response.json::(); + assert_eq!(&keys, fixture.runtime.jwks()); +} + +/// The declared existence-disclosure mode is an enforced invariant, not an +/// inert field: every enabled requirement declares the one closed collapse +/// mode, and the runtime's unresolved outcomes for that mode share one public +/// problem shape whether the record was absent, ambiguous, or uniquely found +/// with inconsistent derivation inputs. +#[tokio::test] +async fn declared_existence_disclosure_mode_governs_the_public_collapse() { + let fixture = acceptance_runtime().await; + for requirement in &fixture.runtime.bundle().config.requirements { + assert_eq!( + requirement.existence_disclosure, + crate::config::ExistenceDisclosure::CollapseUnresolved, + "{}", + requirement.id + ); + } + + mount_parent_source( + &fixture.server, + json!({"total": 1, "records": [{ + "returned_child_reference": "synthetic-other-child-record", + "parent_references": ["synthetic-parent-reference-001"], + "reference_namespace": "urn:example:fixture:person-reference", + "relationship_set_contract": "urn:example:fixture:legal-parent-set:v1", + "relationship_set_complete": true + }]}), + ) + .await; + let mismatch = fixture + .runtime + .evaluate( + "operation-existence-derivation-mismatch", + &access_token(Some(parent_grant_claims())), + &parent_request(), + ) + .await + .expect_err("a returned-child mismatch is not signed evidence"); + assert_eq!(mismatch.problem(), ProblemCode::EvidenceNotAvailable); + + fixture.server.reset().await; + mount_parent_source(&fixture.server, json!({"total": 0, "records": []})).await; + let unknown = fixture + .runtime + .evaluate( + "operation-existence-unknown-record", + &access_token(Some(parent_grant_claims())), + &parent_request(), + ) + .await + .expect_err("an unknown record is not signed evidence"); + + // A uniquely found record with inconsistent derivation inputs is publicly + // indistinguishable from no record at all. + assert_eq!(mismatch.problem(), unknown.problem()); + let operation = "operation-existence-problem-shape-check-000001"; + assert_eq!( + serde_json::to_value(mismatch.problem().body(operation)).expect("problem serializes"), + serde_json::to_value(unknown.problem().body(operation)).expect("problem serializes"), + ); +} + +#[tokio::test] +async fn request_nonce_is_strict_and_never_reaches_source_or_audit() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + + // Missing, empty, short, long, padded, wrong-alphabet, oversized, and + // noncanonical final-symbol nonces fail as malformed requests before + // authorization, credential acquisition, or source access. + let base = serde_json::to_value(adult_request()).expect("request serializes"); + let mut variants = Vec::new(); + let mut missing = base.clone(); + missing + .as_object_mut() + .expect("request is an object") + .remove("requestNonce"); + variants.push(missing); + for nonce in [ + String::new(), + "A".repeat(42), + "A".repeat(44), + format!("{}=", "A".repeat(42)), + format!("{}+", "A".repeat(42)), + format!("{}B", "A".repeat(42)), + "A".repeat(4096), + ] { + let mut variant = base.clone(); + variant["requestNonce"] = json!(nonce); + variants.push(variant); + } + for variant in variants { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&variant) + .await; + assert_eq!(response.status_code(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(response.json::()["code"], json!("malformed_request")); + } + // A duplicate requestNonce member fails strict JSON parsing. + let duplicate = build_app(Arc::clone(&fixture.runtime)) + .oneshot( + HttpRequest::builder() + .method("POST") + .uri("/v1/evidence") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"requestNonce":"{0}","requestNonce":"{0}","requirement":"urn:example:fixture:requirement:adult-status:v1","purpose":"fixture-eligibility","subjects":[{{"role":"subject","selector":{{"profile":"person-demographics-v1","values":{{"given_name":"Amina","family_name":"Diallo","birth_date":"2000-01-01"}}}}}}]}}"#, + "A".repeat(43) + ))) + .expect("duplicate-nonce request builds"), + ) + .await + .expect("router responds"); + assert_eq!(duplicate.status(), axum::http::StatusCode::BAD_REQUEST); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "invalid nonces must fail before credential acquisition or source access" + ); + assert!( + fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty(), + "invalid nonces must not fabricate audit events" + ); + + // A unique canary nonce is echoed exactly into the signed payload and + // reaches nothing else: not the source request, not native audit. + mount_adult_source(&fixture.server, None).await; + let request = adult_request(); + let nonce = request.request_nonce.clone(); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&request) + .await; + response.assert_status_ok(); + let jws = response.json::(); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("payload parses"); + assert_eq!(evidence.request_nonce, nonce, "exact nonce echo"); + + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + assert!( + !audit.contains(&nonce), + "the request nonce must never be recorded in native audit" + ); + for received in fixture + .server + .received_requests() + .await + .expect("request journal is available") + { + assert!(!received.url.as_str().contains(&nonce)); + assert!(!String::from_utf8_lossy(&received.body).contains(&nonce)); + for header_name in received.headers.keys() { + let value = received.headers[header_name].to_str().unwrap_or_default(); + assert!(!value.contains(&nonce)); + } + } +} + +#[tokio::test] +async fn accept_negotiation_is_closed_and_fails_before_source_access() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + + for invalid in [ + "application/json", + "application/jose+json, application/vnd.registrystack.evidence-unsigned+json", + "application/jose+json;q=0.9", + "application/vnd.registrystack.evidence-unsigned+json; charset=utf-8", + "application/*", + "text/html", + ] { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", invalid) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::NOT_ACCEPTABLE, + "{invalid}" + ); + assert_eq!( + response.json::()["code"], + json!("response_format_not_acceptable") + ); + assert_eq!(response.header("vary"), "Accept"); + } + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "unsupported negotiation must fail before source access" + ); + assert!(fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty()); + + // Missing Accept, */*, and the exact signed media type all select JWS. + mount_adult_source(&fixture.server, None).await; + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", "*/*") + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!(response.header("content-type"), "application/jose+json"); + assert_eq!(response.header("vary"), "Accept"); +} + +#[tokio::test] +async fn unsigned_output_requires_both_bundle_and_grant_permission() { + // Grant permits unsigned but the immutable bundle does not enable it. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws]", + 1, + ); + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("bundle-restricted runtime initializes"), + ); + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let token = access_token(None); + let bundle_denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + bundle_denied.status_code(), + axum::http::StatusCode::FORBIDDEN + ); + let bundle_denied_body = bundle_denied.json::(); + assert_eq!(bundle_denied_body["code"], json!("not_authorized")); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // The bundle enables unsigned but the matched grant withholds it. Another + // grant's permission cannot be unioned in, and the denial is identical. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json]", + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws]", + 1, + ); + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("grant-restricted runtime initializes"), + ); + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let grant_denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + grant_denied.status_code(), + axum::http::StatusCode::FORBIDDEN + ); + let grant_denied_body = grant_denied.json::(); + assert_eq!(grant_denied_body["code"], json!("not_authorized")); + // The two denials must not reveal which layer withheld permission. + assert_eq!(bundle_denied_body["code"], grant_denied_body["code"]); + assert_eq!(bundle_denied_body["title"], grant_denied_body["title"]); + assert_eq!(bundle_denied_body["status"], grant_denied_body["status"]); + // The signed default remains available under the restricted grant. + mount_adult_source(&prepared.server, None).await; + let signed = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + signed.assert_status_ok(); + assert_eq!(signed.header("content-type"), "application/jose+json"); + + // Runtime configuration is closed and cannot enable a response format. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime_document = + fs::read_to_string(&prepared.runtime_path).expect("runtime configuration is readable"); + runtime_document.push_str("responseFormats: [signed-jws, unsigned-json]\n"); + make_file_writable(&prepared.runtime_path); + fs::write(&prepared.runtime_path, runtime_document).expect("runtime override is written"); + make_file_read_only(&prepared.runtime_path); + let error = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect_err("a runtime response-format override must fail startup closed"); + assert!(matches!(error, RuntimeInitializationError::Bundle)); +} + +#[tokio::test] +async fn unsigned_envelope_is_exact_audited_and_never_a_signing_fallback() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, None).await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let token = access_token(None); + let request = adult_request(); + let nonce = request.request_nonce.clone(); + + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!( + response.header("content-type"), + EVIDENCE_UNSIGNED_MEDIA_TYPE + ); + assert_eq!(response.header("vary"), "Accept"); + assert_eq!(response.header("cache-control"), "no-store"); + + let body = response.text(); + let value: Value = serde_json::from_str(&body).expect("unsigned body is JSON"); + let members = value + .as_object() + .expect("envelope is an object") + .keys() + .cloned() + .collect::>(); + assert_eq!( + members, + BTreeSet::from([ + "schema".to_owned(), + "type".to_owned(), + "integrityProtection".to_owned(), + "warning".to_owned(), + "evidence".to_owned(), + ]), + "the envelope has no JWS member and no signing-key claim" + ); + assert_eq!( + value["schema"], + json!("registry.unsigned-evidence-envelope/v1") + ); + assert_eq!(value["type"], json!("UnsignedEvidenceEnvelope")); + assert_eq!(value["integrityProtection"], json!("none")); + assert_eq!(value["warning"], json!("not-cryptographically-verifiable")); + let envelope: UnsignedEvidenceEnvelope = + serde_json::from_str(&body).expect("envelope parses strictly"); + assert_eq!(envelope.evidence.request_nonce, nonce); + assert!( + evidence_contract_accepts(&value["evidence"]).expect("evidence contract is available"), + "the nested evidence is the same closed core object" + ); + + // The strict JWS verifier rejects the unsigned representation. + let mut policy = verification_policy_stub(&fixture.runtime, &request); + policy.request_nonce = nonce; + assert!(verify_flattened_jws(body.as_bytes(), fixture.runtime.jwks(), &policy).is_err()); + + // Audit records the closed protection mode; the release event carries no + // signing key identity for unsigned output. + let audit = wait_for_audit_counts(&fixture.audit_path, 1, 1).await; + let events = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .collect::>(); + let access = events + .iter() + .find(|event| event["record"]["phase"] == json!("access-attempt")) + .expect("access event exists"); + let release = events + .iter() + .find(|event| event["record"]["phase"] == json!("disclosure-release")) + .expect("release event exists"); + assert_eq!(access["record"]["responseProtection"], json!("unsigned")); + assert_eq!(release["record"]["responseProtection"], json!("unsigned")); + assert!(release["record"]["signingKeyId"].is_null()); + assert!(release["record"]["evidenceId"].is_string()); + assert!(release["record"]["disclosedConcepts"].is_array()); + assert!(!audit.contains(&envelope.evidence.request_nonce)); + + // An unready ordinary signing dependency also denies unsigned output. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(UnavailableReadinessSigner { delegate }); + let unready_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(unready_signer); + mount_adult_source(&prepared.server, None).await; + let error = runtime + .evaluate_with_format( + "operation-unsigned-unready-signing", + &access_token(None), + &adult_request(), + ResponseFormat::UnsignedJson, + ) + .await + .expect_err("unsigned output still requires the signing dependency to be ready"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +#[tokio::test] +async fn signing_failure_returns_a_problem_and_never_an_unsigned_body() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let http = TestServer::new(build_app(Arc::new(runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(response.header("content-type"), "application/problem+json"); + let text = response.text(); + assert!( + !text.contains("integrityProtection") && !text.contains("UnsignedEvidenceEnvelope"), + "a signed-path failure must never downgrade to unsigned output" + ); +} + +#[tokio::test] +async fn disclosure_audit_failure_prevents_unsigned_response_release() { + let fixture = acceptance_runtime().await; + mount_adult_source(&fixture.server, Some(Duration::from_millis(500))).await; + let runtime = Arc::clone(&fixture.runtime); + let token = access_token(None); + let request = adult_request(); + let evaluation = tokio::spawn(async move { + runtime + .evaluate_with_format( + "operation-unsigned-audit-failure", + &token, + &request, + ResponseFormat::UnsignedJson, + ) + .await + }); + + wait_for_source_request_count(&fixture.server, 1).await; + fs::OpenOptions::new() + .append(true) + .open(&fixture.audit_path) + .and_then(|mut file| file.write_all(b"{}\n")) + .expect("audit tamper writes after access acceptance"); + let error = evaluation + .await + .expect("evaluation task completes") + .expect_err("release audit failure cannot return an unsigned response"); + assert_eq!(error.problem(), ProblemCode::ServiceUnavailable); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +#[tokio::test] +async fn all_four_definitions_pass_the_explicitly_authorized_unsigned_path() { + let fixture = acceptance_runtime().await; + mount_success_sources(&fixture.server, true).await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let cases = [ + (access_token(None), adult_request()), + (access_token(None), residence_request()), + (access_token(None), licence_request()), + (access_token(Some(parent_grant_claims())), parent_request()), + ]; + for (token, request) in cases { + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_UNSIGNED_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + response.assert_status_ok(); + assert_eq!( + response.header("content-type"), + EVIDENCE_UNSIGNED_MEDIA_TYPE + ); + let envelope: UnsignedEvidenceEnvelope = + serde_json::from_str(&response.text()).expect("envelope parses strictly"); + assert_eq!(envelope.evidence.request_nonce, request.request_nonce); + assert!(!envelope.evidence.supported_values.is_empty()); + assert_eq!( + envelope.evidence.subjects.len(), + request.subjects.len(), + "unsigned output binds the same declaration-ordered roles" + ); + } + let audit = wait_for_audit_counts(&fixture.audit_path, 4, 4).await; + assert_eq!( + audit.matches("\"responseProtection\":\"unsigned\"").count(), + 8, + "every unsigned access and release event records the closed mode" + ); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn reordered_grant_subjects_resolve_by_role_and_emit_declaration_order() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + r#" subjects: + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference"#, + r#" subjects: + - role: candidate-parent + selectorProfile: person-reference-v1 + valueOrigin: authenticated-grant + valueClaims: + person_reference: grant.candidate_parent.person_reference + - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request}"#, + 1, + ); + fs::write(&configuration_path, configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("a valid bundle with reordered grant subjects initializes"), + ); + mount_parent_source( + &prepared.server, + parent_source_response(vec![PARENT_REFERENCE]), + ) + .await; + + // The request array order is also reversed independently of grant order. + let mut request = parent_request(); + request.subjects.reverse(); + let jws = runtime + .evaluate( + "operation-reordered-grant-subjects", + &access_token(Some(parent_grant_claims())), + &request, + ) + .await + .expect("reordered grant subjects resolve by unique role"); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("Evidence payload is base64url"); + let evidence: Evidence = serde_json::from_slice(&payload).expect("Evidence payload is JSON"); + assert_eq!(evidence.subjects[0].role, "child"); + assert_eq!(evidence.subjects[1].role, "candidate-parent"); + + // Audit subjects also use requirement declaration order. + let audit = wait_for_audit_counts(&prepared.audit_path, 1, 1).await; + let first_event = + serde_json::from_str::(audit.lines().next().expect("audit has events")) + .expect("audit line is JSON"); + let roles = first_event["record"]["subjects"] + .as_array() + .expect("audit subjects are an array") + .iter() + .map(|subject| subject["role"].as_str().expect("role is text").to_owned()) + .collect::>(); + assert_eq!(roles, ["child", "candidate-parent"]); + + // The verifier accepts the expected subject set in any expectation order. + let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); + let mut policy = verification_policy_stub(&runtime, &request); + policy.request_nonce = request.request_nonce.clone(); + policy.expected_subjects = evidence + .subjects + .iter() + .rev() + .map(|subject| crate::verifier::ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(); + policy.expected_outputs = evidence + .supported_values + .iter() + .map(|value| crate::verifier::ExpectedOutput { + concept: value.provides_value_for.clone(), + form: crate::verifier::ExpectedValueForm::Boolean, + }) + .collect(); + verify_flattened_jws(&serialized, runtime.jwks(), &policy) + .expect("declaration-ordered subjects verify against unordered expectations"); +} + +/// Policy scaffold with the independent bundle-derived fields filled in and +/// empty subject and output expectations for the caller to complete. +fn verification_policy_stub( + runtime: &EvidenceRuntime, + request: &EvidenceRequest, +) -> EvidenceVerificationPolicy { + let requirement = runtime + .bundle() + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .expect("requirement is loaded"); + EvidenceVerificationPolicy { + issued_by: runtime.bundle().config.issuer.id.clone(), + provided_by: runtime.bundle().config.service.provider_id.clone(), + requirement: request.requirement.clone(), + evidence_type: requirement.evidence_type.clone(), + purpose: request.purpose.clone(), + audience: EVIDENCE_AUDIENCE.to_owned(), + configuration_revision: runtime.bundle().revision().to_owned(), + request_nonce: request.request_nonce.clone(), + expected_subjects: Vec::new(), + expected_outputs: Vec::new(), + maximum_assertion_lifetime: Duration::from_secs(48 * 60 * 60), + now: Utc::now(), + clock_skew: Duration::from_secs(30), + } +} + #[tokio::test] async fn multi_role_request_order_is_not_semantic_and_output_uses_declaration_order() { let fixture = acceptance_runtime().await; @@ -1311,7 +2061,7 @@ async fn one_runtime_proves_all_definitions_and_collapses_unresolved_relationshi let evidence = verify_flattened_jws( &serialized, fixture.runtime.jwks(), - &verification_policy(&fixture.runtime, &request), + &verification_policy(&fixture.runtime, &request, &serialized), ) .expect("released JWS verifies under the exact relying-procedure policy"); assert_eq!(evidence.subjects.len(), role_count); @@ -1340,19 +2090,21 @@ async fn one_runtime_proves_all_definitions_and_collapses_unresolved_relationshi ) .await; let non_parent_token = access_token(Some(parent_grant_claims_for(non_parent))); + let false_request = parent_request(); let false_jws = fixture .runtime .evaluate( "operation-acceptance-parent-false", &non_parent_token, - &parent_request(), + &false_request, ) .await .expect("exact non-membership in the complete governed parent set is signed"); + let false_serialized = serde_json::to_vec(&false_jws).expect("JWS serializes"); let false_evidence = verify_flattened_jws( - &serde_json::to_vec(&false_jws).expect("JWS serializes"), + &false_serialized, fixture.runtime.jwks(), - &verification_policy(&fixture.runtime, &parent_request()), + &verification_policy(&fixture.runtime, &false_request, &false_serialized), ) .expect("negative Evidence verifies"); assert_eq!( @@ -1743,7 +2495,7 @@ async fn every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_ver response.assert_status_ok(); let jws = response.json::(); let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); - let mut policy = verification_policy(&fixture.runtime, &request); + let mut policy = verification_policy(&fixture.runtime, &request, &serialized); policy.now = evaluation_time; let evidence = verify_flattened_jws(&serialized, fixture.runtime.jwks(), &policy) .expect("matrix JWS verifies"); @@ -2289,12 +3041,23 @@ fn parent_request_with_candidate_values() -> EvidenceRequest { fn request(requirement: &str, purpose: &str, subjects: Vec) -> EvidenceRequest { EvidenceRequest { + request_nonce: fresh_request_nonce(), requirement: requirement.to_owned(), purpose: purpose.to_owned(), subjects, } } +/// A unique canonical nonce per constructed request, so exact-echo and +/// non-propagation assertions cannot pass by collision. +fn fresh_request_nonce() -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let mut bytes = [0u8; 32]; + let unique = ulid::Ulid::new().to_bytes(); + bytes[..16].copy_from_slice(&unique); + URL_SAFE_NO_PAD.encode(bytes) +} + fn requested_subject<'a, I>(role: &str, profile: &str, values: Option) -> RequestedSubject where I: IntoIterator, @@ -2313,9 +3076,15 @@ where } } +/// Independent policy expectations for one live response. The identity, +/// requirement, purpose, audience, revision, and nonce expectations come from +/// the bundle and the retained request; the subject-binding and output-shape +/// expectations are lifted from the response as an accepted first +/// transaction, exactly as a relying party stores them for later checks. fn verification_policy( runtime: &EvidenceRuntime, request: &EvidenceRequest, + serialized_jws: &[u8], ) -> EvidenceVerificationPolicy { let requirement = runtime .bundle() @@ -2324,17 +3093,27 @@ fn verification_policy( .iter() .find(|candidate| candidate.id == request.requirement) .expect("requirement is loaded"); - EvidenceVerificationPolicy { - issued_by: runtime.bundle().config.issuer.id.clone(), - provided_by: runtime.bundle().config.service.provider_id.clone(), - requirement: request.requirement.clone(), - evidence_type: requirement.evidence_type.clone(), - purpose: request.purpose.clone(), - audience: EVIDENCE_AUDIENCE.to_owned(), - configuration_revision: runtime.bundle().revision().to_owned(), - now: Utc::now(), - clock_skew: Duration::from_secs(30), - } + let jws: FlattenedJws = serde_json::from_slice(serialized_jws).expect("flattened JWS is JSON"); + let payload = URL_SAFE_NO_PAD + .decode(jws.payload) + .expect("flattened JWS payload is base64url"); + let evidence: Evidence = + serde_json::from_slice(&payload).expect("Evidence payload parses for expectations"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + policy.issued_by = runtime.bundle().config.issuer.id.clone(); + policy.provided_by = runtime.bundle().config.service.provider_id.clone(); + policy.requirement = request.requirement.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = request.purpose.clone(); + policy.audience = EVIDENCE_AUDIENCE.to_owned(); + policy.configuration_revision = runtime.bundle().revision().to_owned(); + policy } fn assert_minimized_payload(serialized_jws: &[u8]) { diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index 4040748a8..6b3ad522b 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -14,8 +14,8 @@ use crate::{ }, bundle::{Bundle, Codelist}, config::{ - AuthorityKind, GrantedSubject, SelectorField as ConfiguredField, SelectorProfile, - ValueOrigin, MAX_SAFE_INTEGER, + AuthorityKind, GrantedSubject, ResponseFormat, SelectorField as ConfiguredField, + SelectorProfile, ValueOrigin, MAX_SAFE_INTEGER, }, model::{EvidenceRequest, RequestedSubject, SelectorValue}, }; @@ -281,6 +281,7 @@ impl fmt::Debug for ResolvedAuthorization { pub struct MatchedEntitlement { authority_profile: String, authority_kind: AuthorityKind, + response_formats: Vec, subjects: Vec, } @@ -293,6 +294,12 @@ impl MatchedEntitlement { self.authority_kind } + /// Report whether this one complete matched grant permits the requested + /// response format. Permissions are never unioned across grants. + pub fn permits_response_format(&self, format: ResponseFormat) -> bool { + self.response_formats.contains(&format) + } + pub(crate) fn subjects(&self) -> &[GrantedSubject] { &self.subjects } @@ -364,6 +371,7 @@ pub fn match_entitlement( matched.push(MatchedEntitlement { authority_profile: authority_profile.to_owned(), authority_kind: authority.kind, + response_formats: grant.response_formats.clone(), subjects: grant.subjects.clone(), }); } @@ -387,7 +395,19 @@ pub fn resolve_selectors( context: &AuthenticatedContext, matched: &MatchedEntitlement, ) -> Result { - let subjects = resolve_grant_subjects(bundle, &matched.subjects, &request.subjects, context)?; + let requirement = bundle + .config + .requirements + .iter() + .find(|candidate| candidate.id == request.requirement) + .ok_or(AuthorizationError::Unauthorized)?; + let subjects = resolve_grant_subjects( + bundle, + requirement, + &matched.subjects, + &request.subjects, + context, + )?; let uses_authenticated_grant = matched .subjects .iter() @@ -495,6 +515,7 @@ pub fn resolve_offline_fixture_authorization( fixture_subjects_from_selectors(bundle, requirement, purpose, common, case)? }; let request = EvidenceRequest { + request_nonce: crate::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), requirement: requirement.id.clone(), purpose: purpose.to_owned(), subjects, @@ -739,8 +760,11 @@ fn same_subject_tuples(granted: &[GrantedSubject], requested: &[RequestedSubject }) } +/// Resolve subjects by unique role and emit the requirement's declaration +/// order. Neither grant order nor request array order is semantic. fn resolve_grant_subjects( bundle: &Bundle, + requirement: &crate::config::RequirementConfig, granted: &[GrantedSubject], requested: &[RequestedSubject], context: &AuthenticatedContext, @@ -753,10 +777,18 @@ fn resolve_grant_subjects( { return Err(AuthorizationError::Unauthorized); } + if granted.len() != requirement.subject_roles.len() { + return Err(AuthorizationError::Unauthorized); + } - granted + requirement + .subject_roles .iter() - .map(|grant| { + .map(|declared| { + let grant = granted + .iter() + .find(|grant| grant.role == declared.role) + .ok_or(AuthorizationError::Unauthorized)?; let subject = requested .iter() .find(|subject| subject.role == grant.role) diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 4d125dafb..5ade65db9 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -21,7 +21,9 @@ use axum::{ body::{to_bytes, Body}, extract::State, http::{ - header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, RETRY_AFTER}, + header::{ + ACCEPT, AUTHORIZATION, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, RETRY_AFTER, VARY, + }, HeaderMap, HeaderValue, Request, StatusCode, }, middleware::{from_fn, Next}, @@ -36,12 +38,12 @@ use tokio::{net::TcpListener, sync::Semaphore}; use ulid::Ulid; use crate::{ - config::ListenerConfig, + config::{ListenerConfig, ResponseFormat}, contracts::request_contract_accepts, - model::EvidenceRequest, + model::{request_nonce_is_canonical, EvidenceRequest}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeFailure}, - EVIDENCE_JWS_MEDIA_TYPE, + EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const JSON_MEDIA_TYPE: &str = "application/json"; @@ -252,10 +254,19 @@ where result } +/// Every `/v1/evidence` response varies on the negotiated `Accept` value. async fn create_evidence( State(state): State>, request: Request, ) -> Response { + let mut response = create_evidence_negotiated(state, request).await; + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept")); + response +} + +async fn create_evidence_negotiated(state: Arc, request: Request) -> Response { let operation = operation_id(); let started = Instant::now(); @@ -263,6 +274,14 @@ async fn create_evidence( Ok(token) => token.to_owned(), Err(code) => return problem_response(code, &operation), }; + // Strict media negotiation resolves the requested response format before + // the body is read and long before credential acquisition or source + // access. Selection creates no permission; the bundle and matched grant + // decide authorization later. + let format = match resolve_response_format(request.headers()) { + Ok(format) => format, + Err(code) => return problem_response(code, &operation), + }; if !has_exact_content_type(request.headers(), JSON_MEDIA_TYPE) || content_length_exceeds(request.headers(), state.maximum_request_bytes) { @@ -322,12 +341,18 @@ async fn create_evidence( &evaluation_operation, &access_token, &evidence_request, + format, evaluation_time, ) .await; } runtime - .evaluate(&evaluation_operation, &access_token, &evidence_request) + .evaluate_with_format( + &evaluation_operation, + &access_token, + &evidence_request, + format, + ) .await }); let result = match evaluation.await { @@ -336,14 +361,38 @@ async fn create_evidence( }; match result { - Ok(jws) => match serialize_response(StatusCode::OK, EVIDENCE_JWS_MEDIA_TYPE, &jws) { - Some(response) => response, - None => problem_response(ProblemCode::ServiceUnavailable, &operation), - }, + // Release exactly the immutable bytes serialized before the durable + // disclosure-release audit event, with their exact media type. + Ok(released) => { + let media_type = released.media_type(); + bytes_response(StatusCode::OK, media_type, released.into_bytes()) + } Err(failure) => runtime_failure_response(failure, &operation), } } +/// Resolve the closed Version 1 `Accept` matrix. Missing, `*/*`, and the exact +/// signed media type select signed JWS; only the exact unsigned vendor media +/// type selects the unsigned envelope. Duplicate, combined, parameterized, +/// weighted, or unknown negotiation is not acceptable. +fn resolve_response_format(headers: &HeaderMap) -> Result { + let mut values = headers.get_all(ACCEPT).iter(); + let Some(value) = values.next() else { + return Ok(ResponseFormat::SignedJws); + }; + if values.next().is_some() { + return Err(ProblemCode::ResponseFormatNotAcceptable); + } + match value.as_bytes() { + b"*/*" => Ok(ResponseFormat::SignedJws), + value if value == EVIDENCE_JWS_MEDIA_TYPE.as_bytes() => Ok(ResponseFormat::SignedJws), + value if value == EVIDENCE_UNSIGNED_MEDIA_TYPE.as_bytes() => { + Ok(ResponseFormat::UnsignedJson) + } + _ => Err(ProblemCode::ResponseFormatNotAcceptable), + } +} + async fn discover_evidence( State(state): State>, request: Request, @@ -438,7 +487,14 @@ fn parse_evidence_request(bytes: &[u8]) -> Result Ok(false) => return Err(ProblemCode::MalformedRequest), Err(_) => return Err(ProblemCode::ServiceUnavailable), } - serde_json::from_value(value).map_err(|_| ProblemCode::MalformedRequest) + let request: EvidenceRequest = + serde_json::from_value(value).map_err(|_| ProblemCode::MalformedRequest)?; + // The transport schema pins length and alphabet; canonicality of the + // final base64url symbol is checked here, before authentication. + if !request_nonce_is_canonical(&request.request_nonce) { + return Err(ProblemCode::MalformedRequest); + } + Ok(request) } fn bearer_token(headers: &HeaderMap) -> Result<&str, ProblemCode> { @@ -450,10 +506,13 @@ fn bearer_token(headers: &HeaderMap) -> Result<&str, ProblemCode> { let value = value .to_str() .map_err(|_| ProblemCode::AuthenticationFailed)?; - let token = value - .strip_prefix("Bearer ") + // The HTTP authentication grammar matches the scheme case-insensitively. + // The single-header, single-space, and token-value rules stay exact. + let (scheme, token) = value + .split_once(' ') .ok_or(ProblemCode::AuthenticationFailed)?; - if token.is_empty() + if !scheme.eq_ignore_ascii_case("Bearer") + || token.is_empty() || token .bytes() .any(|byte| byte.is_ascii_whitespace() || byte == b',') @@ -558,8 +617,21 @@ mod tests { "three.parts.value" ); + // The authentication scheme is case-insensitive per the HTTP grammar. + for scheme in ["bearer", "BEARER", "BeArEr"] { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("{scheme} three.parts.value")) + .expect("test header is valid"), + ); + assert_eq!( + bearer_token(&headers).expect("case-insensitive scheme accepted"), + "three.parts.value" + ); + } + for invalid in [ - "bearer three.parts.value", + "Basic three.parts.value", "Bearer", "Bearer three.parts.value", "Bearer three.parts.value ", @@ -584,9 +656,12 @@ mod tests { ); } + const TEST_NONCE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + #[test] fn request_json_is_strict_and_closed() { let valid = br#"{ + "requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "requirement":"urn:example:requirement:v1", "purpose":"review", "subjects":[{ @@ -596,7 +671,8 @@ mod tests { }"#; assert!(parse_evidence_request(valid).is_ok()); for number in ["1.0", "1e0"] { - let request = r#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":NUMBER}}}]}"# + let request = r#"{"requestNonce":"NONCE","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":NUMBER}}}]}"# + .replace("NONCE", TEST_NONCE) .replace("NUMBER", number); let parsed = parse_evidence_request(request.as_bytes()) .expect("schema-valid integral JSON number is accepted"); @@ -611,24 +687,44 @@ mod tests { } assert_eq!( parse_evidence_request( - br#"{"requirement":"a","requirement":"b","purpose":"p","subjects":[]}"# + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"a","requirement":"b","purpose":"p","subjects":[]}"# ), Err(ProblemCode::MalformedRequest) ); assert_eq!( parse_evidence_request( - br#"{"requirement":"a","purpose":"p","subjects":[],"query":"hidden"}"# + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"a","purpose":"p","subjects":[],"query":"hidden"}"# ), Err(ProblemCode::MalformedRequest) ); + let base = r#"{"requestNonce":"NONCE","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#; + for invalid in [ + base.replace("\"requestNonce\":\"NONCE\",", ""), + base.replace("NONCE", ""), + base.replace("NONCE", &"A".repeat(42)), + base.replace("NONCE", &"A".repeat(44)), + base.replace("NONCE", &format!("{}=", "A".repeat(42))), + base.replace("NONCE", &format!("{}+", "A".repeat(42))), + base.replace("NONCE", &format!("{}B", "A".repeat(42))), + format!( + r#"{{"requestNonce":"{TEST_NONCE}","requestNonce":"{TEST_NONCE}","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{{"role":"subject","selector":{{"profile":"opaque-v1","values":{{"opaque":"value"}}}}}}]}}"# + ), + ] { + assert_eq!( + parse_evidence_request(invalid.as_bytes()), + Err(ProblemCode::MalformedRequest), + "{invalid}" + ); + } + for invalid in [ - br#"{"requirement":"not a URI","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), - br#"{"requirement":"urn:example:requirement:v1","purpose":"Uppercase","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), - br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[]}"#.as_slice(), - br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"Uppercase","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), - br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{}}}]}"#.as_slice(), - br#"{"requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":9007199254740992}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"not a URI","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"Uppercase","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"Uppercase","selector":{"profile":"opaque-v1","values":{"opaque":"value"}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{}}}]}"#.as_slice(), + br#"{"requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","requirement":"urn:example:requirement:v1","purpose":"p","subjects":[{"role":"subject","selector":{"profile":"opaque-v1","values":{"opaque":9007199254740992}}}]}"#.as_slice(), ] { assert_eq!( parse_evidence_request(invalid), @@ -637,6 +733,56 @@ mod tests { } } + #[test] + fn accept_negotiation_matrix_is_closed_and_exact() { + let mut headers = HeaderMap::new(); + assert_eq!( + resolve_response_format(&headers), + Ok(ResponseFormat::SignedJws) + ); + + for (value, expected) in [ + ("*/*", ResponseFormat::SignedJws), + ("application/jose+json", ResponseFormat::SignedJws), + ( + "application/vnd.registrystack.evidence-unsigned+json", + ResponseFormat::UnsignedJson, + ), + ] { + headers.insert(ACCEPT, HeaderValue::from_static(value)); + assert_eq!(resolve_response_format(&headers), Ok(expected), "{value}"); + } + + for invalid in [ + "application/json", + "application/jose+json, application/json", + "application/jose+json;q=0.9", + "application/vnd.registrystack.evidence-unsigned+json; charset=utf-8", + "application/*", + "*/*;q=1", + " application/jose+json", + "APPLICATION/JOSE+JSON", + ] { + headers.insert( + ACCEPT, + HeaderValue::from_str(invalid).expect("test header is valid"), + ); + assert_eq!( + resolve_response_format(&headers), + Err(ProblemCode::ResponseFormatNotAcceptable), + "{invalid}" + ); + } + + headers.clear(); + headers.append(ACCEPT, HeaderValue::from_static("application/jose+json")); + headers.append(ACCEPT, HeaderValue::from_static("application/jose+json")); + assert_eq!( + resolve_response_format(&headers), + Err(ProblemCode::ResponseFormatNotAcceptable) + ); + } + #[tokio::test] async fn problem_responses_have_closed_media_and_challenge_headers() { let authentication = problem_response( diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index b169ffede..e634837e4 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -427,7 +427,17 @@ fn build_client( .timeout(timeout) .connect_timeout(timeout.min(Duration::from_secs(10))) .redirect(reqwest::redirect::Policy::none()) - .no_proxy(); + .no_proxy() + // Select rustls explicitly. Cargo unifies reqwest's feature set across + // the whole workspace, so another workspace member enabling + // reqwest's native-tls feature must not silently change which TLS + // backend this client uses. + .use_rustls_tls() + // Evidence source execution is exactly one request per plan step; a + // transport-level retry would duplicate an outbound call the caller + // did not ask for and is not accounted for in the one-request + // contract. + .retry(reqwest::retry::never()); if let Some(profile_name) = source.tls_trust_profile.as_deref() { let (tls, captured_ca_bundles) = outbound_tls.ok_or(SourceError::InvalidPlan)?; if !tls.system_roots { @@ -688,41 +698,13 @@ fn compile_fixed_headers( Ok(headers) } +/// Reject a bundle-configured header name before any credential is resolved. +/// +/// Startup configuration validation already rejects these names. This is the +/// defensive second check on the request path, and it deliberately calls the +/// one shared closed classifier so the two deny sets cannot drift apart. fn reserved_configured_header(name: &str) -> bool { - let name = name.to_ascii_lowercase(); - matches!( - name.as_str(), - "authorization" - | "proxy-authorization" - | "host" - | "cookie" - | "set-cookie" - | "content-length" - | "content-type" - | "transfer-encoding" - | "expect" - | "connection" - | "keep-alive" - | "te" - | "trailer" - | "upgrade" - | "proxy-connection" - | "forwarded" - | "via" - | "x-real-ip" - | "traceparent" - | "tracestate" - | "baggage" - | "x-request-id" - | "x-correlation-id" - | "x-amzn-trace-id" - | "x-original-url" - | "x-rewrite-url" - | "x-http-method-override" - | "x-original-method" - ) || name.starts_with("x-forwarded-") - || name.starts_with("proxy-") - || name.starts_with("x-b3-") + crate::config::is_reserved_header_name(name) } fn compile_authentication( @@ -1570,6 +1552,114 @@ mod tests { .is_empty()); } + /// Spins up a TLS server whose certificate is signed by a private + /// certificate authority that the client under test is never told to + /// trust. The resulting handshake failure is specific to whichever TLS + /// backend the client actually uses, which makes it a proof that the + /// backend selected in `build_client` is the one in effect at runtime. + async fn spawn_untrusted_tls_server( + server_subject_alt_name: &str, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let mut ca_parameters = rcgen::CertificateParams::new(Vec::::new()) + .expect("private CA parameters are valid"); + ca_parameters.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca_key = rcgen::KeyPair::generate().expect("private CA key generates"); + let ca_certificate = ca_parameters + .self_signed(&ca_key) + .expect("private CA certificate generates"); + + let server_parameters = + rcgen::CertificateParams::new(vec![server_subject_alt_name.to_owned()]) + .expect("server certificate parameters are valid"); + let server_key = rcgen::KeyPair::generate().expect("server key generates"); + let server_certificate = server_parameters + .signed_by(&server_key, &ca_certificate, &ca_key) + .expect("private CA signs server certificate"); + let private_key = tokio_rustls::rustls::pki_types::PrivateKeyDer::Pkcs8( + tokio_rustls::rustls::pki_types::PrivatePkcs8KeyDer::from(server_key.serialize_der()), + ); + let server_config = tokio_rustls::rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![server_certificate.der().clone()], private_key) + .expect("TLS server configuration builds"); + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("TLS test server binds"); + let address = listener.local_addr().expect("TLS server address"); + let handle = tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + // The client is expected to abort the handshake once it + // evaluates the certificate chain, so no request or response + // handling is needed here. + let _ = acceptor.accept(stream).await; + }); + (address, handle) + } + + #[tokio::test] + async fn evidence_client_uses_rustls_and_fails_closed_on_an_unrecognized_certificate_authority() + { + let (address, _server) = spawn_untrusted_tls_server("127.0.0.1").await; + let source: SourceConfig = serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": format!("https://{address}"), + "posture": "source-derived", + "authentication": { + "kind": "static-bearer", + "tokenRef": "secret:file/missing-source-token" + }, + "request": { + "method": "POST", + "path": "/data", + "fixedHeaders": [], + "selectorInputs": [{ + "role": "subject", + "alternatives": [{"profile": "record-v1", "fields": ["record_id"]}] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": { + "query": "forbidden", + "jsonBody": "required", + "maximumJsonDepth": 4, + "maximumCollectionItems": 4, + "maximumStringBytes": 64, + "maximumNormalizedBytes": 1024 + }, + "projection": ["/ok"], + "redirects": "deny", + "timeoutMilliseconds": 2000, + "maximumResponseBytes": 1024, + "concurrencyLimit": 1 + }, + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("source config deserializes"); + let client = build_client(Duration::from_secs(5), &source, None).expect("client builds"); + let error = client + .get(format!("https://{address}/")) + .send() + .await + .expect_err("an unrecognized certificate authority is rejected"); + let mut messages = Vec::new(); + let mut current: &dyn std::error::Error = &error; + while let Some(source) = current.source() { + messages.push(source.to_string()); + current = source; + } + assert!( + messages + .iter() + .any(|message| message.contains("invalid peer certificate")), + "expected a rustls certificate-validation error in the source chain, got: {messages:?}" + ); + } + #[tokio::test] async fn saturated_oauth_single_flight_fails_before_credentials_or_transport() { let secret_root = tempfile::tempdir().expect("temporary secret root"); diff --git a/crates/registry-evidence/src/verifier.rs b/crates/registry-evidence/src/verifier.rs index 7fc2403eb..150518784 100644 --- a/crates/registry-evidence/src/verifier.rs +++ b/crates/registry-evidence/src/verifier.rs @@ -19,6 +19,11 @@ const MAX_PROTECTED_BYTES: usize = 8 * 1024; const MAX_PAYLOAD_BYTES: usize = 128 * 1024; const MAX_TRUSTED_KEYS: usize = 33; +/// Complete relying-procedure expectations for strict verification. +/// +/// Every expectation comes from independent trusted state such as the relying +/// procedure, a previously trusted binding, or a trusted requirement contract. +/// Copying values out of the JWS under verification proves nothing. #[derive(Debug, Clone)] pub struct EvidenceVerificationPolicy { pub issued_by: String, @@ -28,10 +33,130 @@ pub struct EvidenceVerificationPolicy { pub purpose: String, pub audience: String, pub configuration_revision: String, + /// The exact nonce from the independently retained original request. + pub request_nonce: String, + /// Expected role-bound opaque subject bindings as an unordered set of + /// unique pairs. Subject order alone is never semantic. + pub expected_subjects: Vec, + /// Expected concept identifiers, value forms, and cardinalities. + pub expected_outputs: Vec, + /// Longest acceptable `validUntil - issuedAt` interval. + pub maximum_assertion_lifetime: Duration, pub now: DateTime, pub clock_skew: Duration, } +impl EvidenceVerificationPolicy { + /// Build expectations from evidence accepted in an original trusted + /// transaction, for later re-verification of the stored response. + /// + /// This is only meaningful when `evidence` was itself verified and + /// accepted at transaction time and then retained under the relying + /// party's record policy. Parsing an untrusted JWS and passing its own + /// values back as expectations proves nothing. The expected nonce comes + /// from the independently retained original request, never from the + /// response. + pub fn from_accepted_transaction( + evidence: &Evidence, + retained_request_nonce: &str, + maximum_assertion_lifetime: Duration, + now: DateTime, + clock_skew: Duration, + ) -> Self { + Self { + issued_by: evidence.issued_by.clone(), + provided_by: evidence.provided_by.clone(), + requirement: evidence.supports_requirement.clone(), + evidence_type: evidence.is_conformant_to.clone(), + purpose: evidence.purpose.clone(), + audience: evidence.audience.clone(), + configuration_revision: evidence.configuration_revision.clone(), + request_nonce: retained_request_nonce.to_owned(), + expected_subjects: evidence + .subjects + .iter() + .map(|subject| ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect(), + expected_outputs: evidence + .supported_values + .iter() + .map(|value| ExpectedOutput { + concept: value.provides_value_for.clone(), + form: expected_form_of(&value.value), + }) + .collect(), + maximum_assertion_lifetime, + now, + clock_skew, + } + } +} + +fn expected_form_of(value: &crate::model::PublicValue) -> ExpectedValueForm { + use crate::model::{BucketForm, PublicValue}; + match value { + PublicValue::Boolean(_) => ExpectedValueForm::Boolean, + PublicValue::Integer(_) => ExpectedValueForm::Integer, + PublicValue::String(_) => ExpectedValueForm::String, + PublicValue::Bucket(bucket) => { + if bucket.form == BucketForm::DateBucket { + ExpectedValueForm::DateBucket + } else { + ExpectedValueForm::TimeBucket + } + } + PublicValue::EntityReference(_) => ExpectedValueForm::EntityReference, + PublicValue::Structured(_) => ExpectedValueForm::Structured, + PublicValue::List(items) => ExpectedValueForm::List { + minimum_items: items.len(), + maximum_items: items.len(), + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ExpectedSubject { + pub role: String, + pub binding: String, +} + +#[derive(Debug, Clone)] +pub struct ExpectedOutput { + pub concept: String, + pub form: ExpectedValueForm, +} + +/// Closed expected form for one disclosed Supported Value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExpectedValueForm { + Boolean, + Integer, + String, + DateBucket, + TimeBucket, + EntityReference, + Structured, + List { + minimum_items: usize, + maximum_items: usize, + }, +} + +/// Result of verifying a stored signed response. +/// +/// A returned report means the trusted key signed the exact payload and every +/// policy expectation held. Current usability is reported separately so an +/// expired assertion can remain cryptographically authentic without being +/// treated as current evidence. +#[derive(Debug)] +pub struct VerificationReport { + pub evidence: Evidence, + pub currently_valid: bool, +} + #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] pub enum VerificationError { #[error("flattened JWS is malformed")] @@ -59,11 +184,28 @@ struct ProtectedHeader { cty: String, } +/// Strict one-call verification: cryptographic authenticity, every policy +/// expectation, and current validity must all hold. pub fn verify_flattened_jws( serialized_jws: &[u8], trusted_jwks: &JwksDocument, policy: &EvidenceVerificationPolicy, ) -> Result { + let report = verify_flattened_jws_report(serialized_jws, trusted_jwks, policy)?; + if !report.currently_valid { + return Err(VerificationError::Time); + } + Ok(report.evidence) +} + +/// Verify a stored signed response against a pinned trusted key set and the +/// complete independent policy, reporting cryptographic authenticity +/// separately from current validity. +pub fn verify_flattened_jws_report( + serialized_jws: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { if serialized_jws.is_empty() || serialized_jws.len() > MAX_JWS_BYTES { return Err(VerificationError::MalformedJws); } @@ -111,8 +253,11 @@ pub fn verify_flattened_jws( } let evidence: Evidence = serde_json::from_value(payload_strict).map_err(|_| VerificationError::Payload)?; - validate_policy(&evidence, policy)?; - Ok(evidence) + let currently_valid = validate_policy(&evidence, policy)?; + Ok(VerificationReport { + evidence, + currently_valid, + }) } fn trusted_keys(jwks: &JwksDocument) -> Result, VerificationError> { @@ -136,10 +281,17 @@ fn trusted_keys(jwks: &JwksDocument) -> Result, Veri Ok(output) } +/// Compare every policy expectation after signature and schema verification. +/// +/// Every mismatch, including the expected nonce, expected role-bound subject +/// set, and expected output contract, returns the one generic policy error so +/// verification does not reveal which hidden comparison failed. The returned +/// boolean is current validity, which is reported separately from +/// authenticity and policy conformance. fn validate_policy( evidence: &Evidence, policy: &EvidenceVerificationPolicy, -) -> Result<(), VerificationError> { +) -> Result { if evidence.schema != EVIDENCE_SCHEMA_V1 || evidence.issued_by != policy.issued_by || evidence.provided_by != policy.provided_by @@ -150,34 +302,123 @@ fn validate_policy( || evidence.configuration_revision != policy.configuration_revision || evidence.subjects.is_empty() || evidence.supported_values.is_empty() + || evidence.request_nonce != policy.request_nonce { return Err(VerificationError::Policy); } + validate_expected_subjects(evidence, policy)?; + validate_expected_outputs(evidence, policy)?; let issued = parse_time(&evidence.issued_at)?; let observed = parse_time(&evidence.observed_at)?; let valid_until = parse_time(&evidence.valid_until)?; let skew = chrono::Duration::from_std(policy.clock_skew).map_err(|_| VerificationError::Time)?; - let latest_acceptable_issue = policy - .now - .checked_add_signed(skew) - .ok_or(VerificationError::Time)?; + let maximum_lifetime = chrono::Duration::from_std(policy.maximum_assertion_lifetime) + .map_err(|_| VerificationError::Time)?; let expiration_with_skew = valid_until .checked_add_signed(skew) .ok_or(VerificationError::Time)?; + // Internal chronology and the accepted-lifetime ceiling are hard errors; + // an internally inconsistent or over-long assertion is never acceptable. if issued < observed - || issued > latest_acceptable_issue - || observed > latest_acceptable_issue || valid_until <= observed || valid_until <= issued - || policy.now >= expiration_with_skew + || valid_until - issued > maximum_lifetime { return Err(VerificationError::Time); } + let latest_acceptable_issue = policy + .now + .checked_add_signed(skew) + .ok_or(VerificationError::Time)?; + let currently_valid = issued <= latest_acceptable_issue + && observed <= latest_acceptable_issue + && policy.now < expiration_with_skew; + Ok(currently_valid) +} + +/// Compare the unordered set of unique expected `(role, binding)` pairs. +fn validate_expected_subjects( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result<(), VerificationError> { + let mut expected = policy.expected_subjects.clone(); + expected.sort(); + if expected.is_empty() || expected.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(VerificationError::Policy); + } + let mut actual = evidence + .subjects + .iter() + .map(|subject| ExpectedSubject { + role: subject.role.clone(), + binding: subject.binding.clone(), + }) + .collect::>(); + actual.sort(); + if actual != expected { + return Err(VerificationError::Policy); + } Ok(()) } +/// Compare the expected concept identifiers, value forms, and cardinalities. +fn validate_expected_outputs( + evidence: &Evidence, + policy: &EvidenceVerificationPolicy, +) -> Result<(), VerificationError> { + let expected = &policy.expected_outputs; + if expected.is_empty() || evidence.supported_values.len() != expected.len() { + return Err(VerificationError::Policy); + } + let mut concepts = BTreeMap::new(); + for output in expected { + if concepts + .insert(output.concept.as_str(), &output.form) + .is_some() + { + return Err(VerificationError::Policy); + } + } + let mut seen = std::collections::BTreeSet::new(); + for value in &evidence.supported_values { + let concept = value.provides_value_for.as_str(); + let Some(form) = concepts.get(concept) else { + return Err(VerificationError::Policy); + }; + if !seen.insert(concept) || !value_matches_form(&value.value, form) { + return Err(VerificationError::Policy); + } + } + Ok(()) +} + +fn value_matches_form(value: &crate::model::PublicValue, form: &ExpectedValueForm) -> bool { + use crate::model::{BucketForm, PublicValue}; + match (value, form) { + (PublicValue::Boolean(_), ExpectedValueForm::Boolean) + | (PublicValue::Integer(_), ExpectedValueForm::Integer) + | (PublicValue::String(_), ExpectedValueForm::String) + | (PublicValue::EntityReference(_), ExpectedValueForm::EntityReference) + | (PublicValue::Structured(_), ExpectedValueForm::Structured) => true, + (PublicValue::Bucket(bucket), ExpectedValueForm::DateBucket) => { + bucket.form == BucketForm::DateBucket + } + (PublicValue::Bucket(bucket), ExpectedValueForm::TimeBucket) => { + bucket.form == BucketForm::TimeBucket + } + ( + PublicValue::List(items), + ExpectedValueForm::List { + minimum_items, + maximum_items, + }, + ) => items.len() >= *minimum_items && items.len() <= *maximum_items, + _ => false, + } +} + fn parse_time(input: &str) -> Result, VerificationError> { DateTime::parse_from_rfc3339(input) .map(|value| value.with_timezone(&Utc)) @@ -251,9 +492,12 @@ mod tests { ) } + const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; + fn fixture_evidence() -> Evidence { Evidence { schema: EVIDENCE_SCHEMA_V1.to_string(), + request_nonce: FIXTURE_NONCE.to_string(), id: "urn:ulid:01K1EXAMPLE0000000000000000".to_string(), evidence_type_name: EvidenceObjectType::Evidence, supports_requirement: "urn:example:requirement:v1".to_string(), @@ -290,20 +534,23 @@ mod tests { let jws = signer.sign_json(&evidence).await.expect("evidence signs"); let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); - let policy = EvidenceVerificationPolicy { - issued_by: evidence.issued_by, - provided_by: evidence.provided_by, - requirement: evidence.supports_requirement, - evidence_type: evidence.is_conformant_to, - purpose: evidence.purpose, - audience: evidence.audience, - configuration_revision: evidence.configuration_revision, - now, - clock_skew: Duration::from_secs(30), - }; + let policy = policy_for(&evidence, now); (serialized, jwks, policy) } + /// Build expectations equal to one known evidence value. Production + /// relying parties obtain these from independent trusted state; the test + /// simulates that state from the fixture it controls. + fn policy_for(evidence: &Evidence, now: DateTime) -> EvidenceVerificationPolicy { + EvidenceVerificationPolicy::from_accepted_transaction( + evidence, + &evidence.request_nonce, + Duration::from_secs(48 * 60 * 60), + now, + Duration::from_secs(30), + ) + } + async fn signed_fixture() -> (Vec, JwksDocument, EvidenceVerificationPolicy) { signed_evidence( fixture_evidence(), @@ -378,7 +625,8 @@ mod tests { "typ": EVIDENCE_JWS_TYP, "cty": EVIDENCE_JWS_CTY }); - let (_, _, policy) = signed_fixture().await; + let (_, _, mut policy) = signed_fixture().await; + policy.expected_outputs[0].form = ExpectedValueForm::Integer; for number in ["1.0", "1e0"] { let payload = base.replace("\"value\":false", &format!("\"value\":{number}")); @@ -614,6 +862,198 @@ mod tests { } } + #[tokio::test] + async fn expected_nonce_must_match_and_reuse_is_not_replay_prevention() { + let (jws, jwks, policy) = signed_fixture().await; + + // Changing the expected nonce fails with the generic policy mismatch. + let mut wrong_expectation = policy.clone(); + wrong_expectation.request_nonce = "B".repeat(43); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &wrong_expectation), + Err(VerificationError::Policy) + ); + + // Changing the signed nonce fails: re-signing a mutated payload with + // the same trusted key still mismatches the retained expectation. + let mut mutated = fixture_evidence(); + mutated.request_nonce = "B".repeat(43); + let header = json!({ + "alg": "EdDSA", + "kid": "evidence-key-1", + "typ": EVIDENCE_JWS_TYP, + "cty": EVIDENCE_JWS_CTY + }); + let (mutated_jws, public) = sign_with_protected_header(PRIVATE_JWK, header, &mutated).await; + let mutated_jwks = jwks_document(public, []).expect("JWKS builds"); + assert_eq!( + verify_flattened_jws(&mutated_jws, &mutated_jwks, &policy), + Err(VerificationError::Policy) + ); + + // The runtime does not store nonces, so verifying the same stored + // response twice with the same expectation succeeds. The nonce proves + // correlation with the retained request, never one-time use. + assert!(verify_flattened_jws(&jws, &jwks, &policy).is_ok()); + assert!(verify_flattened_jws(&jws, &jwks, &policy).is_ok()); + } + + #[tokio::test] + async fn expected_subject_set_is_unordered_unique_and_exact() { + let mut evidence = fixture_evidence(); + evidence.subjects = vec![ + SubjectBinding { + role: "child".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }, + SubjectBinding { + role: "candidate-parent".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "B".repeat(43)), + }, + ]; + let (jws, jwks, policy) = signed_evidence( + evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ) + .await; + + // Subject order alone is non-semantic. + let mut reordered = policy.clone(); + reordered.expected_subjects.reverse(); + assert!(verify_flattened_jws(&jws, &jwks, &reordered).is_ok()); + + // Missing, extra, duplicated, substituted, and wrong-key-version + // expectations all fail with the one generic policy mismatch. + let mut missing = policy.clone(); + missing.expected_subjects.pop(); + let mut extra = policy.clone(); + extra.expected_subjects.push(ExpectedSubject { + role: "witness".to_string(), + binding: format!("urn:evidence:subject:v1_{}", "C".repeat(43)), + }); + let mut duplicated = policy.clone(); + let first = duplicated.expected_subjects[0].clone(); + duplicated.expected_subjects.push(first); + let mut substituted = policy.clone(); + substituted.expected_subjects[0].binding = + format!("urn:evidence:subject:v1_{}", "D".repeat(43)); + let mut wrong_key_version = policy.clone(); + wrong_key_version.expected_subjects[0].binding = wrong_key_version.expected_subjects[0] + .binding + .replace(":v1_", ":v2_"); + let mut empty = policy.clone(); + empty.expected_subjects.clear(); + for broken in [ + missing, + extra, + duplicated, + substituted, + wrong_key_version, + empty, + ] { + assert_eq!( + verify_flattened_jws(&jws, &jwks, &broken), + Err(VerificationError::Policy) + ); + } + } + + #[tokio::test] + async fn expected_output_contract_is_exact_after_signature_verification() { + let (jws, jwks, policy) = signed_fixture().await; + + let mut missing = policy.clone(); + missing.expected_outputs.clear(); + let mut extra = policy.clone(); + extra.expected_outputs.push(ExpectedOutput { + concept: "urn:example:other-concept".to_string(), + form: ExpectedValueForm::Boolean, + }); + let mut duplicated = policy.clone(); + duplicated.expected_outputs.push(ExpectedOutput { + concept: policy.expected_outputs[0].concept.clone(), + form: ExpectedValueForm::Boolean, + }); + let mut wrong_concept = policy.clone(); + wrong_concept.expected_outputs[0].concept = "urn:example:unexpected".to_string(); + let mut wrong_form = policy.clone(); + wrong_form.expected_outputs[0].form = ExpectedValueForm::String; + let mut wrong_cardinality = policy.clone(); + wrong_cardinality.expected_outputs[0].form = ExpectedValueForm::List { + minimum_items: 2, + maximum_items: 4, + }; + for broken in [ + missing, + extra, + duplicated, + wrong_concept, + wrong_form, + wrong_cardinality, + ] { + assert_eq!( + verify_flattened_jws(&jws, &jwks, &broken), + Err(VerificationError::Policy) + ); + } + } + + #[tokio::test] + async fn authenticity_is_reported_separately_from_current_validity() { + // Verified after expiry: still authentic and policy-conformant, but + // not current evidence. + let (jws, jwks, mut policy) = signed_fixture().await; + policy.now = "2026-08-04T00:00:00Z".parse().expect("time parses"); + let report = verify_flattened_jws_report(&jws, &jwks, &policy) + .expect("expired assertion remains cryptographically authentic"); + assert!(!report.currently_valid); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + + // While current, both entry points agree. + let (jws, jwks, policy) = signed_fixture().await; + let report = verify_flattened_jws_report(&jws, &jwks, &policy).expect("report verifies"); + assert!(report.currently_valid); + + // A mutated payload is not authentic in either entry point. + let mut value: serde_json::Value = serde_json::from_slice(&jws).expect("JWS parses"); + let payload = value["payload"].as_str().expect("payload").to_string(); + value["payload"] = Value::String(format!("A{}", &payload[1..])); + let mutated = serde_json::to_vec(&value).expect("serializes"); + assert!(verify_flattened_jws_report(&mutated, &jwks, &policy).is_err()); + } + + #[tokio::test] + async fn assertion_lifetime_above_the_accepted_maximum_fails() { + let (jws, jwks, mut policy) = signed_fixture().await; + policy.maximum_assertion_lifetime = Duration::from_secs(60 * 60); + assert_eq!( + verify_flattened_jws(&jws, &jwks, &policy), + Err(VerificationError::Time) + ); + assert!(verify_flattened_jws_report(&jws, &jwks, &policy).is_err()); + } + + #[tokio::test] + async fn unsigned_envelope_is_rejected_by_the_strict_jws_verifier() { + let (_, jwks, policy) = signed_fixture().await; + let envelope = crate::model::UnsignedEvidenceEnvelope { + schema: crate::EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1.to_owned(), + envelope_type: crate::model::UnsignedEnvelopeType::UnsignedEvidenceEnvelope, + integrity_protection: crate::model::UnsignedIntegrityProtection::None, + warning: crate::model::UnsignedEnvelopeWarning::NotCryptographicallyVerifiable, + evidence: fixture_evidence(), + }; + let serialized = serde_json::to_vec(&envelope).expect("envelope serializes"); + assert_eq!( + verify_flattened_jws(&serialized, &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + assert!(verify_flattened_jws_report(&serialized, &jwks, &policy).is_err()); + } + #[tokio::test] async fn retired_public_key_verifies_only_while_published_and_payload_is_current() { let evidence = fixture_evidence(); diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 61892091f..087cad26d 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -2,11 +2,17 @@ use std::{ fs, + net::TcpStream, os::unix::fs::PermissionsExt as _, - path::Path, - process::{Command, Output}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + time::{Duration, Instant}, }; +/// A value planted in every mutated artifact so a diagnostic that leaks a +/// document value fails loudly instead of quietly. +const CANARY: &str = "s3cr3t-canary-value"; + #[test] fn actual_binary_checks_and_evaluates_an_immutable_project() { let staged = tempfile::tempdir().expect("temporary deployment"); @@ -62,6 +68,718 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { ); } +/// One deployment failure class, with the exact operator text it must produce. +/// +/// The expected text is split into a prefix and a suffix so a case that +/// reports a text location can pin the cause and the location shape without +/// pinning a line number that ordinary fixture edits would move. +struct FailureCase { + label: &'static str, + break_deployment: fn(&Deployment), + prefix: &'static str, + suffix: &'static str, +} + +#[test] +fn check_names_a_safe_artifact_and_a_value_free_cause_for_every_failure_class() { + let cases = [ + FailureCase { + label: "malformed bundle YAML", + break_deployment: |deployment| { + deployment.append("bundle/evidence.yaml", &format!("trailing: [{CANARY}\n")); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: document is not well-formed YAML (line ", + suffix: ")\n", + }, + FailureCase { + label: "unknown bundle field", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " principalClaim: sub\n", + &format!(" principalClaim: sub\n unknownField: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: unknown field at authentication (line ", + suffix: ")\n", + }, + FailureCase { + label: "wrong bundle field type", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + "version: 1\n", + &format!("version: \"{CANARY}\"\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: field has the wrong type at version (line ", + suffix: ")\n", + }, + FailureCase { + label: "unaccepted bundle field variant", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " kind: oidc-access-token\n", + &format!(" kind: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: field value is not one of the accepted variants at authentication.kind (line ", + suffix: ")\n", + }, + FailureCase { + label: "configuration cross-reference", + break_deployment: |deployment| { + deployment.replace( + "bundle/evidence.yaml", + " source: source-a\n", + &format!(" source: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact evidence.yaml: requirement references an unknown source\n", + suffix: "", + }, + FailureCase { + label: "artifact closure references a missing file", + break_deployment: |deployment| { + deployment.remove("bundle/derivations/adult-status.rhai"); + }, + prefix: "evidence: deployment artifact closure is invalid: artifact derivations/adult-status.rhai: the configuration references an artifact the bundle does not contain\n", + suffix: "", + }, + FailureCase { + label: "artifact closure carries an unreferenced file", + break_deployment: |deployment| { + deployment.write("bundle/schemas/orphan.schema.yaml", &format!("x: {CANARY}\n")); + }, + prefix: "evidence: deployment artifact closure is invalid: artifact schemas/orphan.schema.yaml: the bundle contains an artifact the configuration does not reference\n", + suffix: "", + }, + FailureCase { + label: "unsafe artifact name is never echoed", + break_deployment: |deployment| { + deployment.write( + &format!("bundle/fixtures/orphan {CANARY}.yaml"), + "synthetic_only: true\n", + ); + }, + prefix: "evidence: deployment artifact closure is invalid: the bundle contains an artifact the configuration does not reference\n", + suffix: "", + }, + FailureCase { + label: "script", + break_deployment: |deployment| { + deployment.append( + "bundle/derivations/adult-status.rhai", + &format!("\nthis is not rhai {CANARY}(((\n"), + ); + }, + prefix: "evidence: deployment script is invalid: artifact derivations/adult-status.rhai: script does not compile\n", + suffix: "", + }, + FailureCase { + label: "fact schema", + break_deployment: |deployment| { + deployment.write( + "bundle/schemas/adult-status-facts.schema.yaml", + &format!("type: [{CANARY}]\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact schemas/adult-status-facts.schema.yaml: fact schema must close the root object\n", + suffix: "", + }, + FailureCase { + label: "codelist", + break_deployment: |deployment| { + deployment.write( + "bundle/codelists/residence-region-map.yaml", + &format!("id: broken\nversion: \"1\"\nentries: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact codelists/residence-region-map.yaml: codelist YAML is invalid\n", + suffix: "", + }, + FailureCase { + label: "fixture", + break_deployment: |deployment| { + deployment.write( + "bundle/fixtures/adult-status-cases.yaml", + &format!("synthetic_only: true\ncases: {CANARY}\n"), + ); + }, + prefix: "evidence: deployment artifact is invalid: artifact fixtures/adult-status-cases.yaml: fixture cases are missing\n", + suffix: "", + }, + FailureCase { + label: "unknown runtime field", + break_deployment: |deployment| { + deployment.append("runtime.yaml", &format!("unknownField: {CANARY}\n")); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: unknown field (line ", + suffix: ")\n", + }, + FailureCase { + label: "wrong runtime field type", + break_deployment: |deployment| { + deployment.replace( + "runtime.yaml", + " port: 8080\n", + &format!(" port: \"{CANARY}\"\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: field has the wrong type at listener.port (line ", + suffix: ")\n", + }, + FailureCase { + label: "runtime operator path", + break_deployment: |deployment| { + deployment.replace_line( + "runtime.yaml", + "bundleDirectory: ", + &format!("bundleDirectory: relative/{CANARY}\n"), + ); + }, + prefix: "evidence: deployment configuration is invalid: artifact runtime.yaml: absolute operator path is invalid\n", + suffix: "", + }, + ]; + + for case in cases { + let deployment = Deployment::stage("all-definitions"); + (case.break_deployment)(&deployment); + let output = deployment.check(); + + assert!( + !output.status.success(), + "{}: check accepted a broken deployment", + case.label + ); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert!(stdout.is_empty(), "{}: check wrote output", case.label); + assert!( + stderr.starts_with(case.prefix) && stderr.ends_with(case.suffix), + "{}: unexpected diagnostic {stderr:?}", + case.label + ); + assert!( + !stdout.contains(CANARY) && !stderr.contains(CANARY), + "{}: diagnostic disclosed a document value", + case.label + ); + } +} + +/// The documented audit rotation procedure, executed against the real binary. +/// +/// The procedure is: stop the service with SIGTERM, archive the audit file by +/// rename, start the service again on the same path, and confirm readiness on +/// the new chain. This proves the stop and start-new-chain steps of the +/// operator procedure and the SIGTERM handling that makes the stop step +/// possible at all. +#[test] +fn serve_stops_on_sigterm_and_restarts_on_an_archived_audit_chain() { + let port = free_port(); + let deployment = Deployment::stage_on_port("all-definitions", port); + deployment.stage_acceptance_secrets(); + deployment.seal(); + + let mut service = deployment.serve(); + wait_until_ready(port); + let first = deployment.path("audit.jsonl"); + assert!(first.is_file(), "the service did not open an audit chain"); + stop(&mut service); + + // Archive by rename: the audit file must stay a singly linked owner-only + // regular file, so a copy-and-truncate rotation is not the procedure. + let archive = deployment.path("audit-archived.jsonl"); + fs::rename(&first, &archive).expect("archive the audit chain"); + assert!(!first.exists(), "the archived chain was left in place"); + + let mut restarted = deployment.serve(); + wait_until_ready(port); + assert!(first.is_file(), "the restart did not start a new chain"); + stop(&mut restarted); + + assert!(archive.is_file(), "the archived chain was disturbed"); + deployment.unseal(); +} + +/// The staged verification key identifier, echoed by the protected header. +const VERIFY_KEY_ID: &str = "verify-fixture-key"; + +/// A staged request nonce, of the exact 43-character request-nonce shape. +const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; + +/// A different nonce of the same shape, carrying the canary so a policy +/// diagnostic that echoed the expected value would fail loudly. +const CANARY_NONCE: &str = "s3cr3t-canary-value000000000000000000000000"; + +#[test] +fn verify_accepts_an_authentic_and_current_stored_response() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_eq!( + output.status.code(), + Some(0), + "verify rejected a good response" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.starts_with( + "verified-at: 2026-08-02T12:00:00Z\nauthentic: yes\ncurrently-valid: yes\n" + ), + "unexpected verification output {stdout:?}" + ); + assert!( + stdout.contains(&format!("\"requestNonce\": \"{FIXTURE_NONCE}\"")), + "verify did not print the verified Evidence for inspection" + ); +} + +#[test] +fn verify_separates_authenticity_from_current_validity() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + let output = stored.verify(Some("2026-08-05T00:00:00Z")); + + assert_eq!( + output.status.code(), + Some(3), + "an expired response did not report its own exit status" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + assert_eq!( + std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"), + "verified-at: 2026-08-05T00:00:00Z\nauthentic: yes\ncurrently-valid: no\n", + "an expired response must stay authentic without being current" + ); +} + +#[test] +fn verify_rejects_a_tampered_payload_without_naming_a_value() { + let mut tampered = fixture_evidence(); + tampered["supportedValues"][0]["value"] = serde_json::Value::String(CANARY.to_owned()); + let stored = StoredResponse::stage(&fixture_evidence(), &tampered, &fixture_policy()); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (signature)\n", + ); +} + +#[test] +fn verify_reports_only_the_generic_policy_class_for_a_wrong_expected_nonce() { + let policy = fixture_policy().replacen(FIXTURE_NONCE, CANARY_NONCE, 1); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (policy)\n", + ); +} + +#[test] +fn verify_rejects_a_policy_document_with_an_unknown_field() { + let policy = format!("{}unknownField: {CANARY}\n", fixture_policy()); + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &policy); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "", + "evidence: stored response verification failed (malformed)\n", + ); +} + +/// Assert one closed verification failure: exit 1, the chosen instant, the +/// expected remaining stdout, only the closed class on stderr, and no leaked +/// document value on either stream. +fn assert_verification_failure( + output: &Output, + instant: &str, + remaining_stdout: &str, + stderr: &str, +) { + assert_eq!(output.status.code(), Some(1), "verify accepted a bad input"); + let printed_out = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + let printed_err = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + printed_out, + format!("verified-at: {instant}\n{remaining_stdout}"), + "unexpected verification output" + ); + assert_eq!(printed_err, stderr, "unexpected verification diagnostic"); + assert!( + !printed_out.contains(CANARY) && !printed_err.contains(CANARY), + "verification disclosed a document value" + ); +} + +/// The stored Evidence payload the verify tests sign and re-verify. +fn fixture_evidence() -> serde_json::Value { + serde_json::json!({ + "schema": "registry.assertion-evidence/v1", + "requestNonce": FIXTURE_NONCE, + "id": "urn:ulid:01K1EXAMPLE0000000000000000", + "type": "Evidence", + "supportsRequirement": "urn:example:requirement:v1", + "isConformantTo": "urn:example:type:v1", + "issuedBy": "urn:example:issuer", + "providedBy": "urn:example:provider", + "issuedAt": "2026-08-02T00:00:00Z", + "observedAt": "2026-08-02T00:00:00Z", + "validUntil": "2026-08-03T00:00:00Z", + "purpose": "casework", + "audience": "urn:example:audience", + "configurationRevision": format!("sha256:{}", "0".repeat(64)), + "subjects": [{"role": "subject", "binding": format!("urn:evidence:subject:v1_{}", "A".repeat(43))}], + "supportedValues": [{"providesValueFor": "urn:example:concept", "value": false}], + }) +} + +/// The relying-procedure policy matching that payload. +/// +/// A real relying party builds this from independently retained trusted state. +/// The test simulates that state from the fixture it controls. +fn fixture_policy() -> String { + format!( + "issuedBy: urn:example:issuer +providedBy: urn:example:provider +requirement: urn:example:requirement:v1 +evidenceType: urn:example:type:v1 +purpose: casework +audience: urn:example:audience +configurationRevision: sha256:{revision} +requestNonce: {FIXTURE_NONCE} +expectedSubjects: + - role: subject + binding: urn:evidence:subject:v1_{binding} +expectedOutputs: + - concept: urn:example:concept + form: boolean +maximumAssertionLifetimeSeconds: 172800 +clockSkewSeconds: 30 +", + revision = "0".repeat(64), + binding = "A".repeat(43), + ) +} + +/// The three files an operator holds for offline re-verification: one stored +/// signed response, one pinned trusted key set, and one policy document. +struct StoredResponse { + root: tempfile::TempDir, +} + +impl StoredResponse { + /// Sign `signed`, store `stored` as the response payload, and stage + /// `policy`. Passing different payloads produces a tampered response whose + /// signature no longer covers the stored bytes. + fn stage(signed: &serde_json::Value, stored: &serde_json::Value, policy: &str) -> Self { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::Signer as _; + + let root = tempfile::tempdir().expect("temporary verification inputs"); + let key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let protected = URL_SAFE_NO_PAD.encode(format!( + r#"{{"alg":"EdDSA","kid":"{VERIFY_KEY_ID}","typ":"evidence+jws","cty":"application/evidence+json"}}"# + )); + let signed_payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(signed).expect("Evidence payload serializes")); + let signature = URL_SAFE_NO_PAD.encode( + key.sign(format!("{protected}.{signed_payload}").as_bytes()) + .to_bytes(), + ); + let stored_payload = URL_SAFE_NO_PAD + .encode(serde_json::to_vec(stored).expect("Evidence payload serializes")); + + fs::write( + root.path().join("response.jws.json"), + format!( + r#"{{"protected":"{protected}","payload":"{stored_payload}","signature":"{signature}"}}"# + ), + ) + .expect("stage the stored response"); + fs::write( + root.path().join("trusted.jwks.json"), + format!( + r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"{VERIFY_KEY_ID}","x":"{}"}}]}}"#, + URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()) + ), + ) + .expect("stage the pinned key set"); + fs::write(root.path().join("policy.yaml"), policy).expect("stage the policy"); + Self { root } + } + + /// Run `verify` with no runtime file staged, so the command proves it needs + /// no deployment and opens no socket. + fn verify(&self, at: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .arg("--jws") + .arg(self.root.path().join("response.jws.json")) + .arg("--jwks") + .arg(self.root.path().join("trusted.jwks.json")) + .arg("--policy") + .arg(self.root.path().join("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + if let Some(at) = at { + command.arg("--at").arg(at); + } + command.output().expect("evidence binary starts") + } +} + +fn stop(service: &mut Child) { + let pid = rustix::process::Pid::from_raw( + i32::try_from(service.id()).expect("child identifier is a pid"), + ) + .expect("child identifier is a pid"); + rustix::process::kill_process(pid, rustix::process::Signal::TERM).expect("send SIGTERM"); + let status = service.wait().expect("service exits"); + assert!( + status.success(), + "SIGTERM did not stop the service cleanly: {status}" + ); +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .expect("reserve a local port") + .local_addr() + .expect("reserved port") + .port() +} + +/// Poll `/ready` until the service reports a healthy audit chain. +/// +/// Readiness covers the subject-binding key, the signer, the audit chain head, +/// and every source credential, so a ready service proves the whole startup +/// path completed rather than only that a socket is open. +fn wait_until_ready(port: u16) { + let deadline = Instant::now() + Duration::from_secs(20); + let mut last = String::new(); + while Instant::now() < deadline { + if let Some(status) = probe(port, "/ready") { + if status == "HTTP/1.1 200 OK" { + return; + } + last = status; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!("the service never became ready (last status {last:?})"); +} + +fn probe(port: u16, path: &str) -> Option { + use std::io::{BufRead as _, BufReader, Write as _}; + + let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut status = String::new(); + BufReader::new(stream).read_line(&mut status).ok()?; + Some(status.trim_end().to_owned()) +} + +/// A staged deployment: one acceptance bundle, one operator runtime file, and +/// one private secret root under a single temporary directory. +struct Deployment { + root: tempfile::TempDir, + port: u16, +} + +impl Deployment { + fn stage(case: &str) -> Self { + Self::stage_on_port(case, 8080) + } + + fn stage_on_port(case: &str, port: u16) -> Self { + let deployment = Self { + root: tempfile::tempdir().expect("temporary deployment"), + port, + }; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance") + .join(case); + copy_tree(&source, &deployment.path("bundle")); + let secrets = deployment.path("secrets"); + fs::create_dir(&secrets).expect("create private secret root"); + fs::set_permissions(&secrets, fs::Permissions::from_mode(0o700)) + .expect("set private secret-root mode"); + fs::write( + deployment.path("runtime.yaml"), + deployment.runtime_document(), + ) + .expect("stage runtime"); + deployment + } + + fn path(&self, relative: &str) -> PathBuf { + self.root.path().join(relative) + } + + fn runtime_document(&self) -> String { + format!( + "version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {port} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 5000 +secretProviders: + file: + root: {secrets} +auditStorage: + path: {audit} + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {{}} +", + bundle = self.path("bundle").display(), + port = self.port, + secrets = self.path("secrets").display(), + audit = self.path("audit.jsonl").display(), + ) + } + + fn write(&self, relative: &str, contents: &str) { + fs::write(self.path(relative), contents).expect("write staged artifact"); + } + + fn append(&self, relative: &str, contents: &str) { + let path = self.path(relative); + let mut text = fs::read_to_string(&path).expect("read staged artifact"); + text.push_str(contents); + fs::write(path, text).expect("write staged artifact"); + } + + fn remove(&self, relative: &str) { + fs::remove_file(self.path(relative)).expect("remove staged artifact"); + } + + fn replace(&self, relative: &str, from: &str, to: &str) { + let path = self.path(relative); + let text = fs::read_to_string(&path).expect("read staged artifact"); + assert!(text.contains(from), "staged artifact has no {from:?}"); + fs::write(path, text.replacen(from, to, 1)).expect("write staged artifact"); + } + + fn replace_line(&self, relative: &str, prefix: &str, line: &str) { + let path = self.path(relative); + let text = fs::read_to_string(&path).expect("read staged artifact"); + let replaced = text + .lines() + .map(|current| { + if current.starts_with(prefix) { + line.to_owned() + } else { + format!("{current}\n") + } + }) + .collect::(); + assert_ne!(replaced, text, "staged artifact has no {prefix:?} line"); + fs::write(path, replaced).expect("write staged artifact"); + } + + fn write_secret(&self, name: &str, value: &str) { + let path = self.path("secrets").join(name); + fs::write(&path, value).expect("write staged secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only secret mode"); + } + + /// Stage every logical secret the acceptance bundle references. + /// + /// The signing key is generated for this run so no private key material is + /// tracked, and the source credentials are synthetic constants that never + /// reach a network because the test performs no evidence request. + fn stage_acceptance_secrets(&self) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"fixture-key-2026-01","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + self.write_secret("audit-hash-key", "audit-hash-secret-32-bytes-minimum-value"); + self.write_secret( + "subject-binding-key", + "subject-binding-secret-32-bytes-minimum-value", + ); + self.write_secret("signing-key", &private_jwk); + self.write_secret("source-a-token", "synthetic-source-token"); + self.write_secret("source-b-token", "synthetic-source-token"); + self.write_secret("source-c-username", "synthetic-source-user"); + self.write_secret("source-c-password", "synthetic-source-password"); + self.write_secret("source-d-token", "synthetic-source-token"); + } + + /// Start `serve` against the sealed deployment. + fn serve(&self) -> Child { + Command::new(env!("CARGO_BIN_EXE_evidence")) + .arg("--runtime") + .arg(self.path("runtime.yaml")) + .arg("serve") + .env_remove("REGISTRY_EVIDENCE_RUNTIME") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("evidence service starts") + } + + /// Run `check` against the sealed deployment, then restore write access so + /// the temporary directory can be cleaned up. + fn check(&self) -> Output { + self.seal(); + let output = invoke(&self.path("runtime.yaml"), &["check"]); + self.unseal(); + output + } + + fn seal(&self) { + set_tree_mode(&self.path("bundle"), 0o555, 0o444); + fs::set_permissions(self.path("runtime.yaml"), fs::Permissions::from_mode(0o444)) + .expect("seal runtime"); + } + + fn unseal(&self) { + set_tree_mode(&self.path("bundle"), 0o755, 0o644); + fs::set_permissions(self.path("runtime.yaml"), fs::Permissions::from_mode(0o644)) + .expect("unseal runtime"); + } +} + +impl Drop for Deployment { + fn drop(&mut self) { + if self.path("bundle").is_dir() { + set_tree_mode(&self.path("bundle"), 0o755, 0o644); + } + } +} + fn invoke(runtime: &Path, arguments: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_evidence")) .arg("--runtime") diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs index 519dce550..eb90b7be2 100644 --- a/crates/registry-evidence/tests/deployment_projects.rs +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -555,6 +555,7 @@ async fn execute_response( values, EvidenceConstruction { evidence_id: &evidence_id, + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, purpose: &resolved.purpose, audience: AUDIENCE, issued_at, @@ -569,21 +570,25 @@ async fn execute_response( .unwrap_or_else(|_| panic!("{label}: evidence signing failed")); let jwks = jwks_document(signer.public_jwk(), []) .unwrap_or_else(|_| panic!("{label}: fixture JWKS construction failed")); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + issued_at, + Duration::from_secs(0), + ); + policy.issued_by = bundle.config.issuer.id.clone(); + policy.provided_by = bundle.config.service.provider_id.clone(); + policy.requirement = requirement.id.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = resolved.purpose.clone(); + policy.audience = AUDIENCE.to_owned(); + policy.configuration_revision = bundle.revision().to_owned(); let verified = verify_flattened_jws( &serde_json::to_vec(&signed) .unwrap_or_else(|_| panic!("{label}: signed evidence encoding failed")), &jwks, - &EvidenceVerificationPolicy { - issued_by: bundle.config.issuer.id.clone(), - provided_by: bundle.config.service.provider_id.clone(), - requirement: requirement.id.clone(), - evidence_type: requirement.evidence_type.clone(), - purpose: resolved.purpose.clone(), - audience: AUDIENCE.to_owned(), - configuration_revision: bundle.revision().to_owned(), - now: issued_at, - clock_skew: Duration::from_secs(0), - }, + &policy, ) .unwrap_or_else(|_| panic!("{label}: signed evidence verification failed")); if expected.signed == Some(false) { @@ -849,6 +854,10 @@ fn assert_kernel_error(label: &str, expected: &Expected, error: KernelError, der let (expected_signal, expected_problem) = match error { KernelError::Preparation => ("adapter_input_error", "service_unavailable"), KernelError::SourceProtocol => ("source_protocol_error", "dependency_unavailable"), + // The public class collapses with the unresolved lookup classes so a + // uniquely found record with inconsistent derivation inputs is not + // distinguishable from no match. + KernelError::DerivationInput => ("derivation_input_error", "evidence_not_available"), KernelError::Script if derivation_ran => ("derivation_input_error", "service_unavailable"), KernelError::Extraction => ("evidence_not_available", "evidence_not_available"), _ => ("service_unavailable", "service_unavailable"), diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index 19ec93fb5..5f90638c0 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeSet, fs, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; use serde::Deserialize; @@ -44,6 +48,58 @@ struct TestReference { name: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcceptanceTraceability { + contract: String, + entries: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcceptanceEntry { + id: String, + summary: String, + tests: Vec, + /// Names the residual gap when executable tests only partially prove a row. + #[serde(default)] + note: Option, +} + +/// The conformance coverage index. Only the fields consumed by this checker are +/// modelled; the index carries further frozen sections owned by other tests. +#[derive(Deserialize)] +struct CoverageIndex { + categories: Vec, + acceptance_definitions: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CoverageDefinition { + definition: String, + bundle: String, + cases: String, + selector: String, + posture: String, + supported_values: Vec, + coverage: BTreeMap, +} + +#[derive(Deserialize)] +struct CasesFixture { + cases: Vec, +} + +#[derive(Deserialize)] +struct CaseEntry { + id: String, + /// A companion bundle name is how an anti-reconstruction case is addressed + /// by the coverage index, because the case itself is a bundle rejection. + #[serde(default)] + companion_bundle: Option, +} + #[test] fn every_named_security_negative_is_bound_to_an_executable_test() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); @@ -101,35 +157,210 @@ fn every_named_security_negative_is_bound_to_an_executable_test() { "{} has no executable test", entry.id ); - for test in entry.tests { + for test in &entry.tests { + assert_reference_is_an_executable_test(&root, &entry.id, test); + } + } + assert_eq!(mapped, required, "security negative-test mapping drifted"); +} + +/// Prove that one mapped reference still names a real Rust test item, so a +/// renamed, moved, or deleted test fails the traceability checker. +fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { + assert!( + test.file.starts_with("crates/registry-evidence/") + && test.file.ends_with(".rs") + && !test.file.contains(".."), + "{entry_id} has an unsafe source reference" + ); + let source = fs::read_to_string(root.join(&test.file)) + .unwrap_or_else(|_| panic!("{entry_id} source file is missing")); + let signature = format!("fn {}(", test.name); + assert!( + source.contains(&signature), + "{entry_id} points to missing Rust test {}", + test.name + ); + let item_start = source + .find(&signature) + .expect("test signature was just found"); + let prefix = &source[..item_start]; + let attribute_window = &prefix[prefix.len().saturating_sub(160)..]; + assert!( + attribute_window.contains("#[test]") || attribute_window.contains("#[tokio::test]"), + "{entry_id} reference {} is not a test item", + test.name + ); +} + +#[test] +fn every_acceptance_row_is_bound_to_an_executable_test() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let traceability: AcceptanceTraceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/acceptance-test-traceability.yaml")) + .expect("acceptance traceability reads"), + ) + .expect("acceptance traceability parses"); + assert_eq!( + traceability.contract, + "registry.evidence.acceptance-test-traceability/v1" + ); + + let expected = (1..=62) + .map(|row| format!("acceptance-row-{row:02}")) + .collect::>(); + let mapped = traceability + .entries + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + assert_eq!( + mapped, expected, + "acceptance row mapping is not the 62 required rows in order" + ); + + for entry in &traceability.entries { + assert!( + !entry.summary.trim().is_empty(), + "{} has no summary", + entry.id + ); + if let Some(note) = &entry.note { assert!( - test.file.starts_with("crates/registry-evidence/") - && test.file.ends_with(".rs") - && !test.file.contains(".."), - "{} has an unsafe source reference", + !note.trim().is_empty(), + "{} has an empty residual-gap note", entry.id ); - let source = fs::read_to_string(root.join(&test.file)) - .unwrap_or_else(|_| panic!("{} source file is missing", entry.id)); - let signature = format!("fn {}(", test.name); + } + assert!( + !entry.tests.is_empty(), + "{} has no executable test", + entry.id + ); + let mut referenced = BTreeSet::new(); + for test in &entry.tests { assert!( - source.contains(&signature), - "{} points to missing Rust test {}", + referenced.insert((test.file.as_str(), test.name.as_str())), + "{} repeats the reference {}", entry.id, test.name ); - let item_start = source - .find(&signature) - .expect("test signature was just found"); - let prefix = &source[..item_start]; - let attribute_window = &prefix[prefix.len().saturating_sub(160)..]; + assert_reference_is_an_executable_test(&root, &entry.id, test); + } + } + + assert_coverage_index_resolves(&root); +} + +/// The conformance coverage index is an input to acceptance traceability: every +/// acceptance definition must still point at real bundle and case artifacts, +/// and every case named by a coverage category must still exist in them. +fn assert_coverage_index_resolves(root: &Path) { + let fixtures = root.join("products/evidence/fixtures"); + let index: CoverageIndex = serde_norway::from_slice( + &fs::read(fixtures.join("conformance/coverage-matrix.yaml")).expect("coverage index reads"), + ) + .expect("coverage index parses"); + let categories = index + .categories + .iter() + .cloned() + .collect::>(); + assert_eq!( + categories.len(), + index.categories.len(), + "coverage index repeats a category" + ); + assert!( + !index.acceptance_definitions.is_empty(), + "coverage index names no acceptance definition" + ); + + let mut seen = BTreeSet::new(); + for definition in &index.acceptance_definitions { + assert!( + seen.insert(definition.definition.clone()), + "coverage index repeats definition {}", + definition.definition + ); + assert!( + !definition.selector.is_empty() + && !definition.posture.is_empty() + && !definition.supported_values.is_empty(), + "{} has an incomplete coverage declaration", + definition.definition + ); + assert!( + fixture_path(&fixtures, &definition.definition, &definition.bundle).is_file(), + "{} names a missing bundle {}", + definition.definition, + definition.bundle + ); + let cases_path = fixture_path(&fixtures, &definition.definition, &definition.cases); + let cases: CasesFixture = + serde_norway::from_slice(&fs::read(&cases_path).unwrap_or_else(|_| { + panic!("{} names a missing case fixture", definition.definition) + })) + .unwrap_or_else(|_| panic!("{} case fixture parses", definition.definition)); + + let mut addressable = BTreeSet::new(); + for case in &cases.cases { + addressable.insert(case.id.clone()); + if let Some(companion) = &case.companion_bundle { + addressable.insert(companion.clone()); + } + } + assert_eq!( + definition.coverage.keys().cloned().collect::>(), + categories, + "{} does not cover exactly the declared categories", + definition.definition + ); + for (category, named) in &definition.coverage { + let names = match named { + serde_norway::Value::String(one) => vec![one.as_str()], + serde_norway::Value::Sequence(many) => many + .iter() + .map(|value| { + value.as_str().unwrap_or_else(|| { + panic!( + "{}/{category} names a non-string case", + definition.definition + ) + }) + }) + .collect(), + _ => panic!( + "{}/{category} is neither one case nor a case list", + definition.definition + ), + }; assert!( - attribute_window.contains("#[test]") || attribute_window.contains("#[tokio::test]"), - "{} reference {} is not a test item", - entry.id, - test.name + !names.is_empty(), + "{}/{category} names no case", + definition.definition ); + for name in names { + assert!( + addressable.contains(name), + "{}/{category} names {name}, which is absent from {}", + definition.definition, + cases_path.display() + ); + } } } - assert_eq!(mapped, required, "security negative-test mapping drifted"); +} + +/// Resolve one coverage-index path, which is written relative to the +/// `conformance` directory, without allowing it to escape the fixture tree. +fn fixture_path(fixtures: &Path, definition: &str, relative: &str) -> std::path::PathBuf { + let inside = relative + .strip_prefix("../") + .unwrap_or_else(|| panic!("{definition} path {relative} is not fixture-relative")); + assert!( + inside.ends_with(".yaml") && !inside.contains("..") && !inside.starts_with('/'), + "{definition} path {relative} is unsafe" + ); + fixtures.join(inside) } diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index a01c5e444..7e6789814 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -13,7 +13,7 @@ use chrono::Utc; use jsonwebtoken::{jwk::JwkSet, Algorithm}; use registry_evidence::audit::{ AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind as AuditAuthorityKind, - EvidenceAuditEvent, EvidenceAuditLog, + EvidenceAuditEvent, EvidenceAuditLog, ResponseProtection, }; use registry_evidence::auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}; use registry_evidence::bundle::{Bundle, BundleError, DeploymentInputs}; @@ -22,7 +22,7 @@ use registry_evidence::kernel::{ EvidenceConstruction, KernelOutcome, OfflineKernel, ValueProjection, }; use registry_evidence::model::{ - EvidenceRequest, FlattenedJws, RequestedSelector, RequestedSubject, SelectorValue, + Evidence, EvidenceRequest, FlattenedJws, RequestedSelector, RequestedSubject, SelectorValue, SubjectBinding, }; use registry_evidence::secrets::{SecretProvider, SecretResolver}; @@ -117,6 +117,7 @@ impl PreparedService { requester.clone(), authority.clone(), audit_subjects.clone(), + ResponseProtection::Signed, AuditDecision::Authorized, 0, ); @@ -200,6 +201,7 @@ impl PreparedService { values, EvidenceConstruction { evidence_id: &evidence_id, + request_nonce: &request.request_nonce, purpose: &request.purpose, audience: context.evidence_audience(), issued_at, @@ -228,6 +230,7 @@ impl PreparedService { requester, authority, audit_subjects, + ResponseProtection::Signed, AuditDecision::Released, 0, ); @@ -368,20 +371,30 @@ async fn every_selector_profile_runs_the_complete_signed_service_path() { .iter() .find(|candidate| candidate.id == request.requirement) .expect("requirement is configured"); + let unverified: Evidence = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"), + ) + .expect("payload parses for expectations"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &unverified, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + policy.issued_by = service.bundle.config.issuer.id.clone(); + policy.provided_by = service.bundle.config.service.provider_id.clone(); + policy.requirement = request.requirement.clone(); + policy.evidence_type = requirement.evidence_type.clone(); + policy.purpose = request.purpose.clone(); + policy.audience = EVIDENCE_AUDIENCE.to_owned(); + policy.configuration_revision = service.bundle.revision().to_owned(); let evidence = verify_flattened_jws( &serialized, &jwks_document(service.signer.public_jwk(), []).expect("JWKS builds"), - &EvidenceVerificationPolicy { - issued_by: service.bundle.config.issuer.id.clone(), - provided_by: service.bundle.config.service.provider_id.clone(), - requirement: request.requirement.clone(), - evidence_type: requirement.evidence_type.clone(), - purpose: request.purpose.clone(), - audience: EVIDENCE_AUDIENCE.to_owned(), - configuration_revision: service.bundle.revision().to_owned(), - now: Utc::now(), - clock_skew: Duration::from_secs(30), - }, + &policy, ) .expect("signed service result verifies under the relying policy"); assert_eq!( @@ -1162,6 +1175,7 @@ fn opaque_request(values: Option>) -> EvidenceRe fn request(requirement: &str, subjects: Vec) -> EvidenceRequest { EvidenceRequest { + request_nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(), requirement: requirement.to_owned(), purpose: PURPOSE.to_owned(), subjects, diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index 188a706a9..bf4a5c9a2 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -6,6 +6,7 @@ use std::fs; use std::os::unix::fs::PermissionsExt as _; use std::path::Path; use std::process::Command; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -14,7 +15,7 @@ use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair}; use registry_evidence::bundle::{Bundle, BundleError, RuntimeDocument}; use registry_evidence::config::{ AcquisitionPosture, HttpMethod, OutboundTlsConfig, PreparationChannelPolicy, PreparationLimits, - SourceConfig, + SourceConfig, RESERVED_HEADER_CONTRACT_CASES, }; use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; use registry_evidence::model::{LookupResult, PublicValue, SelectorValue, SubjectBinding}; @@ -384,6 +385,31 @@ async fn spawn_private_ca_tls_server( ) } +/// Accepts TCP connections, counts each one, and resets it (`RST`, not a +/// graceful close) before any HTTP bytes are exchanged. Used to prove that a +/// transport failure on a source's very first connection attempt does not +/// trigger a second, silent connection. +async fn spawn_reset_on_connect_server() -> (std::net::SocketAddr, Arc, JoinHandle<()>) +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("reset server binds"); + let address = listener.local_addr().expect("reset server address"); + let attempts = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&attempts); + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + counted.fetch_add(1, Ordering::SeqCst); + let _ = stream.set_zero_linger(); + drop(stream); + } + }); + (address, attempts, handle) +} + #[tokio::test] async fn exact_request_applies_path_query_body_headers_auth_and_projection_once() { let server = MockServer::start().await; @@ -1109,6 +1135,7 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a values, EvidenceConstruction { evidence_id: "urn:ulid:01J4BRXQ0ZZZZZZZZZZZZZZZZZ", + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, purpose: "fixture-routing", audience: "https://relying.invalid/residence-procedure", issued_at: observed_at, @@ -1125,22 +1152,22 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a .await .expect("real residence Evidence signs"); let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); - let verified = verify_flattened_jws( - &serialized, - &jwks, - &EvidenceVerificationPolicy { - issued_by: "urn:example:fixture:issuer:authority".to_owned(), - provided_by: "urn:example:fixture:provider:evidence".to_owned(), - requirement: requirement.to_owned(), - evidence_type: "urn:example:fixture:evidence-type:residence-region:v1".to_owned(), - purpose: "fixture-routing".to_owned(), - audience: "https://relying.invalid/residence-procedure".to_owned(), - configuration_revision: kernel.bundle().revision().to_owned(), - now: observed_at, - clock_skew: Duration::from_secs(0), - }, - ) - .expect("signed residence Evidence verifies under the exact relying policy"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + observed_at, + Duration::from_secs(0), + ); + policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); + policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); + policy.requirement = requirement.to_owned(); + policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); + policy.purpose = "fixture-routing".to_owned(); + policy.audience = "https://relying.invalid/residence-procedure".to_owned(); + policy.configuration_revision = kernel.bundle().revision().to_owned(); + let verified = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("signed residence Evidence verifies under the exact relying policy"); assert_eq!( verified.supported_values[0].value, PublicValue::String("REGION-NORTH".to_owned()) @@ -1828,40 +1855,7 @@ async fn source_executor_failure_matrix_is_exact_single_request_and_value_free() #[test] fn forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation() { let (_root, secrets) = resolver(&[("key", "secret")]); - for header_name in [ - "Authorization", - "Proxy-Authorization", - "Host", - "Cookie", - "Set-Cookie", - "Content-Length", - "Content-Type", - "Transfer-Encoding", - "Expect", - "Connection", - "Keep-Alive", - "TE", - "Trailer", - "Upgrade", - "Proxy-Connection", - "Forwarded", - "Via", - "X-Real-IP", - "X-Forwarded-For", - "X-Forwarded-Proto", - "Proxy-Authenticate", - "Traceparent", - "Tracestate", - "Baggage", - "X-Request-ID", - "X-Correlation-ID", - "X-Amzn-Trace-ID", - "X-Original-URL", - "X-Rewrite-URL", - "X-HTTP-Method-Override", - "X-Original-Method", - "X-B3-TraceId", - ] { + for header_name in RESERVED_HEADER_CONTRACT_CASES { let source = source_config( "http://127.0.0.1:18080", json!({"kind": "static-api-key", "headerName": "X-Api-Key", "valueRef": "secret:file/key"}), @@ -1871,7 +1865,8 @@ fn forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilat ); assert_eq!( SourceExecutor::new(&source, Arc::clone(&secrets)).err(), - Some(SourceError::InvalidPlan) + Some(SourceError::InvalidPlan), + "reserved fixed header {header_name} is rejected" ); } let duplicate = source_config( @@ -1901,15 +1896,7 @@ fn forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilat Some(SourceError::InvalidPlan), "fixed and authentication headers cannot collide" ); - for api_key_header in [ - "Authorization", - "Host", - "Content-Type", - "X-Forwarded-For", - "Proxy-Authenticate", - "Traceparent", - "X-B3-TraceId", - ] { + for api_key_header in RESERVED_HEADER_CONTRACT_CASES { let source = source_config( "http://127.0.0.1:18080", json!({"kind": "static-api-key", "headerName": api_key_header, "valueRef": "secret:file/key"}), @@ -2015,6 +2002,36 @@ async fn private_ca_tls_handshake_succeeds_and_hostname_mismatch_fails() { .expect("mismatched TLS server task completes"); } +#[tokio::test] +async fn a_reset_transport_failure_yields_exactly_one_connection_attempt() { + let (address, attempts, server) = spawn_reset_on_connect_server().await; + let source = fixed_source( + &format!("http://127.0.0.1:{}", address.port()), + json!({"kind": "static-bearer", "tokenRef": "secret:file/token"}), + ); + let (_root, secrets) = resolver(&[("token", "token")]); + let result = SourceExecutor::new(&source, secrets) + .expect("reset-transport source compiles") + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await; + assert_eq!(result, Err(SourceError::Transport)); + // Give the listener a moment to observe a second connection attempt, if + // one were made, before asserting the final count. + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "a reset transport failure must not be retried" + ); + server.abort(); +} + #[test] fn private_ca_plan_rejects_unbound_missing_and_malformed_captures() { let directory = tempfile::tempdir().expect("temporary TLS directory"); diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md new file mode 100644 index 000000000..e5dc06fca --- /dev/null +++ b/products/evidence/AGENTS.md @@ -0,0 +1,163 @@ +# Evidence agent guidance + +Read the repository-root `AGENTS.md` first. Before changing Evidence, read these +files completely in order: + +1. `products/evidence/CONCEPT.md` +2. `products/evidence/IMPLEMENTATION.md` +3. `products/evidence/SOURCE-TESTING.md` +4. `products/evidence/OPERATOR-CONTRACT.md` +5. `products/evidence/reference/request-adapter/ADAPTER-API.md` +6. `products/evidence/reference/request-adapter/deployment-projects/CONFIG.md` +7. `products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md` + +Read the normative files relevant to the change under `contracts/` and the +code-generated JSON Schema and OpenAPI artifacts under `generated/` after those +four product-level contracts. Generated artifacts must be reproduced with +`scripts/check-contracts.sh` and never hand-edited. + +## Product boundary + +Evidence Version 1 is one `registry-evidence` crate, one `evidence` binary, one +process, and one operator-controlled trust domain. It is independent from +Registry Notary and must not depend on or copy abstractions from +`registry-notary*`. It also does not depend on Registry Manifest, +`registry-platform-pdp`, `registry-platform-oid4vci`, +`registry-platform-sdjwt`, `registry-platform-replay`, or +`registry-platform-sts`. + +Selected `registry-platform-*` primitives may be reused only when their existing +contracts fit Evidence directly. The approved candidates are audit, crypto, +OIDC, HTTP security, and testing primitives. Shared-crate changes are separate +platform work and require the platform guidance and affected-consumer gates. + +Production behavior must stay source-product and assertion-case neutral: + +- no DHIS2 or OpenCRVS module, dependency, feature, public configuration field, + route, CLI option, public contract variant, or production branch; +- no adult, age, residence, licence, parentage, personal-name-part, national + identifier, or jurisdiction-specific Rust domain type or operation; +- no broad candidate retrieval, scoring, fuzzy matching, normalization, + transliteration, phonetics, or candidate selection; a reviewed derivation + may perform an exact deterministic comparison between an independently + authorized selector and complete facts from one uniquely resolved + authoritative record; +- no future-profile stub, placeholder schema, module, feature, extension hook, + or empty API. + +The four acceptance definitions are coequal. Every phase preserves all four, +and completion means every Definition of Done row passes on one revision. + +## Trust boundary + +Governed configuration, Rhai scripts, schemas, codelists, and fixtures are one +trusted, immutable, startup-only bundle. A separate closed runtime file owns +only process-local listener, filesystem, audit-storage, secret-mount, and TLS +trust bindings and cannot override the bundle. Rust owns authentication, +authorization, selector validation and minimization, credentials, fixed +networking, path/header authority, response projection, script capabilities +and limits, output validation, evidence construction, signing, and audit. Rhai +owns only bounded query/body preparation, extraction, and +requirement-specific derivation through the approved closed ABIs. + +Before code that touches authentication, authorization, disclosure, audit, +configuration trust, signing, source credentials, or selectors, apply the +`enforcing-security-invariants` guidance. For every affected invariant, record: + +- the threat; +- the Rust enforcement point; +- the focused negative test that pins it. + +Principal derivation uses only the configured claim and denies if it is absent. +Caller values never create authority. Missing, extra, mistyped, oversized, +unauthorized, or wrong-origin selector values fail before credential +acquisition or source access. Access audit is durably accepted before source +access. Disclosure audit is durably accepted after signing and before release. +Signing and audit failures are fail-closed. + +Never commit, log, snapshot, pass on a command line, or place in an error: +credentials, tokens, live responses, raw selector values, per-field hashes of +low-entropy selectors, source values, Supported Values, demo-subject +identifiers, or human login and two-factor details. + +## Phase discipline and commands + +Run commands from the monorepo root unless a command says otherwise. Commands +for a later phase are required interfaces to add in that phase and are not +claims that an unimplemented command currently exists. + +Phase 0, approved documentation and contracts: + +```sh +git diff --check -- products/evidence +git diff -- products/evidence +``` + +Phase 1 and every later package exit: + +```sh +cargo fmt --check +cargo check --locked -p registry-evidence --all-targets +cargo test --locked -p registry-evidence +cargo clippy -p registry-evidence --all-targets -- -D warnings +``` + +Phase 2 source boundary, after the package gate: + +```sh +cargo test --locked -p registry-evidence --test source_contracts +products/evidence/scripts/check-source-neutrality.sh +``` + +Phase 3 reruns the package and source-boundary gates and adds focused negative +tests for every authentication, authority, audit, signing, and privacy +invariant changed in that phase. A phase cannot exit with an unnamed or +unmapped security invariant. + +Phase 4 generated public contracts, after the package gate: + +```sh +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` + +Phase 5 optional public-demo smoke tests run only after the deterministic +package and source-contract suite passes: + +```sh +cargo test --locked -p registry-evidence +cargo test --locked -p registry-evidence --test live_sources dhis2 -- --ignored +cargo test --locked -p registry-evidence --test live_sources opencrvs -- --ignored +``` + +The live commands are read-only, local, non-gating, and must skip when approved +selectors or securely stored credentials are unavailable. Follow +`SOURCE-TESTING.md`; do not improvise credentials, selectors, or broader +queries. + +Phase 6 final gate: + +```sh +cargo fmt --check +cargo metadata --locked --format-version 1 +cargo check --locked --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --locked --workspace +cargo deny check +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` + +Use the repository Cargo wrapper if one is added. Otherwise, in Codex-managed +worktrees set `CARGO_INCREMENTAL=0`, `CARGO_PROFILE_DEV_DEBUG=0`, and +`CARGO_PROFILE_TEST_DEBUG=0` for Cargo check, test, and Clippy commands, as +required by the root guidance. + +## Completion review + +Before claiming a phase complete, review the whole phase diff against its exit +gate, the security invariant matrix, all four acceptance definitions, the +source-product-neutrality boundary, and the non-goals in `CONCEPT.md` sections +4 and 15. Report commands run, results, skipped commands with exact reasons, +changed files, and remaining risks. Do not weaken a pinning test to make a gate +pass. diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md new file mode 100644 index 000000000..66ba7434b --- /dev/null +++ b/products/evidence/CONCEPT.md @@ -0,0 +1,1560 @@ +# Evidence: Minimum-Disclosure Assertion Service + +Status: Approved Version 1 product contract +Date: 2026-08-02 +Audience: Product, architecture, privacy, interoperability, and implementation stakeholders + +Companion implementation note: [IMPLEMENTATION.md](IMPLEMENTATION.md) +Companion source-testing note: [SOURCE-TESTING.md](SOURCE-TESTING.md) + +## Executive summary + +Evidence is a small, sector-neutral service for producing minimum-disclosure assertion evidence from authoritative data sources. It is designed first for government deployments with constrained operational capacity, while remaining useful to EU public administrations and private-sector organizations modernizing existing applications. + +In this note, **Evidence** names the product. Lowercase **evidence** and **assertion evidence** name the CCCEV-aligned domain object it produces. + +Evidence is a greenfield product concept. It is not a rewrite, replacement mode, or reduced configuration of Registry Notary and does not inherit that product's architecture or feature set. + +Given authenticated authority, an authorized purpose, a fixed requirement, and +the configured selector data needed by an authoritative provider, Evidence +obtains the necessary facts and returns the smallest sufficient JSON assertion. +An assertion may describe a property, classification, eligibility decision, +status, or relationship involving one or more role-bound subjects. A national +identifier is one possible selector, not a prerequisite. + +The service is deliberately narrower than a data governance platform, API gateway, identity-matching service, workflow engine, credential suite, or policy decision platform. One service process may host many evidence definitions when they share one operator-controlled trust domain. Governed configuration, scripts, schemas, codelists, and fixtures form one trusted, atomic evidence bundle. A separate closed runtime file binds that bundle to process-local listener, filesystem, audit-storage, secret-mount, and TLS-trust paths without overriding evidence semantics. + +JSON is the native API and evidence representation. Requirements and evidence are aligned with CCCEV, using a documented Evidence JSON profile rather than RDF or XML. YAML declares fixed requirements, authorization conditions, source requests, trusted derivation parameters, concepts, and disclosure forms. Trusted Rhai scripts execute inside the process to extract typed facts and derive declared concept values from a deterministic evaluation context. Rust retains control of authentication, authorization, networking, credentials, script capabilities and limits, output validation, disclosure enforcement, evidence construction, signing, and audit. + +Version one produces assertion evidence with signed flattened JWS as the +default and durable-verification format. A governed authority grant may also +permit an explicitly requested, visibly unsigned JSON envelope for development +or consumers that cannot process JWS. Unsigned output is transport-authenticated +convenience data, not later-verifiable evidence and never a fallback from +signing failure. Evidence does not retrieve or deliver documents, issue holder +credentials, run a general policy engine, expose a public or dynamic catalog, or +implement OOTS. It does expose an authenticated, requester-scoped description of +complete request shapes already authorized by the deployed bundle. Those +deferred capabilities remain separate future profiles and must not shape the +initial runtime beyond stable identifiers and transport-neutral domain objects. + +Version one is accepted against four coequal initial assertion cases: adult status, controlled residence region, professional licence status, and legal-parent relationship. All four must pass the complete production path before the public contracts freeze. None is an implementation slice, privileged reference case, or part of the Rust domain model. + +## 1. Product thesis + +> Given authenticated authority, an authorized purpose, a predefined CCCEV-aligned requirement, and an authorized configured selector for each subject role, Evidence returns the smallest sufficient assertion in an authorized response format and persists no unnecessary source data. + +The unit of behavior is a versioned requirement, not an arbitrary query. A requirement declares: + +- its stable identity and revision; +- the legal, procedural, or contractual context in which it is meaningful; +- the purposes and requester classes that may invoke it; +- the subject roles, allowed selector profiles, and value origins that must be + authorized; +- the semantic concepts it evaluates or provides; +- the authoritative source and fixed source request; +- the typed facts extracted from the source response; +- the trusted, requirement-specific Rhai derivation and its fixed parameters; +- the exact disclosure form permitted for each concept; +- the Evidence Type to which the result conforms; +- its observation, validity, audit, and failure rules. + +Callers choose only among definitions, purposes, and selector profiles for +which they are already authorized. They may supply only the closed selector +values allowed by that authority path. They cannot supply expressions, +thresholds, field names, operators, JSON paths, source fields, scripts, response +projections, relationship types, or matching rules. + +## 2. Target deployments + +The core product is government-first and sector-neutral. + +### Native government deployment + +One binary runs on modest infrastructure, uses locally controlled configuration and audit sinks, and integrates with existing registries. It does not require Kubernetes, a message broker, a database, OPA, or a service mesh. + +### Enterprise and private-sector deployment + +Evidence runs behind an existing gateway or identity boundary and produces assertions such as age eligibility, organization status, professional authorization, insurance coverage, or supplier compliance. It does not become a multi-tenant governance platform. + +### EU deployment + +The same core may later sit behind an OOTS Data Service boundary. OOTS RegRep XML, Evidence Broker and DSD registration, Semantic Repository profiles, preview, AS4, and OOTS retention rules remain in an explicit interoperability profile. + +### Delegated software agents + +AI agents may later invoke fixed evidence operations under an external, task-bound authority grant. The agent is an authenticated actor, not the source of authority. Agent protocols and orchestration remain outside the core. + +These are profiles around one evidence engine, not separate product editions. + +## 3. Goals + +Evidence should: + +- produce structured minimum-disclosure assertion evidence; +- use CCCEV as its semantic foundation; +- expose one simple JSON evidence operation and one authenticated discovery + operation for complete requester-authorized request shapes; +- return signed audience-bound JWS by default, with unsigned JSON available + only through explicit API selection plus governed bundle and grant permission; +- support properties, classifications, statuses, eligibility results, and relationships; +- host many evidence definitions within one operator-controlled trust domain; +- use startup-only YAML configuration and trusted Rhai extraction and derivation scripts; +- execute fixed, least-privilege source requests through Rust; +- reuse platform audit and operational logging primitives where they fit; +- provide privacy-safe, tamper-evident audit records; +- minimize acquisition when the source supports it and always minimize disclosure; +- validate all definitions, scripts, schemas, and fixtures before serving; +- prove source independence against materially different JSON API shapes; +- complement existing gateways, exchange layers, and workflow systems; +- remain small enough for a maintainer to trace an evidence request end to end. + +## 4. Non-goals + +Version one is not: + +- a general-purpose data governance platform; +- a data lake, registry, evidence repository, or document service; +- a birth-certificate or other official-document generator; +- an API gateway, identity provider, consent service, or authorization server; +- a general identity-resolution, fuzzy or probabilistic matching, candidate-search, or deduplication service; +- a workflow, orchestration, or case-management engine; +- a general ETL, mapping, or query platform; +- a runtime policy engine or general PDP; +- a verifiable credential, OID4VCI, SD-JWT VC, holder-proof, or credential-status service; +- a multi-tenant SaaS control plane; +- a federation or delegated-evaluation protocol; +- an AI agent runtime, MCP server, or agent discovery service; +- an OOTS Evidence Broker, Data Service Directory, Semantic Repository, Preview Space, or AS4 Access Point; +- a replacement for source-system access control. + +Document evidence, holder credentials, transaction-bound replay protection, OOTS execution, public or federated catalogs, multi-source fulfillment, source-planning scripts, and delegated-agent access are explicitly deferred. The closed requester-scoped definition response is not a catalog or authorization source. + +## 5. Design principles + +### 5.1 Fixed concepts instead of predicates + +`adultStatus` is a versioned Information Concept with a fixed legal meaning. It is not a caller-defined predicate over a hidden date of birth. + +A catalog of selectable thresholds such as `age >= 18`, `age >= 19`, and `age >= 20` would reconstruct the protected value. Evidence therefore exposes reviewed concepts, never a caller-supplied expression language. Trusted derivation code and parameters remain part of the atomic bundle and its combined disclosure review. + +### 5.2 The deployment bundle is the disclosure boundary + +Fixed requirements are not safe when their combined answers reveal more than any one answer. The complete simultaneously enabled bundle must be reviewed for: + +- threshold ladders; +- overlapping categories; +- increasingly precise geographic partitions; +- jurisdiction variants; +- coexisting revisions; +- different entitlements held by the same requester; +- relationships whose combination reveals a protected identity or fact. + +Rate controls and audit analysis may detect abuse, but they do not make an unsafe bundle safe. + +The requirement is the unit of disclosure. Purpose, audience, and requester entitlement decide whether a requirement may be invoked at all; they never narrow the answer it returns. Two callers authorized for the same requirement receive the same concepts and disclosure forms whatever purpose each declared. A purpose that justifies only a coarser answer therefore needs its own requirement and its own place in this combined review. + +### 5.3 Minimize across the lifecycle + +Evidence distinguishes three source-access postures: + +| Posture | Source behavior | Claim | +|---|---|---| +| `source-derived` | Source returns the final fact | Full acquisition and disclosure minimization | +| `field-projected` | Source returns only facts needed for derivation | Strong acquisition and disclosure minimization | +| `record-transformed` | Legacy source returns a broader record | Disclosure minimization only | + +Every requirement declares its posture. `record-transformed` is a legitimate migration state, but it must not be described as full lifecycle minimization. + +For every posture: + +1. Request only configured fields and selectors. +2. Keep source values in memory only for evaluation. +3. Construct responses from declared concepts and typed disclosure forms. +4. Persist no raw source response. +5. Exclude source values and disclosed values from logs and audit. + +Configured provider lookup is compatible with this boundary. Evidence may send +an identifier or a closed compound selector such as name components and date of +birth. A reviewed requirement derivation may compare a separately authorized +selector with facts from one uniquely resolved authoritative record. Evidence +never requests a broad candidate list, scores or chooses a best match, or +treats a selector as proof of authority. + +### 5.4 Keep the core transport-neutral + +The evaluator consumes typed domain objects and returns typed assertion evidence. JSON, future OOTS XML, gateways, and agent-tool protocols are boundary representations. Rhai does not construct public responses. + +### 5.5 Prefer immutable deployment artifacts + +The governed bundle and closed runtime configuration are trusted deployment artifacts. They are validated at startup, scripts are compiled at startup, both inputs are mounted read-only, and each is identified by its own content hash. Version one has no runtime upload, mutation API, editor, approval workflow, hot reload, override layer, or fallback bundle. + +### 5.6 One process means one trust domain + +One process may serve many definitions, sources, and evidence types only when they share one operator, deployment lifecycle, audit boundary, and failure domain. Mutually distrustful issuers or customers use separate deployments. + +### 5.7 Prove generality at the source boundary + +Evidence must not be validated only against one idealized `person-facts` API. +The same Rust source executor and Rhai interfaces must handle materially +different JSON contracts without adding source-product concepts to the core. + +Version one proves this with small, sanitized compatibility mocks for: + +- a flat REST JSON response; +- a paged, nested DHIS2 Tracker-style REST response; +- an OpenCRVS Version 2 Event Search-style JSON response using OAuth 2.0 + client credentials. + +These mocks reproduce only the boundary behavior Evidence consumes. They are +not emulators, conformance claims, or bundled vendor connectors. Optional +read-only tests against public demo systems follow the deterministic mock suite +and never gate ordinary CI. + +DHIS2 and OpenCRVS names, data shapes, and behaviors are test concerns only. +Production Rust, Cargo features and dependencies, public configuration schemas, +routes, and CLI options remain source-product neutral. The runtime sees only +generic fixed HTTP requests, generic authentication profiles, bounded JSON, +and Rhai extraction. + +## 6. CCCEV-aligned assertion model + +The Evidence JSON profile pins CCCEV 2.2.0 as its initial semantic reference. It uses selected CCCEV concepts with stricter runtime rules and explicit Evidence extensions. + +### Requirement + +A named, versioned prerequisite or information need. Implementations normally use a more specific CCCEV kind: + +- **Criterion:** a condition to evaluate; +- **Information Requirement:** information to provide; +- **Constraint:** a limitation on a requirement or concept. + +### Information Concept + +A semantic fact needed by a requirement or provided by evidence, such as: + +- adult status; +- residence region; +- professional licence status; +- organization registration status; +- legal-parent relationship confirmed; +- registered legal parents. + +Each concept has a stable identifier, value schema, semantics, permitted disclosure form, and reference framework. + +### Supported Value + +A typed value supplied for an Information Concept. Version one supports closed schemas declared by the concept, including: + +- boolean; +- controlled code or category; +- bounded integer or decimal; +- date or time bucket; +- audience-scoped entity reference; +- bounded lists of controlled codes or entity references. + +Arbitrary JSON objects and caller-defined schemas are not accepted. A concept may define a reviewed structured value when its semantics require one, but the shape remains part of the trusted bundle. + +### Evidence Type + +A description of the assertion evidence expected for a requirement. Evidence Types may vary by jurisdiction or reference framework while supporting the same broader requirement. + +### Evidence Type List + +A CCCEV fulfillment alternative. Evidence Types within one list use `AND`; alternative lists use `OR`. Version one preserves these semantics in the conceptual and interchange model but does not execute multi-source or multi-evidence fulfillment. + +### Evidence + +The attributable assertion supporting a requirement. It includes: + +- requirement and Evidence Type identifiers; +- legal issuer and technical provider; +- issued, observed, and optional validity times; +- role-bound subject bindings; +- audience and purpose context where appropriate; +- configuration revision; +- Supported Values. + +Evidence extensions such as role-bound subject bindings, purpose, audience, and configuration revision are not presented as CCCEV-native properties. The JSON Schema must document the exact mapping between Evidence fields and CCCEV or Dublin Core properties. + +### Reference Framework and jurisdiction + +The legislation, policy, procedure, or contract from which a requirement derives. Jurisdiction and human-readable metadata belong to the definition bundle. Version one does not implement jurisdiction selection or a localization engine. + +### Relationship assertions + +Assertion evidence may involve more than one subject. Requirements declare fixed roles, cardinalities, and meanings. + +For example, `confirm-legal-parentage` declares `child` and `candidate-parent` roles and returns a boolean `legal-parent-relationship-confirmed`. `identify-legal-parents` declares a `child` role and may return a bounded list of audience-scoped entity references. + +`legal parent`, `biological parent`, `adoptive parent`, `guardian`, and `person with parental responsibility` are distinct concepts. A generic `parent` predicate is not accepted. + +## 7. Runtime model + +```mermaid +flowchart LR + A["Requester"] --> B["JSON boundary"] + B --> C["Authenticate and resolve authority context"] + C --> D["Resolve and authorize selector profiles and values"] + D --> E["Write access-attempt audit"] + E --> F["Rhai renders bounded request parts"] + F --> G["Rust validates request parts and resolves credentials"] + G --> H["Rust executes one fixed-authority source request"] + H --> I["Authoritative source"] + I --> J["Rhai maps response to a closed lookup result"] + J --> K["On match, Rhai derives declared concept values"] + K --> L["Rust validates values and constructs evidence"] + L --> M{"Authorized response format"} + M -->|"signed"| N["Sign and serialize exact JWS"] + M -->|"unsigned"| O["Serialize marked unsigned envelope"] + N --> P["Write disclosure-release audit"] + O --> P + P --> Q["Return exact serialized response"] +``` + +The critical boundary is between derived concept values and public evidence. Rhai may return values only for concepts declared by the selected requirement. It cannot return evidence objects, create identifiers or subject bindings, select envelope fields, write audit events, or access signing material. Rust validates identifiers, types, codelists, cardinalities, sizes, and the exact output set before constructing evidence. Response protection and serialization are core-owned release steps after validation, not adapter capabilities or a second form of policy. + +## 8. Authorization and subject authority + +### 8.1 Authenticated authority context + +Each deployment supports one reviewed authentication profile. It produces a normalized context containing: + +- requester principal; +- configured requester attributes; +- optional delegated actor identity; +- authority basis and optional grant identifier; +- derived audience; +- permitted purposes and requirement revisions; +- permitted subject roles, selector profiles, and value origins. + +Principals and attributes derive only from configured, validated sources. Missing required identity information denies the request. Evidence does not fall back to alternative token claims, request fields, or unsigned headers. + +### 8.2 One authorization decision + +Before source access, Rust binds one decision over: + +```text +requester principal ++ optional delegated actor ++ requirement revision ++ purpose ++ subject roles, selector profiles, value origins, and authority ++ audience +``` + +Every element must be authorized together. Authorization for a purpose does not automatically authorize every subject, requirement revision, or audience. + +Exactly one authority path must match. No matching path denies, and two or more matching paths also deny rather than choosing between them. Startup validation confirms that every declared purpose, subject role, and selector profile has an authority path; it does not detect two paths covering the same combination, so an overlapping bundle is denied at request time rather than rejected at load. + +### 8.3 Configured subject selectors + +A subject selector is only input to a provider lookup. Possession of an +identifier, name, date of birth, record reference, or any other selector value +does not grant authority. + +Each subject role admits one or more named selector profiles from trusted YAML. +A selector profile declares one exact field set, scalar types and bounds, value +provenance, and where it may be used by reviewed source preparation and +requirement derivation. If a provider +supports alternative sufficient data sets or an additional disambiguating +field, the bundle declares separate profiles instead of a conditional or +caller-built query. Examples include: + +- one opaque civil-registration identifier; +- `given_name + family_name + birth_date`; +- locally meaningful name components and date of birth; +- a person selector plus a configured event or record disambiguator; +- two role-bound person selectors for a relationship lookup. + +Field names are deployment-defined stable identifiers. `given_name`, +`family_name`, and `birth_date` are examples, not core Evidence vocabulary. +Version one selector values are bounded strings, full dates, integers, +booleans, or controlled codes. Selector objects, arrays, and arbitrary JSON are +not accepted. The core treats bounded name fields as opaque Unicode strings and +a date as a typed calendar value. It performs no case folding, +transliteration, phonetic comparison, fuzzy matching, confidence scoring, or +Western-name parsing. +Canonical selector serialization means deterministic encoding of the declared +typed field names and values. It does not mean semantic name normalization. + +The public request names an allowed selector profile and supplies only that +profile's permitted values. Unknown, missing, extra, mistyped, or oversized +values are rejected before credentials are acquired or a source is contacted. +The caller cannot supply field names, operators, weights, thresholds, +normalization rules, or a query plan. + +For a self or subject-bound flow, selector values should normally derive from +authenticated context or an authenticated grant and are omitted from the +request. An authorized caseworker flow may permit caller-supplied values for a +specific selector profile. Value origin is part of the subject-authority +profile and the authorization decision. These are distinct flows. + +The authoritative provider owns record meaning and lookup cardinality under +its law and data-quality rules. Evidence accepts only the closed outcomes +`match`, `no_match`, and `ambiguous`. Only `match` may carry facts. A reviewed +requirement derivation may apply a deterministic, versioned comparison between +those facts and its authorized selectors. `ambiguous` never causes Evidence to +choose a candidate, and neither failed outcome exposes candidates, scores, +counts, or field-by-field diagnostics. + +This boundary follows the useful part of the current Notary consultation +model: closed compiler-defined selector inputs, provider-owned cardinality, +and no candidate selection. It does not import Notary, Relay, evidence-pack, +PDP, or credential architecture into Evidence. + +#### Research basis + +The selector model is deliberately jurisdiction-neutral, but it reflects three +useful findings: + +- OOTS sends an authenticated natural person's name and birth-date attributes + to the Data Service, may omit a destination-specific person identifier, lets + the Data Service apply national matching policy, and treats two or more + results as failure rather than selecting one. +- UK GPG 45 models a claimed identity as a combination of attributes, commonly + name, date of birth, and address, and keeps identity checking and assurance + semantics distinct from the attribute data itself. +- The reviewed OOTS-derived gap spec records that a person identifier may have + zero occurrences and that event evidence such as birth or marriage may need + multiple role-bound persons or an additional configured record discriminator. + +These references justify configurable compound selectors. They do not justify +shipping EU or UK field names, assurance rules, fuzzy algorithms, or civil-event +types in the Evidence core. + +### 8.4 Consent, statutory authority, and delegation + +Evidence consumes an authenticated authority context. Its basis may be statutory authority, organizational authority, consent, delegation, or an OOTS explicit request. A per-request grant reference is optional because statutory flows may derive authority from the requester and configured procedure. Evidence does not issue, manage, revoke, or infer that authority. + +A caller-supplied consent or approval reference never creates authority by itself. + +### 8.5 Existence disclosure + +No-match, ambiguous-match, required-fact-missing, false, and source-unavailable +states must not accidentally disclose registry membership through status +codes, messages, or avoidable timing differences. + +If a procedure is entitled to learn that a record exists or does not exist, +existence is modeled as a fixed, authorized concept. It is never an incidental +error detail. By default, `no_match` and `ambiguous` collapse to the same safe +public failure. The protected native audit may retain the closed `no_match` or +`ambiguous` class for accountability, but never a count, candidate, score, or +comparison diagnostic. + +## 9. Deployment bundle and source adapters + +Governed configuration, scripts, schemas, codelists, mappings, and fixtures form +one atomic bundle. A separate closed runtime file owns only process-local +listener, filesystem, audit-storage, secret-mount, and TLS-trust bindings. Both +inputs are startup-only, read-only, independently digested, and immutable for +the process lifetime. Runtime configuration is not an override layer and cannot +change service identity, trust domain, authentication or authority policy, +sources, requests, scripts, disclosure, rate limits, signing policy, or audit +fail-closed behavior. Readiness fails if either input is incomplete, +inconsistent, mutable, or cannot be validated. + +Illustrative YAML: + +```yaml +version: 1 + +service: + provider_id: urn:example:data-service:evidence + +signing: + format: jws-json + algorithm: EdDSA + key_ref: secret:evidence-signing-key + jwks_path: /.well-known/evidence/jwks.json + +issuer: + id: urn:example:authority:population-registry + +selector_profiles: + person-demographics-v1: + fields: + given_name: { type: string, maximum_bytes: 200 } + family_name: { type: string, maximum_bytes: 200 } + birth_date: { type: date } + +sources: + civil-registry: + transport: http-json + base_url: https://civil-registry.internal + posture: field-projected + tls_trust_profile: government-internal-pki + authentication: + kind: static-bearer + token_ref: secret:file/civil-registry-token + request: + method: POST + path: /v1/person-facts + fixed_headers: + - { name: Accept, value: application/json } + selector_inputs: + - role: subject + alternatives: + - profile: person-demographics-v1 + fields: [given_name, family_name, birth_date] + prepare_script: adapters/civil-registry-prepare.rhai + adapter_parameters: + requested_fields: [date_of_birth] + result_limit: 2 + adapter_parameters_schema: schemas/civil-registry-parameters.schema.yaml + preparation_limits: + query: forbidden + json_body: required + projection: + - /total + - /results/*/date_of_birth + redirects: deny + timeout: PT3S + maximum_response_bytes: 65536 + extract_script: adapters/civil-registry-extract.rhai + fact_schema: schemas/civil-registry-facts.schema.yaml + +requirements: + - id: urn:example:requirement:adult-status:v1 + kind: criterion + name: Adult status + source: civil-registry + purposes: + - benefit-eligibility + requester_tags: + - benefits-agency + audience_from: requester + subject_roles: + - role: subject + cardinality: one + selector_profiles: + - person-demographics-v1 + reference_frameworks: + - urn:example:law:benefits-act + evidence_type: urn:example:evidence-type:adult-status:v1 + observation_timezone: America/Santo_Domingo + validity: PT24H + derivation: + script: derivations/adult-status.rhai + parameters: + minimum_age_years: 18 + concepts: + - id: urn:example:concept:adult-status + type: boolean + disclose: value +``` + +### 9.1 Fixed source execution with reviewed request rendering + +Rust owns scheme, host, method, the fixed path or closed selector-bound path +template, permitted query and body channels, fixed headers, credentials, TLS +trust, redirect policy, timeouts, response limits, concurrency limits, and the +one-request ceiling. + +After authorization and durable access-attempt audit, Rust supplies only the +source-required authorized selectors and closed non-secret parameters to a +reviewed preparation script. The script renders ordered query pairs and at +most one JSON body. It cannot choose the source, origin, path template or path +binding, method, headers, credentials, redirects, retries, pagination +traversal, or another request. +Rust validates and encodes the complete result before credential acquisition. +This is deterministic request rendering, not caller-supplied templating or +dynamic source planning. + +After bounded JSON parsing, Rust applies the source's non-empty extended JSON +Pointer projection before extraction. Unselected object keys are removed, +array order and length are preserved, and missing leaves remain missing. The +acquisition posture still describes the pre-projection wire response. Exact +projection grammar and conflict rules are part of the reviewed adapter ABI. + +A Version 1 source must provide a bounded lookup that can establish zero, one, +or multiple results from the configured selector. If an existing system cannot +do that safely, a governed intermediary such as an existing integration layer +may expose the bounded lookup. Evidence does not download a registry or a broad +candidate set to compensate. + +The initial generic source-authentication profiles are HTTP Basic, static +Bearer, static API-key header, and OAuth 2.0 client credentials. All values come +from secret references. API-key header names are bundle-fixed and cannot +override authorization, routing, framing, cookie, forwarding, proxy, or tracing +headers. For OAuth, token acquisition is credential bootstrap rather than an +evidence-data source call. Rust owns the fixed token endpoint, grant, +credential placement, token lifetime handling, bounds, and redaction. Rhai sees +neither the credential flow nor the resulting token. + +Bundle-fixed non-secret headers support media types, API versions, and tenant +selectors without giving scripts header authority. A source may name a logical +TLS trust profile whose private-CA file is bound by runtime configuration. +Hostname verification and fixed-origin verification remain mandatory; there is +no insecure or trust-all mode. Version 1 ignores ambient HTTP proxy environment +variables and has no application-level proxy configuration. + +### 9.2 Rhai extraction and derivation + +Version one uses two small Rhai interfaces in the same process: + +```text +prepare(source_required_selectors, adapter_parameters) -> RequestParts +extract(source_response, adapter_parameters) -> LookupResult +derive(facts, declared_authorized_selectors, evaluation_context) + -> array +``` + +`LookupResult` is a closed tagged union: + +```text +match(FactSet) | no_match | ambiguous +``` + +The source adapter maps a bounded provider response into that union. Facts are +valid only on `match`; Rust rejects facts attached to another outcome, a match +without the required facts, unknown outcomes, broad candidate arrays, scores, +counts, or diagnostics. Derivation runs only for `match` and converts facts +plus only the authorized selector roles and fields declared by the derivation +into values for the concepts declared by the selected requirement. It may +apply a reviewed deterministic +relationship rule, such as exact membership of a stable candidate identifier +in a complete authoritative parent set after exact returned-record binding to +the authorized child selector. Scripts do not execute requests, +authorize access, choose a disclosure profile, or construct Evidence. + +The immutable evaluation context contains only deterministic inputs owned by the trusted bundle and runtime: + +- observation instant; +- legal local date and time resolved from the configured IANA timezone; +- fixed, typed definition parameters; +- bounded references to bundle codelists required by the derivation. + +Selectors are supplied as a separate explicit derivation argument and contain +only the roles, profiles, and values already authorized for the selected +requirement. The evaluation context contains no requester, actor, purpose, +audience, authority grant, credential, source client, signing material, or +audit handle. A requirement with different legal semantics uses a different +versioned definition rather than branching on caller context. + +Rhai receives no ambient access to: + +- filesystem; +- environment variables; +- credentials; +- network; +- clock or randomness; +- process execution; +- application logging; +- audit sinks; +- signing keys. + +Scripts compile at startup and are identified by bundle hash. Each invocation receives fresh local state. Explicit limits apply to operations, call depth, strings, collections, modules, and result size. + +A future `plan(context) -> SourceCall` hook requires a separate design and a demonstrated source that cannot use a fixed request. It is not a hidden extension point in version one. + +### 9.3 Rhai primitives + +Rust supplies a small standard library of pure, deterministic, bounded primitives to Rhai. Initial primitives cover: + +- typed calendar dates, instants, and durations; +- date and time comparison and calendar-safe arithmetic over runtime-supplied legal local values; +- bounded numeric comparison and bucketing; +- controlled-code and codelist lookup; +- bounded list and set membership; +- explicit missing-value handling. + +Primitive names and behavior are domain-neutral. Rust does not expose operations named `adult_status`, `age_at_least`, `licence_active`, or `legal_parent`. Country-specific and requirement-specific meaning stays in trusted Rhai, YAML parameters, reference-framework metadata, and fixtures. + +Primitives perform no I/O, authorization, logging, audit, signing, or response construction. New primitives require a generic need demonstrated by more than one definition shape, bounded behavior, and focused tests. Adding a new evidence definition should normally require no Rust change. + +### 9.4 Output validation + +Rust accepts a derivation result only when its concept identifiers exactly match the selected requirement's permitted output set and every value satisfies its declared type, codelist, cardinality, and size. Arbitrary JSON objects and undeclared metadata are rejected. + +This makes the trusted bundle responsible for domain semantics while keeping disclosure enforcement in the core. A trusted script can still be semantically wrong, so every requirement carries positive, negative, boundary, missing-data, and anti-reconstruction fixtures that run before readiness. + +### 9.5 Source compatibility contract + +The source layer is accepted only when the same core passes all three reference +shapes described in section 5.7. The contract suite verifies: + +- exact Rust-owned method, URL, selector, projection, headers, body, timeout, + redirect, and response-size behavior; +- authentication injection without exposing credentials to YAML values, Rhai, + logs, audit, errors, or test output; +- zero, one, and multiple-match behavior without an unintended existence + oracle; +- identifier selectors, compound selectors without an identifier, and + multi-role selectors using deployment-defined field names; +- flat objects, nested attribute arrays, pagination metadata, event-index + declarations, provider error envelopes, and missing or malformed facts; +- safe handling of `401`, `403`, `429`, `5xx`, timeout, redirect, invalid JSON, + wrong media type, and oversized responses; +- no change to the Evidence API, model, evaluator, signing, or audit path when + the source shape changes. + +Compatibility fixtures are hand-authored from public API documentation and use +invented subjects and values. Live responses, public-demo subject identifiers, +credentials, and tokens are never committed. The detailed mock and optional +live-smoke rules are in [SOURCE-TESTING.md](SOURCE-TESTING.md). + +No source-product test profile creates a production module, type, enum variant, +feature flag, dependency, configuration field, route, CLI option, or branch. +Provider request and response variants remain ordinary bounded JSON rather than +runtime subsystems. + +The executor prefers count plus one minimized result when a provider supports +it. Otherwise it may request at most two minimally projected results so the +adapter can distinguish a unique match from ambiguity. It never follows pages +or performs broad candidate retrieval. Rhai must return `ambiguous` when two +results are present and cannot compare them to choose one. + +## 10. Policy model + +Version one has no policy language and no policy-engine abstraction. + +YAML declares: + +- permitted purposes; +- requester identities or tags; +- subject roles, selector profiles, and value origins; +- audience derivation; +- requirement revisions; +- Evidence Types; +- disclosure forms; +- validity rules. + +Rust evaluates these declarations with fail-closed semantics. Requirement-specific Rhai derivation is not policy execution: it receives no requester or authority context and cannot alter authorization, disclosure shape, subject binding, audience, or evidence construction. Rego or a separate Rhai policy interface may be reconsidered only when a concrete deployment rule cannot be expressed without changing Rust. That future decision must not weaken the fixed requirement and core-owned validation and projection boundaries. + +## 11. Native JSON API + +Version one exposes one evidence operation: + +```text +POST /v1/evidence +``` + +Illustrative request: + +```json +{ + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "requirement": "urn:example:requirement:adult-status:v1", + "purpose": "benefit-eligibility", + "subjects": [ + { + "role": "subject", + "selector": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "1984-02-10" + } + } + } + ] +} +``` + +`requestNonce` is the required canonical unpadded base64url encoding of exactly +32 independently generated random bytes, represented as exactly 43 ASCII +characters. It is echoed into Evidence for request-response correlation and +challenge-style transaction binding when a verifier supplies the independently +retained expected value. The runtime does not store it, reject reuse, or claim +one-time use, same-transaction replay prevention, presenter binding, or +server-observed uniqueness. The nonce is not part of the stable subject binding +and is absent from authorization, rate-limit labels, Rhai, source requests, +audit, logs, metrics, and traces. Callers must not encode identifiers, +selectors, secrets, or document digests into this uninterpreted random value. + +The request selects a configured purpose and selector profile. It supplies +selector values only where the active authority profile permits caller +selection. A context-derived selector uses the same profile but omits `values`; +Rust obtains them from the authenticated context or grant. Request fields do +not create authority. Audience derives from the authenticated context. + +`subjects` is an unordered set encoded as a JSON array. Each role must appear +exactly once with the configured profile. Rust resolves entries by role, +rejects duplicate, missing, unknown, or wrong-profile roles, and constructs its +internal and evidence subject arrays in the requirement's declaration order. +Callers do not need to reproduce bundle order. + +The schema for `values` is closed by the named profile. The example names are +ordinary bounded strings and the date is a typed full date. Rust attaches no +universal meaning to those field names and does not normalize or compare their +contents. A reviewed derivation may compare only the selector roles and fields +explicitly declared by that requirement. + +Missing `Accept`, `Accept: */*`, or exact `Accept: application/jose+json` +selects the default flattened JWS JSON Serialization. Exact +`Accept: application/vnd.registrystack.evidence-unsigned+json` selects unsigned +JSON only when the immutable bundle enables it and the complete matched +authority grant permits it. +Duplicate, combined, parameterized, weighted, or unknown negotiation returns +`406 Not Acceptable` before source access. Every response varies on `Accept` +and remains `no-store`. + +The following is the decoded JWS payload and the nested Evidence object used by +the unsigned envelope: + +```json +{ + "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "id": "urn:ulid:01K1EXAMPLE0000000000000000", + "type": "Evidence", + "supportsRequirement": "urn:example:requirement:adult-status:v1", + "isConformantTo": "urn:example:evidence-type:adult-status:v1", + "issuedBy": "urn:example:authority:population-registry", + "providedBy": "urn:example:data-service:evidence", + "issuedAt": "2026-08-02T12:00:00Z", + "observedAt": "2026-08-02T12:00:00Z", + "validUntil": "2026-08-03T12:00:00Z", + "purpose": "benefit-eligibility", + "audience": "urn:example:agency:benefits", + "configurationRevision": "sha256:bundle-digest", + "subjects": [ + { + "role": "subject", + "binding": "audience-scoped-subject-binding" + } + ], + "supportedValues": [ + { + "providesValueFor": "urn:example:concept:adult-status", + "value": true + } + ] +} +``` + +The JWS object contains `protected`, `payload`, and `signature` members. `payload` is the base64url encoding of the exact UTF-8 JSON evidence bytes. This avoids a separate JSON canonicalization contract and does not duplicate the evidence object beside its signature. + +The unsigned success is deliberately distinct: + +```json +{ + "schema": "registry.unsigned-evidence-envelope/v1", + "type": "UnsignedEvidenceEnvelope", + "integrityProtection": "none", + "warning": "not-cryptographically-verifiable", + "evidence": { + "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "type": "Evidence" + } +} +``` + +The nested object is complete on the wire; it is abbreviated above. The fixed +outer schema and markers ensure stored unsigned output does not claim a JWS +proof. The JWS verifier rejects this representation. A separate unsigned parser +may check schema and policy but returns an explicitly unverified result. Version +one never uses JWS `alg: none`, an empty signature, or a JWS-shaped unsigned +object. + +### 11.1 Response integrity and verification + +Every deployment supports signed JWS and uses it by default. Unsigned JSON is a +separately governed response format selected only by exact API media +negotiation and permitted by both the bundle and matched authority grant. +Runtime configuration cannot enable it. A signed request never falls back to +unsigned output after a signing, key, serialization, audit, or dependency +failure. + +The protected JWS header contains an allowlisted `alg`, a required `kid`, a media-type identifier, and the payload content type. Version one starts with one configured active signing key and publishes its public key through `/.well-known/evidence/jwks.json`. Retired public keys remain available for at least the maximum assertion validity plus allowed clock skew. Private key material is resolved through a secret or signing-provider reference and never appears in YAML, Rhai, logs, audit, or public errors. + +The published JWKS is key discovery, not a trust anchor. A verifier obtains the provider identity and JWKS location through trusted deployment configuration or governed metadata, then allowlists the algorithm and resolves `kid` only within that trusted key set. It never follows a message-provided `jku`, `x5u`, or equivalent remote key URL. + +The signature covers the request nonce, issuer, technical provider, Evidence +Type, requirement revision, purpose, audience, role-bound subjects, Supported +Values, bundle revision, evidence identifier, and all observation and validity +times because those fields are inside the payload. + +Verification proves that the technical provider controlling the referenced key signed the exact payload. It does not by itself prove the source fact is true, confer legal notarization, create a qualified electronic signature, or turn the assertion into a holder credential. Governance must establish that the technical provider is authorized to produce evidence for the named legal issuer. + +Signing occurs after core-owned projection. For JWS, Rust signs and serializes +the final immutable response bytes. For unsigned output, Rust constructs and +serializes the final immutable envelope bytes. The fail-closed +disclosure-release audit is durably accepted only after that serialization and +before those exact bytes are returned. The audit records the closed response +protection mode and records a signing key id only for JWS. Signing-key absence +makes readiness fail for every deployment. A runtime signing failure returns +`503 Service Unavailable`; it never downgrades to unsigned evidence. + +Strict signed verification checks the trusted key and exact payload, the +expected issuer, provider, requirement, Evidence Type, purpose, audience, +configuration revision, validity interval, request nonce, expected role-bound +opaque subject bindings, and the expected concept identifiers, value forms, +and cardinalities. Expectations come from the relying procedure, previously +trusted bindings, or a trusted requirement contract, never by copying values +from the JWS being checked. A relying party that needs later verification +retains those expectations and its trusted key snapshot with the exact JWS. +Cryptographic authenticity remains distinguishable from current validity after +the assertion expires. + +Version one does not add nonce storage. The echoed request nonce proves +correlation with a request retained by the relying party, not freshness, +single use, or replay prevention. An assertion is a time-bounded statement for +a named audience, not a one-time authorization token. A consumer that treats +an assertion as authorization for a non-repeatable action owns that action's +replay control until a separate transaction-bound profile is defined. + +### 11.2 Relationship assertion example + +A legal-parent confirmation uses the same operation and model: + +```json +{ + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", + "requirement": "urn:example:requirement:confirm-legal-parentage:v1", + "purpose": "school-enrolment", + "subjects": [ + { + "role": "child", + "selector": { + "profile": "civil-record-reference-v1", + "values": { + "record_reference": "opaque-child-reference" + } + } + }, + { + "role": "candidate-parent", + "selector": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Binta", + "family_name": "Diallo", + "birth_date": "1960-06-15" + } + } + } + ] +} +``` + +The response binds both roles and returns only +`legal-parent-relationship-confirmed: true|false`. It does not return selector +profiles or values, a birth certificate, names, dates of birth, addresses, or +unrelated family relationships. An audience-scoped subject binding is derived +over the complete canonical role and selector bundle, not separate hashes of +low-entropy fields. It is a request-binding handle, not a public identifier or +an assertion that the selectors are globally unique. + +### 11.3 Failure semantics + +Public failures use stable problem codes and safe descriptions. Source responses, selectors, policy inputs, script data, and protected values are never reflected into error bodies. + +Transient dependency failure returns `503 Service Unavailable` and may include `Retry-After`. Version one has no job queue or generic asynchronous state. + +`no_match`, `ambiguous`, and fact-missing behavior follows the requirement's +reviewed existence-disclosure rule. The safe default makes `no_match` and +`ambiguous` publicly indistinguishable. False evidence after a unique match is +a successful assertion, not an error. + +### 11.4 Operational endpoints + +```text +GET /v1/evidence-definitions +GET /health +GET /ready +GET /.well-known/evidence/jwks.json +``` + +Readiness confirms that the governed bundle compiled, the runtime file and every +logical path/trust binding validated, required credentials and signing material +are available, the audit sink accepts writes, and required source dependencies +satisfy the deployment posture. + +### 11.5 Offline authoring + +```text +evidence check +evidence evaluate --fixture +``` + +Every requirement ships with positive, negative, missing-data, source-failure, existence-disclosure, and anti-reconstruction fixtures. Offline evaluation does not require a running server or source network. + +### 11.6 Discovery and publication + +The set of definitions a requester may use is the intersection of the exact +deployed bundle revision and the caller's verified authority context. It +depends on requirement, purpose, audience, the complete role/profile/origin +tuple, and any token-owned selector values together. A process-wide catalog +would overstate availability and reveal definitions or selector structure that +another requester is not entitled to know. + +`GET /v1/evidence-definitions` authenticates the caller and returns only +complete request shapes that match exactly one authority path. Each item +contains bundle revision, issuer, provider, requirement, Evidence Type, +purpose, reference frameworks, output concepts and forms, complete subject +roles, selector profiles, value origins, and safe selector field types and +bounds. Controlled-code fields expose the governed scheme identity and +version, never the configured code values. A client selects one whole item; it +must not form a request by combining metadata across items. + +An unentitled caller receives an empty list. A shape whose authority decision +is ambiguous is omitted because the corresponding evidence request would be +denied. A shape depending on missing or invalid authenticated-context or grant +selector material is also omitted. Discovery uses the ordinary +per-principal request-rate budget but performs no source credential resolution, +provider access, signing, or evidence-data audit write. + +The response must omit source origins and identifiers, paths, projections, +scripts, adapter parameters, secret references, internal requester tags, +authority-profile identifiers, selector values, codelist values, unrelated +definitions, and every other bundle field not on its explicit allowlist. +Possessing discovery metadata, a requirement identifier, or selector values +never creates authority. `POST /v1/evidence` authenticates and authorizes the +complete tuple again. + +The generated OpenAPI describes both operations. Operators separately publish +static onboarding material for token acquisition, human labels, procedural and +legal context, endpoint trust, and verifier policy through an existing API +catalog, developer portal, configuration repository, or bilateral process. +The JWKS endpoint publishes verification keys only. Version one has no public, +cross-requester, searchable, mutable, or federated definition catalog and no +registration editor or `describe` CLI command. + +## 12. Audit and operational logging + +Operational logs describe service health and performance. They may contain: + +- route template; +- operation identifier; +- duration; +- status category; +- safe internal error category. + +They must not contain request bodies, selector profiles or values, source +responses, Supported Values, credentials, tokens, authority grants, or Rhai +inputs. + +Audit records establish accountable access. Reusable platform primitives may provide tamper-evident envelopes, keyed pseudonymization, redaction helpers, sinks, and chain verification. + +The audit event contains only reviewed fields: + +- operation identifier and phase; +- requirement and bundle revision; +- purpose code; +- pseudonymized requester and optional actor; +- selector profile identifiers and one pseudonymized complete selector bundle + per role, only where correlation is required; +- authority type and optional pseudonymized grant reference; +- source and adapter identifiers; +- decision code; +- disclosed concept identifiers, never values; +- evidence identifier and signing key identifier on release; +- timing and safe error category. + +Audit pseudonyms use keyed, domain-separated hashing with separate requester, +actor, authority, and subject domains. A subject pseudonym covers the canonical +role, selector-profile id, ordered field names, and complete value bundle. The +native audit never stores raw selector values or separate hashes for names, +dates of birth, addresses, identifiers, or other low-entropy fields. Scoping +prevents unnecessary cross-purpose linking and includes a key version for +controlled rotation. Plain hashes and globally stable subject pseudonyms are +prohibited. + +Two writes are fail-closed: + +1. The access-attempt event must be durably accepted before the first source read. +2. The disclosure-release event must be durably accepted after final response + serialization and before those exact bytes are released. + +Denial and transient-failure events are attempted without reflecting protected inputs once authorization has produced the privacy-safe audit material required by the native schema. Authentication failures, unmatched-authority failures, and invalid-selector failures happen before a complete authorized authority and selector bundle exist; the core does not fabricate a native event from that untrusted or protected request material. A deployment-specific compliance profile may require separate edge telemetry, more reviewed metadata, or retention, but it cannot silently change the native privacy contract. + +## 13. Trust and privacy invariants + +Version one must preserve these invariants: + +1. Only predefined, versioned requirements can be evaluated. +2. Callers cannot provide thresholds, expressions, scripts, JSON paths, source fields, relationship types, or response projections. +3. The complete enabled bundle is reviewed as one disclosure surface. +4. Authentication derives principals and attributes only from configured validated sources; missing data denies. +5. One authorization decision binds requester, optional actor, requirement revision, purpose, selector profile and value origin for every subject role, subject authority, and audience. +6. Selector profiles and values are provider-lookup inputs, never proof of authority. +7. Caller-provided consent, approval, or grant references never create authority. +8. Callers cannot choose selector field names, operators, weights, thresholds, normalization, or query plans. +9. Source calls are fixed by trusted configuration and executed only by Rust. +10. Provider lookup has only `match`, `no_match`, and `ambiguous`; Evidence never returns or chooses candidates. +11. Rhai returns one closed lookup result and declared typed concept values only. +12. Rust rejects undeclared concept identifiers, extra fields, and values that violate configured types, codelists, cardinalities, or sizes. +13. Missing facts, undefined decisions, script failures, audit failures, and evaluation failures deny or return a safe transient failure. +14. Raw source responses are never persisted or logged. +15. No selector value, source value, or disclosed value appears in operational logs or native audit records. +16. No-match and ambiguous behavior cannot accidentally disclose registry membership. +17. Subject bindings are scoped to the intended audience and do not create globally linkable identifiers. +18. Configuration is immutable for the lifetime of a serving process. +19. One process serves one operator-controlled trust domain. +20. Rate controls are defense in depth and never substitute for safe concept design or authorization. +21. Signed flattened JWS over the exact Evidence payload is mandatory, + available to every authorized grant, and the default response. Unsigned + output is available only through its exact media type when both the + immutable bundle and complete matched grant permit it. +22. Missing or failed signing never falls back to an unsigned response. +23. Private signing-key material is core-owned and is never exposed to deployment-bundle values, Rhai, logs, audit, or errors. +24. A signature authenticates the technical provider and payload integrity; it does not silently assert legal-signature status or source truth. +25. Public evidence is constructed only by the core after derivation output passes the complete requirement contract. +26. Every request carries one exact 32-byte random nonce that is echoed into + Evidence but never stored or exposed to scripts, sources, diagnostics, or + native audit. +27. Strict signed verification compares the nonce, subjects, and output + contract against independent trusted expectations, not values copied from + the assertion under verification. +28. Unsigned output is a separately typed, visibly unprotected envelope that + cannot enter the signed-verification path or claim later verifiability. +29. Final immutable response bytes exist before the disclosure-release audit + is durably accepted and are the exact bytes released afterward. + +## 14. Complementary deployment patterns + +### Standalone + +Evidence exposes HTTPS directly using one supported authentication profile and a configured durable audit sink. Production exposure includes per-principal rate controls. + +### WSO2 or another API gateway + +The gateway manages API publication, authentication protocol integration, rate controls, and routing. Evidence independently validates the configured identity context and enforces requirement, purpose, subject-authority, and disclosure rules. + +### X-Road + +Evidence is exposed behind a provider Security Server or calls a source through X-Road. X-Road provides trusted exchange and transaction protections. Evidence remains responsible for deriving, protecting, and releasing the minimized assertion. + +### OpenFn + +An OpenFn workflow calls Evidence as one atomic step and routes the minimized result. Evidence does not absorb workflow branching, business retries, or destination writes. + +## 15. Future profiles and guarded extensions + +The following capabilities require separate profiles or design decisions. They are not latent version-one features. + +### 15.1 OOTS assertion-evidence profile + +Evidence may implement all or part of a Data Service operated by an Evidence Provider. The competent authority remains legally responsible for the derived evidence. Creator, issuing authority, technical provider, and cryptographic signer remain explicit roles. + +Each minimized assertion used through OOTS is governed as an Evidence Type in its own right. It must be mapped by the Evidence Broker to the applicable requirement and exposed by the DSD as a Data Service Evidence Type. An adult-status assertion is not silently reclassified as a birth certificate. + +OOTS may carry Evidence assertion JSON as the main evidence attachment when `application/json` is registered for the selected Data Service Evidence Type. When `sdg:ConformsTo` is present, it identifies the applicable OOTS Semantic Repository data model. + +The boundary receives RegRep XML together with authenticated AS4 message context. It validates official XSD, Schematron, codelists, and profile rules, then maps to the canonical request. Existing Evidence Broker, DSD, Semantic Repository, Preview Space, and AS4 Access Point infrastructure remains external. + +OOTS uses a separate audit and retention profile. Its legal logging and non-repudiation obligations do not silently change native audit behavior. + +Traditional document evidence, MIME attachments containing certificates, translations, and annexes remain a later document-evidence profile. + +### 15.2 Transaction-bound use profile + +Core JWS signing plus the caller nonce supports integrity and limited +transaction binding when the named audience independently retains and checks +its challenge during the assertion's validity period. It does not make an +assertion single-use, bind a holder or presenter, prove that the server has not +seen the nonce before, or prevent reuse with the same expected nonce. + +A later transaction-bound profile may add server-issued challenges, one-time +consumption, holder or presenter binding, and replay state when a concrete +relying-party action requires them. Those semantics are designed together and +do not alter ordinary assertions by default. No bespoke signature or challenge +format is introduced. + +### 15.3 Delegated-agent profile + +An AI agent is an authenticated workload actor operating under an external authority grant. The grant binds: + +- delegating principal; +- agent workload identity; +- fixed requirement; +- purpose; +- subject authority; +- audience; +- validity and call constraints. + +The agent invokes fixed operations such as `getAdultStatus` or `confirmLegalParentage`. It cannot submit a free-form evidence query. Prompt text, conversation history, model names, and agent reasoning never enter Evidence or audit. + +An MCP or other tool facade may compile static tool descriptions from the trusted bundle and call the JSON API. It remains outside the core. Direct delivery to the relying party may allow the agent to receive only a receipt rather than the assertion value. + +### 15.4 Document evidence + +A later document profile may retrieve and transiently deliver an existing official artifact. It does not make Evidence a document repository or certificate generator. Multipart responses, artifact integrity, supplementary documents, and retention require a separate design. + +### 15.5 Dynamic source planning and policy + +Version one includes deterministic rendering of query pairs and one JSON body +under a Rust-fixed transport plan. Script-selected sources, URLs, methods, +headers, credentials, retries, pagination traversal, response-led requests, +multi-call orchestration, and a richer policy language remain separate +proposals. None is added merely as an extension seam. + +## 16. Initial assertion cases + +### Adult status + +Input fact: date of birth or source-derived adult status. +Output: boolean. +Purpose: prove unary minimum disclosure, calendar arithmetic, and legal-time +boundaries. +Primary risk: threshold reconstruction and date-boundary errors. + +### Residence region + +Input fact: official residence code or bounded address field. +Output: controlled administrative-region code. +Purpose: prove code mapping and geographic coarsening. +Primary risk: overly precise disclosure and unversioned mapping tables. + +### Professional licence status + +Input facts: licence state and validity dates. +Output: active boolean and controlled expiry bucket. +Purpose: prove multiple concepts and time bucketing. +Primary risk: disclosure of exact dates or licence history. + +### Legal-parent relationship + +Input roles: child and candidate parent. +Output: legal-parent-relationship-confirmed boolean. +Purpose: prove multi-subject, role-bound assertions. +Primary risk: subject substitution, relationship ambiguity, and family-graph disclosure. + +All four cases are mandatory full-path acceptance definitions. Each passes +offline evaluation and the production HTTP path, including authentication, +authorization, response-format permission, source access, access audit, output +gating, evidence construction, signed and explicitly permitted unsigned +response paths, release audit, and verification. The public contracts do not +freeze until all four pass together. None becomes a Rust domain type, built-in +derivation, special route, or preferred implementation order. + +## 17. Version-one release scope + +Version one is one synchronous assertion service with signed JWS as the +mandatory default and includes: + +- one `registry-evidence` crate, one `evidence` binary, and one serving process; +- one operator-controlled trust domain; +- all four initial assertion cases as complete test-only acceptance bundles; +- conformance fixtures for every Version 1 Supported Value form; +- multiple enabled evidence definitions in one process; +- one generic fixed HTTP JSON evidence-data request executor; +- generic Basic, static Bearer, static API-key header, and OAuth 2.0 + client-credentials source authentication using secret references; +- fixed non-secret request headers, Rust-owned selector-bound path templates, + and logical private-CA trust profiles without script transport authority; +- explicit `source-derived`, `field-projected`, and `record-transformed` + acquisition postures with no overclaiming of minimization; +- one strict OIDC access-token reference profile; +- one reviewed statutory-agency subject-authority profile; +- configured identifier, compound demographic, and multi-role selector profiles + with provider-owned `match`, `no_match`, and `ambiguous` outcomes; +- bounded Rhai extraction and requirement-specific derivation; +- generic Rust-provided date, time, codelist, numeric, and collection primitives; +- Rust-owned validation of derived values, evidence construction, and projection; +- authenticated `GET /v1/evidence-definitions` requester-scoped discovery and + one `POST /v1/evidence` assertion operation with a required fixed-size + request nonce; +- one active EdDSA reference signing key, default flattened JWS JSON responses, + a governed explicitly selected unsigned envelope, and a public JWKS endpoint; +- keyed JSONL audit on explicitly durable storage, fail-closed before source access and before release; +- offline bundle checking and fixture evaluation; +- deterministic source-contract mocks for flat REST, DHIS2 Tracker-style REST, + and OpenCRVS Version 2 Event Search-style JSON; +- generated JSON Schema and OpenAPI artifacts; +- focused authorization, minimization, existence, isolation, signing-failure, + signature-verification, codelist, multi-concept, multi-subject, and + date-boundary tests. + +It does not include: + +- public, cross-requester, searchable, mutable, or federated catalog endpoints; +- nonce or replay storage beyond stateless request-nonce echo and comparison; +- holder credentials, server-issued challenge flows, one-time consumption, or + presenter binding; +- evidence retention; +- a policy engine; +- document evidence; +- OOTS runtime types; +- multi-source fulfillment; +- source-planning scripts; +- application-level or ambient-environment HTTP proxy routing; +- federation; +- runtime configuration mutation; +- an application database unless the selected audit sink requires an external durable service. + +## 18. Delivery sequence + +`IMPLEMENTATION.md` owns the detailed phase exit gates and Definition of Done. +The complete Version 1 sequence is: + +### Phase 0: freeze contracts, acceptance definitions, and DoD + +- Review and accept this concept note. +- Define the CCCEV-to-JSON mapping. +- Define the governed-bundle and runtime YAML schemas, selector-profile + contract, ownership split, and atomic bundle layout. +- Define the closed lookup-result and derivation Rhai ABIs. +- Define the initial domain-neutral primitive set and its resource bounds. +- Define the normalized authority context. +- Define the flattened JWS profile, signer identity, key discovery, rotation, and verifier rules. +- Create golden fixtures for boolean, code, category, and role-bound relationship assertions. +- Define negative fixtures for bundle-level inference and existence disclosure. +- Define the three source-shape compatibility mocks and their exact request, + authentication, cardinality, and failure expectations. +- Define identifier-only, compound no-identifier, additional-disambiguator, + and multi-role selector fixtures with authorization and redaction + expectations. +- Define all four initial assertion cases before production architecture is + written. +- Map each security invariant to a threat, enforcement point, and negative + test. + +### Phase 1: generic offline kernel + +- Parse and validate the bundle. +- Compile the source-adapter and requirement-derivation Rhai scripts. +- Validate selector profiles and map source fixtures to closed lookup outcomes. +- Run all four initial assertion cases through the same evaluator. +- Reject undeclared, mistyped, oversized, or incomplete concept values before evidence construction. +- Construct deterministic JSON evidence and sign it with a fixture key. +- Verify that payload or protected-header modification invalidates the signature. +- Prove that raw source facts cannot enter evidence, logs, audit, or errors. + +### Phase 2: generic source boundary + +- Add fixed HTTP JSON source execution, fixed headers, selector-bound path + templates, private-CA trust profiles, and generic Basic, static Bearer, + static API-key header, and OAuth 2.0 client-credentials authentication. +- Run flat REST, paged nested REST, and OpenCRVS Event Search-shaped contracts + through one source executor. +- Prove the selector matrix, zero, one, and multiple lookup outcomes, and no + broad candidate retrieval or candidate choice. +- Prove at least one definition can change source shapes using YAML and Rhai + only. +- Reject DHIS2 or OpenCRVS code, dependencies, features, or public contract + variants outside test and fixture paths. + +### Phase 3: trust, authorization, audit, and signing + +- Add the selected authentication profile. +- Add selector value-origin, subject-authority, and authorization enforcement. +- Add production signing-key resolution, fail-closed signing, and public JWKS publication. +- Add durable audit before source access and before release. +- Run all four cases through every trust boundary. + +### Phase 4: native HTTP service and operations + +- Add the evidence and operational endpoints, limits, safe errors, readiness, + rate controls, and generated public contracts. +- Run all four cases through the real router while multiple definitions are + enabled in one process. + +### Phase 5: privacy, isolation, and schema freeze + +- Prove minimization, selector confidentiality, no-match and ambiguity + behavior, combined disclosure safety, cross-definition isolation, failure + closure, and signature verification. +- Attempt optional read-only public-demo smoke tests after deterministic mocks. +- Freeze Version 1 schemas only when the complete acceptance set passes. + +### Phase 6: release readiness and stop + +- Complete operator and verifier guidance and all applicable package, + contract, dependency, and workspace gates. +- Satisfy every Definition of Done row in `IMPLEMENTATION.md` on one revision. +- Stop implementation before every future profile in section 15. Future work + requires a new approved concept and plan. + +## 19. Success criteria + +The concept succeeds if: + +- a requirement is understandable from one YAML definition, small Rhai extraction and derivation scripts, and its fixtures; +- the Rust core contains no adult-status, residence, licence, or parent-specific response path; +- the Rust core contains no domain operation named for adult status, age thresholds, licence state, residence, or parentage; +- every disclosed value maps to one declared Information Concept; +- every subject binding maps to one fixed role; +- each role uses one authorized, closed selector profile and selector value + origin, including at least one profile that requires no identifier; +- source calls request no unnecessary fields in source-derived or field-projected cases; +- record-transformed cases are identified honestly; +- no raw selector, source, or disclosed value appears in logs, audit, or errors; +- authorization binds requester, purpose, requirement revision, each role's + selector profile and value origin, subject authority, and audience; +- one process safely serves multiple definitions within one trust domain; +- adding a code or relationship assertion requires no new subsystem; +- flat REST, paged nested REST, and event-index source shapes require no + source-product domain code in Rust; +- all four initial assertion cases pass the complete production path on the + same revision before public contracts freeze; +- production code, dependencies, features, configuration schemas, routes, and + CLI options contain no DHIS2 or OpenCRVS specialization; +- JSON clients do not need to understand CCCEV RDF or XML; +- operators can validate the complete bundle before deployment; +- relying parties can verify every signed assertion using a governed trusted + public key and independent expected nonce, subjects, and output contract; +- unsigned responses are visibly unprotected, explicitly authorized, and + rejected by signed-verification tooling; +- signing-key absence or failure can never produce an unsigned success response; +- the service remains small enough for a maintainer to trace a request end to end. + +## 20. Principal risks + +### Scope expansion + +Catalogs, policy, documents, workflow, credentials, transaction proofs, and interoperability can each grow into separate platforms. Future capabilities stay in named profiles with demonstrated adopters. + +### Signature overclaim and key operations + +A valid JWS can be mistaken for legal notarization or proof that the underlying registry fact is correct. Documentation and field semantics keep legal issuer, technical provider, and signer distinct. Signing failure is fail-closed, private key material never enters the deployment bundle, and public keys remain available through the assertion validity window. + +### False minimization claims + +Redacting after fetching a complete record minimizes disclosure but not acquisition. Every definition declares its source-access posture. + +### Cross-definition inference + +Individually safe assertions may combine into a reconstruction attack. Bundle validation, review, authorization, and negative fixtures treat the bundle as one disclosure surface. + +### Subject substitution and existence oracles + +Identifiers and compound demographic fields are not authority. Closed +role-bound selector profiles, authorization over value origin, pre-source +denial, bounded failed-attempt controls, and collapsed no-match or ambiguous +failures prevent broken object-level authorization and incidental +registry-membership disclosure. + +### Matching scope creep + +Fetching a broad candidate set for scoring or best-match selection would +increase acquisition and turn Evidence into an identity-resolution service. +The provider owns record meaning and lookup cardinality. Evidence accepts only +the closed cardinality outcome plus minimized facts on one unique match. A +reviewed deterministic requirement rule may compare those facts with an +independently authorized role selector without creating a general matcher. + +### Script and configuration capability creep + +Convenience functions can gradually give Rhai request planning, credentials, authorization context, logging, or response construction. The two-function ABI and domain-neutral primitive allowlist remain closed. + +### Shared-process blast radius + +One slow source or expensive script can affect other definitions. Bounded execution, response sizes, timeouts, and per-source concurrency limits are required before serving multiple definitions. + +### Profile concerns entering the core + +OOTS, transaction-bound proof, agent, and document requirements can distort the native model. Profiles translate at the boundary and do not introduce their protocols into the evaluator. + +### Accidental source-product coupling + +A convenient first API can turn its pagination, identifiers, field names, or +authentication flow into hidden core assumptions. The source contract matrix +keeps those details in fixed configuration, generic credential handling, and +Rhai extraction. Public demo checks supplement but never replace deterministic +mocks. + +## 21. Decisions made + +This concept fixes the following decisions: + +1. The product name is Evidence; lowercase evidence denotes the CCCEV-aligned domain object. +2. Version one produces assertion evidence only. +3. JSON is the native request and evidence representation. +4. CCCEV 2.2.0 is the initial semantic reference. +5. Definitions and authorization declarations use startup-only YAML. +6. Rhai performs source extraction and requirement-specific derivation. +7. Rust provides only bounded, deterministic, domain-neutral Rhai primitives. +8. Rust owns networking, credentials, authorization, output validation, evidence construction, projection, signing, and audit. +9. Version one has no policy engine. +10. Signed flattened JWS over the exact Evidence payload is mandatory and the + default. Exact API negotiation may select a distinctly typed unsigned + envelope only when the immutable bundle and complete matched grant permit + it. No signed-path failure falls back to unsigned output. +11. One process serves one operator-controlled trust domain. +12. The reference implementation is one `registry-evidence` crate and one `evidence` binary. +13. The governed evidence bundle is the disclosure-review boundary; closed + runtime bindings cannot override it. +14. General identity resolution, broad candidate retrieval, scoring or + selection, consent issuance, federation, documents, OOTS execution, and + agent authorization are outside version one. Configured provider lookup + and reviewed deterministic requirement comparison over one uniquely + resolved authoritative record are inside version one. +15. Adult status, residence region, professional licence status, and + legal-parent relationship are coequal full-path acceptance definitions, + not Rust product concepts or implementation phases. +16. Source independence is proven in tests with flat REST, DHIS2 Tracker-style + REST, and OpenCRVS Version 2 Event Search-style JSON mocks before any live demo + test. No named source product enters production code or public contracts. +17. Public demo tests are read-only, explicit, local-only, and non-gating; + credentials, tokens, live responses, and demo-subject identifiers are not + repository artifacts. +18. Subject lookup uses trusted, closed selector profiles. Field names and + exact sets are deployment-defined, so an identifier-only profile and a + compound profile such as name components plus date of birth use the same + core. +19. Each selector profile has one exact field set. Alternative sufficient input + sets or additional disambiguators use separate profiles. +20. The authoritative provider owns record meaning and lookup cardinality. + Evidence recognizes only `match`, `no_match`, and `ambiguous`, never + performs broad candidate retrieval, scoring, or selection, and releases + facts only on `match`. A reviewed derivation may apply a deterministic, + versioned rule to matched facts and authorized selectors. +21. Raw selector values and per-field quasi-identifier hashes are forbidden in + logs and native audit. Where audit correlation is needed, one scoped keyed + pseudonym covers the complete canonical role and selector bundle. +22. Evidence-definition discovery uses authenticated + `GET /v1/evidence-definitions`, which returns only complete request shapes + matching exactly one authority path for the verified caller and exact + bundle revision. Static onboarding owns token acquisition, human and legal + context, and verifier trust; OpenAPI describes the wire contract and JWKS + provides key discovery. Discovery metadata never creates authority, and no + public or cross-requester catalog exists. +23. Each request carries one exact 32-byte random nonce echoed into Evidence. + The service does not store, consume, or uniqueness-check it, and strict + signed verification compares it with an independently retained expected + value. +24. Strict verification also requires independently trusted expected subject + bindings and output concepts. Copying expectations from the same response + is not verification. + +## 22. Production deployment decisions + +The product boundary stays closed. Each production deployment still supplies +or confirms these governed choices without changing public schemas or runtime +semantics: + +1. What issuer, audience, token type, algorithm allowlist, and principal claim define the first deployment's OIDC profile? +2. How does that deployment convey and govern subject authority? +3. Which selector profiles, exact field sets, value origins, and public + cardinality-disclosure rules does the first deployment authorize? +4. What exact source contract and acquisition posture does the first production + deployment use? +5. Which source-authentication profiles does that deployment enable? +6. Which Version 1 Evidence Types and fixed CCCEV-aligned concepts does the + deployment enable? +7. Which durable audit sink is the first production target? +8. What legal timezone and observation-time rules govern each time-dependent + production requirement? +9. Which supported signing algorithm and key provider fit the first deployment? +10. How will relying parties obtain and pin the Evidence provider's verification trust? +11. Which permitted existence-disclosure behavior applies to each enabled + requirement under the closed public problem contract? + +## 23. Working references + +- SEMIC, [Core Criterion and Core Evidence Vocabulary 2.2.0](https://semiceu.github.io/CCCEV/releases/2.2.0/). +- European Commission, [OOTS Technical Design Documents v2.0.1, Chapter 4: Evidence Exchange](https://ec.europa.eu/digital-building-blocks/sites/spaces/TDD/pages/973932908/Chapter+4+Evidence+Exchange+v2.0.1+July+2026). +- European Commission, [OOTS Identity and Record Matching](https://ec.europa.eu/digital-building-blocks/sites/pages/viewpage.action?pageId=797081682). +- European Commission, [OOTS Evidence Request Syntax Mapping](https://ec.europa.eu/digital-building-blocks/sites/spaces/TDD/pages/973932961/4.5.1+-+Evidence+Request+Syntax+Mapping+v2.0.1+July+2026). +- European Commission, [OOTS Evidence Response Syntax Mapping](https://ec.europa.eu/digital-building-blocks/sites/spaces/TDD/pages/973932951/4.5.2+-+Evidence+Response+Syntax+Mapping+v2.0.1+July+2026). +- European Commission, [OOTS eDelivery Profiling and Configuration](https://ec.europa.eu/digital-building-blocks/sites/spaces/TDD/pages/973932931/4.7+-+eDelivery+Profiling+and+Configuration+v2.0.1+July+2026). +- European Commission, [OOTS Evidence Exchange Logging](https://ec.europa.eu/digital-building-blocks/sites/spaces/TDD/pages/973932926/4.8+-+Evidence+Exchange+Logging+v2.0.1+July+2026). +- Rhai, [Maximum Number of Operations](https://rhai.rs/book/safety/max-operations.html). +- IETF, [JSON Web Signature](https://www.rfc-editor.org/rfc/rfc7515.html). +- IETF, [JSON Web Key](https://www.rfc-editor.org/rfc/rfc7517.html). +- OAuth 2.0, [Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html). +- OAuth 2.0, [Rich Authorization Requests](https://www.rfc-editor.org/rfc/rfc9396.html). +- X-Road, [Data Exchange](https://x-road.global/data-exchange). +- OpenFn, [Workflows](https://docs.openfn.org/documentation/build/workflows). +- DHIS2, [Tracker API 2.43](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-243/tracker.html). +- OpenCRVS, [Record Search clients](https://documentation.opencrvs.org/v1.8/technology/interoperability/create-a-client/record-search-clients). +- OpenCRVS, [Authenticate a client](https://documentation.opencrvs.org/technology/interoperability/authenticate-a-client). +- UK Government, [How to check someone's identity, GPG 45 version 1.0](https://www.gov.uk/government/publications/how-to-check-someones-identity-1-0). +- UK Government, [Data taxonomy, data model and data dictionary for GPG 45](https://www.gov.uk/government/publications/uk-digital-verification-services-trust-framework-data-schema-1-0/data-taxonomy-data-model-and-data-dictionary-for-gpg-45). +- Registry Notary, [Consultation identity and outcomes](../../products/notary/docs/identity-and-record-matching.md). +- Internal design input, `jurisdiction-neutral-evidence-packs-gap-spec-2026-06-19.md`, reviewed 2026-08-02. diff --git a/products/evidence/FIRST-CURL-TEST.md b/products/evidence/FIRST-CURL-TEST.md new file mode 100644 index 000000000..a2f9a8749 --- /dev/null +++ b/products/evidence/FIRST-CURL-TEST.md @@ -0,0 +1,217 @@ +# First Evidence server curl + +Status: Ready for deterministic local operator test + +This checkpoint curls the Evidence server itself. DHIS2 and OpenCRVS are not +called. A deterministic local source returns synthetic data so the result +proves authenticated requester-scoped definition discovery plus the Evidence +assertion route, bearer authentication, authorization, request-nonce +validation and echo, response-format negotiation, selector handling, +source request, Rhai extraction and derivation, minimum-disclosure output gate, +signing, JWS verification, and both durable audit events. + +The harness uses the production Evidence router and runtime with two deliberate +test substitutions: an in-memory test JWKS authenticates the requester, and a +local mock stands in for the upstream source. Those substitutions keep the +first curl reproducible and credential-free. Production OIDC JWKS retrieval, +the `evidence serve` startup command, and live provider compatibility are later +checkpoints and are not implied by this pass. + +## Run the server + +From the repository root, run this in terminal 1: + +```bash +CARGO_INCREMENTAL=0 \ +CARGO_PROFILE_DEV_DEBUG=0 \ +CARGO_PROFILE_TEST_DEBUG=0 \ +cargo test --locked -p registry-evidence \ + first_curl_exercises_and_verifies_the_evidence_server \ + -- --ignored --nocapture +``` + +Wait until the harness prints `Evidence first-curl server is ready`. It listens +only on `127.0.0.1:18080` and creates an ignored, owner-only directory at +`products/evidence/.first-curl/`. The directory contains the exact synthetic +request plus `session.env`, which contains only a short-lived synthetic bearer +token. It does not read `products/evidence/.env`. + +## Discover available Evidence + +Load the short-lived synthetic bearer token, then ask the Evidence server what +this authenticated caller can request: + +```bash +set -a +. products/evidence/.first-curl/session.env +set +a + +curl --fail-with-body \ + --request GET \ + --header "Authorization: Bearer ${EVIDENCE_ACCESS_TOKEN}" \ + --header 'Accept: application/json' \ + --output products/evidence/.first-curl/definitions.json \ + --write-out 'HTTP %{http_code}\n' \ + http://127.0.0.1:18080/v1/evidence-definitions + +jq . products/evidence/.first-curl/definitions.json +``` + +The response lists four complete, requester-authorized definitions. Each item +contains the requirement, Evidence Type, purpose, reference frameworks, +subject roles, selector profile and value origin, safe selector field contract, +and output concepts. It does not expose source identifiers, URLs, scripts, +credentials, authority-profile names, requester tags, selector values, or +codelist values. Discovery performs no provider call and writes no evidence-data +audit event. + +## Request Evidence + +Inspect the complete request: + +```bash +jq . products/evidence/.first-curl/request.json +``` + +Every request carries a required `requestNonce`: the canonical unpadded +base64url encoding of exactly 32 random bytes, so exactly 43 characters. The +harness writes one into `request.json`. When you compose a request by hand, +generate a fresh value per request and never reuse, hand-edit, or derive it +from identifiers, selectors, secrets, or document digests: + +```bash +openssl rand 32 | basenc --base64url | tr -d '=\n' +``` + +Evidence echoes the exact value into the Evidence payload under +`requestNonce` and covers it by the signature. Keep your copy of the request so +a verifier can compare the echoed nonce with the value it sent. Evidence does +not store the nonce, does not reject reuse, and makes no replay-prevention +claim. + +### Optional unsigned variant + +The first-curl bundle and its matched grant both permit `unsigned-json`, so you +may ask the same route for a visibly unsigned envelope. Run this before the +signed request below, because the harness shuts down as soon as it verifies the +signed response: + +```bash +curl --fail-with-body \ + --request POST \ + --header "Authorization: Bearer ${EVIDENCE_ACCESS_TOKEN}" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/vnd.registrystack.evidence-unsigned+json' \ + --data-binary @products/evidence/.first-curl/request.json \ + --output products/evidence/.first-curl/response-unsigned.json \ + --write-out 'HTTP %{http_code}\n' \ + http://127.0.0.1:18080/v1/evidence + +jq . products/evidence/.first-curl/response-unsigned.json +``` + +The envelope carries `"integrityProtection": "none"` and a +`"not-cryptographically-verifiable"` warning around the same closed Evidence +object. It is transport-authenticated convenience data for development and for +consumers that cannot process JWS, never later-verifiable evidence and never a +fallback when signing fails. + +Unsigned output is governed, not a client choice. It succeeds only when the +immutable bundle and the one complete matched grant both permit it; otherwise +the request is refused with the ordinary `not_authorized` problem before +credentials or source access, without revealing which layer refused. The +production reference bundles declare `responseFormats: [signed-jws]`, so the +same header there returns that refusal. A duplicate, combined, parameterized, +weighted, or unknown `Accept` returns the +`response_format_not_acceptable` problem with HTTP 406 before source access. + +### Signed request + +Then run this plain curl. There is no curl config, wrapper, proxy, redirect, or +hidden request option: + +```bash +curl --fail-with-body \ + --request POST \ + --header "Authorization: Bearer ${EVIDENCE_ACCESS_TOKEN}" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/jose+json' \ + --data-binary @products/evidence/.first-curl/request.json \ + --output products/evidence/.first-curl/response.json \ + --write-out 'HTTP %{http_code}\n' \ + http://127.0.0.1:18080/v1/evidence + +unset EVIDENCE_ACCESS_TOKEN +jq . products/evidence/.first-curl/response.json +``` + +Every HTTP choice is visible in the command. `session.env` prevents only the +short-lived bearer value from being committed into this document. You may +inspect that local file, but do not paste its token into chat. + +The curl prints: + +```text +HTTP 200 +``` + +`jq` then prints the actual flattened JWS response. In parallel, the server +harness validates the explicit discovery response, reads the assertion response +file, verifies the JWS against the running Evidence JWKS, checks the expected +minimized boolean, confirms protected source and selector fields are absent, +confirms both audit events are durable, shuts down, and ends with: + +```text +PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable. +``` + +Anything else is not a pass. Do not paste `session.env` or its bearer token +into chat. The responses are retained at +`products/evidence/.first-curl/definitions.json`, +`products/evidence/.first-curl/response.json`, and, if you ran the optional +variant, `products/evidence/.first-curl/response-unsigned.json` for local +inspection and are gitignored. + +## Local provider credentials + +Optional live-provider work uses `products/evidence/.env`. The real file is +gitignored, owner-only, and contains the supplied DHIS2 public-demo credentials +plus the existing OpenCRVS system-client values and approved demo selector from +the local `.opencrvs.env`. The tracked `.env.example` lists the exact keys. + +The Evidence runtime does not load credentials from environment variables. +When a live Evidence deployment is prepared, a launcher must copy only the +selected profile's values from `.env` into the runtime's owner-only secret files +and then start Evidence. This preserves the Version 1 file-secret boundary. + +Do not source `.env` into an interactive shell, print it, pass its values on a +command line, commit it, or send it in chat. Provider calls remain read-only and +must use one approved demo record. A server-level DHIS2 or OpenCRVS checkpoint +is not ready until its deployment-specific selector and mapping are confirmed. + +## What may wait until after this curl + +The following may wait for this first deterministic Evidence-server curl, but +not for Version 1 completion: + +- prepare an ephemeral production-startup harness that runs `evidence serve` + with HTTPS OIDC JWKS and the file-secret boundary; +- run the Evidence server against one bounded DHIS2 demo record and one bounded + OpenCRVS demo record using `.env`, then verify each returned JWS; +- bind the remaining authenticated definition-discovery acceptance requirement + to executable traceability so every numbered requirement maps to a test; +- make `existenceDisclosure` and the fixed JWKS path explicit enforced + invariants rather than decorative configuration; +- document audit capacity, rotation/restart, and independent chain files, and + bind the Unix single-link secret check to security traceability; +- rerun the final Evidence package, contract, neutrality, generated-artifact, + and ignored live-source gates on one stable revision; +- stage the exact Evidence scope. Workspace-wide gates remain outside the + current Evidence-only instruction. + +Lower-priority review notes may also wait: decide whether governed requirements +need human-readable labels, add the pre-implementation decision-to-contract +index, clarify that the provider-side two-result limit is governed adapter +policy rather than a Rust domain rule, and correct the narrower selector +wording in the scratch review note. The deprecated unkeyed platform audit trait +method is unused by Evidence and belongs to shared platform maintenance. diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md new file mode 100644 index 000000000..138004d70 --- /dev/null +++ b/products/evidence/IMPLEMENTATION.md @@ -0,0 +1,890 @@ +# Evidence Implementation Approach + +Status: Approved Version 1 implementation schedule and Definition of Done +Date: 2026-08-02 + +Source-contract and local-smoke details: [SOURCE-TESTING.md](SOURCE-TESTING.md) + +## Outcome + +Implement the complete Evidence Version 1 contract defined by `CONCEPT.md`. +The work is not complete when one assertion works. It is complete only when +the full API, bundle, source, authorization, Rhai, validation, signing, audit, +operations, and verification boundaries satisfy the Definition of Done in this +document across the complete acceptance-definition set. + +No acceptance case is the architectural seed or an implementation milestone. +Adult status, controlled region, professional licence status, and legal-parent +relationship are coequal proofs of a generic engine from the first offline +phase onward. The implementation schedule stops at the Version 1 boundary. It +does not begin any capability listed under `CONCEPT.md` section 15, Future +profiles and guarded extensions. + +## Version 1 product shape + +Version one is one crate and one binary. Product-owned contracts and fixtures +remain outside the crate: + +```text +crates/registry-evidence/ + src/ + audit.rs + auth.rs + binding.rs + bundle.rs + config.rs + contracts.rs + kernel.rs + lib.rs + main.rs + model.rs + problem.rs + rate_limit.rs + rhai_runtime.rs + runtime.rs + secrets.rs + selector.rs + server.rs + signing.rs + source.rs + values.rs + verifier.rs + tests/ + cli.rs + deployment_projects.rs + source_contracts.rs + selector_conformance.rs + security_contract_traceability.rs + live_sources.rs +products/evidence/ + contracts/ + fixtures/ + generated/ + reference/ + scripts/ +``` + +The binary exposes three commands: + +```text +evidence serve +evidence check +evidence evaluate --fixture +``` + +Do not create client, worker, adapter, policy, credential, or interoperability +crates in version one. Rhai adapters and derivations are deployment-bundle +artifacts, not Rust crates. + +Production code is source-product neutral. `src/`, production Cargo features, +dependencies, public types, configuration schemas, routes, and CLI options +must contain no DHIS2-specific or OpenCRVS-specific behavior. Those product +names and shapes may appear only in tests, sanitized fixtures, test-only bundle +artifacts, and the local smoke guide. Event Search is an ordinary bounded JSON +`POST`; a Tracker request is an ordinary fixed-authority `GET` with prepared +query parameters and Rhai extraction. + +## Dependency boundary + +Reuse selected shared primitives without inheriting another product model: + +| Crate | Version-one use | +|---|---| +| `registry-platform-audit` | Keyed chain integrity, scoped pseudonymization, JSONL sink, and chain verification | +| `registry-platform-crypto` | `SigningProvider`, protected JWK handling, signing, and public JWK publication | +| `registry-platform-oidc` | Strict access-token and JWKS verification for the reference authentication profile | +| `registry-platform-httpsec` | Security response headers where the existing contract fits | +| `registry-platform-httputil` | Bounded source-response body reads | + +Evidence must not depend on `registry-notary*`, `registry-platform-pdp`, +`registry-platform-oid4vci`, `registry-platform-sdjwt`, +`registry-platform-replay`, `registry-platform-sts`, or Registry Manifest. + +The governed bundle and closed operator runtime file are trusted and +startup-only. Use typed YAML and explicit secret references. Runtime +configuration binds only process-local listener, filesystem, audit-storage, +secret-mount, and TLS-trust paths; it is not an override layer. Do not expand +private key material or source credentials into either parsed YAML document. + +## Reference implementation defaults + +These defaults unblock implementation without claiming that every deployment +must use them: + +| Area | Reference default | +|---|---| +| Authentication | OIDC access token with exact issuer, audience, type, and algorithm allowlists | +| Principal | One configured claim, initially `sub`; missing claim denies with no fallback | +| Subject authority | Configured statutory-agency profile permits an authorized requester to use only named selector profiles and approved value origins | +| Subject selector | Closed identifier or compound field set with deployment-defined names, scalar types, bounds, and fixed source placements | +| Lookup outcome | Provider-owned `match`, `no_match`, or `ambiguous`; facts exist only on `match` | +| Source | One fixed HTTP JSON data request using field projection and denied redirects | +| Source authentication | Secret-referenced Basic, static Bearer, static API-key header, or OAuth 2.0 client credentials | +| Audit | `registry-platform-audit` JSONL sink on explicitly durable storage, fail-closed | +| Signing | Flattened JWS JSON with one active EdDSA key and a public JWKS endpoint | +| Response format | Signed JWS by default; exact `Accept: application/vnd.registrystack.evidence-unsigned+json` only when the bundle and complete matched grant permit it | +| Evidence storage | None | +| Runtime mutation | None | + +The real identity provider, authority mapping, source contracts, reference +framework rules, audit storage, and verifier trust distribution remain +deployment inputs. Mocked contracts allow the generic runtime to be built +before those integrations are available. + +## Runtime pipeline + +The production path is fixed: + +1. Parse and bound the JSON request, including the required canonical 43-byte + base64url encoding of an exact 32-byte random request nonce. +2. Authenticate the token from configured issuer metadata. +3. Derive the principal only from the configured claim. +4. Resolve the requirement, purpose, audience, exact requested response format, + and subject-authority profile. +5. Resolve each role's configured selector profile and obtain its values only + from the profile's permitted origin: authenticated context, authenticated + grant, or the bounded request. +6. Validate the exact field set, scalar types, bounds, role, and fixed source + binding. Reject missing, unknown, or extra material. +7. Make one authorization decision over requester, optional actor, requirement + revision, purpose, role-bound selector profiles and value origins, subject + authority, audience, and response format. +8. Durably write the access-attempt audit event with at most one scoped keyed + pseudonym over each complete canonical role and selector bundle. +9. Run bounded request preparation with only the source-required authorized + selectors and closed adapter parameters, then validate the complete + `RequestParts` result. +10. Resolve the configured source credential. When required, acquire or reuse a + bounded OAuth 2.0 client-credentials token through the Rust-owned credential + provider. +11. Execute the one Rust-owned evidence-data source request. +12. Run bounded Rhai extraction and validate the closed `match`, `no_match`, or + `ambiguous` result. Stop safely on either non-match outcome. +13. On `match`, run bounded Rhai derivation with only its declared authorized + selector inputs and deterministic context. +14. Validate the complete concept-value result against the selected + requirement. +15. Construct the Evidence JSON payload in Rust without selector profiles or + values and with the exact request nonce. +16. For signed JWS, sign and serialize the exact final response bytes. For an + explicitly authorized unsigned request, construct and serialize the closed + self-identifying unsigned envelope without invoking the signer. +17. Durably write the pseudonymized disclosure-release audit event with the + closed response-protection mode and a signing key id only for JWS. +18. Return those exact pre-audited bytes with their exact media type. + +Any failure through request-parts validation in step 9 prevents credential +acquisition and source access. Any failure after source +access prevents evidence release. Audit failure prevents either response. +Signing failure on the signed path never produces an unsigned success response. + +## Bundle and runtime contracts + +The governed atomic bundle contains: + +```text +evidence.yaml +adapters/ + source-a-prepare.rhai + source-a-extract.rhai +derivations/ + requirement-a.rhai +schemas/ +codelists/ +fixtures/ +``` + +The separate `runtime.yaml` binds the bundle directory, listener, secret root, +audit destination, and logical TLS trust profiles to local paths. The bundle +and runtime hashes identify the exact inputs loaded by the process. They do not +prove trust. Deployment controls establish trust by mounting both reviewed +inputs read-only and starting a new process for a new revision. + +Bundle checking must validate: + +- stable and unique requirement, concept, Evidence Type, source, adapter, and + derivation identifiers; +- exact subject roles and cardinalities; +- selector profiles with one exact field set, stable deployment-defined field + names, scalar types, byte bounds, and a maximum aggregate size; +- allowed selector profiles and value origins for every subject role and + authority profile; +- closed source-required selector inputs, reviewed request-preparation scripts, + and a source binding for every allowed role and profile combination; +- closed derivation selector inputs that are exact subsets of the selected + requirement roles and profiles; +- fixed purposes, requester classes, audiences, and subject-authority paths; +- fixed source scheme, host, method, fixed path or selector-bound path + template, fixed non-secret headers, fields, credentials, TLS trust-profile + name, limits, and redirect policy; +- fixed source-authentication scheme and, for OAuth, token endpoint, grant, + credential placement, scope, bounds, and cache lifetime; +- a closed runtime file with no governed-field override, complete bundle and + logical trust-profile bindings, and no ambient proxy behavior; +- fact and concept schemas; +- derivation parameter types; +- codelist references and versions; +- successful Rhai compilation; +- positive, negative, boundary, missing-data, no-match, ambiguous-match, and + anti-reconstruction fixtures; +- combined disclosure safety of every simultaneously enabled definition. + +## Source and credential boundary + +Version one implements one generic HTTP JSON source executor. Product-specific +source crates, clients, and domain types are out of scope. A source definition +declares a fixed request and selects one generic authentication profile: + +- HTTP Basic with username and password secret references; +- static Bearer with a token secret reference; +- static API-key header with a fixed allowlisted header name and secret + reference; +- OAuth 2.0 client credentials with client identifier and secret references, + a fixed HTTPS token endpoint, fixed grant and optional fixed scope. + +Credential acquisition is not available to Rhai. OAuth token acquisition may +make a separate HTTP request, but it is not a second evidence-data lookup and +cannot contribute facts. Rust bounds its response, accepts only the configured +token shape, clamps cache lifetime to the returned expiry and configured +maximum, and never logs the request URL, query, body, response, token, client +identifier, or secret. A provider that requires credentials in query +parameters is supported only by an explicit credential-placement setting and +the same redaction rule. + +Every evidence-data request still has one Rust-fixed host, method, fixed path or +closed Rust-expanded complete-segment path template, fixed non-secret headers, +permitted query and body channels, response projection, TLS trust profile, +timeout, redirect denial, maximum response size, concurrency limit, and +one-request ceiling. +After authorization and durable access-attempt audit, a reviewed preparation +script renders ordered logical query pairs and at most one JSON body from only +the source-required authorized selectors and closed non-secret parameters. +Rust validates the result, percent-encodes query components exactly once, +expands any selector-bound path segments without script involvement, and +constructs the request before credential acquisition. It applies the configured +extended JSON Pointer allowlist after bounded parsing and before extraction. +The core does not normalize names or implement source matching semantics. + +Private-CA files are runtime bindings for logical bundle trust-profile names. +Hostname and fixed-origin verification remain mandatory. Version 1 has no +application-level proxy and ignores ambient HTTP proxy environment variables. + +The request asks the provider for only enough information to distinguish no +match, one match, or ambiguity and to produce the declared facts. Prefer a +count plus one minimized result. When the API cannot provide that shape, +request at most two minimally projected results and never follow pages. Rhai +may map their cardinality but must not score or choose between candidates. A +requirement derivation may compare minimized facts from one unique +authoritative record with its separately authorized selectors using a +deterministic, versioned rule. + +A source that cannot expose this bounded lookup is not directly compatible with +Version 1. It requires an external governed integration service. Do not add a +bulk read, local index, local matching database, probabilistic matcher, or +candidate-ranking script to Evidence as a workaround. + +## Source-shape contract suite + +The first implementation includes three minimal local mock profiles. They +exercise different wire contracts while using the same source executor, +evaluator, output gate, signer, and audit path. + +| Profile | Boundary shape | What it proves | +|---|---|---| +| `flat-rest` | Fixed JSON request and flat JSON object | Identifier and compound selector contracts plus direct fact extraction | +| `dhis2-tracker` | `GET` query, selected fields, pager, collection, nested attribute array, Basic auth | REST query encoding, compound selectors, cardinality, pagination refusal, and code-based extraction | +| `opencrvs-event-search` | OAuth client token, bounded JSON `POST`, nested event index, and country-configured declaration | Credential bootstrap, exact tracking-ID lookup, nested extraction, and selector-aware relational derivation | + +These are compatibility-shaped mocks, not whole-product emulators or claims of +certified DHIS2 or OpenCRVS support. Fixtures are small, invented, and +hand-authored from public documentation. No captured live response is checked +in. A source-specific behavior is added to the mock only when Evidence relies +on it. + +The mock profile names do not become runtime identifiers. Production code sees +only generic HTTP methods, URLs, query and JSON-body templates, authentication +profiles, bounds, and parsed JSON. Basic, Bearer, and OAuth client-credentials +support must each have a generic contract and tests independent of either +named product. + +The suite must prove at least: + +- exact request method, path, query, body, fields, headers, and authentication; +- identifier-only, no-identifier compound, additional-disambiguator, and + multi-role selector profiles with deployment-defined field names; +- missing, extra, unknown, mistyped, oversized, or unauthorized selector input + rejected before credential acquisition and source access; +- no extra evidence-data request after zero, one, or multiple results; +- configured `pageSize` detects ambiguity rather than following pagination; +- no broad candidate list, score, near-match diagnostic, or comparison detail + is returned to the caller, logs, audit, or evidence; +- malformed event-index envelopes and incomplete declarations fail closed; +- credentials, raw selector values, source values, and response bodies are + absent from logs, audit, errors, snapshots, and assertion messages; +- `401`, `403`, `429`, `5xx`, timeout, redirect, invalid JSON, wrong media type, + oversized response, missing fact, zero matches, and multiple matches; +- switching among all three profiles requires bundle and Rhai changes only, + with no domain branch in Rust. + +The OpenCRVS shape may support an adult-status fixture because a birth record +can supply a date of birth. The DHIS2 shape should exercise a controlled code +or status instead, so the compatibility suite also resists adult-status +overfitting. + +## Rhai contracts + +Use three functions with separate responsibilities: + +```text +prepare(source_required_selectors, adapter_parameters) -> RequestParts +extract(source_response, adapter_parameters) -> LookupResult +derive(facts, declared_authorized_selectors, evaluation_context) + -> array +``` + +`LookupResult` is exactly `match(FactSet)`, `no_match`, or `ambiguous`. The +source adapter performs source-specific response parsing and cardinality +mapping. It is not separately given the request selector profile or values and +cannot make another source call, return candidates, or choose one row from an +ambiguous result. If a `record-transformed` source response repeats a selector +value, it is protected source data and cannot leave the declared fact boundary. +Rust rejects facts on a non-match outcome and does not invoke derivation unless +the outcome is `match`. + +The requirement derivation implements country-specific, sector-specific, or +legal meaning over the uniquely matched facts. It may compare those facts with +only the authorized selector roles and fields declared by the derivation. +Preparation, +extraction, and derivation run in the same Evidence process as separate, +startup-compiled scripts in the immutable bundle. + +The evaluation context contains only: + +- the observation instant; +- legal local date and time resolved by Rust from the configured IANA timezone; +- fixed typed parameters from the selected requirement; +- bounded access to named, versioned bundle codelists. + +Authorized selectors are a separate explicit derivation input. The evaluation +context never contains requester identity, actor identity, purpose, audience, +grants, tokens, credentials, source clients, logging handles, audit handles, +signing keys, filesystem access, network access, process access, ambient clock +access, or randomness. + +### Primitive standard library + +Rust provides a small, versioned set of pure and bounded primitives: + +- typed ISO calendar dates and instants; +- comparison and calendar-safe addition for dates and durations; +- bounded numeric comparison and bucketing; +- controlled-code lookup in named bundle codelists; +- bounded list and set membership; +- explicit missing-value handling. + +The library contains no operation named for a use case. For example, adult +status can be expressed conceptually as: + +```text +attainment_date = add_calendar_years(date_of_birth, minimum_age_years) +adult_status = legal_date >= attainment_date +``` + +`minimum_age_years` comes from trusted YAML, not the caller. The bundle fixes +the exact reference framework, date semantics, timezone, and boundary fixtures. + +Every primitive has explicit input and output types, maximum sizes, deterministic +behavior, and focused boundary tests. A primitive is added only for a generic +operation that cannot be expressed safely with the existing set. Adding a new +evidence definition should normally require no Rust change. + +### Output gate + +Rust accepts `array` only when: + +- every returned concept is declared by the selected requirement; +- every required concept is present exactly once unless its schema says + otherwise; +- no extra concept or metadata field exists; +- each value matches its scalar, controlled-code, bounded-list, or reviewed + structured schema; +- codelist, cardinality, string, collection, and total-result limits pass. + +Rhai never creates the Evidence identifier, issuer, provider, requirement, +Evidence Type, purpose, audience, subject bindings, timestamps, bundle revision, +JWS headers, signature, or audit record. + +## Version-one acceptance definition set + +All four initial assertion cases in `CONCEPT.md` are mandatory, full-path +acceptance definitions. None is merely an illustrative fixture or a follow-up +generality check. + +| Case | Input facts | Supported Values | Generic capability proved | +|---|---|---|---| +| Adult status | Date of birth or source-derived status | Boolean | Unary assertion, calendar arithmetic, legal-time boundary, and false-as-success semantics | +| Residence region | Official residence code or bounded address field | Controlled region code | Codelist mapping, geographic coarsening, and bounded category disclosure | +| Professional licence status | Licence state and validity dates | Active boolean plus controlled expiry bucket | Multiple concepts, time bucketing, and omission of exact dates and history | +| Legal-parent relationship | Child and candidate-parent roles plus an authoritative relationship fact | Relationship-confirmed boolean | Multiple role-bound subjects, subject substitution resistance, and relationship-specific semantics | + +The test bundles deliberately vary selector shapes: + +- adult status uses a compound person selector without an identifier; +- residence uses an opaque identifier profile; +- professional licence uses a compound sector selector; +- legal-parent relationship uses two role-bound selectors and exercises both an + identifier profile and a no-identifier compound profile. + +These are fixture choices, not product semantics. At least one additional +fixture uses deployment-defined field names that are not `given_name`, +`family_name`, `birth_date`, `person_id`, or another built-in-looking +vocabulary. Changing those names or mappings changes YAML, source fixtures, and +Rhai only. + +Each case has a complete test-only bundle with YAML, Rhai extraction, Rhai +derivation, codelists where needed, positive, negative, boundary, +missing-record, missing-fact, source-failure, and anti-reconstruction fixtures. +Each must pass offline evaluation and the production HTTP pipeline, including +authentication, authorization, source execution, access audit, output gating, +evidence construction, signing, release audit, and verification. + +Production Rust contains no type, operation, field, route, feature, or branch +named for adult status, age thresholds, residence, licence, parentage, DHIS2, +or OpenCRVS. Adding or changing any acceptance definition changes test bundles +and Rhai, not the core domain model. + +## Implementation schedule + +This is a dependency sequence, not a set of partial product releases. Every +phase must preserve all acceptance definitions introduced in Phase 0. + +### Phase 0: freeze Version 1 contracts and DoD + +- Promote accepted contracts into tracked `products/evidence` material when + Jeremi authorizes implementation. +- Freeze the CCCEV-to-JSON profile, public request, request-nonce, JWS, and + unsigned-envelope response schemas, + governed-bundle and runtime YAML schemas, their ownership split, bundle + layout, selector-profile contract, `LookupResult` and derivation Rhai ABIs, + generic primitive set, source contract, normalized + authority context, authorization inputs, audit event schemas, problem codes, + signing profile, content negotiation, response-format authorization, and + verifier rules. +- Create the four acceptance-definition bundles and the three source-shape + mock contracts before production architecture is written. +- Create identifier-only, no-identifier compound, additional-disambiguator, + and multi-role selector fixtures before production architecture is written. +- Create conformance fixtures for every Supported Value form and every + source-access posture declared for Version 1. +- Freeze the project fixture vocabulary, response-projection grammar, fixed + header rules, selector-bound path-template rules, source-authentication + profiles, private-CA bindings, and ambient-proxy rejection. +- Map every security invariant to its threat, Rust enforcement point, and + negative test. +- Record the future-profile stop boundary from `CONCEPT.md` section 15. + +Exit gate: every Version 1 contract and every DoD row is reviewable; no future +profile has a placeholder API, module, configuration field, or extension hook. + +### Phase 1: generic offline kernel + +- Create the single crate and binary with typed domain models, bundle loading, + atomic hashing, and startup validation. +- Implement `evidence check` and fixture-only `evidence evaluate`. +- Implement bounded Rhai extraction and derivation, the domain-neutral + primitive library, exact output gating, and deterministic Evidence JSON + construction. +- Implement generic selector-profile parsing and validation without any + identity field vocabulary or matching algorithm in Rust. +- Add fixture signing solely to verify the complete evidence model offline, + including independently expected nonce, subject-binding, and output-contract + checks. +- Run all four acceptance definitions through the same kernel. +- Run type, size, precision, code, reference, list, and cardinality fixtures + for the complete Supported Value contract. + +Exit gate: all four cases pass offline, including their boundary and negative +fixtures, and production Rust contains no case-specific branch or type. + +### Phase 2: generic source boundary + +- Implement one fixed-authority HTTP JSON data-request executor with bounded + response parsing, denied redirects, timeouts, concurrency limits, reviewed + query/body rendering, fixed non-secret headers, selector-bound path + templates, private-CA trust profiles, and exact client-side response + projection. +- Implement generic HTTP Basic, static Bearer, static API-key header, and OAuth + 2.0 client-credentials providers using secret references. +- Implement strict provider-text `parse_integer` without enabling implicit + query-value conversion or a provider-resolution DSL. +- Run flat REST, paged nested REST, and Event Search-shaped local mocks through + the same executor. +- Cover all four acceptance definitions across the source-shape matrix and run + at least one definition against two different shapes using only YAML and + Rhai changes. +- Prove identifier, compound no-identifier, additional-disambiguator, and + multi-role selectors; prove zero, one, and multiple outcomes without broad + candidate retrieval or candidate exposure. +- Add a repository check that rejects DHIS2 or OpenCRVS names, dependencies, + features, modules, types, and branches in production source, Cargo metadata, + and generated public contracts. +- Prove ambient proxy variables cannot redirect either evidence-data or OAuth + requests, and prove unbound, malformed, mutable, or insecure private-CA files + prevent readiness. + +Exit gate: all source contract tests pass, no product-specific source code +exists, and no source value, raw selector value, credential, token, or response +reaches logs, audit, errors, or disk. + +### Phase 3: trust, authorization, audit, and signing + +- Implement the strict OIDC reference profile and configured-principal claim + with no claim fallback. +- Implement subject-authority profiles and one fail-closed authorization + decision over requester, optional actor, requirement revision, purpose, + role-bound selector profile and value origin, subject authority, and + audience and requested response format. +- Write the pseudonymized access-attempt audit durably before source access and + the disclosure-release audit durably after final response serialization and + before release. +- Resolve production signing keys, create the exact flattened JWS JSON + response, publish public JWKS, and define key rollover and retired-key + availability. +- Bind enabled response formats into the immutable bundle and allowed formats + into every authority grant; signed JWS remains mandatory and default. +- Run every acceptance definition through these boundaries. + +Exit gate: every denial occurs before source access; audit or signing failure +prevents release; principal, subject, purpose, audience, values, and keys obey +the privacy and trust invariants. + +### Phase 4: native HTTP service and operations + +- Implement authenticated `GET /v1/evidence-definitions`, + `POST /v1/evidence`, `/health`, `/ready`, and the public JWKS endpoint with + exact media types, strict `Accept` handling, `Vary: Accept`, and a closed + `406` response-format problem. +- Implement the self-identifying unsigned envelope through the same authorized, + minimized, audited path without a second evaluator or signing fallback. +- Add request and response limits, safe problem responses, per-principal rate + controls, dependency timeouts, and shutdown behavior. +- Generate JSON Schema and OpenAPI from code and add drift checks. +- Test all four acceptance definitions through the real router and HTTP client + while multiple definitions are enabled in one process. + +Exit gate: each acceptance case produces a verifiable signed assertion and an +explicitly authorized unsigned envelope through the same public operation, and +operational endpoints expose no protected data. + +### Phase 5: privacy, isolation, and schema freeze + +- Prove source minimization, existence-disclosure behavior, combined-definition + inference controls, selector confidentiality, safe no-match and ambiguity + collapse, script isolation, cross-definition state isolation, and safe + concurrency and failure behavior. +- Prove payload or protected-header mutation fails verification; nonce, + expected-subject, or expected-output-contract mismatch fails; and unsigned + output is explicit, governed, self-identifying, and never a fallback from + signed failure. +- Attempt the ignored, read-only DHIS2 and OpenCRVS public-demo smoke tests only + after deterministic mocks pass, following `SOURCE-TESTING.md`. +- Freeze Version 1 schemas only after all four initial assertion cases and all + negative security tests pass unchanged through the complete pipeline. + +Exit gate: the complete Definition of Done is green except packaging and final +workspace gates, and any live-demo result is recorded only as pass, skipped, or +inconclusive without protected data. + +### Phase 6: release readiness and stop + +- Provide operator configuration, secret, bundle-mount, audit-storage, + signing-key, key-rotation, backup, and verifier guidance for the supported + deployment mode. +- Prove a clean build, package tests, generated-contract reproducibility, + dependency policy, and the applicable workspace gates. +- Self-review the complete diff against the security acceptance matrix and the + future-profile stop boundary. + +Exit gate: every Definition of Done row is satisfied with reproducible test or +review evidence. Version 1 implementation stops here. Future profiles require +new concept approval and are not continuation tasks for this schedule. + +## Definition of Done + +Evidence Version 1 is done only when every row below is satisfied on the same +revision. A passing adult-status demonstration, a working endpoint, or a green +subset of tests is not completion. No requested Version 1 behavior may remain +as a stub, TODO, partially implemented path, undocumented manual step, or +follow-up issue. + +| Area | Done when | +|---|---| +| Scope and architecture | One `registry-evidence` crate and one `evidence` binary implement the complete Version 1 path without any `registry-notary*`, PDP, credential-issuance, replay, worker, or interoperability subsystem dependency. | +| Public contracts | The CCCEV-aligned Evidence JSON profile, request nonce, request and selector schemas, flattened JWS and unsigned-envelope responses, exact content negotiation, governed-bundle and runtime YAML schemas, closed `prepare/2`, `extract/2`, and selector-aware `derive/3` Rhai ABIs, projection and fixture contracts, audit events, problem codes, JSON Schema, and OpenAPI are reviewed, versioned, generated where applicable, and protected by CI drift tests. Subject-array order is not semantic; Rust resolves unique roles and emits declaration order internally. | +| Initial assertion cases | Adult status, residence region, professional licence status, and legal-parent relationship each pass offline and through the real HTTP service, including authentication, authorization, response-format permission, source access, both audit gates, output validation, signed JWS, explicitly authorized unsigned output, and strict verification. | +| Generic domain model | The four cases use one model and operation. Production Rust has no adult, age, residence, licence, parentage, personal-name-part, national-identifier, or other acceptance-case or jurisdiction-specific type, field, operation, route, feature, or conditional. Deployment-defined selector field names are opaque stable names. | +| Source-product neutrality | Production code, Cargo metadata, and generated public contracts have no DHIS2 or OpenCRVS module, type, dependency, feature, configuration variant, route, CLI option, or conditional. Product names and shapes appear only in tests, sanitized fixtures, test-only bundles, and design or local-smoke documentation. | +| Bundle and Rhai | Startup rejects incomplete, inconsistent, mutable, or uncompilable governed bundles and runtime files and serves only their one immutable revision. Runtime bindings cannot override governed fields. Every role and authority path has a complete selector-profile and source binding. Rhai preparation, extraction, and derivation are deterministic, bounded, and fresh per invocation. Preparation receives only source-required authorized selectors and closed parameters; extraction sees only the bounded projected response and parameters; derivation sees matched facts, only its declared authorized selector inputs, and the closed evaluation context. No script receives network, filesystem, environment, ambient clock, randomness, credentials, authorization objects, logs, audit, or signing material. Extraction returns only `match(FactSet)`, `no_match`, or `ambiguous`; derivation runs only on `match`. | +| Values and validation | Every Version 1 Supported Value form declared in `CONCEPT.md` passes positive, negative, boundary, size, cardinality, Evidence construction, JWS serialization, and verification tests. The four initial assertion cases exercise boolean, controlled-code, time-bucket, multiple-concept, and multi-subject behavior through the full service. | +| Selector and matching boundary | Identifier-only, compound no-identifier, additional-disambiguator, and multi-role selector profiles pass the complete service. Each profile has one exact field set. Missing, extra, unknown, mistyped, oversized, unauthorized, or wrong-origin values fail before credential acquisition and source access. Provider results are limited to `match`, `no_match`, and `ambiguous`; Evidence never performs broad candidate retrieval, scoring, or selection. Reviewed deterministic derivation may compare authorized selectors with facts from one unique authoritative record. Explicit false relationship evidence requires a complete valid relationship set. A source that lacks count metadata may return at most two minimally projected results solely to distinguish ambiguity. | +| Source minimization | Rust makes exactly one evidence-data request with fixed transport authority, fixed or closed selector-bound path, fixed non-secret headers, bounded reviewed query/body rendering, and an explicit client-side response projection. It declares `source-derived`, `field-projected`, or `record-transformed` honestly, enforces response, time, redirect, pagination, TLS trust, concurrency, and ambient-proxy denial, and never persists a source response. Basic, static Bearer, static API-key, and OAuth client-credentials authentication and all three postures pass generic contract tests through the same executor. | +| Authentication and authority | Strict OIDC verification and the configured principal claim fail closed. One authorization decision binds requester, optional actor, requirement revision, purpose, every role's selector profile and value origin, subject authority path, audience, and requested response format. Possessing selector values or discovery metadata, or choosing an API media type, creates no authority. Authenticated discovery lists only complete shapes matching exactly one authority path and valid token-owned selector material; unentitled, ambiguous, and invalid-context shapes are absent. Every denial occurs before credential acquisition or source access. | +| Privacy and audit | Access-attempt audit is durably accepted before source access. Rust serializes the final immutable signed or unsigned response bytes, durably accepts disclosure-release audit, then releases those exact bytes. Sink failure blocks the applicable step. Audit records the closed response-protection mode and a signing key only for JWS, and uses at most one scoped keyed pseudonym over each complete canonical role and selector bundle. Neither audit, logs, errors, metrics, nor traces contain credentials, tokens, request nonces, raw selector values, per-field quasi-identifier hashes, source values, Supported Values, or raw subject identifiers. | +| Evidence and response integrity | Rust alone constructs Evidence, signed flattened JWS, and the unsigned envelope. Signed JWS is mandatory and default, uses allowlisted protected headers and trusted key resolution, has verifiable nonce, independently expected subjects and output contract, audience, policy, and validity, and publishes usable current and retired public keys. Unsigned JSON is self-identifying, requires bundle and complete matched grant permission plus exact API selection, and makes no later-verification claim. Signed failure never falls back to unsigned. | +| Failure and operations | Stable safe errors, reviewed existence-disclosure semantics, public collapse of `no_match` and `ambiguous` by default, request limits, per-principal and failed-selector-attempt rate controls, authenticated requester-scoped discovery, health, readiness, dependency timeouts, and graceful shutdown work without exposing protected data. Discovery performs no source access and exposes no source plan, scripts, credentials, internal authority metadata, selector values, codelist values, or unrelated definitions. Readiness fails for missing bundle, selector binding, credential, audit, or signing dependencies required by the configured deployment. | +| Multiple definitions | All four definitions run concurrently in one process and one trust domain without script state, limits, identifiers, subjects, source responses, audit context, or results crossing definition boundaries. Unsafe combined disclosure and mutually distrustful issuer configurations are rejected. | +| Verification evidence | Focused invariant tests, all package tests, contract drift checks, dependency policy, formatting, package and workspace check, Clippy with warnings denied, and workspace tests pass. Security-sensitive behavior has a named threat, enforcement point, and negative test. | +| Local compatibility smoke | After deterministic mocks pass, the read-only DHIS2 and OpenCRVS smoke tests are attempted when local credentials and approved demo selectors are available. Unavailability may be recorded as inconclusive; authenticated schema drift or excess disclosure is investigated and cannot be ignored. No credential or live-data artifact enters the repository or test output. | +| Operability | An adopter can author, test, deploy, and maintain a source integration from the configuration, adapter API, fixture contract, and complete DHIS2/OpenCRVS-shaped projects without editing Rust. An operator can independently bind the immutable governed bundle to listener, secret, audit, and private-CA paths for each environment without overriding evidence semantics, configure authentication, authority mappings, source bindings, signing rollover, rate limits, and verifier trust using documented supported paths, and let an authenticated consumer discover the exact revision-bound request shapes it may invoke. Static onboarding still owns token acquisition, human and legal descriptions, endpoint trust, and verifier policy. | +| Stop boundary | No capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed. This includes document evidence, holder credentials, VC, OID4VCI, SD-JWT, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, agents or MCP, federation, workflow, public or federated catalogs, runtime bundle mutation, script-selected transport or multi-call source planning, multi-source fulfillment, a general policy engine, application database, message broker, or worker process. | + +## Required Version 1 acceptance tests + +At minimum, pin these acceptance and negative cases: + +1. Missing configured principal claim denies without `client_id` or `azp` + fallback. +2. Unknown or unauthorized requirement, purpose, audience, selector profile, + selector value origin, or subject path never acquires source credentials or + contacts the source. +3. Caller-supplied identifier or compound selector value never creates + authority. +4. Access-audit failure prevents the source request. +5. Source request method, URL, fields, credentials, size, timeout, and redirect + behavior remain fixed by trusted configuration. +6. Rhai cannot access network, filesystem, environment, credentials, clock, + logging, audit, or signing material. +7. Extra, missing, mistyped, or oversized derived values are rejected. +8. Source data and disclosed values are absent from logs, audit, and errors. +9. Disclosure-audit failure prevents response release. +10. Signing failure returns a safe transient error and never falls back to + unsigned evidence. +11. JWS verification fails after any protected-header or payload mutation. +12. A valid false boolean result is a success in either authorized response + format, not an error. +13. `no_match`, `ambiguous`, and missing fact do not create an unintended + existence oracle and use the same public failure by default. +14. Legal-timezone and calendar-boundary fixtures are deterministic. +15. Unsafe combinations, including threshold ladders and overlapping + categories, are rejected at bundle review or validation. +16. Flat REST, DHIS2 Tracker-style REST, and OpenCRVS Version 2 Event + Search-style JSON mocks all use the same Rust source executor. +17. Zero, one, and multiple results map consistently to `no_match`, `match`, + and `ambiguous` across the three source shapes; facts exist only on + `match`. +18. Event-index envelope errors and incomplete declaration data fail closed. +19. OAuth token requests and responses are absent from all diagnostics, even + when the provider requires credentials in the query string. +20. Live-source tests are ignored by default, read-only, and refuse missing or + permissively stored credential files. +21. Adult status passes the complete path with before, on, and after-boundary + dates in the configured legal timezone. +22. Residence region passes the complete path with valid, unknown, and + overly precise codes and a pinned codelist version. +23. Professional licence status passes the complete path with multiple + concepts, validity boundaries, and proof that exact dates and history are + absent from evidence and diagnostics. +24. Legal-parent relationship passes the complete path with correct roles, + swapped roles, unauthorized candidate substitution, false relationship, + returned-child mismatch, missing relationship, ambiguous lookup, and + ambiguous relationship facts. + The selector-aware derivation proves exact governed membership; `false` is + signed only after unique child resolution and a complete valid parent set. +25. All four definitions run together and under concurrency without state, + subject, source, audit, limit, or result leakage. +26. At least one acceptance definition runs against two different mock source + shapes with only bundle and Rhai changes. +27. A repository boundary check rejects DHIS2 or OpenCRVS names and behavior in + production Rust, Cargo dependencies and features, public configuration + schemas, routes, and CLI options. +28. JSON Schema and OpenAPI drift checks reproduce committed artifacts exactly. +29. Every declared Supported Value form rejects wrong scalar types, unknown + codes, invalid entity references, excessive precision, oversized strings, + oversized lists, duplicate values where prohibited, and wrong + cardinalities, and each valid form survives Evidence construction, JWS + serialization, and verification without type loss. +30. `source-derived`, `field-projected`, and `record-transformed` definitions + use the same executor and report their acquisition guarantees honestly. +31. A serving process cannot reload, mutate, merge, or fall back to another + bundle revision at runtime. +32. No test, log, trace, metric, audit event, snapshot, panic, or failure + artifact contains the canary credentials, raw selector values, source + facts, or Supported Values used by the acceptance suite. +33. An identifier-only selector profile and a compound selector profile with no + identifier both pass the complete service path. +34. Missing required, unknown, extra, mistyped, empty, oversized, and + aggregate-oversized selector fields fail before credential acquisition or + source access, with no protected value in the error. +35. Alternative sufficient field sets and sets with an additional + disambiguating field require distinct named profiles and are never inferred + from caller input. +36. At least one selector profile uses deployment-defined field names that have + no person, identifier, EU, UK, DHIS2, OpenCRVS, or acceptance-case meaning + to Rust. +37. Unicode and multipart name-like values pass as bounded opaque strings. + Core behavior does not case-fold, transliterate, tokenize, apply phonetics, + parse Western name order, or perform partial-date matching. +38. Provider ambiguity never causes a second data request, page traversal, + retrieval beyond the configured maximum of two minimally projected + results, candidate choice, derivation execution, or success response. +39. Candidate records, candidate counts beyond the closed outcome, scores, + confidence, near-match hints, and field-by-field comparison results are + rejected by the extraction boundary and absent from public responses. +40. Native audit records at most the selector-profile id and one scoped keyed + pseudonym over each complete canonical role and selector bundle. Separate + hashes of names, dates, addresses, identifiers, or other low-entropy fields + fail redaction tests. +41. Context-derived and authenticated-grant-derived selector profiles reject + caller-provided values; an authorized request-derived caseworker profile + accepts only its configured closed field set. +42. Evidence and the JWS payload never echo selector profile ids or values, and + audience-scoped subject bindings do not become globally stable identifiers. +43. Failed selector attempts are bounded per principal and authority profile + without using raw selector values as metric or rate-limit labels. +44. All four initial assertion cases pass their assigned selector shapes and + lookup outcomes through offline fixtures, local HTTP mocks, the real router, + both audit gates, signed JWS, explicitly authorized unsigned output, and + strict verification on one revision. +45. Governed bundle and runtime configuration have separate closed schemas, + independent startup digests, read-only lifetime enforcement, and negative + tests proving runtime fields cannot override sources, authorization, + disclosure, limits, signing, or audit policy. +46. Fixed paths and selector-bound path templates pass exact encoding tests. + Missing, extra, duplicated, slash, backslash, percent, control, empty, and + dot-segment bindings fail before credential acquisition or source access. +47. Fixed non-secret headers, static Bearer, and static API-key authentication + pass generic exact-request and redaction tests. Forbidden, duplicate, + framing, routing, forwarding, proxy, tracing, cookie, and authentication + header collisions fail at startup. +48. System roots and logical private-CA trust profiles pass positive TLS tests. + Unbound, malformed, insecure, symlinked, mutable, or hostname-bypassing CA + configurations prevent readiness, and ambient HTTP proxy environment + variables cannot redirect evidence-data or OAuth requests. +49. Extended JSON Pointer response projection passes flat, nested, array, + literal-dot-key, missing-leaf, mistyped-intermediate, invalid-escape, + duplicate, overlap, size-before-projection, and privacy-canary tests. Rhai + sees only the projected tree while posture reflects the pre-projection wire + response. +50. `parse_integer` accepts the documented ASCII grammar and leading zeroes and + rejects empty, plus-prefixed, whitespace, non-ASCII, fractional, + exponential, and overflowing values. Query values remain strings and no + implicit value-to-string conversion is introduced. +51. Public request subjects are resolved uniquely by role rather than array + position. Permutations produce the same authorized internal role order; + duplicate, missing, unknown, or wrong-profile roles fail before credentials + or source access. +52. The request nonce is exactly the canonical 43-character unpadded base64url + encoding of 32 bytes; missing, duplicate, padding, noncanonical encoding, + malformed alphabet, wrong length, and excessive length fail before + credential acquisition or source access. Nonce canaries never reach + authorization, Rhai, source preparation, source calls, logs, or audit. +53. Signed Evidence echoes the exact request nonce; changing the expected or + signed nonce fails verification, and nonce reuse is not represented as + replay prevention. +54. The verifier requires independently trusted expected subject roles and + opaque bindings plus expected concept identifiers, forms, and cardinalities + after signature verification. Missing, extra, duplicated, substituted, or + wrong-key-version bindings and unexpected output fail; subject order alone + is non-semantic. +55. Missing `Accept`, `*/*`, and exact signed media select JWS. Only exact + unsigned media selects unsigned JSON. Duplicate, combined, parameterized, + weighted, or unknown negotiation fails before source access. +56. Unsigned selection succeeds only when both the bundle and matched authority + grant permit it. Caller selection, runtime configuration, or a different + grant cannot create permission. +57. Signing or signed-release failure never returns unsigned output. An + explicitly authorized unsigned request performs no signing operation and + still requires the ordinary signing dependency to be ready. +58. The unsigned envelope has its exact vendor media type, schema, type, + integrity marker, warning, closed nested Evidence, and no `protected`, + `payload`, `signature`, or signing-key claim. The JWS verifier rejects it. +59. Both response formats serialize final immutable bytes and require durable + disclosure-release audit before releasing them. Audit records the closed + protection mode and conditionally requires or forbids `signingKeyId` + without recording nonce, selectors, source values, or disclosed values. +60. All four coequal definitions pass signed and explicitly authorized unsigned + paths through the same router, source executor, derivation, output gate, + subject binding, minimization, and audit logic without domain branches. +61. Verification tooling re-verifies a stored signed response against a pinned + trusted key, expected policy, request nonce, subject bindings, and output + contract, and reports cryptographic authenticity separately from current + validity. +62. Authenticated `GET /v1/evidence-definitions` returns only complete request + shapes matching exactly one authority path for the verified caller and + valid token-owned selector material. Unentitled callers receive an empty + list; ambiguous shapes are omitted; source identifiers and plans, scripts, + credentials, authority-profile names and tags, selector values, codelist + values, and unrelated definitions are absent; no provider request or + evidence-data audit event occurs. + +## Verification gates + +During implementation, run package-scoped checks while iterating and the +complete package gate at every phase exit: + +```text +cargo fmt --check +cargo check --locked -p registry-evidence --all-targets +cargo test --locked -p registry-evidence +cargo clippy -p registry-evidence --all-targets -- -D warnings +``` + +The deterministic source mocks are part of ordinary package tests. Public +demo tests live in a separate ignored integration-test target and run only +after the package suite succeeds: + +```text +cargo test --locked -p registry-evidence --test source_contracts +cargo test --locked -p registry-evidence --test live_sources dhis2 -- --ignored +cargo test --locked -p registry-evidence --test live_sources opencrvs -- --ignored +``` + +The exact environment loading and live-data rules are in +[SOURCE-TESTING.md](SOURCE-TESTING.md). Live tests never run in CI, never use +credentials on the command line, and never turn an unavailable or changed +public demo into a product regression. + +Before a PR, also run the root workspace checks selected by CI, the Evidence +contract command that regenerates and compares JSON Schema and OpenAPI, and the +source-product-neutrality check that scans production code and Cargo metadata. +The final DoD gate includes: + +```text +cargo fmt --check +cargo metadata --locked --format-version 1 +cargo check --locked --workspace --all-targets +cargo clippy --workspace --all-targets -- -D warnings +cargo test --locked --workspace +cargo deny check +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` + +Generated artifacts are reproduced from code, never hand-edited. If a shared +platform crate changes, run its affected consumer tests during iteration as +well as the final workspace gates. + +## Explicitly deferred + +The Version 1 schedule and DoD stop before every item below. Do not add +implementations, stubs, placeholder schemas, empty modules, feature flags, or +extension APIs for them: + +- Rego or a Rhai authorization-policy interface; +- caller-defined predicates or thresholds; +- broad candidate retrieval, fuzzy or probabilistic scoring, best-match + selection, matching weights or thresholds, phonetic candidate comparison, + deduplication, and an Evidence-wide identity policy; +- script-selected sources, URLs, methods, paths, headers, credentials, retries, + page traversal, response-led requests, or multi-call source planning; +- multiple evidence-data lookups or multi-source fulfillment for one + requirement; +- evidence or raw-source persistence; +- document retrieval or multipart responses; +- VC, OID4VCI, SD-JWT, holder binding, status lists, or wallets; +- nonce or replay storage beyond stateless request-nonce echo and comparison; +- OOTS XML, AS4, Evidence Broker, or DSD runtime code; +- federation, agents, MCP, workflow, or public, cross-requester, searchable, + mutable, or federated catalog endpoints; +- runtime bundle upload, mutation, hot reload, or approval workflows; +- an application database, message broker, or worker process. diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md new file mode 100644 index 000000000..9775a8552 --- /dev/null +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -0,0 +1,463 @@ +# Evidence Version 1 operator contract + +Status: Implemented Version 1 operator contract + +This document defines the supported native deployment and operator duties for +the Evidence Version 1 `evidence` binary. + +## Supported deployment + +The supported native deployment has: + +- one `evidence` process in one operator-controlled trust domain; +- one reviewed, immutable governed evidence bundle and one closed operator + runtime file, both mounted read-only at startup; +- one reviewed OIDC access-token profile with exactly one trusted issuer and + exact audience, token type, and algorithm allowlists; +- one configured principal claim with no `client_id`, `azp`, header, or request + fallback; +- reviewed requester and subject-authority mappings for named requirement + revisions, purposes, audiences, roles, selector profiles, and value origins; +- fixed, bounded HTTP JSON source requests with fixed or selector-bound paths, + fixed non-secret headers, client-side response projection, denied redirects, + logical private-CA trust profiles, and generic Basic, static Bearer, static + API-key, or OAuth 2.0 client-credentials authentication through secret + references; +- one active EdDSA reference signing key, flattened JWS JSON success responses, + and public key discovery at `/.well-known/evidence/jwks.json`; +- keyed JSONL audit on storage whose durability the operator has explicitly + established; +- production HTTPS exposure, dependency timeouts, per-source concurrency + limits, per-principal rate controls, and bounded failed-selector attempts. + +Multiple evidence definitions may be enabled only when they share the same +operator, deployment lifecycle, audit boundary, and failure domain. Mutually +distrustful issuers or customers require separate processes and bundles. The +authentication profile admits exactly one token issuer and one set of claim +names, so a second issuer, or one issuer whose clients carry the same authority +under different claim names, requires a second deployment even when the +operators trust each other. Evidence Version 1 has no application database and +persists no selector, source, evidence, or response data. An external durable +audit service may own its own storage. + +A gateway may provide publication, protocol integration, routing, and +additional rate controls. Evidence still validates its configured identity +context and independently enforces requirement, purpose, subject authority, +selector, audience, disclosure, signing, and audit rules. Unsigned headers or +caller request fields never substitute for authenticated authority. + +## Governed bundle and operator runtime + +The operator supplies one atomic bundle containing the approved YAML, +preparation scripts, extraction scripts, derivation scripts, schemas, codelists, +mappings, and fixtures. A separate closed `runtime.yaml` binds the bundle to +one listener, bundle directory, secret root, audit destination, and local TLS +trust files. The runtime file cannot override service identity, trust domain, +authentication, authority, sources, request policy, scripts, disclosure, rate +limits, signing policy, or audit fail-closed behavior. The two content hashes +identify the exact loaded inputs but are not trust decisions. The operator +establishes trust through review, distribution controls, read-only mounts, and +process replacement for every revision. + +There is no runtime upload, editor, approval API, hot reload, merge, mutation, +governed-field override, or fallback bundle/runtime file. Startup and readiness +fail if either input is incomplete, inconsistent, mutable, uncompilable, unsafe +in combination, or cannot bind every +allowed role, selector profile, value origin, authority path, and source +placement. + +Before deployment, the operator must review the entire simultaneously enabled +bundle as one disclosure surface. This review includes threshold ladders, +overlapping categories, increasingly precise regions, jurisdiction variants, +coexisting revisions, differing requester entitlements, and relationship +combinations. Rate controls and after-the-fact audit analysis do not make an +unsafe bundle safe. + +## Discovery of available evidence + +Evidence Version 1 answers “what may this caller request?” with authenticated +`GET /v1/evidence-definitions`. Availability is requester-relative: the +definition must exist in the exact deployed bundle and exactly one authority +path must match the verified token, requirement, purpose, audience, complete +subject-role set, selector profiles, and value origins. The runtime never +publishes a global unauthenticated list. + +Discovery uses four separately trusted surfaces: + +| Artifact | Purpose | What it does not do | +|---|---|---| +| Generated Evidence OpenAPI | Describes `GET /v1/evidence-definitions`, `POST /v1/evidence`, operational routes, envelopes, media types, and safe problems. | It contains no deployment definitions or entitlements. | +| Authenticated definition response | Lists the exact complete request shapes available to this verified token at this bundle revision. | It performs no provider access, does not grant authority, and is not a global catalog. | +| Static onboarding material | Gives an approved consumer token-acquisition instructions, human descriptions, legal context, endpoint trust, and verifier policy through the existing API catalog, developer portal, configuration repository, or bilateral process. | It is not accepted by the runtime and grants no authority. | +| Evidence JWKS | Publishes the active and retained public verification keys. | It is not a trust anchor and contains no definition or entitlement metadata. | + +Each item in `definitions` is one complete invocable combination, not a +cartesian product for the client to assemble. It contains: + +- exact governed bundle revision plus legal issuer and technical provider; +- requirement and Evidence Type identifiers; +- one allowed purpose; +- output concept identifiers and value forms; +- complete subject roles, cardinality, selector profile, and value origin; and +- safe selector field types and bounds. A controlled-code field exposes its + governed scheme identifier and version, never the bundle file path or code + values. + +The endpoint omits a request shape unless its token-owned context or grant +selector values are present and valid. If no authority path matches, the +response has an empty `definitions` array. If multiple authority paths match +the same shape, that shape is omitted because `POST /v1/evidence` would deny it +as ambiguous. Discovery consumes the same per-principal request-rate budget as +evidence creation. It performs no source credential resolution, source call, +signing, or evidence-data audit write. The operation accepts no query +parameters or request body; callers cannot filter it into a definition oracle. + +Human-readable titles, descriptions, legal references, examples, and support +contacts remain static onboarding documentation. The runtime response and that +documentation must not include source origins or identifiers, source paths, +response projections, scripts, adapter parameters, secret references, +internal requester-tag values, authority-profile identifiers, selector values, +codelist values, or unrelated definitions. Possessing discovery metadata does +not authorize its recipient; the identity provider must issue the configured +claims, and Evidence re-authenticates and re-authorizes every evidence request. + +The publication workflow is: + +1. Review the complete bundle and its combined disclosure surface. +2. Run `evidence check` and every referenced fixture, and record the exact + governed bundle revision. +3. Publish the generic OpenAPI and static onboarding material; configure token + issuance and verifier trust through the same governed process. +4. Obtain a token, call `GET /v1/evidence-definitions`, and bind the returned + `configurationRevision` to the deployment revision expected during rollout. +5. Construct requests only from one returned complete shape. Do not combine + subjects, profiles, purposes, or fields across items. +6. On a relevant bundle or trust change, update onboarding material and + coordinate rollout. Clients observe the new revision through authenticated + discovery, not by probing problem responses. + +Version one does not implement a public, cross-requester, searchable, mutable, +or federated catalog, a registration editor, or a `describe` CLI command. +`/health`, `/ready`, public problems, and JWKS never reveal enabled definitions +or selector profiles. + +## Requester authority and purpose + +Authorization keys off the requester tags in the configured claim, not off the +requester principal. The principal is used only for rate accounting and audit +pseudonyms and never decides access. Two clients presenting the same tags hold +the same access, so differentiated access is expressed by issuing different +tags. An authority profile matches only when every one of its declared tags is +present, and exactly one authority path may match a request: zero paths and two +or more paths both deny. Startup validation does not detect two paths covering +the same requirement, purpose, and subject tuple, so the operator owns that +review. + +The request declares its purpose and any purpose the matched grant does not +carry is rejected. Within the granted set the caller still chooses, so a +declared purpose is an authorized selection rather than an identity-provider +attestation. Where the purpose must be attributable to the token issuer, issue +a distinct requester tag per purpose and give each tag an authority profile +granting only that purpose. Purpose is then bound to a verified claim with no +change to Evidence. + +Purpose is enforced rather than advisory in either arrangement. An unauthorized +purpose is denied before credential acquisition and source contact, purpose is +an input to every subject binding and audit pseudonym so one subject is not +linkable across purposes, and purpose is inside the signed payload where a +verifier rejects an assertion whose purpose does not match its expected policy. + +Purpose does not narrow disclosure. A requirement returns the same concepts and +disclosure forms for every purpose that may invoke it. A purpose that justifies +only a coarser answer needs its own requirement and its own place in the +combined disclosure review. + +Native rate controls are uniform. The configured request, burst, and +failed-selector limits are single values applied to every principal, and the +request-rate scope deliberately excludes purpose, audience, and requirement so +a caller cannot multiply its budget by varying them. Per-client quotas are a +gateway responsibility. + +The listener request timeout bounds admission, concurrency queueing, and body +collection. It is not a total evaluation deadline. Once a protected evaluation +starts, Evidence lets it finish under the separately bounded OIDC and source +operations so cancellation cannot bypass required audit or signed-response +release ordering. + +## Secrets and keys + +Source credentials and private signing material are supplied only through the +supported secret-reference mechanism. They do not appear in YAML values, +Rhai, command arguments, environment dumps, logs, audit, errors, snapshots, +or generated contracts. Private key parsing uses an explicit algorithm +allowlist. Missing or failed signing is fail-closed and never releases an +unsigned success response. + +The operator configures one active signing key and retains each retired public +key in the published JWKS for at least the maximum assertion validity plus +allowed clock skew. The JWKS is discovery, not a trust anchor. Verifiers obtain +the provider identity and JWKS location through governed configuration, pin +that trust, allowlist the expected algorithm, and resolve `kid` only within the +trusted key set. They never follow a message-provided remote key URL. + +A valid signature proves that the technical provider controlling the key signed +the exact payload. It does not prove the source fact is true, confer legal +notarization, create a qualified electronic signature, or create a holder +credential. Governance establishes the provider's authority to act for the +named legal issuer. + +## Source and selector controls + +Each subject role admits only named selector profiles from the trusted bundle. +Each profile has one exact deployment-defined scalar field set, byte and +aggregate bounds, permitted value origin, and fixed source placement. +Alternative sufficient inputs and additional disambiguators are separate named +profiles. A national identifier is optional and possession of any selector +value never creates authority. + +The authoritative provider owns record lookup. Evidence accepts only +`match`, `no_match`, or `ambiguous`; only `match` carries facts. Evidence does +not fetch broad candidates, follow pages, score candidates, choose a provider +record, or expose counts, records, confidence, near-match hints, or per-field +diagnostics. A reviewed deterministic derivation may compare its explicitly +declared authorized selector fields with complete facts from one uniquely +resolved authoritative record. When count plus one minimized result is unavailable, the fixed +request may retrieve at most two minimally projected results solely to detect +ambiguity. + +Every source declares its acquisition posture. Requirements inherit the +posture of their configured source: + +| Posture | Operator claim | +|---|---| +| `source-derived` | Full acquisition and disclosure minimization | +| `field-projected` | Strong acquisition and disclosure minimization | +| `record-transformed` | Disclosure minimization only | + +The operator must not describe a `record-transformed` integration as full +lifecycle minimization. Rust applies every source's extended JSON Pointer +projection after bounded JSON parsing and before extraction, but the posture +describes the pre-projection wire response. The fixed request mock must prove +provider-specific field selection where claimed. A provider whose wire response +cannot be closed at that boundary must use `record-transformed`, even when +local projection and Rhai emit only narrow facts. + +Bundle-fixed headers cannot set authentication, routing, cookies, framing, +forwarding, proxy, or tracing fields. Selector-bound path placeholders occupy +complete segments and Rust expands them directly from already authorized +selectors. Scripts render only query pairs and one JSON body. + +A source may name a logical TLS trust profile. `runtime.yaml` binds it to one +bounded PEM CA file. Hostname and fixed-origin verification remain mandatory; +there is no insecure or trust-all mode. Version 1 ignores `HTTP_PROXY`, +`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` and has no application-level proxy. + +## Audit and operational data + +The configured audit sink must durably accept the access-attempt event before +the first evidence-data source read. It must durably accept the +disclosure-release event after signing and before response release. Either +failure blocks the applicable action. + +Audit contains reviewed identifiers and decision categories, never raw +selector values, per-field selector hashes, source values, Supported Values, +credentials, tokens, or raw subject identifiers. When correlation is required, +one keyed, domain-separated, versioned pseudonym covers the complete canonical +role, selector-profile identifier, ordered field names, and selector value +bundle. It must not be globally stable across purposes or audiences. + +Operational logs contain route templates, operation identifiers, duration, +status category, and safe internal error categories only. Request bodies, +selector profile identifiers and values, source requests and responses, +authority grants, Rhai inputs, credentials, tokens, and disclosed values are +excluded from logs, metrics, traces, snapshots, panics, and errors. + +The operator owns audit retention, backup, restore, access control, key +rotation, and chain verification for the selected durable sink. A deployment +profile may require more reviewed metadata or retention, but it cannot silently +weaken the native privacy contract. + +At process startup the runtime verifies the complete keyed JSONL chain and +captures the exact audit file identity, length, modification fingerprint, and +verified tail. Steady-state appends and readiness probes validate that pinned +identity and fingerprint plus the expected tail and length without rescanning +the growing file. Any external replacement or modification makes readiness and +future appends fail closed. A restart performs the complete keyed-chain +verification again. Operators should also run their governed offline chain +verification during backup, restore, and incident procedures. + +## Audit chain rotation and rollback + +`auditStorage.maximumFileBytes` is a hard ceiling. The runtime enforces it when +it opens the file at startup, before every append, and on every readiness +probe. A deployment that reaches the ceiling fails closed: appends are refused, +so evidence requests fail, and readiness reports the service unavailable. +Version 1 has no online rotation, so the operator must rotate the chain during +a planned stop before the ceiling is reached. + +Rotation is a stop-and-rename procedure for three reasons. The service holds an +exclusive advisory lock on `.lock` for its whole life, so no +second process can write the same chain. The running process pins the open +audit file by identity and fingerprint, so a rename underneath a running +process makes readiness and every later append fail closed rather than silently +continue. Both the audit file and its lock file must stay owner-only mode +`0600`, singly linked, regular files, so copy-and-truncate, hard links, and +symlinks are all rejected. + +1. Watch the audit file length against `auditStorage.maximumFileBytes` and + schedule the window with headroom. The ceiling is not a rotation trigger; it + is an outage. +2. Stop the service with SIGTERM, which is what a service manager and a + container runtime both send, or with Ctrl-C for an interactive process. The + server stops accepting connections, finishes the evaluations already + admitted, completes their audit writes, and exits successfully. + `listener.shutdownGraceMilliseconds` is the operational target for that + drain, not a cancellation boundary: an evaluation already inside the runtime + is allowed to finish so its audit and signing invariants hold. Confirm the + process has exited before continuing, which is also what releases the lock. +3. Archive the retired chain by rename, preserving owner and mode: + + ```sh + mv /var/lib/registry-evidence/audit/evidence.jsonl \ + /var/lib/registry-evidence/audit/evidence-.jsonl + ``` + + Leave `evidence.jsonl.lock` in place. It carries no chain state. +4. Record the archived file name, its byte length, its final record hash, and + the stop and start times in the operator change record. Each chain file + begins at genesis and is independently verifiable, and nothing inside the + new file points back at the archived one, so this record is the only link + between the retired chain and its successor. Without it the deployment has a + gap it cannot later explain. +5. Start the service again. Startup finds no file at the audit path, creates + one with mode `0600`, and begins a new keyed chain at genesis. +6. Verify before restoring traffic. `GET /ready` must return `200` on the new + chain, and the governed offline chain verification must pass over both the + archived file and the new file. Retain the archived file under the + deployment's audit retention rule. +7. Roll back by repeating step 2, renaming the archived chain back to the audit + path, and starting again. Startup reverifies the complete keyed chain, so a + restored file that was modified refuses to start rather than continuing on a + forked chain. Never concatenate, merge, or edit chain files, and never + restore a chain that a later process has already appended to. + +Readiness behavior across the window is deliberate. The service is stopped for +steps 3 and 4, so `/ready` does not answer at all and the operator must drain +traffic on the stop rather than wait for a readiness signal. After the start it +returns `200` only once the new chain is opened and verified along with the +subject-binding key, the signing provider, and every source credential. + +No event is lost and no record is ambiguous, provided the order above is kept. +The stop is graceful, so an admitted evaluation writes its disclosure-release +event before the process exits. The archive is a rename of an already closed +file, so nothing can be appended to a retired chain. The successor is a new +file starting at genesis, so no record belongs to two chains. + +## Startup and readiness + +Before production exposure, the operator runs: + +```sh +evidence check +evidence evaluate --fixture +``` + +All commands accept `--runtime `. The same path may be supplied +through `REGISTRY_EVIDENCE_RUNTIME`; the reference default is +`/etc/registry-evidence/runtime.yaml`. That file supplies the absolute +`bundleDirectory`. Command-line or environment values cannot override governed +bundle fields. The runtime file, bundle directory, and every captured artifact must +be non-writable to the service process. Evidence Version 1 supports Unix targets +only because its secret and audit invariants require owner, mode, no-follow, +link-count, and open-file identity checks. A read-only mount is preferred; +directories use no write bits and files use no write bits. Fixture +paths are normalized, bundle-relative `fixtures/*.yaml` paths referenced by +exactly one requirement. + +The reference file-secret provider reads only regular, non-symlink files below +the configured `secretProviders.file.root`. The secret root is operator-only and +each secret file must be owned by the service identity with mode `0600`. +Audit and subject-binding secret files contain independently generated raw key +bytes and must each be at least 32 bytes. They are not decoded as base64 by the +file provider. Source credentials retain their provider-defined lexical form. +Signing material is an Ed25519 private JWK whose `kid` exactly matches +`signing.activeKeyId`; only the public current key and configured retired public +keys appear at the JWKS endpoint. The audit JSONL path must be on storage whose +append durability, permissions, capacity, backup, restore, retention, and keyed +chain verification the operator owns. + +`evidence check` validates and compiles the complete bundle. Fixture evaluation +covers positive, negative, boundary, missing-data, source-failure, +existence-disclosure, and anti-reconstruction behavior without a running +source. + +`disclosureGuard.families` is a trusted bundle-review attestation, not a +domain-semantic classifier. The runtime rejects two simultaneously enabled +requirements with the same declared family. It cannot infer that differently +labelled families are semantically equivalent without adding forbidden domain +policy to the generic core. Operators must therefore review the complete +bundle for threshold ladders, overlapping partitions, relationship graphs, and +equivalent definitions before assigning distinct family identifiers. The +anti-reconstruction fixtures record that reviewed decision. + +`observed_at` is supplied by the runtime and normalized to UTC. Rust derives +`legal_local_date` and `legal_local_time` from the requirement's optional IANA +`observationTimezone`; omission uses UTC. Requirements whose result depends on +local legal time should declare the timezone explicitly and include fixtures +on both sides of relevant date, time, daylight-saving, and offset boundaries. + +The operator starts the reviewed revision with: + +```sh +evidence serve +``` + +Startup confirms that the immutable bundle compiled, runtime ownership and +every local path/trust binding validated, mounted secret files and signing +material parsed, and the audit chain opened and verified. Readiness rechecks +the subject-binding key, signing provider, pinned audit sink, and every source +credential. Basic, static Bearer, and static API-key credentials are checked +locally. OAuth client-credentials readiness performs its bounded token +bootstrap against the configured token endpoint. OIDC JWKS retrieval is lazy +and follows the verifier cache lifecycle, so readiness does not prefetch it. +Neither startup nor readiness sends an evidence-data request or probes a source +data endpoint. Readiness +fails when a required local runtime or bundle input, selector binding, +credential, CA binding, audit dependency, or signing dependency is absent, +mutable, or invalid. + +The native operations are: + +```text +GET /v1/evidence-definitions +POST /v1/evidence +GET /health +GET /ready +GET /.well-known/evidence/jwks.json +``` + +A successful `GET /v1/evidence-definitions` response uses `application/json` +and the closed requester-scoped definition schema. It requires the same strict +Bearer authentication profile and per-principal request budget as evidence +creation. + +A successful `POST /v1/evidence` response uses `application/jose+json` and the +flattened JWS JSON Serialization. No public or cross-requester catalog is +supported. +No-match and ambiguous outcomes are publicly indistinguishable by default. +Source, signing, and dependency failures use stable safe problem codes and do +not reflect protected inputs. Signing failure returns a safe transient failure. + +## Verification and release limit + +Operators must verify a candidate revision with the applicable phase and final +commands in [AGENTS.md](AGENTS.md). Public-demo source tests are optional, +ignored, read-only, local, and non-gating. They may run only after deterministic +mocks pass and only with approved synthetic selectors and securely stored +credentials under [the source-testing contract](SOURCE-TESTING.md). + +Evidence Version 1 is releasable only when all four coequal acceptance +definitions pass the complete offline and HTTP path, all Definition of Done +rows are green on one revision, generated contracts reproduce exactly, and the +security acceptance matrix is reviewed. Implementation stops at that boundary. +Future profiles require a separately approved concept and plan. diff --git a/products/evidence/README.md b/products/evidence/README.md new file mode 100644 index 000000000..d92d57719 --- /dev/null +++ b/products/evidence/README.md @@ -0,0 +1,146 @@ +# Evidence + +Status: implemented Version 1 contracts, runtime, reference deployments, and +reproducible Evidence-specific verification gates. + +Evidence is a greenfield, sector-neutral minimum-disclosure assertion service. +Given authenticated authority, an authorized purpose, a predefined requirement, +and the configured selector data needed by an authoritative provider, it returns +the smallest sufficient JSON assertion in an authorized response format. +Evidence is not a Registry Notary mode, rewrite, or reduced configuration. + +The approved Version 1 product boundary is one `registry-evidence` crate, one +`evidence` binary, one serving process, and one operator-controlled trust domain. +A process may host multiple evidence definitions only when they share that trust +domain. Governed configuration, Rhai scripts, schemas, codelists, and fixtures +are one trusted, immutable, startup-only evidence bundle. A separate closed +runtime file owns only process-local listener, filesystem, audit-storage, +secret-mount, and TLS-trust bindings and cannot override governed semantics. + +The following contracts define and verify the implemented Version 1 boundary: + +- [Product concept](CONCEPT.md): product boundary, data model, trust and privacy + invariants, native API, and Version 1 acceptance set. +- [Implementation schedule and Definition of Done](IMPLEMENTATION.md): phases, + exit gates, required tests, verification, and stop boundary. +- [Source-testing contract](SOURCE-TESTING.md): deterministic mock matrix, + optional public-demo smoke tests, credential handling, and failure + interpretation. +- [Operator contract](OPERATOR-CONTRACT.md): supported deployment shape, + requester authority and purpose duties, required configuration and secrets, + readiness, audit, key, and verification obligations. +- [Trusted request-adapter reference](reference/request-adapter/ADAPTER-API.md): + complete Rhai API, configuration and fixture contracts, and deployable DHIS2 + and OpenCRVS-shaped reference projects. + +Any normative schemas, examples, and generated public artifacts live in their +own tracked contract directories. Generated files must be reproduced by their +documented generator and never edited by hand. + +## Version 1 boundary + +Version 1 supports assertion evidence through one synchronous JSON operation +with signed flattened JWS as the mandatory default format. Rust owns +authentication, authorization, minimized preparation +inputs, fixed source execution, response projection, bounded Rhai execution, +output validation, evidence construction, response protection, and audit. Rhai owns +reviewed request query/body rendering, source extraction, and +requirement-specific derivation using only deterministic, bounded, +domain-neutral primitives supplied by Rust. + +Adult status, residence region, professional licence status, and legal-parent +relationship are coequal full-path acceptance definitions. All four must pass +the same offline and production path on one revision before Version 1 can be +called implemented. None may become a Rust domain type, built-in operation, +special route, or preferred implementation phase. + +Version 1 does not include documents, holder credentials, OID4VCI, SD-JWT, +nonce or replay storage beyond stateless request-nonce echo and comparison, +server-issued challenges, OOTS execution, federation, delegated agents, MCP, +workflow, public or federated catalogs, runtime policy, runtime bundle mutation, +multi-source fulfillment, source planning, an application database, a message +broker, or workers. It must not depend on `registry-notary*`. + +DHIS2 and OpenCRVS are compatibility-shaped test profiles only. Their names and +behavior may appear in tests, sanitized fixtures, test-only bundles, and local +smoke documentation. Production Rust, Cargo metadata, public configuration, +routes, CLI options, and generated public contracts must remain source-product +neutral. + +## Discovering available evidence + +An authenticated caller lists the complete Evidence request shapes it can +currently invoke with `GET /v1/evidence-definitions`. The response is computed +from the immutable deployed bundle and the caller's verified token. It contains +only combinations that match exactly one authority path, including requirement, +Evidence Type, purpose, output concepts, subject roles, selector profiles, +value origins, and safe selector field validation metadata. An unentitled +caller receives an empty list. An ambiguous authority shape is omitted because +the corresponding evidence request would be denied. + +This is requester-scoped discovery, not a public or process-wide catalog. The +response excludes source URLs and identifiers, paths, projections, scripts, +adapter parameters, credentials, internal authority-profile names and tags, +selector values, codelist values, and definitions unavailable to that caller. +Discovery performs no provider request and creates no evidence-data audit +event. Metadata never grants authority; `POST /v1/evidence` authenticates and +authorizes the complete tuple again. + +The generated OpenAPI defines both operations. Operators still publish static +onboarding material through their API catalog, developer portal, configuration +repository, or bilateral process for token acquisition, human descriptions, +legal context, endpoint trust, and verifier policy. The public JWKS at +`/.well-known/evidence/jwks.json` supplies verification keys only. The complete +contract and change rules are in +[the operator contract](OPERATOR-CONTRACT.md#discovery-of-available-evidence). + +## Requesting evidence + +`POST /v1/evidence` takes one complete request naming the requirement, purpose, +subjects, and a required `requestNonce`. The nonce is the canonical unpadded +base64url encoding of exactly 32 random bytes, so exactly 43 characters, and +must be freshly generated for every request. Evidence echoes it into the +Evidence payload under `requestNonce` and covers it by the signature, so a +caller that retained the value it sent can confirm the assertion answers that +request. Evidence never stores it, never rejects reuse, and never uses it for +authorization, rate limits, scripts, source requests, logs, metrics, traces, or +audit. Callers must not encode identifiers, selectors, secrets, or document +digests in it. + +Signed flattened JWS is the default and the only later-verifiable format. A +missing `Accept`, `*/*`, or the exact `application/jose+json` all select it. The +exact `application/vnd.registrystack.evidence-unsigned+json` selects a visibly +unsigned envelope, and only when both the immutable bundle and the one complete +matched grant permit that format; otherwise the request is refused with the +ordinary `not_authorized` problem before credentials or source access, without +revealing which layer refused. A duplicate, combined, parameterized, weighted, +or unknown `Accept` returns the `response_format_not_acceptable` problem with +HTTP 406 before source access. Unsigned output is transport-authenticated +convenience data for development and for consumers that cannot process JWS. It +is never later-verifiable evidence and never a fallback when signing fails. + +## Current verification + +From the monorepo root, the Evidence-specific reproducible gate is: + +```sh +cargo fmt --check +cargo check --locked -p registry-evidence --all-targets +cargo test --locked -p registry-evidence +cargo clippy --locked -p registry-evidence --all-targets -- -D warnings +products/evidence/scripts/check-contracts.sh +products/evidence/scripts/check-source-neutrality.sh +``` + +Generated JSON Schema and OpenAPI artifacts are under `generated/`. The +contract gate recreates them from Rust in a temporary directory and requires an +exact diff. The complete workspace and dependency-policy gates remain those in +[the implementation schedule](IMPLEMENTATION.md). + +## Security + +Changes to authentication, authorization, disclosure, audit, configuration +trust, signing, source credentials, or selector handling require explicit +security review notes naming the threat, Rust enforcement point, and negative +test. Report suspected vulnerabilities through the repository process in +`SECURITY.md`, not a public issue. diff --git a/products/evidence/SOURCE-TESTING.md b/products/evidence/SOURCE-TESTING.md new file mode 100644 index 000000000..fa7da7fa3 --- /dev/null +++ b/products/evidence/SOURCE-TESTING.md @@ -0,0 +1,401 @@ +# Evidence Source Testing + +Status: Approved Version 1 source-testing contract +Date: 2026-08-02 + +## Purpose + +Evidence must prove that its source boundary is generic without building a +connector framework or emulating entire source products. Testing therefore has +three ordered layers: + +1. offline requirement fixtures for extraction and derivation semantics; +2. deterministic local HTTP mocks for materially different source contracts; +3. explicit, read-only local smoke tests against public demo systems. + +Only the first two layers run in ordinary CI. A live smoke test supplements the +mock contract. It never replaces it and never decides whether a commit is +correct. + +## Compatibility matrix + +| Profile | Request | Response | Authentication | Generality pressure | +|---|---|---|---|---| +| `flat-rest` | Reviewed JSON preparation with identifier or compound selectors | Flat JSON object | Static Bearer | Closed selector inputs and direct fact extraction | +| `dhis2-tracker` | `GET` with prepared filters, fixed `fields`, and `pageSize` | Pager, `trackedEntities` collection, nested attributes | HTTP Basic | Query rendering, encoding, cardinality, collection handling, and controlled codes | +| `opencrvs-event-search` | Prepared bounded JSON `POST` for one tracking ID | Nested event index and country-configured declaration | OAuth 2.0 client credentials, then Bearer | Credential bootstrap, exact event lookup, nested extraction, and relational derivation | + +The product names identify compatibility-shaped test profiles. They do not +promise a maintained vendor connector, reproduce a whole server, or certify +support for every release and configuration. + +They are test-only names. Production Rust, Cargo dependencies and features, +public configuration schemas, routes, and CLI options contain no DHIS2 or +OpenCRVS specialization. The compatibility test must fail if either product is +introduced into production source, Cargo metadata, or generated public +contracts. + +The DHIS2 profile follows the version 2.43 Tracker contract because the first +public demo target is a stable 2.43 deployment. It requests only configured +fields and sets a result limit that can distinguish one match from ambiguity. +It does not follow pages to enumerate people. + +The OpenCRVS profile follows the documented Event Search flow: acquire a +short-lived system-client token, then submit one bounded JSON search for an +exact child tracking ID. Malformed envelopes, zero results, multiple +results, and incomplete configured declaration facts all fail according to the +reviewed requirement rule. + +The matrix includes four generic selector contracts independent of those +product-shaped profiles: + +- one opaque identifier; +- one compound selector with no identifier; +- one compound profile with an additional configured disambiguator; +- one relationship request with two independently role-bound selectors. + +Selector field names, exact field sets, scalar types, value origins, and +permitted script inputs come from trusted test YAML. Reviewed preparation +scripts render the wire request. The core does not know what a +name, civil identifier, licence number, or birth date means. Alternative +sufficient field sets use separate named profiles instead of caller-selected +field combinations. + +## What the mocks contain + +Each profile uses a small local HTTP mock and invented, obviously synthetic raw +provider responses. Rust applies the same configured extended JSON Pointer +projection used in production before extraction. A `field-projected` fixture +models a wire response containing only requested fields. A +`record-transformed` fixture may contain additional fields before local +projection. +Every `record-transformed` fixture also includes at least one unrelated +synthetic canary to prove excess transient data cannot cross extraction, +derivation, error, audit, log, metric, trace, snapshot, or evidence boundaries. +Do not capture a public demo response and redact it after the fact. + +The shared cases are: + +- one exact match; +- no match; +- two matches or a total count greater than one; +- identifier-only and no-identifier compound selectors; +- missing, extra, unknown, mistyped, empty, oversized, and unauthorized + selector values rejected before credentials or source access; +- an additional disambiguating field accepted only as a distinct configured + profile, never as a caller-added field; +- two role-bound selectors with swapped-role and substitution failures; +- required fact absent; +- wrong fact type or controlled code; +- malformed JSON and wrong media type; +- `401`, `403`, `429`, and `5xx`; +- timeout, redirect, and response larger than the configured maximum; +- credentials rejected without any credential value in diagnostics; +- raw selector values and source values absent from logs, audit, errors, and + snapshots; audit may contain only the configured profile id and one scoped + keyed pseudonym over the complete role and selector bundle. +- broad candidates, scores, near-match hints, and comparison diagnostics absent + from evidence, errors, responses, logs, and audit; +- exact relationship membership succeeds and fails using an independently + authorized candidate selector, while incomplete parent sets, mismatched + namespaces, role substitution, and ambiguous child lookup stop without an + authoritative negative assertion. + +Profile-specific cases include: + +- DHIS2 pager and `trackedEntities` shape, nested attribute lookup by configured + identifier, fixed `fields`, and refusal to enumerate a second page; +- OpenCRVS token expiry, malformed token response, exact child-event body, + bounded event result, configured declaration fields, and missing or malformed + parent references. +- fixed and selector-bound path expansion, fixed headers, Basic, static Bearer, + static API-key, OAuth client credentials, system-root and private-CA TLS, + projection conflicts, and proof that ambient proxy variables are ignored. + +The mocks assert the received wire request. Preparation Rhai sees only the +source-required authorized selectors and closed parameters. Extraction Rhai +sees only the bounded projected JSON response and parameters. Neither can inspect +credentials, request headers, URLs, or the source client. + +Extraction maps the response to exactly `match(FactSet)`, `no_match`, or +`ambiguous`. It may interpret a provider result count or at most two minimally +projected results when the provider cannot return count plus one result. It +must not receive a broad candidate set or select between results. Derivation +runs only on `match` and may compare the facts with only its declared +authorized selector inputs using the reviewed requirement rule. `ambiguous` stops without +derivation, page traversal, a second evidence-data request, or a success +response in any format. + +The same suite runs every initial assertion case from `CONCEPT.md` through the +complete Evidence service. At least one case runs against two mock source +shapes with only YAML and Rhai changes, proving that a source swap does not +require Rust changes. + +Across those cases, adult status uses a no-identifier compound selector, +residence uses an identifier profile, professional licence uses a compound +sector selector, and legal-parent relationship uses a child record reference +plus an independently role-bound candidate reference. These assignments exist +only in test bundles and do not create production domain types. + +## Local public-demo smoke tests + +For the operator-facing first checkpoint, expected outputs, and the explicit +post-checkpoint gap list, see [`FIRST-CURL-TEST.md`](FIRST-CURL-TEST.md). + +Live tests are implemented in a separate ignored integration-test target. The +required order is: + +```text +cargo test --locked -p registry-evidence +cargo test --locked -p registry-evidence --test live_sources dhis2 -- --ignored +cargo test --locked -p registry-evidence --test live_sources opencrvs -- --ignored +``` + +The package test includes `source_contracts`; it must be green before either +live command is run. + +The live target requires an explicit profile name and local configuration. It +must skip, rather than improvise, when required values or an approved synthetic +subject selector are absent. + +Live tests are read-only. They may authenticate, request a token, and perform a +bounded record lookup. They must not create, update, register, certify, print, +archive, or delete records. They must not use a browser session, a human login, +or interactive two-factor credentials. OpenCRVS may itself record the +system-client search in its remote audit log; that expected server-side audit +effect and any request quota are part of the operator's decision to run the +test. + +### DHIS2 public demo + +Initial target: +`https://play.im.dhis2.org/stable-2-43-0-1/` + +The local profile accepts these names, with values supplied outside the +repository: + +```text +DHIS2_BASE_URL +DHIS2_USERNAME +DHIS2_PASSWORD +DHIS2_TEST_PROGRAM_ID +DHIS2_TEST_ORG_UNIT_ID +DHIS2_TEST_TRACKED_ENTITY_ID +``` + +The owner-only file path is supplied through +`EVIDENCE_DHIS2_LIVE_ENV_FILE`. The smoke test first verifies authentication +through a safe metadata request, then performs one fixed Tracker read scoped by +the reviewed program, organisation unit, and synthetic/demo tracked-entity +selector with minimum `fields`. It never searches broadly to find a convenient +person. Public demonstration credentials are intentionally not reproduced in +repository material. + +### OpenCRVS public demo + +The owner-only file path is supplied through +`EVIDENCE_OPENCRVS_LIVE_ENV_FILE`. Its exact required keys are: + +```text +OPENCRVS_CLIENT_ID +OPENCRVS_SECRET +OPENCRVS_URL +OPENCRVS_TEST_TRACKING_ID +``` + +The selector value and any alternative tracking or national identifier remain +local. They are never placed in a fixture, test name, snapshot, log, audit +record, error, or command line. + +The live runner derives only the documented authentication and event-search +hosts from the configured base domain. It requests a client-credentials token +and then makes one bounded, exact event lookup that consumes only the count and +facts needed by the test. It does not retrieve a certificate or perform a +broad person search. + +These live checks prove only that the selected demo version still accepts the +documented authentication and bounded lookup shape. The DHIS2 check does not +run the deployable adult-status derivation or prove its complete minimization +and response-protection path. The OpenCRVS check does not prove country-specific parent +reference fields, authoritative relationship-set completeness, parent +membership semantics, or the deployable family requirements. Deterministic +mocks and executable project fixtures own those contracts. A passing live +check must not be described as certification of a complete deployment project. + +### Direct curl diagnosis + +Use these snippets only to diagnose an upstream API when the ignored live test +cannot establish why a deployment differs. They are not Evidence service +acceptance proof: they do not exercise authorization, audit, scripts, output +validation, signing, or disclosure release. Run them in a shell that does not +record terminal input. Values are prompted, sent to `curl` through standard +input with `--config -`, held only in shell memory, and unset at the end. The +commands print only shape, cardinality, and exact-match booleans. + +For a bounded DHIS2 Tracker collection lookup: + +```bash +( + set -eu + trap 'unset EVIDENCE_DIAG_EXPECTED DHIS2_BASE_URL DHIS2_USERNAME DHIS2_PASSWORD DHIS2_PROGRAM_ID DHIS2_ORG_UNIT_ID DHIS2_TRACKED_ENTITY_ID DHIS2_USER_CONFIG DHIS2_PROGRAM_CONFIG DHIS2_ORG_CONFIG DHIS2_ENTITY_CONFIG' EXIT HUP INT TERM + curl_config_escape() { sed 's/\\/\\\\/g; s/"/\\"/g'; } + curl_config_value_is_safe() { + [[ $1 != *$'\n'* && $1 != *$'\r'* ]] && + ! printf %s "$1" | LC_ALL=C grep -q '[[:cntrl:]]' + } + read -rp 'DHIS2 HTTPS base URL: ' DHIS2_BASE_URL + read -rp 'DHIS2 username: ' DHIS2_USERNAME + read -rsp 'DHIS2 password: ' DHIS2_PASSWORD; printf '\n' + read -rsp 'Program id: ' DHIS2_PROGRAM_ID; printf '\n' + read -rsp 'Organisation unit id: ' DHIS2_ORG_UNIT_ID; printf '\n' + read -rsp 'Tracked entity id: ' DHIS2_TRACKED_ENTITY_ID; printf '\n' + test -n "$DHIS2_USERNAME" && test -n "$DHIS2_PASSWORD" || { printf 'Non-empty credentials required\n' >&2; exit 1; } + if ! curl_config_value_is_safe "$DHIS2_USERNAME" || ! curl_config_value_is_safe "$DHIS2_PASSWORD"; then + printf 'Credential contains a prohibited control byte\n' >&2 + exit 1 + fi + printf %s "$DHIS2_BASE_URL" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?$' || { printf 'Conservative HTTPS base URL required\n' >&2; exit 1; } + for value in "$DHIS2_PROGRAM_ID" "$DHIS2_ORG_UNIT_ID" "$DHIS2_TRACKED_ENTITY_ID"; do + printf %s "$value" | grep -Eq '^[A-Za-z0-9._:-]{1,256}$' || { printf 'Conservative identifier shape required\n' >&2; exit 1; } + done + DHIS2_USER_CONFIG=$(printf '%s:%s' "$DHIS2_USERNAME" "$DHIS2_PASSWORD" | curl_config_escape) + DHIS2_PROGRAM_CONFIG=$(printf %s "$DHIS2_PROGRAM_ID" | curl_config_escape) + DHIS2_ORG_CONFIG=$(printf %s "$DHIS2_ORG_UNIT_ID" | curl_config_escape) + DHIS2_ENTITY_CONFIG=$(printf %s "$DHIS2_TRACKED_ENTITY_ID" | curl_config_escape) + export EVIDENCE_DIAG_EXPECTED=$DHIS2_TRACKED_ENTITY_ID + curl --config - <&2; exit 1; } + if ! curl_config_value_is_safe "$OPENCRVS_CLIENT_ID" || ! curl_config_value_is_safe "$OPENCRVS_CLIENT_SECRET"; then + printf 'Credential contains a prohibited control byte\n' >&2 + exit 1 + fi + printf %s "$OPENCRVS_DOMAIN" | grep -Eq '^([A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$' || { printf 'Conservative deployment domain required\n' >&2; exit 1; } + printf %s "$OPENCRVS_TRACKING_ID" | grep -Eq '^[A-Za-z0-9._:-]{1,256}$' || { printf 'Conservative tracking-id shape required\n' >&2; exit 1; } + case "$OPENCRVS_DOMAIN" in gateway.*|register.*|auth.*|events.*) OPENCRVS_DOMAIN=${OPENCRVS_DOMAIN#*.} ;; esac + OPENCRVS_CLIENT_ID_CONFIG=$(printf %s "$OPENCRVS_CLIENT_ID" | curl_config_escape) + OPENCRVS_CLIENT_SECRET_CONFIG=$(printf %s "$OPENCRVS_CLIENT_SECRET" | curl_config_escape) + OPENCRVS_TOKEN_RESULT=$( + curl --config - < 0 and ((.token_type // "Bearer") | ascii_downcase) == "bearer") then {token_shape_ok: true, access_token: .access_token} else error("token shape rejected") end' +silent +show-error +fail +no-location +max-redirs = 0 +proto = "=https" +connect-timeout = 5 +max-time = 15 +get +request = "POST" +data-urlencode = "client_id=$OPENCRVS_CLIENT_ID_CONFIG" +data-urlencode = "client_secret=$OPENCRVS_CLIENT_SECRET_CONFIG" +data-urlencode = "grant_type=client_credentials" +url = "https://auth.$OPENCRVS_DOMAIN/token" +EOF + ) + printf %s "$OPENCRVS_TOKEN_RESULT" | jq '{token_shape_ok}' + OPENCRVS_ACCESS_TOKEN=$(printf %s "$OPENCRVS_TOKEN_RESULT" | jq -er .access_token) + if ! curl_config_value_is_safe "$OPENCRVS_ACCESS_TOKEN"; then + printf 'Token contains a prohibited control byte\n' >&2 + exit 1 + fi + OPENCRVS_TOKEN_CONFIG=$(printf 'Authorization: Bearer %s' "$OPENCRVS_ACCESS_TOKEN" | curl_config_escape) + export EVIDENCE_DIAG_EXPECTED=$OPENCRVS_TRACKING_ID + OPENCRVS_BODY=$(jq -cn '{query: {type: "and", clauses: [{eventType: "birth", status: {type: "exact", term: "REGISTERED"}, trackingId: {type: "exact", term: env.EVIDENCE_DIAG_EXPECTED}}]}, limit: 2, offset: 0}') + OPENCRVS_BODY_CONFIG=$(printf %s "$OPENCRVS_BODY" | curl_config_escape) + curl --config - <- + The two opt-in public-demo entry tests and the two per-profile bounded + read-only query tests in crates/registry-evidence/tests/live_sources.rs + are named after their source products, so they cannot be referenced from + this contract without breaking source neutrality, which + products/evidence/scripts/check-source-neutrality.sh enforces over + products/evidence/contracts/. Their ignored-by-default status is carried + by their own #[ignore] attributes in that file. + - id: acceptance-row-21 + summary: Adult status passes the complete path with before, on, and after-boundary dates in the configured legal timezone. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_coequal_acceptance_fixture} + - {file: crates/registry-evidence/src/main.rs, name: case_local_date_overrides_common_observation_time} + - id: acceptance-row-22 + summary: Residence region passes the complete path with valid, unknown, and overly precise codes and a pinned codelist version. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_coequal_acceptance_fixture} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: codelist_required_missing_and_exact_collections_work} + - id: acceptance-row-23 + summary: Professional licence status passes the complete path with multiple concepts, validity boundaries, and proof that exact dates and history are absent from evidence and diagnostics. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_coequal_acceptance_fixture} + - {file: crates/registry-evidence/src/main.rs, name: privacy_expectations_check_exact_projected_strings} + - id: acceptance-row-24 + summary: Legal-parent relationship passes the complete path with correct and swapped roles, unauthorized candidate substitution, false relationship, returned-child mismatch, missing and ambiguous lookup, and ambiguous relationship facts; false is signed only after unique child resolution and a complete valid parent set. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_coequal_acceptance_fixture} + - {file: crates/registry-evidence/src/main.rs, name: unresolved_lookup_rejects_derivation_or_signed_success_claims} + - id: acceptance-row-25 + summary: All four definitions run together and under concurrency without state, subject, source, audit, limit, or result leakage. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: real_router_serves_all_definitions_concurrently_without_crossing_boundaries} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} + - {file: crates/registry-evidence/src/bundle.rs, name: combined_acceptance_bundle_loads_as_one_atomic_revision} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: all_four_acceptance_script_pairs_run_through_one_runtime} + - id: acceptance-row-26 + summary: At least one acceptance definition runs against two different mock source shapes with only bundle and Rhai changes. + tests: + - {file: crates/registry-evidence/tests/deployment_projects.rs, name: reference_deployment_projects_execute_the_closed_fixture_contract} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_reference_deployment_fixture} + - {file: crates/registry-evidence/src/bundle.rs, name: deployment_reference_projects_are_complete_compilable_bundles} + - id: acceptance-row-27 + summary: A repository boundary check rejects source-product names and behavior in production Rust, Cargo dependencies and features, public configuration schemas, routes, and CLI options. + tests: + - {file: crates/registry-evidence/tests/deployment_projects.rs, name: reference_deployment_projects_execute_the_closed_fixture_contract} + - {file: crates/registry-evidence/src/bundle.rs, name: deployment_reference_projects_are_complete_compilable_bundles} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} + note: >- + The mapped tests prove the positive half only: source-product behavior is + reached through governed bundles and scripts without editing Rust, and the + public routes carry no source-product name. The scanning boundary check + over production Rust, Cargo metadata, public schemas, and CLI options is + products/evidence/scripts/check-source-neutrality.sh, which is a CI script + rather than a Rust test, so this row is not fully executable from cargo. + - id: acceptance-row-28 + summary: JSON Schema and OpenAPI drift checks reproduce committed artifacts exactly. + tests: + - {file: crates/registry-evidence/src/contracts.rs, name: every_json_schema_is_valid_draft_2020_12} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_document_is_valid_utoipa_model} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} + - {file: crates/registry-evidence/src/contracts.rs, name: schemas_accept_the_exact_public_wire_shapes} + note: >- + These tests pin the generated contract content and its agreement with the + public Rust wire types. Byte-for-byte reproduction against the committed + artifacts under products/evidence/generated/ is proven by + products/evidence/scripts/check-contracts.sh, not by a cargo test. + - id: acceptance-row-29 + summary: Every declared Supported Value form rejects wrong scalar types, unknown codes, invalid entity references, excessive precision, oversized strings and lists, prohibited duplicates, and wrong cardinalities, and each valid form survives Evidence construction, JWS serialization, and verification without type loss. + tests: + - {file: crates/registry-evidence/src/kernel.rs, name: supported_value_fixture_cases_use_the_real_gate_and_signed_round_trip} + - {file: crates/registry-evidence/src/kernel.rs, name: supported_value_fixture_global_negatives_are_enforced} + - {file: crates/registry-evidence/src/kernel.rs, name: scalar_decimal_and_collection_forms_are_exact} + - {file: crates/registry-evidence/src/kernel.rs, name: bucket_entity_and_structured_forms_are_closed} + - {file: crates/registry-evidence/src/verifier.rs, name: signed_schema_integer_lexical_forms_verify_without_type_loss} + - id: acceptance-row-30 + summary: source-derived, field-projected, and record-transformed definitions use the same executor and report their acquisition guarantees honestly. + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_acquisition_posture_fixture_executes_with_one_bounded_request} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_frozen_source_shape_executes_through_production_materialization_and_projection} + - id: acceptance-row-31 + summary: A serving process cannot reload, mutate, merge, or fall back to another bundle revision at runtime. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: serving_runtime_never_reloads_merges_or_falls_back_after_bundle_capture} + - {file: crates/registry-evidence/src/bundle.rs, name: revision_binds_paths_and_exact_bytes_deterministically} + - {file: crates/registry-evidence/src/bundle.rs, name: writable_bundle_and_unknown_files_fail_closed} + - id: acceptance-row-32 + summary: No test, log, trace, metric, audit event, snapshot, panic, or failure artifact contains the canary credentials, raw selector values, source facts, or Supported Values used by the acceptance suite. + tests: + - {file: crates/registry-evidence/src/main.rs, name: privacy_expectations_check_exact_projected_strings} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} + - {file: crates/registry-evidence/src/model.rs, name: debug_surfaces_redact_requests_facts_disclosures_and_signed_payloads} + - {file: crates/registry-evidence/src/audit.rs, name: audit_is_durable_keyed_and_redacted} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - id: acceptance-row-33 + summary: An identifier-only selector profile and a compound selector profile with no identifier both pass the complete service path. + tests: [{file: crates/registry-evidence/tests/selector_conformance.rs, name: every_selector_profile_runs_the_complete_signed_service_path}] + - id: acceptance-row-34 + summary: Missing required, unknown, extra, mistyped, empty, oversized, and aggregate-oversized selector fields fail before credential acquisition or source access, with no protected value in the error. + tests: + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - {file: crates/registry-evidence/src/selector.rs, name: selector_claim_values_are_scalar_only} + - id: acceptance-row-35 + summary: Alternative sufficient field sets and sets with an additional disambiguating field require distinct named profiles and are never inferred from caller input. + tests: + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: every_selector_profile_runs_the_complete_signed_service_path} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: configuration_selector_negatives_are_rejected_at_immutable_bundle_load} + - id: acceptance-row-36 + summary: At least one selector profile uses deployment-defined field names that have no person, identifier, jurisdiction, source-product, or acceptance-case meaning to Rust. + tests: + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: every_selector_profile_runs_the_complete_signed_service_path} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - id: acceptance-row-37 + summary: Unicode and multipart name-like values pass as bounded opaque strings; core behavior never case-folds, transliterates, tokenizes, applies phonetics, parses Western name order, or performs partial-date matching. + tests: + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: every_selector_profile_runs_the_complete_signed_service_path} + - {file: crates/registry-evidence/src/selector.rs, name: canonical_selector_input_is_order_and_audience_sensitive} + - id: acceptance-row-38 + summary: Provider ambiguity never causes a second data request, page traversal, retrieval beyond the configured maximum of two minimally projected results, candidate choice, derivation execution, or success response. + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_frozen_source_shape_executes_through_production_materialization_and_projection} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: source_executor_failure_matrix_is_exact_single_request_and_value_free} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: a_reset_transport_failure_yields_exactly_one_connection_attempt} + - {file: crates/registry-evidence/src/runtime.rs, name: public_unavailability_does_not_distinguish_no_match_from_ambiguity} + - id: acceptance-row-39 + summary: Candidate records, candidate counts beyond the closed outcome, scores, confidence, near-match hints, and field-by-field comparison results are rejected by the extraction boundary and absent from public responses. + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: candidate_numeric_string_and_opaque_type_surface_is_pinned} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: compiler_rejects_every_forbidden_candidate_construct} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: extraction_decodes_only_the_closed_union_and_validates_facts} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_rejects_an_extra_extracted_fact_before_derivation_or_release} + - id: acceptance-row-40 + summary: Native audit records at most the selector-profile id and one scoped keyed pseudonym over each complete canonical role and selector bundle; separate hashes of low-entropy fields fail redaction tests. + tests: + - {file: crates/registry-evidence/src/audit.rs, name: audit_is_durable_keyed_and_redacted} + - {file: crates/registry-evidence/src/audit.rs, name: frozen_audit_fixture_matches_native_event_shape_and_phase_rules} + - {file: crates/registry-evidence/src/selector.rs, name: canonical_selector_input_is_order_and_audience_sensitive} + - {file: crates/registry-evidence/src/binding.rs, name: subject_binding_is_field_order_sensitive} + - id: acceptance-row-41 + summary: Context-derived and authenticated-grant-derived selector profiles reject caller-provided values; an authorized request-derived caseworker profile accepts only its configured closed field set. + tests: + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: every_selector_profile_runs_the_complete_signed_service_path} + - {file: crates/registry-evidence/src/config.rs, name: context_and_grant_claim_maps_are_exact_and_non_aliasing} + - id: acceptance-row-42 + summary: Evidence and the JWS payload never echo selector profile ids or values, and audience-scoped subject bindings do not become globally stable identifiers. + tests: + - {file: crates/registry-evidence/src/model.rs, name: evidence_has_no_selector_echo_field} + - {file: crates/registry-evidence/src/binding.rs, name: subject_binding_is_stable_and_scoped} + - {file: crates/registry-evidence/src/binding.rs, name: entity_reference_is_audience_and_concept_scoped} + - {file: crates/registry-evidence/src/binding.rs, name: every_subject_binding_scope_component_is_cryptographically_bound} + - id: acceptance-row-43 + summary: Failed selector attempts are bounded per principal and authority profile without using raw selector values as metric or rate-limit labels. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: failed_selector_budget_is_enforced_by_the_runtime_and_scoped_to_authority} + - {file: crates/registry-evidence/src/rate_limit.rs, name: selector_failure_budget_is_separate_and_authority_scoped} + - {file: crates/registry-evidence/src/rate_limit.rs, name: selector_failure_budget_is_shared_across_request_contexts} + - {file: crates/registry-evidence/src/runtime.rs, name: runtime_failures_and_rate_keys_are_value_free} + - {file: crates/registry-evidence/src/runtime.rs, name: rate_limit_scope_cannot_be_fragmented_by_request_dimensions} + - id: acceptance-row-44 + summary: All four initial assertion cases pass their assigned selector shapes and lookup outcomes through offline fixtures, local HTTP mocks, the real router, both audit gates, signed JWS, explicitly authorized unsigned output, and strict verification on one revision. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: all_four_definitions_pass_the_explicitly_authorized_unsigned_path} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_every_coequal_acceptance_fixture} + - {file: crates/registry-evidence/src/main.rs, name: offline_cli_evaluates_the_combined_acceptance_bundle} + - {file: crates/registry-evidence/src/kernel.rs, name: all_four_acceptance_bundles_use_the_same_kernel} + - id: acceptance-row-45 + summary: Governed bundle and runtime configuration have separate closed schemas, independent startup digests, read-only lifetime enforcement, and negative tests proving runtime fields cannot override sources, authorization, disclosure, limits, signing, or audit policy. + tests: + - {file: crates/registry-evidence/src/config.rs, name: runtime_document_is_closed_and_contains_no_governed_override_surface} + - {file: crates/registry-evidence/src/bundle.rs, name: runtime_and_ca_bytes_are_captured_under_an_independent_read_only_revision} + - {file: crates/registry-evidence/src/bundle.rs, name: writable_bundle_and_unknown_files_fail_closed} + - {file: crates/registry-evidence/src/bundle.rs, name: symlinked_artifact_fails_before_file_access} + - id: acceptance-row-46 + summary: Fixed paths and selector-bound path templates pass exact encoding tests; missing, extra, duplicated, slash, backslash, percent, control, empty, and dot-segment bindings fail before credential acquisition or source access. + tests: + - {file: crates/registry-evidence/src/source.rs, name: path_selector_encoding_is_single_pass_and_hostile_values_fail_closed} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: path_binding_contract_rejects_empty_missing_and_extra_material_before_credentials} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: hostile_path_values_and_malformed_preparation_fail_before_transport_and_redact} + - {file: crates/registry-evidence/src/config.rs, name: path_templates_headers_and_projection_fail_closed} + - id: acceptance-row-47 + summary: Fixed non-secret headers, static Bearer, and static API-key authentication pass generic exact-request and redaction tests; forbidden, duplicate, framing, routing, forwarding, proxy, tracing, cookie, and authentication header collisions fail at startup. + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: basic_bearer_and_static_api_key_headers_are_exact_and_failures_are_redacted} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: exact_request_applies_path_query_body_headers_auth_and_projection_once} + - id: acceptance-row-48 + summary: System roots and logical private-CA trust profiles pass positive TLS tests; unbound, malformed, insecure, symlinked, mutable, or hostname-bypassing CA configurations prevent readiness, and ambient HTTP proxy environment variables cannot redirect evidence-data or OAuth requests. + tests: + - {file: crates/registry-evidence/tests/source_contracts.rs, name: private_ca_tls_handshake_succeeds_and_hostname_mismatch_fails} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: private_ca_plan_rejects_unbound_missing_and_malformed_captures} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: runtime_ca_capture_rejects_symlink_malformed_and_mutable_files} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: ambient_proxy_variables_are_ignored_in_an_isolated_process} + - {file: crates/registry-evidence/src/source.rs, name: evidence_client_uses_rustls_and_fails_closed_on_an_unrecognized_certificate_authority} + - id: acceptance-row-49 + summary: Extended JSON Pointer response projection passes flat, nested, array, literal-dot-key, missing-leaf, mistyped-intermediate, invalid-escape, duplicate, overlap, size-before-projection, and privacy-canary tests; Rhai sees only the projected tree while posture reflects the pre-projection wire response. + tests: + - {file: crates/registry-evidence/src/source.rs, name: projection_supports_nested_arrays_escapes_and_literal_dots} + - {file: crates/registry-evidence/src/source.rs, name: projection_omits_missing_leaves_but_rejects_missing_or_mistyped_intermediates} + - {file: crates/registry-evidence/src/source.rs, name: projection_rejects_duplicates_conflicts_indexes_and_mixed_container_shapes} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: projection_missing_leaf_is_omitted_but_bad_intermediate_stops_before_extraction} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_acquisition_posture_fixture_executes_with_one_bounded_request} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: extraction_source_input_uses_the_configured_one_mebibyte_boundary} + - id: acceptance-row-50 + summary: parse_integer accepts the documented ASCII grammar and leading zeroes and rejects empty, plus-prefixed, whitespace, non-ASCII, fractional, exponential, and overflowing values; query values remain strings and no implicit value-to-string conversion is introduced. + tests: + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: strict_integer_parser_and_mutating_helpers_enforce_bounds} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: out_of_range_json_integer_tokens_fail_before_rhai} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: preparation_inputs_and_request_parts_are_closed_and_bounded} + - {file: crates/registry-evidence/src/rhai_runtime.rs, name: candidate_numeric_string_and_opaque_type_surface_is_pinned} + - id: acceptance-row-51 + summary: Public request subjects are resolved uniquely by role rather than array position; permutations produce the same authorized internal role order, and duplicate, missing, unknown, or wrong-profile roles fail before credentials or source access. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: multi_role_request_order_is_not_semantic_and_output_uses_declaration_order} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: reordered_grant_subjects_resolve_by_role_and_emit_declaration_order} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: all_runtime_selector_negatives_fail_closed_before_source_access} + - {file: crates/registry-evidence/src/kernel.rs, name: evidence_construction_is_deterministic_and_role_ordered} + - id: acceptance-row-52 + summary: The request nonce is exactly the canonical 43-character unpadded base64url encoding of 32 bytes; missing, duplicate, padding, noncanonical, malformed, wrong-length, and oversized values fail before credential acquisition or source access, and nonce canaries never reach authorization, Rhai, source preparation, source calls, logs, or audit. + tests: + - {file: crates/registry-evidence/src/model.rs, name: request_nonce_canonicality_is_exact} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_nonce_is_strict_and_never_reaches_source_or_audit} + - {file: crates/registry-evidence/src/server.rs, name: request_json_is_strict_and_closed} + - id: acceptance-row-53 + summary: Signed Evidence echoes the exact request nonce; changing the expected or signed nonce fails verification, and nonce reuse is not represented as replay prevention. + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_nonce_is_strict_and_never_reaches_source_or_audit} + - id: acceptance-row-54 + summary: The verifier requires independently trusted expected subject roles and opaque bindings plus expected concept identifiers, forms, and cardinalities after signature verification; missing, extra, duplicated, substituted, or wrong-key-version bindings and unexpected output fail, and subject order alone is non-semantic. + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence/src/verifier.rs, name: signature_never_substitutes_for_provider_and_issuer_trust_policy} + - {file: crates/registry-evidence/src/verifier.rs, name: retired_public_key_verifies_only_while_published_and_payload_is_current} + - {file: crates/registry-evidence/src/verifier.rs, name: active_plus_maximum_retired_keys_is_a_usable_trusted_set} + - id: acceptance-row-55 + summary: Missing Accept, */*, and exact signed media select JWS; only exact unsigned media selects unsigned JSON, and duplicate, combined, parameterized, weighted, or unknown negotiation fails before source access. + tests: + - {file: crates/registry-evidence/src/server.rs, name: accept_negotiation_matrix_is_closed_and_exact} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: accept_negotiation_is_closed_and_fails_before_source_access} + - id: acceptance-row-56 + summary: Unsigned selection succeeds only when both the bundle and the matched authority grant permit it; caller selection, runtime configuration, or a different grant cannot create permission. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_output_requires_both_bundle_and_grant_permission} + - {file: crates/registry-evidence/src/config.rs, name: response_formats_are_closed_unique_and_keep_signed_mandatory} + - {file: crates/registry-evidence/src/config.rs, name: runtime_document_is_closed_and_contains_no_governed_override_surface} + - id: acceptance-row-57 + summary: Signing or signed-release failure never returns unsigned output; an explicitly authorized unsigned request performs no signing operation and still requires the ordinary signing dependency to be ready. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_returns_a_problem_and_never_an_unsigned_body} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_is_transient_audited_and_never_releases_unsigned_evidence} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: readiness_fails_for_missing_credentials_tampered_audit_and_unready_signing} + - id: acceptance-row-58 + summary: The unsigned envelope has its exact vendor media type, schema, type, integrity marker, warning, and closed nested Evidence with no protected, payload, signature, or signing-key claim, and the JWS verifier rejects it. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} + - {file: crates/registry-evidence/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} + - {file: crates/registry-evidence/src/contracts.rs, name: schemas_accept_the_exact_public_wire_shapes} + - {file: crates/registry-evidence/src/contracts.rs, name: jws_and_problem_schemas_are_closed} + - id: acceptance-row-59 + summary: Both response formats serialize final immutable bytes and require durable disclosure-release audit before release; audit records the closed protection mode and conditionally requires or forbids signingKeyId without recording nonce, selectors, source values, or disclosed values. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_signed_response_release} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_unsigned_response_release} + - {file: crates/registry-evidence/src/audit.rs, name: frozen_audit_fixture_matches_native_event_shape_and_phase_rules} + - {file: crates/registry-evidence/src/audit.rs, name: invalid_release_shape_and_size_limit_fail_closed} + - {file: crates/registry-evidence/src/audit.rs, name: audit_is_durable_keyed_and_redacted} + - id: acceptance-row-60 + summary: All four coequal definitions pass signed and explicitly authorized unsigned paths through the same router, source executor, derivation, output gate, subject binding, minimization, and audit logic without domain branches. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: all_four_definitions_pass_the_explicitly_authorized_unsigned_path} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_verification} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} + - {file: crates/registry-evidence/src/config.rs, name: all_coequal_acceptance_definitions_use_the_same_typed_config} + - id: acceptance-row-61 + summary: Verification tooling re-verifies a stored signed response against a pinned trusted key, expected policy, request nonce, subject bindings, and output contract, and reports cryptographic authenticity separately from current validity. + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} + - {file: crates/registry-evidence/src/verifier.rs, name: complete_jws_negative_fixture_is_executable} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_accepts_an_authentic_and_current_stored_response} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_separates_authenticity_from_current_validity} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_rejects_a_tampered_payload_without_naming_a_value} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_reports_only_the_generic_policy_class_for_a_wrong_expected_nonce} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_rejects_a_policy_document_with_an_unknown_field} + - id: acceptance-row-62 + summary: Authenticated discovery returns only complete request shapes matching exactly one authority path for the verified caller; unentitled callers receive an empty list, ambiguous shapes are omitted, deployment internals are absent, and no provider request or evidence-data audit event occurs. + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_requires_authentication_and_returns_no_unentitled_definitions} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_omits_an_authority_shape_that_the_runtime_would_deny_as_ambiguous} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_uses_the_bounded_per_principal_request_budget} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} diff --git a/products/evidence/contracts/audit-event.schema.yaml b/products/evidence/contracts/audit-event.schema.yaml index 4a0c53abb..7120adf56 100644 --- a/products/evidence/contracts/audit-event.schema.yaml +++ b/products/evidence/contracts/audit-event.schema.yaml @@ -15,6 +15,7 @@ required: - requesterPseudonym - authority - subjects + - responseProtection - decision - durationMilliseconds properties: @@ -47,6 +48,7 @@ properties: role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} selectorProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} selectorBundlePseudonym: {$ref: '#/$defs/pseudonym'} + responseProtection: {enum: [signed, unsigned]} sourceId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} adapterId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} decision: @@ -66,16 +68,24 @@ $defs: pattern: '^hmac-sha256:v[1-9][0-9]*:[a-f0-9]{64}$' allOf: - if: {properties: {phase: {const: disclosure-release}}} - then: {required: [disclosedConcepts, evidenceId, signingKeyId]} + then: {required: [disclosedConcepts, evidenceId]} else: not: anyOf: - {required: [disclosedConcepts]} - {required: [evidenceId]} - {required: [signingKeyId]} + - if: + properties: + phase: {const: disclosure-release} + responseProtection: {const: signed} + then: {required: [signingKeyId]} + - if: {properties: {responseProtection: {const: unsigned}}} + then: {not: {required: [signingKeyId]}} audit_rules: access_gate: access-attempt is durably accepted after authorization and before credential acquisition or source access - release_gate: disclosure-release is durably accepted after signing and before response release + release_gate: disclosure-release is durably accepted after the final immutable response bytes are serialized and before those exact bytes are released + response_protection: every event records the closed non-secret responseProtection mode resolved with authorization; signingKeyId exists exactly for signed disclosure release and is forbidden for unsigned output failure_policy: sink failure blocks the applicable step chain_verification: The complete keyed chain is verified at startup and after restart; steady-state appends and readiness verify the pinned file identity, modification fingerprint, expected length, and verified tail without rescanning the growing file. external_mutation: Any external replacement or modification fails readiness and future appends closed until a restart completes full keyed-chain verification. @@ -86,5 +96,6 @@ audit_rules: - raw principal, actor, grant, selector, source, or supported values - separate hashes of low-entropy selector fields - credentials, tokens, request or response bodies + - the request nonce - candidate count, candidates, scores, hints, or comparisons - script inputs, outputs, stacks, or signing material diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 4646622ef..5e7d9f5cd 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -39,6 +39,7 @@ properties: jwksPath: {const: /.well-known/evidence/jwks.json} maximumAssertionValiditySeconds: {type: integer, minimum: 1, maximum: 31536000} verifierClockSkewSeconds: {type: integer, minimum: 0, maximum: 600} + responseFormats: {$ref: '#/$defs/response-formats'} selectorProfiles: type: object minProperties: 1 @@ -65,6 +66,16 @@ properties: $defs: local-id: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} uri: {type: string, format: uri, maxLength: 512} + response-formats: + # Closed enabled response formats. Signed flattened JWS is mandatory and + # remains the default; the unsigned format must be enabled by the bundle + # and permitted by the complete matched grant. Omission means signed only. + type: array + minItems: 1 + maxItems: 2 + uniqueItems: true + contains: {const: signed-jws} + items: {enum: [signed-jws, unsigned-json]} secret-ref: {type: string, pattern: '^secret:file/[a-z][a-z0-9._-]{0,127}$'} relative-path: {type: string, pattern: '^(adapters|derivations|schemas|codelists|fixtures)/[A-Za-z0-9._/-]+$'} @@ -374,6 +385,7 @@ $defs: requirement: {$ref: '#/$defs/uri'} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} audienceFrom: {const: authenticated-requester} + responseFormats: {$ref: '#/$defs/response-formats'} subjects: type: array minItems: 1 diff --git a/products/evidence/contracts/cccev-field-mapping.yaml b/products/evidence/contracts/cccev-field-mapping.yaml index fd644ae4f..3d5e67a86 100644 --- a/products/evidence/contracts/cccev-field-mapping.yaml +++ b/products/evidence/contracts/cccev-field-mapping.yaml @@ -8,6 +8,9 @@ mapping: Evidence.schema: source: evidence_extension rule: Fixed profile discriminator registry.assertion-evidence/v1. + Evidence.requestNonce: + source: evidence_extension + rule: Exact echo of the caller-supplied request nonce; uninterpreted correlation data with no CCCEV-native counterpart, never a subject binding, identifier, or freshness proof. Evidence.id: source: dct:identifier rule: Core-created globally unique evidence identifier. diff --git a/products/evidence/contracts/evidence.schema.yaml b/products/evidence/contracts/evidence.schema.yaml index 71d9f1b32..ff2be46fc 100644 --- a/products/evidence/contracts/evidence.schema.yaml +++ b/products/evidence/contracts/evidence.schema.yaml @@ -5,6 +5,7 @@ type: object additionalProperties: false required: - schema + - requestNonce - id - type - supportsRequirement @@ -21,6 +22,7 @@ required: - supportedValues properties: schema: {const: registry.assertion-evidence/v1} + requestNonce: {type: string, pattern: '^[A-Za-z0-9_-]{43}$'} id: {type: string, format: uri, maxLength: 512} type: {const: Evidence} supportsRequirement: {type: string, format: uri, maxLength: 512} @@ -101,4 +103,9 @@ $comment: >- codelist, precision, cardinality, sizes, structured fields, and uniqueness. A value accepted by this transport schema but not by that declaration is rejected before evidence construction. Subject selector profiles and values - never appear in evidence. + never appear in evidence. requestNonce is the exact echo of the caller's + request nonce and is covered by the JWS signature in the signed format. It + is not part of the audience-scoped subject binding, and its reuse is not + rejected; challenge-style transaction binding exists only when the relying + party independently retains the original request and requires the expected + nonce during verification. diff --git a/products/evidence/contracts/jws-profile.yaml b/products/evidence/contracts/jws-profile.yaml index 8f94a399a..1ab2ca250 100644 --- a/products/evidence/contracts/jws-profile.yaml +++ b/products/evidence/contracts/jws-profile.yaml @@ -7,6 +7,15 @@ response: required: [protected, payload, signature] prohibited: [header] payload: base64url without padding of the exact UTF-8 Evidence JSON bytes + negotiation: Missing Accept, */*, and exact application/jose+json select this default signed format. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the separately governed unsigned envelope, and only when the immutable bundle and the complete matched authority grant permit it. Every response varies on Accept and remains no-store. +unsigned_envelope: + media_type: application/vnd.registrystack.evidence-unsigned+json + schema: registry.unsigned-evidence-envelope/v1 + members: + required: [schema, type, integrityProtection, warning, evidence] + prohibited: [protected, payload, signature, header] + integrity: none; transport-authenticated convenience only, never later-verifiable evidence and never a fallback from any signed-path failure + verifier_rule: The strict JWS verifier rejects this representation. A dedicated unsigned parser may validate the envelope, the Evidence schema, and expected policy fields, but returns an explicitly unverified result type and never the verified-JWS result type. Version 1 never uses JWS alg none, an empty signature, or a JWS-shaped unsigned object. protected_header: exact_members: [alg, kid, typ, cty] alg: @@ -26,10 +35,12 @@ signing: bundle_private_key_material: prohibited order: - core validates the complete derivation result - - core constructs the exact evidence payload + - core constructs the exact evidence payload including the exact request nonce - signing provider signs protected header plus payload + - core serializes the final immutable response bytes - disclosure-release audit is durably accepted - - JWS is released + - the exact pre-audited bytes are released + unsigned_order: For an explicitly authorized unsigned request the core performs no signing operation, still requires the ordinary signing dependency to be ready, constructs and serializes the final immutable envelope bytes, durably accepts disclosure-release audit, then releases those exact bytes. failure: safe 503 with no unsigned fallback key_discovery: path: /.well-known/evidence/jwks.json @@ -47,8 +58,23 @@ verifier_rules: - Verify the signature before parsing or acting on payload claims. - Validate the strict payload against the complete committed Evidence JSON Schema before deserializing or applying relying-procedure policy. - Require schema, issuer, provider, requirement, Evidence Type, purpose, audience, observation, validity, and configuration revision expected by the relying procedure. + - Require the exact request nonce from the independently retained original request. Nonce reuse is not replay prevention and is not rejected by the runtime. + - Require the exact expected role-bound opaque subject bindings as an unordered set of unique role and binding pairs, compared only after signature and schema verification. Expectations come from independent trusted state, never from the JWS under verification. + - Require the exact expected concept identifiers, value forms, and cardinalities; missing, extra, duplicated, or wrongly formed output fails even under a valid signature. + - Return one generic policy mismatch without revealing which hidden selector or policy comparison failed. + - Impose a maximum accepted assertion lifetime in addition to current validity. - Treat validUntil as an exclusive upper bound and apply only the configured clock skew. + - Report cryptographic authenticity separately from current validity; an expired assertion may remain authentic without being current evidence. - Do not infer source truth, legal-signature status, holder binding, or single-use semantics from a valid signature. +durable_verification_record: + - the exact flattened JWS bytes + - the original request and its request nonce + - the expected audience, purpose, requirement, Evidence Type, issuer, and technical provider + - the expected role-bound subject bindings + - the expected concept identifiers, forms, and cardinalities or the exact trusted requirement contract that defines them + - the configuration revision + - the trusted signing-key or JWKS snapshot selected through governed metadata + - the trusted time at which current-validity verification was performed negative_tests: - jws-payload-modification - jws-protected-header-modification @@ -57,3 +83,8 @@ negative_tests: - jws-schema-invalid-signed-payload-rejected - jws-retired-key-window - signing-failure-no-unsigned-success + - expected-nonce-mismatch + - expected-subject-set-mismatch + - expected-output-contract-mismatch + - assertion-lifetime-above-accepted-maximum + - unsigned-envelope-rejected-by-jws-verifier diff --git a/products/evidence/contracts/primitive-library.yaml b/products/evidence/contracts/primitive-library.yaml index 3f2d610af..da7d45922 100644 --- a/products/evidence/contracts/primitive-library.yaml +++ b/products/evidence/contracts/primitive-library.yaml @@ -71,10 +71,10 @@ primitives: behavior: Exact code lookup with no normalization; returns only the configured output code. list_contains: signature: '[array, scalar] -> boolean' - behavior: Exact typed equality over at most 256 items. + behavior: Exact typed equality over at most 256 items. Every item is validated as a scalar before any answer is produced, so an invalid item fails even when an earlier item matches. set_contains: signature: '[array, scalar] -> boolean' - behavior: Exact typed equality over at most 256 items; a duplicate input item fails because the array represents a set. + behavior: Exact typed equality over at most 256 items. Every item is validated as a scalar before any answer is produced, and a duplicate input item fails because the array represents a set. array_push: signature: 'array.push(value) -> unit' behavior: Appends one local value when the resulting array remains within the 256-item bound; otherwise fails without a partial output. @@ -83,14 +83,16 @@ primitives: behavior: Mutates the local receiver by replacing every exact non-overlapping literal occurrence, with no regex or Unicode normalization, when the result remains within the 16384-byte string bound. required: signature: '[Option, string] -> T' - behavior: Returns the value or fails with the supplied safe bundle-owned error code; protected data may not be used as the code. + behavior: Returns the value, or terminates the invocation with the host-private unavailable signal. The second argument must have a safe bundle-owned code shape; it is validated and then discarded, because shape validation cannot prove a code is a reviewed literal rather than derived protected data. No supplied code is observable in evidence, problems, audit, logs, or diagnostics. is_missing: signature: Option -> boolean behavior: Explicit missing-value test with no implicit coercion. global_rules: - Primitives are pure, deterministic, typed, bounded, and domain-neutral. - Strings are compared as exact UTF-8 values; no Unicode normalization, case folding, transliteration, or phonetics occurs. - - Decimal operations use the Rust-owned exact Decimal type; Rhai floating-point arithmetic is not accepted at the output gate. + - Decimal operations use the Rust-owned exact Decimal type. Ordinary floats remain available in request preparation and source extraction and are rejected for every public derived value; exact numbers use the declared integer or Decimal forms. + - Array and map indexing is routed through a host-owned guard; a negative index fails whether it is written literally or computed. + - JSON numbers are admitted only as signed 64-bit integers or finite floats; an integer token outside the signed 64-bit range fails before Rhai, and a provider identifier outside that range must be represented as a string. - EntityReferenceSeed is a protected projection input, not public data; only the core can HMAC-project it to an audience-scoped reference. - Query names and values remain strings. parse_integer exists only for provider text and does not introduce implicit integer-to-string or value-to-string conversion. - Array and string mutation affects only the fresh invocation-local copy and cannot modify the governed bundle, another script stage, or another request. diff --git a/products/evidence/contracts/problem-contract.yaml b/products/evidence/contracts/problem-contract.yaml index 3b4558713..c72ba7bd2 100644 --- a/products/evidence/contracts/problem-contract.yaml +++ b/products/evidence/contracts/problem-contract.yaml @@ -16,13 +16,17 @@ codes: invalid_selector: {status: 400, title: Request is not valid} authentication_failed: {status: 401, title: Authentication failed} not_authorized: {status: 403, title: Request is not authorized} + response_format_not_acceptable: {status: 406, title: Requested response format is not acceptable} evidence_not_available: {status: 422, title: Evidence could not be produced} rate_limited: {status: 429, title: Request rate exceeded} dependency_unavailable: {status: 503, title: Service temporarily unavailable} service_unavailable: {status: 503, title: Service temporarily unavailable} +response_format_rules: + negotiation: Missing Accept, */*, and exact application/jose+json select signed JWS. Only exact application/vnd.registrystack.evidence-unsigned+json selects the unsigned envelope. Duplicate, combined, parameterized, quality-weighted, or unknown negotiation returns response_format_not_acceptable before source access. + authorization: A recognized unsigned request that the immutable bundle or the complete matched grant does not permit returns the ordinary not_authorized problem before credential acquisition or source access, without revealing which layer withheld permission. existence_disclosure: default_public_collapse: - internal_classes: [no_match, ambiguous, required_fact_missing] + internal_classes: [no_match, ambiguous, required_fact_missing, derivation_input_unresolved] public_code: evidence_not_available same_status: 422 same_title: Evidence could not be produced diff --git a/products/evidence/contracts/request.schema.yaml b/products/evidence/contracts/request.schema.yaml index 356b005a4..fcd16af1e 100644 --- a/products/evidence/contracts/request.schema.yaml +++ b/products/evidence/contracts/request.schema.yaml @@ -3,8 +3,11 @@ $id: https://registrystack.org/schemas/evidence/request-v1.json title: Evidence request Version 1 type: object additionalProperties: false -required: [requirement, purpose, subjects] +required: [requestNonce, requirement, purpose, subjects] properties: + requestNonce: + type: string + pattern: '^[A-Za-z0-9_-]{43}$' requirement: type: string format: uri @@ -62,3 +65,11 @@ $comment: >- mistyped, empty, oversized, wrong-origin, or unauthorized values fail before credential acquisition or source access. Array position is not semantic; duplicate, missing, unknown, or wrong-profile roles fail before access audit. + requestNonce is the canonical unpadded base64url encoding of exactly 32 + bytes generated independently for each request by a cryptographically secure + random source; a padded, wrong-length, or noncanonical value fails before + credential acquisition or source access. The nonce is uninterpreted + correlation data echoed into the Evidence payload. It is never stored, + never uniqueness-checked, and never reaches authorization, rate limits, + Rhai, source requests, logs, metrics, traces, or native audit. Callers must + not encode identifiers, selectors, secrets, or document digests into it. diff --git a/products/evidence/contracts/rhai-abi.yaml b/products/evidence/contracts/rhai-abi.yaml index 5a536e529..1371326c0 100644 --- a/products/evidence/contracts/rhai-abi.yaml +++ b/products/evidence/contracts/rhai-abi.yaml @@ -33,7 +33,7 @@ functions: extraction: signature: extract(projected_source_response, adapter_parameters) -> LookupResult input: - projected_source_response: Parsed JSON from the one completed evidence-data request after pre-projection response bounds and the Rust-enforced extended JSON Pointer allowlist. + projected_source_response: Parsed JSON from the one completed evidence-data request after pre-projection response bounds and the Rust-enforced extended JSON Pointer allowlist. The projected tree handed to extraction is limited to 65536 serialized bytes; the 1 MiB wire-response maximum bounds the pre-projection response and the two limits are compatible layers. Integer tokens outside the signed 64-bit range fail before Rhai rather than converting to a precision-losing float; provider identifiers outside that range must be represented as strings. adapter_parameters: A fresh copy of the same closed non-secret parameters supplied to preparation. prohibited: - selectors or prepared request parts @@ -60,7 +60,7 @@ functions: derivation: signature: derive(facts, declared_authorized_selectors, evaluation_context) -> ConceptValueSet input: - facts: Immutable validated FactSet from one unique match. + facts: Immutable validated FactSet from one unique match. Integer tokens outside the signed 64-bit range fail before Rhai rather than converting to a precision-losing float. declared_authorized_selectors: Exact role, profile, and field map declared by derivation.selectorInputs, resolved from already authorized requirement subjects; omission yields an empty map. evaluation_context: exact_keys: [observed_at, legal_local_date, legal_local_time, parameters, codelists] @@ -72,7 +72,7 @@ functions: output: representation: array of exact maps with keys concept_id and value maximum_items: 16 - validation: Concept identifiers must equal the requirement output set; duplicates, missing required values, extra fields, and invalid values are rejected. + validation: Concept identifiers must equal the requirement output set; duplicates, missing required values, extra fields, and invalid values are rejected. An ordinary Rhai float is never a public derived value; exact numbers use the declared integer or Rust-owned Decimal forms. Ordinary f64 remains available only inside the preparation and extraction adapter surfaces. protected_types: Decimal: construction: decimal(canonical_text) or parse_decimal(canonical_text) @@ -108,7 +108,8 @@ capabilities: - filesystem, environment, network, process, import, eval, plugins, or modules - ambient clock, timezone lookup, randomness, UUID, logging, printing, diagnostics, audit, credentials, or signing - anonymous functions, function pointers, dynamic dispatch, top-level executable statements, or catchable host-private unavailable termination - syntax_contract: The detailed pinned Rhai 1.25.1 syntax surface and forbidden constructs are defined by the trusted request-adapter API and enforced by startup compilation tests. + - raw string literals, block and if expressions in operand position, and negative array indexing + syntax_contract: The detailed pinned Rhai 1.25.1 syntax surface and forbidden constructs are defined by the trusted request-adapter API and enforced by startup compilation tests. Startup review also rewrites every index operand through a host-owned guard that rejects a negative index whether it is written literally or computed. The lexical restriction is an enforceable reviewed-language contract; the raw engine capability set remains the isolation boundary. lifecycle: compilation: Startup only; each script must expose exactly one public entry point at the required arity before readiness. state: Fresh invocation state and fresh input copies; no cross-stage, cross-request, or cross-definition mutable state. diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 8047a542f..d2ceb6ff4 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -78,9 +78,9 @@ invariants: enforcement: Field allowlists and at most one scoped keyed pseudonym over each complete canonical role/selector bundle. negative_test: sec-protected-canaries-redacted - id: V1-I16 - rule: No-match and ambiguous behavior cannot accidentally disclose registry membership. - threat: Existence oracle through status, message, diagnostics, count, or avoidable timing. - enforcement: Default public collapse to one problem plus closed protected audit class without counts. + rule: No-match, ambiguous, required-fact-missing, and derivation-input-inconsistent behavior cannot accidentally disclose registry membership. + threat: Existence oracle through status, message, diagnostics, count, avoidable timing, or a distinguishable failure class for a uniquely found record with inconsistent derivation inputs. + enforcement: Default public collapse of every unresolved class, including derivation-input inconsistency, to one problem shape plus a closed value-free protected audit category without counts. negative_test: sec-unresolved-public-collapse - id: V1-I17 rule: Subject bindings are audience-scoped and not globally linkable. @@ -103,9 +103,9 @@ invariants: enforcement: Bundle combination validation is mandatory independently of configured rate limits. negative_test: sec-rate-limit-does-not-legalize-ladder - id: V1-I21 - rule: Every successful production response is a standard JWS over the exact evidence payload. - threat: Payload substitution, unsigned success, or unverifiable parallel representations. - enforcement: One flattened-JWS response type containing only protected, payload, and signature. + rule: Signed flattened JWS over the exact evidence payload is mandatory, available to every authorized grant, and the default response; unsigned output exists only through its exact media type when both the immutable bundle and the complete matched grant permit it. + threat: Payload substitution, an unsigned success masquerading as verified evidence, or unverifiable parallel representations. + enforcement: One flattened-JWS response type containing only protected, payload, and signature, plus a strict closed Accept matrix resolved before source access. negative_test: sec-jws-mutation-and-duplicate-payload - id: V1-I22 rule: Missing or failed signing never falls back to unsigned evidence. @@ -132,6 +132,31 @@ invariants: threat: A public catalog, entitlement oracle, or overbroad metadata response reveals unavailable definitions, authority structure, selectors, source plans, or credentials. enforcement: Rust authenticates and rate-limits discovery, projects only complete shapes matching exactly one authority path and valid token-owned selector material through a closed response allowlist, omits unentitled and ambiguous shapes, and performs no provider or evidence-data audit access. negative_test: sec-discovery-requester-scoped + - id: V1-I27 + rule: Every request carries one exact canonical 32-byte random nonce that is echoed into Evidence but never stored, uniqueness-checked, or exposed to authorization, rate limits, Rhai, source requests, logs, metrics, traces, or native audit. + threat: A malformed or attacker-shaped nonce reaches downstream boundaries, or the nonce becomes a covert identifier channel correlated through audit or source records. + enforcement: Strict canonical base64url parsing before authentication, credential acquisition, and source access; the exact value is copied only into the core-constructed Evidence payload. + negative_test: sec-request-nonce-strict-and-contained + - id: V1-I28 + rule: The unsigned response format is authorized only when the immutable bundle enables it and the one complete matched grant permits it; API selection, runtime configuration, and other grants create no permission. + threat: A caller or operator activates unsigned output around governance, or permissions union across grants. + enforcement: Closed bundle responseFormats and per-grant responseFormats validated at startup with signed JWS mandatory, checked together after exact entitlement match and before selector resolution, credentials, or source access; the closed runtime file has no response-format field. + negative_test: sec-unsigned-requires-bundle-and-grant + - id: V1-I29 + rule: Final immutable response bytes exist before the disclosure-release audit is durably accepted and are the exact bytes released afterward, for both response formats. + threat: Audit describes bytes that were never released, or a response is released without a durable release record. + enforcement: The runtime serializes the signed JWS or unsigned envelope to its final bytes, durably appends the release event, then returns those bytes unchanged through the HTTP boundary. + negative_test: sec-release-bytes-pre-audited + - id: V1-I30 + rule: Audit records the closed response-protection mode on every native event and a signing key identity exactly for signed disclosure release. + threat: An unsigned release is indistinguishable from a signed one in accountability records, or a fabricated signing-key claim appears on unsigned output. + enforcement: A mandatory closed responseProtection field with schema-conditional signingKeyId validation in the native audit event. + negative_test: sec-audit-response-protection-mode + - id: V1-I31 + rule: Strict signed verification requires the independently retained expected nonce, the exact expected unordered set of unique role-bound subject bindings, and the expected concept identifiers, forms, and cardinalities, returns one generic policy mismatch, and reports cryptographic authenticity separately from current validity. + threat: A relying party accepts substituted subjects or outputs under a valid signature, or leaks which hidden comparison failed. + enforcement: Verifier policy comparison after signature and schema verification with a single generic policy error and a separate current-validity result. + negative_test: sec-verifier-independent-expectations cross_cutting: config_trust: threat: A missing, writable, or unreviewed bundle is treated as trusted configuration. @@ -171,6 +196,26 @@ cross_cutting: negative_test: sec-tls-and-proxy-authority-fixed subject_role_order: threat: Caller-controlled array position substitutes one subject role for another or changes the signed binding order. - enforcement: Rust resolves a unique subject by declared role, rejects duplicate, missing, unknown, and wrong-profile entries, then emits requirement declaration order. + enforcement: Rust resolves a unique subject by declared role independently of both grant order and request array order, rejects duplicate, missing, unknown, and wrong-profile entries, then emits requirement declaration order in evidence, audit, and the JWS. negative_test: sec-subject-array-order-nonsemantic + unsigned_envelope_distinct: + threat: A stored unsigned response is mistaken for verified signed evidence. + enforcement: The unsigned envelope is self-identifying with a fixed schema, type, integrity marker, warning, and exact vendor media type, contains no JWS member, and is rejected by the strict JWS verifier. + negative_test: sec-unsigned-envelope-not-jws + secret_file_identity: + threat: A group- or world-accessible, symlinked, multi-link, or oversized secret file exposes or substitutes key material. + enforcement: File secrets must be regular owner-only files opened without following symlinks, with exact ownership, mode, single-hard-link, and size checks before use. + negative_test: sec-secret-file-identity + transport_pinning: + threat: Workspace dependency feature unification silently changes the TLS backend or introduces transport retries, breaking the one-request and TLS contracts. + enforcement: The production source client selects the rustls backend explicitly and disables reqwest transport retries; a connection failure yields exactly one attempt. + negative_test: sec-transport-backend-and-single-attempt + jwks_route_parity: + threat: The configured JWKS path and the served discovery route drift apart. + enforcement: Bundle validation pins the one Version 1 discovery path and the served route returns the runtime key set at exactly that configured path. + negative_test: sec-jwks-route-config-parity + script_resource_exhaustion: + threat: A hostile or defective reviewed script consumes unbounded execution inside the shared process. + enforcement: The engine's normative operation ceiling terminates the invocation with a closed value-free error. + negative_test: sec-script-operation-exhaustion fixture_index: ../fixtures/conformance/coverage-matrix.yaml diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 6a0bafee7..d175a54c7 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -51,7 +51,10 @@ entries: - {file: crates/registry-evidence/src/rhai_runtime.rs, name: request_parts_debug_redacts_query_and_body_values} - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_output_gate_rejects_every_fixture_injected_derivation_without_release} - id: sec-unresolved-public-collapse - tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships}] + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: declared_existence_disclosure_mode_governs_the_public_collapse} + - {file: crates/registry-evidence/src/runtime.rs, name: derivation_input_inconsistency_collapses_with_the_unresolved_classes} - id: sec-subject-binding-scope tests: [{file: crates/registry-evidence/src/binding.rs, name: every_subject_binding_scope_component_is_cryptographically_bound}] - id: sec-runtime-bundle-mutation-absent @@ -130,4 +133,53 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: runtime_ca_capture_rejects_symlink_malformed_and_mutable_files} - {file: crates/registry-evidence/tests/source_contracts.rs, name: ambient_proxy_variables_are_ignored_in_an_isolated_process} - id: sec-subject-array-order-nonsemantic - tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: multi_role_request_order_is_not_semantic_and_output_uses_declaration_order}] + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: multi_role_request_order_is_not_semantic_and_output_uses_declaration_order} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: reordered_grant_subjects_resolve_by_role_and_emit_declaration_order} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - id: sec-request-nonce-strict-and-contained + tests: + - {file: crates/registry-evidence/src/model.rs, name: request_nonce_canonicality_is_exact} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_nonce_is_strict_and_never_reaches_source_or_audit} + - {file: crates/registry-evidence/src/server.rs, name: request_json_is_strict_and_closed} + - id: sec-unsigned-requires-bundle-and-grant + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_output_requires_both_bundle_and_grant_permission} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: accept_negotiation_is_closed_and_fails_before_source_access} + - {file: crates/registry-evidence/src/server.rs, name: accept_negotiation_matrix_is_closed_and_exact} + - {file: crates/registry-evidence/src/config.rs, name: response_formats_are_closed_unique_and_keep_signed_mandatory} + - id: sec-release-bytes-pre-audited + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_signed_response_release} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_unsigned_response_release} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_returns_a_problem_and_never_an_unsigned_body} + - id: sec-audit-response-protection-mode + tests: + - {file: crates/registry-evidence/src/audit.rs, name: frozen_audit_fixture_matches_native_event_shape_and_phase_rules} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: all_four_definitions_pass_the_explicitly_authorized_unsigned_path} + - id: sec-verifier-independent-expectations + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_subject_set_is_unordered_unique_and_exact} + - {file: crates/registry-evidence/src/verifier.rs, name: expected_output_contract_is_exact_after_signature_verification} + - {file: crates/registry-evidence/src/verifier.rs, name: authenticity_is_reported_separately_from_current_validity} + - {file: crates/registry-evidence/src/verifier.rs, name: assertion_lifetime_above_the_accepted_maximum_fails} + - id: sec-unsigned-envelope-not-jws + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: unsigned_envelope_is_rejected_by_the_strict_jws_verifier} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} + - id: sec-secret-file-identity + tests: + - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_uses_open_file_owner_and_exact_mode_checks} + - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_rejects_symlinks_and_non_regular_files} + - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_rejects_every_name_for_a_hard_link} + - {file: crates/registry-evidence/src/secrets.rs, name: file_secret_read_is_bounded} + - id: sec-transport-backend-and-single-attempt + tests: + - {file: crates/registry-evidence/src/source.rs, name: evidence_client_uses_rustls_and_fails_closed_on_an_unrecognized_certificate_authority} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: a_reset_transport_failure_yields_exactly_one_connection_attempt} + - id: sec-jwks-route-config-parity + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: configured_jwks_path_is_mechanically_the_served_route}] + - id: sec-script-operation-exhaustion + tests: [{file: crates/registry-evidence/src/rhai_runtime.rs, name: operation_exhaustion_terminates_a_hostile_script_with_a_value_free_error}] diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 09662bb4a..ba61b50bb 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -41,6 +41,47 @@ evidence_data_request: forbidden_collisions: - authentication, host and routing, cookies, body framing, content length and type - connection and hop-by-hop, forwarding, proxy, tracing, and configured API-key headers + classifier: One closed ASCII-case-insensitive deny set shared by startup configuration validation and source plan compilation. Prefix families are denied before exact names, and both checks run before any credential is resolved. + denied_prefix_families: [x-forwarded-, proxy-, sec-, x-b3-, x-envoy-, x-datadog-, x-http-method] + denied_names: + - authorization + - proxy-authorization + - www-authenticate + - proxy-authenticate + - host + - cookie + - set-cookie + - content-length + - content-type + - transfer-encoding + - expect + - connection + - keep-alive + - te + - trailer + - upgrade + - proxy-connection + - forwarded + - via + - x-real-ip + - x-client-ip + - x-cluster-client-ip + - true-client-ip + - cf-connecting-ip + - fastly-client-ip + - x-appengine-user-ip + - x-azure-clientip + - traceparent + - tracestate + - baggage + - b3 + - x-cloud-trace-context + - x-request-id + - x-correlation-id + - x-amzn-trace-id + - x-original-url + - x-rewrite-url + - x-original-method core_headers: Rust owns authentication and framing and adds Content-Type application/json for a JSON body. script_authority: prohibited url_security: diff --git a/products/evidence/contracts/verification-policy.schema.yaml b/products/evidence/contracts/verification-policy.schema.yaml new file mode 100644 index 000000000..2fd69c266 --- /dev/null +++ b/products/evidence/contracts/verification-policy.schema.yaml @@ -0,0 +1,118 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://registrystack.org/schemas/evidence/verification-policy-v1.json +title: Evidence relying-procedure verification policy Version 1 +type: object +additionalProperties: false +required: + - issuedBy + - providedBy + - requirement + - evidenceType + - purpose + - audience + - configurationRevision + - requestNonce + - expectedSubjects + - expectedOutputs + - maximumAssertionLifetimeSeconds +properties: + issuedBy: {type: string, format: uri, maxLength: 512} + providedBy: {type: string, format: uri, maxLength: 512} + requirement: {type: string, format: uri, maxLength: 512} + evidenceType: {type: string, format: uri, maxLength: 512} + purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} + audience: {type: string, format: uri, maxLength: 512} + configurationRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} + requestNonce: + type: string + pattern: '^[A-Za-z0-9_-]{43}$' + description: The exact nonce from the independently retained original request. + expectedSubjects: + type: array + minItems: 1 + maxItems: 8 + uniqueItems: true + description: Unordered set of unique role-bound opaque bindings. Subject order alone is never semantic. + items: {$ref: '#/$defs/expected-subject'} + expectedOutputs: + type: array + minItems: 1 + maxItems: 16 + uniqueItems: true + description: Expected concept identifiers, value forms, and cardinalities, one per disclosed Supported Value. + items: {$ref: '#/$defs/expected-output'} + maximumAssertionLifetimeSeconds: + type: integer + minimum: 1 + maximum: 31536000 + description: Longest acceptable validUntil minus issuedAt interval. + clockSkewSeconds: + type: integer + minimum: 0 + maximum: 300 + default: 0 + description: The only optional field. Omitting it means zero tolerance. +$defs: + expected-subject: + type: object + additionalProperties: false + required: [role, binding] + properties: + role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} + binding: {type: string, pattern: '^urn:evidence:subject:v[1-9][0-9]*_[A-Za-z0-9_-]{43}$'} + expected-output: + type: object + additionalProperties: false + required: [concept, form] + properties: + concept: {type: string, format: uri, maxLength: 512} + form: {$ref: '#/$defs/expected-form'} + expected-form: + oneOf: + - enum: [boolean, integer, string, date-bucket, time-bucket, entity-reference, structured] + - type: object + additionalProperties: false + required: [list] + properties: + list: + type: object + additionalProperties: false + required: [minimumItems, maximumItems] + properties: + minimumItems: {type: integer, minimum: 1, maximum: 64} + maximumItems: {type: integer, minimum: 1, maximum: 64} +ownership: + document: relying party + producer: the relying procedure that retained the original request and accepted the original transaction + consumer: offline re-verification by the `evidence verify` operator command +command: + surface: evidence verify --jws --jwks --policy [--at ] + jws: one stored flattened JWS JSON response file + jwks: the pinned trusted key set, which is the complete trust set for the run + policy: one document accepted by this schema + at: strict RFC 3339 at zero offset; system time when omitted + network: prohibited; no socket is opened, no metadata is resolved, and no key is fetched + input_bound: every input file is refused above 1 MiB before it is read +output: + lines: + - 'verified-at: the chosen verification instant' + - 'authentic: yes or no, printed once verification ran' + - 'currently-valid: yes or no, printed only for an authentic response' + inspection: on full success the verified Evidence JSON is also printed, for the operator who already holds the stored response + failure: only the closed class is reported, on standard error, with no field-level detail + classes: [malformed, protected-header, key, signature, payload, policy, time] + exit_codes: + 0: authentic and currently valid + 3: authentic but not currently valid + 1: every other outcome, including a policy mismatch, an unusable input document, and an unreadable file +$comment: >- + Every expectation in this document must come from independent trusted state, + such as the independently retained original request and an accepted original + transaction. Copying values out of the JWS under verification proves nothing, + and a policy that omitted an expectation would silently skip a comparison, so + the document is closed and every field except clockSkewSeconds is required. A + failed comparison, including the expected nonce, the expected role-bound + subject set, and the expected output contract, reports the one generic policy + class, so re-verification is never an oracle for which hidden comparison + failed. Authenticity and current usability are separate answers: an expired + assertion stays cryptographically authentic without being current evidence. diff --git a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml index 117e3dc15..00c0f23d0 100644 --- a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml +++ b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml @@ -25,6 +25,8 @@ signing: jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + selectorProfiles: person-demographics-v1: maximumAggregateBytes: 420 @@ -65,6 +67,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:adult-status:v1 purpose: fixture-eligibility audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: [{role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}] requirements: - id: urn:example:fixture:requirement:adult-status:v1 diff --git a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml index 905fbe8dd..732e3331b 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml @@ -29,6 +29,11 @@ signing: maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +# The acceptance deployment deliberately enables the governed unsigned format +# so every coequal definition proves both response paths. Production reference +# bundles list only signed-jws. +responseFormats: [signed-jws, unsigned-json] + selectorProfiles: person-demographics-v1: maximumAggregateBytes: 420 @@ -164,21 +169,25 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:adult-status:v1 purpose: fixture-eligibility audienceFrom: authenticated-requester + responseFormats: [signed-jws, unsigned-json] subjects: - {role: subject, selectorProfile: person-demographics-v1, valueOrigin: request} - requirement: urn:example:fixture:requirement:residence-region:v1 purpose: fixture-routing audienceFrom: authenticated-requester + responseFormats: [signed-jws, unsigned-json] subjects: - {role: subject, selectorProfile: residence-record-v1, valueOrigin: request} - requirement: urn:example:fixture:requirement:professional-licence-status:v1 purpose: fixture-registration audienceFrom: authenticated-requester + responseFormats: [signed-jws, unsigned-json] subjects: - {role: subject, selectorProfile: licence-register-v1, valueOrigin: request} - requirement: urn:example:fixture:requirement:legal-parent-relationship:v1 purpose: fixture-enrolment audienceFrom: authenticated-requester + responseFormats: [signed-jws, unsigned-json] subjects: - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} - role: candidate-parent diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml index bb2453784..ec274c8d3 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml @@ -44,7 +44,7 @@ cases: - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: service_unavailable, derivation_runs: true, signed_success: false} + - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: evidence_not_available, derivation_runs: true, signed_success: false} - {id: negative-incomplete-parent-set, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: false}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} - {id: negative-relationship-status-type, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: synthetic-parent-reference-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} - {id: negative-status-on-none, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml index c33931a4d..0e447b4c3 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml @@ -6,6 +6,8 @@ audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyV subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +responseFormats: [signed-jws] + selectorProfiles: civil-record-reference-v1: maximumAggregateBytes: 96 @@ -56,6 +58,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:legal-parent-relationship:v1 purpose: fixture-enrolment audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: child, selectorProfile: civil-record-reference-v1, valueOrigin: request} - role: candidate-parent diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml index f927a7831..1d4146196 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml @@ -82,7 +82,7 @@ cases: - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} - - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: service_unavailable, derivation_runs: true, signed_success: false} + - {id: negative-returned-child-mismatch, source: {total: 1, records: [{returned_child_reference: synthetic-other-child-record, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: evidence_not_available, derivation_runs: true, signed_success: false} - {id: negative-incomplete-parent-set, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: false}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} - {id: negative-relationship-status-type, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: synthetic-parent-reference-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} - {id: negative-status-on-none, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_public_problem: dependency_unavailable, derivation_runs: false, signed_success: false} diff --git a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml index 21dcc8311..be697d550 100644 --- a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml +++ b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml @@ -6,6 +6,8 @@ audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyV subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +responseFormats: [signed-jws] + selectorProfiles: licence-register-v1: maximumAggregateBytes: 128 @@ -44,6 +46,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:professional-licence-status:v1 purpose: fixture-registration audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: [{role: subject, selectorProfile: licence-register-v1, valueOrigin: request}] requirements: - id: urn:example:fixture:requirement:professional-licence-status:v1 diff --git a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml index 0f1109815..6ef618e91 100644 --- a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml +++ b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml @@ -6,6 +6,8 @@ audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyV subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: fixture-key-2026-01, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +responseFormats: [signed-jws] + selectorProfiles: residence-record-v1: maximumAggregateBytes: 96 @@ -42,6 +44,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:residence-region:v1 purpose: fixture-routing audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: [{role: subject, selectorProfile: residence-record-v1, valueOrigin: request}] requirements: - id: urn:example:fixture:requirement:residence-region:v1 diff --git a/products/evidence/fixtures/conformance/audit-events.yaml b/products/evidence/fixtures/conformance/audit-events.yaml index 41c7194e2..a9428ecca 100644 --- a/products/evidence/fixtures/conformance/audit-events.yaml +++ b/products/evidence/fixtures/conformance/audit-events.yaml @@ -15,6 +15,7 @@ access_attempt: - role: subject selectorProfile: opaque-record-v1 selectorBundlePseudonym: hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222 + responseProtection: signed sourceId: source-a adapterId: adapter-a decision: authorized @@ -34,6 +35,7 @@ disclosure_release: - role: subject selectorProfile: opaque-record-v1 selectorBundlePseudonym: hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222 + responseProtection: signed sourceId: source-a adapterId: adapter-a decision: released @@ -41,6 +43,28 @@ disclosure_release: evidenceId: urn:example:fixture:evidence:001 signingKeyId: fixture-key-2026-01 durationMilliseconds: 12 +unsigned_disclosure_release: + schema: registry.evidence.audit/v1 + eventId: urn:example:fixture:audit:release-002 + occurredAt: '2026-08-02T00:00:02Z' + operation: fixture-operation-00000001 + phase: disclosure-release + requirement: urn:example:fixture:requirement:property:v1 + bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + purpose: fixture-procedure + requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 + authority: {kind: statutory} + subjects: + - role: subject + selectorProfile: opaque-record-v1 + selectorBundlePseudonym: hmac-sha256:v1:2222222222222222222222222222222222222222222222222222222222222222 + responseProtection: unsigned + sourceId: source-a + adapterId: adapter-a + decision: released + disclosedConcepts: [urn:example:fixture:concept:boolean-a] + evidenceId: urn:example:fixture:evidence:001 + durationMilliseconds: 12 negative: - raw-principal - raw-actor-or-grant @@ -54,6 +78,9 @@ negative: - candidate-count-score-hint-or-comparison - release-fields-on-access-event - missing-release-fields-on-release-event + - signing-key-on-unsigned-release-event + - missing-signing-key-on-signed-release-event + - request-nonce-in-any-event order: access_attempt_durable_before: [credential-resolution, source-access] disclosure_release_durable_after: [signing] diff --git a/products/evidence/fixtures/conformance/golden/adult-evidence.json b/products/evidence/fixtures/conformance/golden/adult-evidence.json index d2e3d3689..85b389102 100644 --- a/products/evidence/fixtures/conformance/golden/adult-evidence.json +++ b/products/evidence/fixtures/conformance/golden/adult-evidence.json @@ -1,5 +1,6 @@ { "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "id": "urn:example:fixture:evidence:adult-001", "type": "Evidence", "supportsRequirement": "urn:example:fixture:requirement:adult-status:v1", diff --git a/products/evidence/fixtures/conformance/golden/adult-request.json b/products/evidence/fixtures/conformance/golden/adult-request.json index 4732fb507..dc7a996ef 100644 --- a/products/evidence/fixtures/conformance/golden/adult-request.json +++ b/products/evidence/fixtures/conformance/golden/adult-request.json @@ -1,4 +1,5 @@ { + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "requirement": "urn:example:fixture:requirement:adult-status:v1", "purpose": "fixture-eligibility", "subjects": [ diff --git a/products/evidence/fixtures/conformance/golden/licence-evidence.json b/products/evidence/fixtures/conformance/golden/licence-evidence.json index f027f107d..cc9483b29 100644 --- a/products/evidence/fixtures/conformance/golden/licence-evidence.json +++ b/products/evidence/fixtures/conformance/golden/licence-evidence.json @@ -1,5 +1,6 @@ { "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "id": "urn:example:fixture:evidence:licence-001", "type": "Evidence", "supportsRequirement": "urn:example:fixture:requirement:professional-licence-status:v1", diff --git a/products/evidence/fixtures/conformance/golden/licence-request.json b/products/evidence/fixtures/conformance/golden/licence-request.json index 0319c9702..e0bfe0540 100644 --- a/products/evidence/fixtures/conformance/golden/licence-request.json +++ b/products/evidence/fixtures/conformance/golden/licence-request.json @@ -1,4 +1,5 @@ { + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "requirement": "urn:example:fixture:requirement:professional-licence-status:v1", "purpose": "fixture-registration", "subjects": [ diff --git a/products/evidence/fixtures/conformance/golden/relationship-evidence.json b/products/evidence/fixtures/conformance/golden/relationship-evidence.json index 9f922dfbd..ddf8bbbe2 100644 --- a/products/evidence/fixtures/conformance/golden/relationship-evidence.json +++ b/products/evidence/fixtures/conformance/golden/relationship-evidence.json @@ -1,5 +1,6 @@ { "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "id": "urn:example:fixture:evidence:relationship-001", "type": "Evidence", "supportsRequirement": "urn:example:fixture:requirement:legal-parent-relationship:v1", diff --git a/products/evidence/fixtures/conformance/golden/relationship-request.json b/products/evidence/fixtures/conformance/golden/relationship-request.json index 1bf85182a..48ddff993 100644 --- a/products/evidence/fixtures/conformance/golden/relationship-request.json +++ b/products/evidence/fixtures/conformance/golden/relationship-request.json @@ -1,4 +1,5 @@ { + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "requirement": "urn:example:fixture:requirement:legal-parent-relationship:v1", "purpose": "fixture-enrolment", "subjects": [ diff --git a/products/evidence/fixtures/conformance/golden/residence-evidence.json b/products/evidence/fixtures/conformance/golden/residence-evidence.json index 95129cf6b..93086720b 100644 --- a/products/evidence/fixtures/conformance/golden/residence-evidence.json +++ b/products/evidence/fixtures/conformance/golden/residence-evidence.json @@ -1,5 +1,6 @@ { "schema": "registry.assertion-evidence/v1", + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "id": "urn:example:fixture:evidence:residence-001", "type": "Evidence", "supportsRequirement": "urn:example:fixture:requirement:residence-region:v1", diff --git a/products/evidence/fixtures/conformance/golden/residence-request.json b/products/evidence/fixtures/conformance/golden/residence-request.json index 0e4b19224..28d5d532d 100644 --- a/products/evidence/fixtures/conformance/golden/residence-request.json +++ b/products/evidence/fixtures/conformance/golden/residence-request.json @@ -1,4 +1,5 @@ { + "requestNonce": "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I", "requirement": "urn:example:fixture:requirement:residence-region:v1", "purpose": "fixture-routing", "subjects": [ diff --git a/products/evidence/fixtures/conformance/selectors/evidence.yaml b/products/evidence/fixtures/conformance/selectors/evidence.yaml index e8cb6aff7..016e1f96a 100644 --- a/products/evidence/fixtures/conformance/selectors/evidence.yaml +++ b/products/evidence/fixtures/conformance/selectors/evidence.yaml @@ -36,6 +36,8 @@ signing: jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + selectorProfiles: opaque-record-v1: maximumAggregateBytes: 96 @@ -194,17 +196,20 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:classification:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: subject, selectorProfile: opaque-record-v1, valueOrigin: request} - requirement: urn:example:fixture:requirement:relationship:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: subject-a, selectorProfile: opaque-record-v1, valueOrigin: request} - {role: subject-b, selectorProfile: demographics-v1, valueOrigin: request} - requirement: urn:example:fixture:requirement:opaque:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: subject, selectorProfile: opaque-coordinates-v1, valueOrigin: request} subject-bound-v1: @@ -214,6 +219,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:property:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: subject selectorProfile: demographics-v1 @@ -229,6 +235,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:property-with-event:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: subject selectorProfile: demographics-with-event-v1 @@ -245,6 +252,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:relationship:v1 purpose: fixture-procedure audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: subject-a, selectorProfile: opaque-coordinates-v1, valueOrigin: request} - {role: subject-b, selectorProfile: demographics-with-event-v1, valueOrigin: request} diff --git a/products/evidence/fixtures/conformance/supported-values/evidence.yaml b/products/evidence/fixtures/conformance/supported-values/evidence.yaml index 94d4e2651..62f612e6c 100644 --- a/products/evidence/fixtures/conformance/supported-values/evidence.yaml +++ b/products/evidence/fixtures/conformance/supported-values/evidence.yaml @@ -36,6 +36,8 @@ signing: jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + selectorProfiles: synthetic-subject-v1: maximumAggregateBytes: 64 @@ -80,6 +82,7 @@ authorityProfiles: - requirement: urn:example:fixture:requirement:supported-values:v1 purpose: conformance audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - {role: subject, selectorProfile: synthetic-subject-v1, valueOrigin: request} requirements: diff --git a/products/evidence/generated/evidence-request-v1.schema.json b/products/evidence/generated/evidence-request-v1.schema.json index 357e3258e..1d25a0c49 100644 --- a/products/evidence/generated/evidence-request-v1.schema.json +++ b/products/evidence/generated/evidence-request-v1.schema.json @@ -1,5 +1,5 @@ { - "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access.", + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it.", "$defs": { "scalar-selector-value": { "oneOf": [ @@ -69,6 +69,10 @@ "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" }, + "requestNonce": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, "requirement": { "format": "uri", "maxLength": 512, @@ -85,6 +89,7 @@ } }, "required": [ + "requestNonce", "requirement", "purpose", "subjects" diff --git a/products/evidence/generated/evidence-unsigned-envelope-v1.schema.json b/products/evidence/generated/evidence-unsigned-envelope-v1.schema.json new file mode 100644 index 000000000..9d8c47b5b --- /dev/null +++ b/products/evidence/generated/evidence-unsigned-envelope-v1.schema.json @@ -0,0 +1,32 @@ +{ + "$comment": "Transport-authenticated convenience representation selected only by its exact vendor media type when the immutable bundle and the complete matched grant permit it. Once separated from its HTTPS exchange it provides no issuer-authenticity, integrity, non-repudiation, or later-verification property. The nested evidence is the same closed core object that would be JWS encoded. There is no protected, payload, or signature member, so the strict JWS verifier rejects this representation; tooling that parses it must return an explicitly unverified result.", + "$id": "https://registrystack.org/schemas/evidence/unsigned-envelope-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "evidence": { + "$ref": "https://registrystack.org/schemas/evidence/assertion-evidence-v1.json" + }, + "integrityProtection": { + "const": "none" + }, + "schema": { + "const": "registry.unsigned-evidence-envelope/v1" + }, + "type": { + "const": "UnsignedEvidenceEnvelope" + }, + "warning": { + "const": "not-cryptographically-verifiable" + } + }, + "required": [ + "schema", + "type", + "integrityProtection", + "warning", + "evidence" + ], + "title": "Evidence unsigned response envelope Version 1", + "type": "object" +} diff --git a/products/evidence/generated/evidence-v1.schema.json b/products/evidence/generated/evidence-v1.schema.json index bfa4ef24e..e087dadcd 100644 --- a/products/evidence/generated/evidence-v1.schema.json +++ b/products/evidence/generated/evidence-v1.schema.json @@ -193,6 +193,10 @@ "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" }, + "requestNonce": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, "schema": { "const": "registry.assertion-evidence/v1" }, @@ -227,6 +231,7 @@ }, "required": [ "schema", + "requestNonce", "id", "type", "supportsRequirement", diff --git a/products/evidence/generated/problem-v1.schema.json b/products/evidence/generated/problem-v1.schema.json index bc46f22d8..835500500 100644 --- a/products/evidence/generated/problem-v1.schema.json +++ b/products/evidence/generated/problem-v1.schema.json @@ -68,6 +68,22 @@ } } }, + { + "properties": { + "code": { + "const": "response_format_not_acceptable" + }, + "status": { + "const": 406 + }, + "title": { + "const": "Requested response format is not acceptable" + }, + "type": { + "const": "https://registrystack.org/problems/evidence/response_format_not_acceptable" + } + } + }, { "properties": { "code": { @@ -140,6 +156,7 @@ "invalid_selector", "authentication_failed", "not_authorized", + "response_format_not_acceptable", "evidence_not_available", "rate_limited", "dependency_unavailable", @@ -156,6 +173,7 @@ 400, 401, 403, + 406, 422, 429, 503 @@ -167,6 +185,7 @@ "Request is not valid", "Authentication failed", "Request is not authorized", + "Requested response format is not acceptable", "Evidence could not be produced", "Request rate exceeded", "Service temporarily unavailable" @@ -179,6 +198,7 @@ "https://registrystack.org/problems/evidence/invalid_selector", "https://registrystack.org/problems/evidence/authentication_failed", "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/response_format_not_acceptable", "https://registrystack.org/problems/evidence/evidence_not_available", "https://registrystack.org/problems/evidence/rate_limited", "https://registrystack.org/problems/evidence/dependency_unavailable", diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index 8af541441..793aa3e08 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -133,6 +133,10 @@ "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" }, + "requestNonce": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, "schema": { "enum": [ "registry.assertion-evidence/v1" @@ -173,6 +177,7 @@ }, "required": [ "schema", + "requestNonce", "id", "type", "supportsRequirement", @@ -429,6 +434,10 @@ "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" }, + "requestNonce": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + }, "requirement": { "format": "uri", "maxLength": 512, @@ -445,6 +454,7 @@ } }, "required": [ + "requestNonce", "requirement", "purpose", "subjects" @@ -821,6 +831,34 @@ } } }, + { + "properties": { + "code": { + "enum": [ + "response_format_not_acceptable" + ], + "type": "string" + }, + "status": { + "enum": [ + 406 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Requested response format is not acceptable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/response_format_not_acceptable" + ], + "type": "string" + } + } + }, { "properties": { "code": { @@ -941,6 +979,7 @@ "invalid_selector", "authentication_failed", "not_authorized", + "response_format_not_acceptable", "evidence_not_available", "rate_limited", "dependency_unavailable", @@ -957,6 +996,7 @@ 400, 401, 403, + 406, 422, 429, 503 @@ -968,6 +1008,7 @@ "Request is not valid", "Authentication failed", "Request is not authorized", + "Requested response format is not acceptable", "Evidence could not be produced", "Request rate exceeded", "Service temporarily unavailable" @@ -980,6 +1021,7 @@ "https://registrystack.org/problems/evidence/invalid_selector", "https://registrystack.org/problems/evidence/authentication_failed", "https://registrystack.org/problems/evidence/not_authorized", + "https://registrystack.org/problems/evidence/response_format_not_acceptable", "https://registrystack.org/problems/evidence/evidence_not_available", "https://registrystack.org/problems/evidence/rate_limited", "https://registrystack.org/problems/evidence/dependency_unavailable", @@ -1135,6 +1177,47 @@ "value" ], "type": "object" + }, + "UnsignedEvidenceEnvelope": { + "additionalProperties": false, + "properties": { + "evidence": { + "$ref": "#/components/schemas/Evidence" + }, + "integrityProtection": { + "enum": [ + "none" + ], + "type": "string" + }, + "schema": { + "enum": [ + "registry.unsigned-evidence-envelope/v1" + ], + "type": "string" + }, + "type": { + "enum": [ + "UnsignedEvidenceEnvelope" + ], + "type": "string" + }, + "warning": { + "enum": [ + "not-cryptographically-verifiable" + ], + "type": "string" + } + }, + "required": [ + "schema", + "type", + "integrityProtection", + "warning", + "evidence" + ], + "title": "Evidence unsigned response envelope Version 1", + "type": "object" } }, "securitySchemes": { @@ -1296,6 +1379,7 @@ }, "/v1/evidence": { "post": { + "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", "operationId": "createEvidence", "requestBody": { "content": { @@ -1314,9 +1398,14 @@ "schema": { "$ref": "#/components/schemas/FlattenedJws" } + }, + "application/vnd.registrystack.evidence-unsigned+json": { + "schema": { + "$ref": "#/components/schemas/UnsignedEvidenceEnvelope" + } } }, - "description": "Signed Evidence as flattened JWS JSON Serialization", + "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized self-identifying unsigned envelope", "headers": { "Cache-Control": { "description": "Evidence responses are never cacheable.", @@ -1326,6 +1415,15 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } }, @@ -1420,6 +1518,15 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } }, @@ -1475,6 +1582,15 @@ "type": "string" } }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } + }, "WWW-Authenticate": { "schema": { "enum": [ @@ -1526,7 +1642,7 @@ } } }, - "description": "Request is not authorized", + "description": "Request is not authorized, including a recognized response format the bundle or matched grant does not permit", "headers": { "Cache-Control": { "description": "Evidence responses are never cacheable.", @@ -1536,6 +1652,78 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } + } + } + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "response_format_not_acceptable" + ], + "type": "string" + }, + "status": { + "enum": [ + 406 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Requested response format is not acceptable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/response_format_not_acceptable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Media negotiation is outside the closed Accept matrix", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } }, @@ -1590,6 +1778,15 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } }, @@ -1652,6 +1849,15 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } }, @@ -1746,6 +1952,15 @@ ], "type": "string" } + }, + "Vary": { + "description": "The response format is negotiated through the exact Accept matrix.", + "schema": { + "enum": [ + "Accept" + ], + "type": "string" + } } } } @@ -1755,7 +1970,7 @@ "bearerAuth": [] } ], - "summary": "Produce signed evidence for one authorized fixed requirement" + "summary": "Produce evidence for one authorized fixed requirement" } }, "/v1/evidence-definitions": { diff --git a/products/evidence/reference/request-adapter/ADAPTER-API.md b/products/evidence/reference/request-adapter/ADAPTER-API.md new file mode 100644 index 000000000..a91393b3c --- /dev/null +++ b/products/evidence/reference/request-adapter/ADAPTER-API.md @@ -0,0 +1,675 @@ +# Evidence request-adapter API + +Status: Implemented Version 1 trusted request-adapter ABI + +This document defines the Version 1 API visible to reviewed source-adapter +scripts. It is intentionally smaller than Rhai's standard library. The host is +Rust. Adapter scripts cannot perform I/O or choose transport authority. + +This contract is the `registry.evidence.request-adapter/v1` ABI and is bound to +the Evidence bundle contract Version 1. A +source does not carry a redundant `adapterAbiVersion` field unless the product +later demonstrates a need to run multiple adapter ABIs under one bundle +version. + +## Complete Version 1 surface + +This inventory is the complete adopter-facing Version 1 script surface. It +defines all three entry points, every host-provided helper, the pinned Rhai +syntax, result types, resource bounds, and forbidden constructs. Adopters do +not need to inspect the Rust implementation to determine whether a script is +supported. + +```text +prepare(selectors: map, parameters: map) -> RequestParts +extract(response: JSON, parameters: map) -> LookupResult +derive(facts: map, selectors: map, evaluation_context: map) + -> array +``` + +Version 1 uses one shared deterministic helper catalogue for `prepare`, +`extract`, and `derive`. The helpers are pure and provide no external +authority. Capability separation instead comes from the values Rust supplies +and the closed result validator for each entry point. For example, preparation +receives no evaluation context or codelist handles, and `RequestParts` rejects +typed derivation values. + +The compiler accepts exactly one function with the required entry-point name +and arity. It rejects top-level executable statements, an absent or overloaded +entry point, function pointers, data-derived dispatch, anonymous functions, +and closures. Statically named, bounded same-file helper functions are allowed; +Rust invokes only the declared entry point. + +## Entry points + +A source has two separately compiled adapter scripts, and each requirement has +one separately compiled derivation script: + +```text +prepare(selectors: map, parameters: map) -> RequestParts +extract(response: JSON, parameters: map) -> LookupResult +derive(facts: map, selectors: map, evaluation_context: map) + -> array +``` + +- `prepare`, `extract`, and `derive` run with fresh state on every invocation. +- Inputs are isolated per-invocation copies constructed by Rust. A script may + mutate a local nested map, array, or string, but that mutation cannot affect + the bundle, a later invocation, or the other adapter stage. +- Scripts compile at startup. Top-level executable statements are forbidden. +- Named same-file helper functions are allowed, but Rust invokes only the + declared entry point. Calls remain statically named in reviewed source. +- Preparation runs after successful authorization and durable access-attempt + audit, but before credential resolution or source access. +- Extraction receives neither selectors nor prepared request parts. +- Derivation runs only for `match`, receives only the authorized roles and + fields declared by the requirement's closed `derivation.selectorInputs`, and + receives neither the source response nor prepared request parts. A + derivation that declares no selector inputs receives an empty map. + +## Bundle configuration shape + +The Version 1 bundle schema adds these exact script-owned fields while keeping +transport authority in the existing source request object: + +```yaml +sources: + source-a: + transport: http-json + baseUrl: https://source.example + posture: record-transformed + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/search + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: child + alternatives: + - {profile: record-reference-v1, fields: [record_reference]} + prepareScript: adapters/source-a-prepare.rhai + adapterParameters: {resultLimit: 2} + adapterParametersSchema: schemas/source-a-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 12 + maximumCollectionItems: 32 + maximumStringBytes: 512 + maximumNormalizedBytes: 8192 + projection: [/total, /results] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/source-a-facts.schema.yaml +requirements: + - id: urn:example:requirement:relationship:v1 + derivation: + script: derivations/relationship.rhai + selectorInputs: + - role: candidate + alternatives: + - {profile: person-reference-v1, fields: [person_reference]} + parameters: {matching_policy: exact-reference-v1} +``` + +`request.selectorInputs` controls preparation exposure. +`derivation.selectorInputs` independently controls derivation exposure. Every +alternative must exactly match one declared requirement role, profile, and +field set. Rust rejects a missing, surplus, unauthorized, or incompatible +binding at startup. `prepareScript` and `extractScript` are symmetric, +separately compiled entry points; neither is a transport plugin. + +The ceiling of at most two minimally projected results, which lets one bounded +request separate a unique match from ambiguity, is governed adapter policy. Its +value is declared by the reviewed source configuration, here +`adapterParameters: {resultLimit: 2}`, and rendered into the request by that +source's `prepareScript`. Rust enforces only the generic one-request, +projection, and response bounds around it. Two is not a Rust domain rule, not a +built-in operation, and not a property of any source product. + +## Inputs + +For `prepare`, `selectors` contains only roles, profiles, and fields declared +by the source's closed `selectorInputs` contract. Rust has already validated +and authorized them. Surplus request fields are not passed to preparation. +Scripts may rely on this exact shape and should not repeat role, profile, +field, type, or bound validation. Host-shape violations are Rust contract +failures and belong in host negative tests. A script still validates +requirement-specific semantic relationships that Rust cannot infer, such as a +provider status or a governed namespace agreement. + +For `derive`, `selectors` contains only the roles, profiles, and fields declared +by that requirement's closed derivation selector-input contract. Rust resolves +those values from the already authorized requirement subjects. A relationship +requirement can therefore retrieve a record using only a child selector and +compare an extracted parent fact with only the separately authorized +candidate-parent reference. Derivation cannot inspect the child selector when +it did not declare it, the complete HTTP request, bearer token, grant, or +caller-supplied surplus data. + +```json +{ + "subject": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Synthetic", + "family_name": "Subject", + "birth_date": "2000-02-29" + } + } +} +``` + +Source-adapter `parameters` is non-secret bundle data validated at startup +against a closed JSON Schema. It may contain provider field identifiers and +fixed request constants. It must not contain credentials, tokens, requester +identity, authorization objects, audit handles, or runtime state. The same +parameters are supplied independently to `prepare` and `extract`; neither +invocation can mutate the other's copy. + +Derivation parameters remain in `evaluation_context.parameters`, alongside +Rust-owned observation time and codelist handles. They are independent of the +source-adapter parameters unless the reviewed bundle deliberately repeats a +non-secret constant in both closed configurations. + +The adapter-parameter conversion is closed: + +| JSON form | Rhai form | +|---|---| +| boolean | `bool` | +| integer in the signed 64-bit range | `i64` | +| bounded UTF-8 string | `string` | +| bounded array of permitted forms | `array` | +| bounded object of permitted forms | `map` | + +Null, fractional numbers, integers outside the signed 64-bit range, and typed +derivation envelopes such as `{type: "decimal", value: "..."}` are not adapter +parameters. They fail startup validation. No implicit numeric conversion is +performed. + +`response` is one bounded JSON response from the fixed source request. Rust +rejects an invalid media type, oversized body, malformed JSON, redirect, or +transport failure before extraction. JSON integers outside the signed 64-bit +range are rejected before Rhai so they cannot silently become imprecise binary +floating-point values. Ordinary fractional JSON numbers remain `f64`; an +adapter must reject one wherever the provider contract requires an integer. + +### Response projection + +`request.projection` is a Rust-enforced, client-side allowlist applied after +the bounded JSON response is parsed and before it is converted to Rhai. It is +not documentation and does not request provider-side field selection. A source +should also use a provider field-selection feature when one exists, but its +acquisition posture describes what crosses the wire before this local pruning. + +Each projection entry is an extended JSON Pointer. Ordinary segments follow +RFC 6901 escaping: `~0` means `~` and `~1` means `/`. The reserved segment `*` +visits every current array element. Numeric array indexes, recursive descent, +filters, predicates, unions, and script-computed projection paths are not +supported. Examples: + +```text +/total +/results/*/status +/results/*/declaration/mother.personReference +``` + +Projection constructs a new JSON tree containing only present selected leaves +and the objects or arrays needed to reach them. Object keys not selected are +dropped. Array order and length are preserved; each selected array element is +projected independently. A missing selected leaf is omitted rather than +invented or treated as `null`, so extraction can distinguish missing data from +an explicit JSON null and fail according to the provider contract. A missing +or mistyped intermediate container is a source-protocol failure before Rhai. +An empty projection, duplicate path, invalid escape, overlapping ancestor and +descendant paths, or path that cannot be reconciled with another selected path +fails bundle validation. + +Response byte limits and JSON parsing bounds apply before projection, so the +configured `maximumResponseBytes` describes the wire body Evidence is willing +to read. The projected tree is bounded separately: it must serialize to at most +65,536 bytes, and Rhai input bounds apply to it again. Exact root-envelope +checks in an extraction script may therefore rely on unselected root keys having +been removed. The fixture harness supplies raw provider JSON and runs this same +projection before extraction. + +## Output from `prepare` + +`RequestParts` has exactly two members: + +```text +RequestParts { + query: array<{name: string, value: string}>, + body: JSON | null +} +``` + +- Query pairs remain ordered and may repeat a name. +- Query names and values are lexical strings even when the provider interprets + them as numbers or booleans. Constants destined for the query string must be + authored as strings such as `"2"` and `"true"`; JSON body values retain + their JSON types. Rust performs no implicit query conversion. +- Query names and values are logical raw strings. Names must be non-empty. + Rust rejects CR or LF in either component and percent-encodes each component + exactly once as UTF-8. ASCII letters, digits, `-`, `.`, `_`, and `~` remain + unescaped; every other byte uses uppercase `%HH`, including space as `%20`. +- Rust validates the complete result before acquiring credentials. +- The source definition decides whether query and body output are permitted. +- Unknown members or non-JSON values fail closed. + +The following are never script-controlled: + +```text +source, origin, URL, path, method, headers, authentication, credentials, +content type, timeout, redirect policy, retry policy, pagination, proxy use, +response limit, concurrency, or number of requests +``` + +Rust executes exactly one evidence-data request. A response cannot supply a +second URL, next page, or retry request. + +A bundle may define a Rust-owned `pathTemplate` with complete-segment +`pathBindings` to already validated and authorized selector fields. Those +bindings are not `RequestParts` and are never supplied by the script. Each +placeholder occupies one complete path segment. Values reject `/`, `\`, `%`, +controls, `.` and `..`, then Rust percent-encodes them exactly once. Expansion +cannot change the configured origin, endpoint family, method, credentials, or +request count. A source declares exactly one of fixed `path` or +`pathTemplate`. + +Bundle-fixed `fixedHeaders` are ordered, non-secret constants. Names are unique +after ASCII case folding and cannot set authentication, host/routing, cookies, +body framing, content length/type, connection, forwarding, proxy, or tracing +headers. Rust adds `Content-Type: application/json` for a JSON body and owns all +authentication and framing headers. Header names and values are bounded and +reject controls, CR, and LF. Scripts cannot observe or modify headers. + +The governed bundle and operator runtime split, Basic/static-Bearer/static- +API-key/OAuth profiles, and logical private-CA bindings are defined in +[`deployment-projects/CONFIG.md`](deployment-projects/CONFIG.md). They are +transport contracts, not script capabilities. + +### Normalization and exact fixtures + +Rust converts the Rhai result into a JSON tree before applying result limits. +For byte limits and deterministic transport, normalized JSON is compact UTF-8 +with no insignificant whitespace, object keys in lexical byte order, array +order preserved, canonical JSON integer spelling, and finite floating-point +values rendered in the shortest round-trippable form. Unsupported and +non-finite values fail closed. + +Expected `RequestParts` may be an external JSON file in a focused adapter +fixture or the inline `common.expectedRequestParts` object in a complete +project fixture. Both are parsed and compared structurally after normalization. +Source-file whitespace and object-member order are not significant. The order +of the query-pair array is significant. A separate transport assertion compares +the exact encoded query string and exact normalized JSON request-body bytes +sent to the mock. The closed project fixture vocabulary is defined in +[`deployment-projects/FIXTURES.md`](deployment-projects/FIXTURES.md). + +## Output from `extract` + +`LookupResult` is one member of this closed union: + +```text +#{outcome: "no_match"} +#{outcome: "ambiguous"} +#{outcome: "match", facts: } +``` + +- `no_match` and `ambiguous` contain no `facts` member. +- `match` requires exactly one provider match and facts that satisfy the + source's closed fact schema. +- Provider protocol inconsistencies must fail. They must not become + `no_match`, `ambiguous`, or default facts. +- An absent result never represents an authoritative negative assertion. + +## Output from `derive` + +`derive/3` returns an array of `DerivedConceptValue` maps: + +```text +DerivedConceptValue { + concept_id: string, + value: supported value +} +``` + +Each map has exactly `concept_id` and `value`. The array contains at most 16 +items. Concept identifiers must equal the selected requirement's declared +output set exactly. Duplicates, missing required concepts, undeclared concepts, +and extra map members fail before evidence construction. + +Ordinary JSON-compatible values remain subject to the concept's declared form, +codelist, cardinality, size, range, and precision constraints. Three protected +result forms require host constructors: + +- `Decimal` is created only by `decimal(canonical_text)` or + `parse_decimal(canonical_text)`. It has at most 28 significant digits and 9 + fractional digits and is serialized by Rust as its exact canonical JSON + string. Rhai floats cannot satisfy a bounded-decimal concept. +- `EntityReferenceSeed` is created only by + `entity_reference_seed(protected_string)`, whose input is 1 through 512 UTF-8 + bytes. The seed cannot be serialized, logged, compared, or used directly as + a public identifier. Rust projects it to an audience-scoped reference after + validation. +- `EntityReferenceSeedList` is an array containing only + `EntityReferenceSeed` values, with at most 64 items. Rust projects each seed + independently after the complete output gate passes. + +## Selector-aware derivation and relationship decisions + +Selector-aware derivation exists for relational assertions whose truth is +defined by both an authoritative source record and an independently authorized +subject role. It does not turn Evidence into a general identity resolver. + +Permitted reviewed rules include: + +- exact membership of an authoritative opaque identifier in a complete source + relationship set; +- exact comparison of a closed tuple of authoritative attributes after a + deterministic, jurisdiction-governed canonicalization implemented in the + derivation script; and +- direct mapping of an explicit source-owned relationship decision. + +The default reference rule is exact opaque-identifier membership. A bundle +using names, dates, transliteration, or another attribute rule must version and +review that rule explicitly and name the resulting concept no more strongly +than the source and governance justify. Probabilistic matching, fuzzy scoring, +candidate ranking, deduplication, and best-match selection remain outside this +ABI. + +A derivation may return `false` only after extraction established one uniquely +resolved authoritative record and emitted a schema-required returned-record +identifier, relationship-set contract identifier, reference namespace, and +`relationship_set_complete: true` fact. When the provider returns the lookup +identifier, the derivation declares that authorized lookup selector and +requires exact equality before using the record. The derivation must require +and validate those facts before comparing the candidate. No match, ambiguity, +returned-record mismatch, partial relationship data, namespace mismatch, +or protocol uncertainty stops before the boolean result and never becomes an +authoritative negative assertion. The bundle review must justify how the configured source +contract distinguishes an absent optional relationship from missing data. + +In the portable family example, `reference_namespace` and +`relationship_set_contract` are copied from closed adapter parameters and then +compared with closed requirement parameters. That is startup constant +agreement between reviewed bundle sections, not proof that the provider +returned a namespace or contract identifier. If either value can vary by +record, extraction must derive and validate it from projected provider data, +the fact schema must require it, and fixtures must cover mismatch. Governance +must always establish that returned references belong to the declared +namespace and that the relationship set has the claimed meaning and +completeness. + +`legal-parent-relationship` is valid only where deployment governance states +that the configured source fields and matching rule establish legal +parentage. The portable OpenCRVS reference therefore uses +`registered-parent-relationship` until that stronger jurisdiction-specific +meaning is documented. + +## Version 1 combined language surface + +With the Version 1 additions, adapter scripts may construct `bool`, signed +64-bit integer, finite floating-point number, character, string, array, map, +and unit/null values. Adapter outputs must be JSON-compatible; non-finite +numbers and unsupported host-object values are rejected. + +The Version 1 ABI pins Rhai 1.25.1 core syntax plus the host registrations in +this document. Rhai syntax does not grant host authority, but its observable +behavior is still part of the reviewed-script contract. The supported surface +includes: + +| Surface | Available behavior | +|---|---| +| Bindings | `let`, `const`, reassignment, and local function parameters | +| Control | `if`/`else`, `return`, `throw`, and bounded `for` over arrays | +| Construction | array, map, string, integer, boolean, and unit literals | +| Access | fixed or computed map indexing and bounded non-negative array indexing | +| Boolean | `!`, `&&`, `||`, equality and inequality | +| Integer | arithmetic, bitwise operations, shifts, equality, and ordering | +| Floating point | arithmetic, equality, and ordering, including mixed integer/float operands | +| String | concatenation, substring removal, equality, and ordering | +| Functions | statically named same-file functions within the global call-depth limit | + +Only arrays are registered as iterable by the host. Ranges, string slicing, +`switch`, `try`/`catch`, unbounded loop forms, optional chaining, and null +coalescing are rejected by the hardened compiler. A missing map key produces +unit. Map membership uses the registered `map.contains` helper. Equality between +different types returns false and inequality returns true; same-type array or +map comparison is not registered and fails. + +Rhai counts a negative array index from the end of the array. The startup source +review therefore routes every index operand through a host-owned index guard, +and a negative index fails the invocation however it was computed rather than +silently addressing another element. + +The following are deliberately forbidden: + +- `Fn(...)`, function pointers, `.call`, `.curry`, and data-derived dispatch; +- anonymous functions and closures; +- interpolated backtick strings and their implicit value-to-string + conversion; +- raw string literals such as `#"..."#`, so the source review and the Rhai + tokenizer can never disagree about where a string ends. `#{` remains the map + literal; +- a block or an `if` chain used in operand position, because the source review + could not then classify a following `[` and the index guard would be lost. + Assign the intermediate result to a mutable local and use that local; +- top-level executable statements; +- `try`/`catch`, so a script cannot suppress the host-private unavailable + termination raised by `required`; and +- `switch`, `while`, `until`, `loop`, `do`, range construction, string slicing, + optional chaining, and null coalescing because Version 1 adapters do not + require them. + +Negative tests pin each forbidden construct and the index guard. The operation, +call-depth, expression-depth, string, array, and map ceilings apply to every +otherwise permitted construct. + +### Language essentials + +| Function or operator | Signature and meaning | +|---|---| +| Integer equality | `i64 == i64`, `i64 != i64` | +| Integer ordering | `<`, `<=`, `>`, `>=` over signed 64-bit integers | +| Integer arithmetic | Binary `+`, `-`, `*`, `/`, `%`, `**`; unary `+` and `-` | +| Integer bitwise | `&`, `|`, `^`, `<<`, `>>` | +| Floating arithmetic | `+`, `-`, `*`, `/`, `%`, `**`, unary `+/-`, equality, and ordering over `f64` | +| Mixed numeric operations | Arithmetic, equality, and ordering between `i64` and `f64` in either operand order | +| Boolean logic | `!bool`, boolean equality and inequality; `&&` and `||` are language control operators | +| String operations | `string + string`, `string + char`, `char + string`, `string - string`, `+=`, equality, and lexical ordering | +| `array.len` | Number of array items as an integer | +| `len(map)` | Number of map entries as an integer | +| `map.contains(name)` | Whether an exact string key exists | +| Array iteration | `for value in array`, bounded by the operation and array limits | +| `type_of(value)` | Pinned Rhai type name, including registered opaque type names | + +### Evidence primitives + +The opaque types cannot be serialized into +`RequestParts` or extraction facts. Some helpers require handles that only +Rust can supply through derivation context. + +| Function | Signature and behavior | +|---|---| +| `parse_date` | `string -> Date`; strict canonical proleptic-Gregorian `YYYY-MM-DD` | +| `parse_instant` | `string -> Instant`; uppercase `T` and `Z`, explicit offset, no leap second, optional 1 to 9 fractional digits, normalized to UTC | +| `parse_integer` | `string -> i64`; optional ASCII `-` and one or more ASCII digits; leading zeroes allowed; rejects `+`, whitespace, non-ASCII digits, fractions, exponents, empty input, and overflow | +| `decimal` | `string -> Decimal`; alias of `parse_decimal` | +| `parse_decimal` | `string -> Decimal`; canonical exact decimal with no `+`, exponent, leading zero, trailing fractional zero, empty fraction, negative zero, NaN, or infinity; zero is exactly `0` | +| `integer_to_decimal` | `i64 -> Decimal` | +| `add_calendar_years` | `(Date, i64) -> Date`; clamps the day at month end, from -1000 through 1000 years inclusive | +| `add_calendar_months` | `(Date, i64) -> Date`; clamps the day at month end, from -12000 through 12000 months inclusive | +| `compare_dates` | `(Date, Date) -> i64`; returns `-1`, `0`, or `1` | +| `compare_instants` | `(Instant, Instant) -> i64`; returns `-1`, `0`, or `1` | +| `days_between` | `(Date, Date) -> i64`; second minus first, from -365000 through 365000 days inclusive | +| `compare_decimals` | `(Decimal, Decimal) -> i64`; returns `-1`, `0`, or `1` | +| `bucket_number` | `(Decimal, array) -> string`; requires 1 to 64 contiguous, ordered half-open buckets with unique codes; a value outside every bucket fails the invocation | +| `entity_reference_seed` | `string -> EntityReferenceSeed`; opaque value permitted only through the derived-value gate | +| `codelist_lookup` | `(CodelistHandle, string) -> string or unit`; handle is supplied only by Rust | +| `list_contains` | `(array, scalar) -> bool`; supports boolean, integer, string, Date, Instant, and Decimal while keeping types distinct; validates every element before answering | +| `set_contains` | `(array, scalar) -> bool`; same scalar types and the same complete validation, and rejects duplicate set entries | +| `array.push(value)` | Appends one value to the invocation-local array within the 256-item bound and returns unit | +| `string.replace(from, to)` | Replaces every exact non-overlapping literal occurrence in the invocation-local string within the 16,384-byte bound and returns unit; no regex or normalization | +| `required` | `(value, safe_error_code) -> value`; unit becomes the closed unavailable outcome; the code must match `[a-z][a-z0-9_]*` and be at most 64 ASCII bytes | +| `required` code handling | The bundle-owned code is validated for shape and then discarded. It documents the reviewed script; it never reaches the public problem, audit, logs, or the raised signal, which is a host-private, unforgeable, uncatchable value | +| `is_missing` | `value -> bool`; true only for unit | + +`NumericBucket` has exactly `minimumInclusive: Decimal`, +`maximumExclusive: Decimal`, and `code: string`. `LegalLocalTime` is another +opaque registered type. It is supplied only as +`evaluation_context.legal_local_time`; there is no script constructor. + +For context, Rust supplies derivation with exactly `observed_at: Instant`, +`legal_local_date: Date`, `legal_local_time: LegalLocalTime`, validated +`parameters: map`, and `codelists: map`. The authorized +selectors are a distinct explicit derivation argument, not ambient context. +None of those values is supplied to request preparation or extraction except +for preparation's separately minimized selector argument. + +`observed_at` is always a runtime-supplied instant normalized to UTC. +`legal_local_date` and `legal_local_time` use the requirement's validated IANA +`observationTimezone`; when that optional field is omitted, they use UTC. A +time-dependent definition should declare its legal timezone explicitly and +pin boundary fixtures rather than rely on the fallback. + +For JSON-originating values, `type_of` tags include `"i64"`, `"f64"`, +`"bool"`, `"string"`, `"array"`, `"map"`, and `"()"`. A script-created +character has tag `"char"`. Ranges and `Fn` values and their construction +paths are forbidden. The function can also return the registered names +`"Date"`, `"Instant"`, `"LegalLocalTime"`, `"Decimal"`, +`"EntityReferenceSeed"`, and `"CodelistHandle"`. This limited type-name +inspection is intentionally part of the reviewed surface. + +No implicit conversion to string, JSON parsing, JSON serialization, regular +expression, sorting, object merge, string splitting or joining, Unicode +normalization, case folding, URL encoding, Base64, hashing, or cryptographic +helper is available. Rhai does perform its existing mixed integer/float numeric +operations. Exact legal or financial arithmetic must use the `Decimal` +helpers. Rust performs serialization, URL encoding, authentication, signing, +and hashing outside the script. + +Ordinary Rhai floats are carried only on the adapter surfaces that see +provider-shaped JSON: request preparation and source extraction, and only when +finite and inside the signed 64-bit magnitude. A public derived value is never +an ordinary float. Declare an integer concept or an exact `Decimal` instead; +a float reaching the derived-value gate fails the invocation. + +`parse_integer` permits leading zeroes because provider text may use them; the +result has ordinary integer semantics. Extraction must still enforce +provider-specific bounds such as a non-negative count. Invalid input terminates +the invocation under the closed error class for that adapter stage. + +No other Rhai standard package is loaded. In particular, scripts have no: + +```text +network, HTTP client, filesystem, environment, process execution, logging, +printing, diagnostics, clock, timezone database, randomness, UUID generation, +shared mutable state, imports, modules, eval, plugins, dynamic function +dispatch, object reflection beyond type_of, or secrets +``` + +## Hard limits + +These engine ceilings apply independently of script logic: + +| Resource | Hard ceiling | +|---|---:| +| Script source | 65,536 bytes | +| Operations per invocation | 100,000 | +| Call depth | 32 | +| Expression depth | 64 | +| Modules | 0 | +| String value | 16,384 bytes | +| Array | 256 items | +| Map | 256 entries | +| Source-response body before projection | 1,048,576 wire JSON bytes | +| Projected source response | 65,536 serialized JSON bytes | +| Source-response input | 1,048,576 normalized JSON bytes | +| Facts, parameters, or result | 65,536 normalized bytes | +| Extracted fact entries | 64 | +| Derived concept values | 16 | +| Configured codelists | 256 | +| Entries per codelist | 4,096 | +| Numeric buckets | 64 | +| Entity-reference seeds in one derived value | 64 | +| Entity-reference seed input | 512 bytes | +| `required` error code | 64 ASCII bytes | +| Exact decimal precision | 28 significant digits | +| Exact decimal scale | 9 fractional digits | +| Instant/local-time fractional precision | 9 digits | + +The Version 1 preparation profile adds these hard ceilings. A source may +configure stricter `RequestParts` limits: + +| Resource | Hard ceiling | +|---|---:| +| Combined normalized preparation input | 1,048,576 bytes | +| Normalized `RequestParts` output | 65,536 bytes | +| Query pairs | 64 | +| Query name | 64 bytes | +| Query value | 4,096 bytes | +| JSON body depth | 32 | + +Operation exhaustion, bound violations, invalid indexing, integer errors, and +explicit `throw` terminate the invocation. They never produce partial request +parts or partial facts. + +## Errors and observability + +Adapter scripts use only stable, value-free signals: + +```text +adapter_input_error +source_protocol_error +derivation_input_error +``` + +`source_protocol_error` is the extraction signal. +`adapter_input_error` is the request-preparation signal. +`derivation_input_error` rejects an +inconsistent selector/fact/policy contract without including any input value. +Its public collapse is deliberate: a uniquely found record whose derivation +inputs are inconsistent returns the same `evidence_not_available` problem as +`no_match` and `ambiguous`, so a caller cannot learn from the response that a +record exists. The internal category stays a value-free operator diagnostic in +audit. The `required` primitive uses another, +host-private unavailable termination. It is an opaque Rust-owned value that +script source cannot construct, reproduce with `throw`, or catch. The compiler +rejects `try`/`catch`. + +Rust maps compilation, entry-point, input-bound, invocation, request-result, +source-protocol, and extraction-result failures to closed internal classes. +Selector values, source facts, response bodies, parameters, credentials, +tokens, and raw Rhai errors must not enter HTTP problems, audit data, logs, +metrics, traces, snapshots, panic output, or test-failure artifacts. + +An adapter failure after the access-attempt audit prevents credential use, +source access, evidence construction, response protection, and disclosure as +applicable to its pipeline position. There is no fallback request or fallback +adapter. + +Tests prove that a script cannot forge the host-private +unavailable marker, use `Fn` or an anonymous function, interpolate an opaque +host value, or place a selector in an error, log, audit detail, metric, trace, +snapshot, or failed-test diagnostic. + +## Review examples + +- [DHIS2 preparation](dhis2-tracker/prepare.rhai) produces ordered repeated + `filter` query pairs while Rust keeps the endpoint and credentials fixed. +- [OpenCRVS preparation](opencrvs-event-search/prepare.rhai) produces one + bounded Event Search body. +- [OpenCRVS extraction](opencrvs-event-search/extract.rhai) emits only a narrow + provider-search fact. It does not decide legal parenthood. +- [Deployment-shaped examples](deployment-projects/README.md) show complete + DHIS2 adult-status and OpenCRVS adult-status, registered-parent confirmation, + and registered-parent identification projects using this ABI. + +The accepted Evidence contracts and runtime implement this API together with +startup, negative, resource-bound, redaction, exact-request, and one-request +transport tests. diff --git a/products/evidence/reference/request-adapter/README.md b/products/evidence/reference/request-adapter/README.md new file mode 100644 index 000000000..545d48fab --- /dev/null +++ b/products/evidence/reference/request-adapter/README.md @@ -0,0 +1,526 @@ +# Trusted request-adapter reference + +Status: Implemented Version 1 adopter reference + +This is the adopter entry point for the implemented Version 1 trusted +request-adapter model. Source-adapter Rhai is part of the reviewed, immutable +deployment bundle with Evidence YAML, schemas, codelists, and fixtures. Scripts +may render bounded query pairs and one JSON body, extract closed facts, and +derive declared values. Rust retains transport, credentials, authorization, +validation, evidence construction, signing, and audit authority. + +All values in this reference are invented. It contains no live credentials, +tokens, responses, or demo-subject identifiers. + +The concise colleague-review contract is +[`ADAPTER-API.md`](ADAPTER-API.md). This README explains the design rationale +and provider examples around that Version 1 API. + +## Authoring a new adapter + +Follow this order. Do not begin with scripts before confirming that the +provider can satisfy the Version 1 request and cardinality boundary. + +1. Check the [provider prerequisites](deployment-projects/CONFIG.md#provider-prerequisites). + Confirm fixed-origin HTTPS access, JSON responses, bounded one-request + lookup, and reliable distinction among zero, one, and multiple matches. +2. Declare each closed [selector profile](deployment-projects/CONFIG.md#selector-profiles). + Choose exact scalar fields, bounds, and one authorized value origin for each + authority path. Possessing a selector never grants authority. +3. Declare the [source and request](deployment-projects/CONFIG.md#source), + including honest acquisition posture, fixed origin and method, credentials, + preparation limits, and the response projection Rhai is allowed to see. +4. Write `prepare/2` using only source-required authorized selectors and closed + parameters. Verify exact logical query order and JSON body shape against + [`RequestParts`](ADAPTER-API.md#output-from-prepare). +5. Write `extract/2` and a closed fact schema. Map provider cardinality to only + `match`, `no_match`, or `ambiguous`; never select a candidate or hide a + provider protocol inconsistency. +6. Declare the [requirement and concepts](deployment-projects/CONFIG.md#requirements-and-concepts), + including legal reference frameworks, validity, timezone where time matters, + disclosure family, and exact Supported Value constraints. +7. Write `derive/3`. Declare only the authorized selector fields it needs and + return the exact [`DerivedConceptValue`](ADAPTER-API.md#output-from-derive) + set. Keep requester, audience, authority, and transport logic out of it. +8. Write sanitized [fixtures](deployment-projects/FIXTURES.md) covering positive, + false-as-success, boundary, no-match, ambiguity, missing data, protocol + failure, privacy canaries, and exact request transport. +9. Run `evidence check --runtime ` to validate the + complete immutable bundle and runtime bindings. +10. Run `evidence evaluate --runtime --fixture + ` for every referenced fixture before + deployment. +11. Promote the same reviewed bundle through staging and production using + environment-specific runtime files and secret mounts. Follow the complete + [authoring and promotion workflow](deployment-projects/CONFIG.md#authoring-and-promotion-workflow). + +The complete projects under +[`deployment-projects/`](deployment-projects/README.md) are maintained +executable references, not pseudoconfiguration. + +Offline evaluation proves request materialization, extraction, derivation, +output validation, Evidence construction, ephemeral signing and verification, +and privacy expectations. It does not start HTTP, authenticate a deployment +JWT, resolve deployment source credentials, write audit, or contact a provider. +Use package and HTTP-path tests for those boundaries and staging for the actual +identity, credential, private-CA, source, audit, and signing bindings. + +## Design conclusion + +Use a trusted script to render provider-specific query parameters and a JSON +body. Do not give that script request-execution authority. + +```text +validate and authorize selectors in Rust + -> durably accept the access-attempt audit + -> prepare(source_required_selectors, adapter_parameters) + -> validate RequestParts in Rust + -> resolve credentials in Rust + -> execute one fixed request in Rust + -> parse and bound the response in Rust + -> extract(response, adapter_parameters) + -> validate LookupResult in Rust + -> derive(facts, declared_authorized_selectors, evaluation_context) + -> validate minimum-disclosure values in Rust +``` + +Preparation and extraction use fresh script state. Extraction does not receive +the selector bundle or the prepared request. The requirement-specific +derivation receives only the authorized roles and fields declared by its +closed selector-input contract so it can evaluate reviewed relational +semantics against uniquely resolved authoritative facts. +This supports exact parent-reference membership and other governed +deterministic comparisons without giving the source adapter a broad candidate +set or turning Rust into an identity matcher. + +This removes the growing YAML placement and interpolation vocabulary. YAML +continues to declare fixed authority, authentication, resource limits, script +paths, and reviewed non-secret constants. The preparation script expresses the +provider's repeated query parameters or nested JSON shape. A new HTTP JSON API +normally needs another reviewed script plus fixtures, not another Rust request +operation or a larger public connector DSL. + +## What to learn from connector systems + +### Airbyte + +Airbyte usefully separates a requester, authentication, pagination, response +selection, and transformation. That decomposition makes each connector concern +reviewable and independently testable. It also demonstrates the failure mode +of a declarative connector language: once arbitrary request and response shapes +must be represented in YAML, request options, selectors, interpolations, +paginators, partition routers, transformations, and custom-component escape +hatches accumulate. + +Evidence should keep the component separation but not reproduce the component +catalog. Version 1 needs exactly one evidence-data request, not streams, +partitions, cursor state, pagination, incremental synchronization, discovery, +or retries that create additional evidence reads. + +Useful references: + +- [Airbyte low-code CDK overview](https://docs.airbyte.com/platform/connector-development/config-based/low-code-cdk-overview) +- [Airbyte requester](https://docs.airbyte.com/platform/connector-development/config-based/understanding-the-yaml-file/requester) +- [Airbyte record selector](https://docs.airbyte.com/platform/connector-development/config-based/understanding-the-yaml-file/record-selector) +- [Airbyte custom components](https://docs.airbyte.com/platform/connector-development/config-based/advanced-topics/custom-components) + +### n8n + +n8n distinguishes declarative REST nodes from programmatic nodes whose +`execute()` method reads parameters, builds requests, performs I/O, and maps +responses. The attractive part for Evidence is the authoring model: central +transport defaults, separately defined credentials, and small operation-owned +request mappings. + +Evidence must not copy the authority of an n8n programmatic node. The adapter +cannot choose a URL, inject authorization, follow a response-provided next URL, +or perform the request. + +Useful references: + +- [n8n node-building approaches](https://docs.n8n.io/connect/create-nodes/plan-your-node/choose-a-node-building-style/) +- [n8n starter request defaults](https://github.com/n8n-io/n8n-nodes-starter/blob/master/nodes/GithubIssues/GithubIssues.node.ts) +- [n8n operation routing](https://github.com/n8n-io/n8n-nodes-starter/blob/master/nodes/GithubIssues/resources/issue/getAll.ts) +- [n8n credential separation](https://github.com/n8n-io/n8n-nodes-starter/blob/master/credentials/GithubIssuesApi.credentials.ts) + +### Vector VRL and Redpanda Connect Bloblang + +VRL provides the strongest execution model to copy: compile at startup, operate +on one input, expose no host or network access, keep state local to an +invocation, and make fallible operations explicit. Bloblang provides the best +mapper mental model: the script constructs a new result document rather than +mutating an external source. Evidence supplies isolated per-invocation copies; +local nested mutation is permitted but cannot escape that invocation. Both +systems support direct input-to-output fixture tests. + +Evidence must use a curated Rhai surface. General Bloblang, JavaScript, or +Python capabilities such as environment access, files, clocks, randomness, +plugins, counters, and network helpers are outside this adapter ABI. + +Useful references: + +- [Vector Remap Language](https://vector.dev/docs/reference/vrl/) +- [Vector configuration unit tests](https://vector.dev/docs/reference/configuration/unit-tests/) +- [Bloblang mapping model](https://docs.redpanda.com/connect/guides/bloblang/about/) +- [Redpanda Connect mapping tests](https://docs.redpanda.com/connect/configuration/unit_testing/) + +## Version 1 ABI + +One reviewed source definition uses two scripts with separate inputs, entry +points, and result validators. Each requirement has one derivation script: + +```text +prepare(authorized_selectors, adapter_parameters) -> RequestParts +extract(source_response, adapter_parameters) -> LookupResult +derive(facts, declared_authorized_selectors, evaluation_context) + -> array +``` + +`authorized_selectors` contains only complete selector profiles that Rust has +already resolved, validated, and authorized. It has this conceptual shape: + +```json +{ + "subject": { + "profile": "person-demographics-v1", + "values": { + "given_name": "Synthetic", + "family_name": "Subject", + "birth_date": "2000-02-29" + } + } +} +``` + +`adapter_parameters` is trusted, non-secret, startup-only data. It contains +provider field identifiers, fixed program or event identifiers, fixed +projection declarations, and other constants needed by both directions of the +adapter. It contains no credentials or runtime authorization context. A closed +schema validates its exact keys, types, and bounds at startup. + +Each source also declares closed `selectorInputs`: permitted roles, profiles, +and fields, but no query/body placements. Rust intersects that declaration +with the already-authorized request and passes only the fields required by the +selected adapter profile. The script never receives the complete Evidence +request or surplus authorized selector fields. + +The derivation has its own closed `selectorInputs`, independent of source +request inputs. This distinction lets a source request resolve a child record +using the child's reference while derivation receives only the candidate +parent reference needed to compare with the complete parent-reference set. The +candidate reference need not be sent to the source, and the child selector +need not be exposed to derivation. + +`RequestParts` is a closed result: + +```text +RequestParts { + query: ordered list of {name: string, value: string}, + body: null or one JSON value +} +``` + +The ordered query-pair list is intentional. It preserves repeated parameters +such as DHIS2 `filter` without inventing map-to-array encoding rules. + +Rust rejects every other result member, including: + +```text +url, origin, path, method, headers, credentials, authentication, +timeout, redirect, retry, pagination, next_request, source +``` + +Rust also rejects empty query names, CR or LF, unsupported Rhai values, +non-finite numbers, excessive nesting, excessive collections, too many query +pairs, and an oversized normalized request. Query names and values remain +logical raw strings; Rust percent-encodes both exactly once while preserving +pair order and repetition. + +## Script capability surface + +Trusting the deployment bundle changes the review model, not the execution +boundary. Preparation needs a small additional set of deterministic language +operations so that provider request formats can be expressed without growing a +second YAML interpolation language: + +- bounded string concatenation; +- bounded literal replacement for provider-specific escaping; +- bounded array construction and append; +- map construction, indexing, membership, and length; +- integer arithmetic and integer, string, and exact JSON-type comparisons; +- local functions within the same startup-compiled script. + +The preparation engine still exposes no environment, filesystem, network, +logger, clock, randomness, dynamic evaluation, imports, modules, plugins, or +credential handles. Rust applies global operation, call-depth, expression- +depth, string, array, map, input, and output limits independently of any checks +written in the script. + +Version 1 permits statically named same-file helper functions but forbids +function pointers, data-derived +dispatch, anonymous functions, closures, interpolated strings, raw string +literals, and block or `if` expressions in operand position. It should +not enable Rhai's full standard package. + +## Rust-owned request policy + +The source definition still fixes: + +- HTTPS origin; +- HTTP method; +- normalized path; +- whether a query and/or JSON body is permitted; +- generic Basic, static Bearer, or OAuth client-credentials authentication; +- permitted content type and core-owned headers; +- one evidence-data request; +- denied redirects; +- timeout; +- maximum response bytes; +- per-source concurrency; +- acquisition posture; +- adapter script and parameters. + +The trusted script owns provider semantics inside the permitted query/body. A +generic runtime cannot know that `pageSize`, `limit`, or a provider-specific +filter means what the provider documents. That guarantee comes from the +reviewed adapter, exact request fixtures, and mock contract, just as it comes +from reviewed fixed YAML today. Rust still enforces the bounds it can know: +one request, no page traversal, fixed transport authority, bounded output, and +bounded response parsing. + +## Reference layouts + +The paired references use the same Version 1 ABI: + +```text +dhis2-tracker/ + source.yaml + prepare.rhai + extract.rhai + parameters.schema.yaml + facts.schema.yaml + prepare-input.json + expected-request-parts.json + response-match.json + response-malformed-count.json + expected-lookup-result.json + +opencrvs-event-search/ + source.yaml + prepare.rhai + extract.rhai + parameters.schema.yaml + facts.schema.yaml + prepare-input.json + expected-request-parts.json + response-match.json + response-malformed-count.json + expected-lookup-result.json +``` + +Each `response-malformed-count.json` is a negative fixture. Its fractional +provider count must produce `source_protocol_error`, never `match` or +`ambiguous`. + +The focused source YAML files are small conformance fragments rather than full +deployment bundles. `selectorInputs` is the placement-free Version 1 source +input contract. The configuration and executable fixture vocabularies are defined in +[`deployment-projects/CONFIG.md`](deployment-projects/CONFIG.md) and +[`deployment-projects/FIXTURES.md`](deployment-projects/FIXTURES.md). + +## DHIS2 reference + +The DHIS2 example exercises the collection endpoint rather than a dynamic URL. +The preparation script supports two closed, separately authorized profiles: + +- one tracked-entity UID rendered as `trackedEntities`; +- one compound selector rendered as repeated exact attribute `filter` pairs. + +The example fixes the program, organisation-unit boundary, field projection, +page one, and a page size of two. Rust sends one request and never follows a +page. The extraction script interprets `pager`, `trackedEntities`, and nested +attributes, returning only `no_match`, `ambiguous`, or one status fact. + +The provider, not Evidence, evaluates the attribute filters. The script does +not compare returned candidates to the selector values. + +DHIS2 documents the tracked-entity collection, attribute filters, `fields`, and +pagination in the [Tracker API 2.43](https://docs.dhis2.org/en/develop/using-the-api/dhis-core-version-243/tracker.html). + +## OpenCRVS reference + +The deployment-shaped OpenCRVS project uses a uniquely resolved registered +birth event as an authoritative record. It demonstrates three independent +requirements: + +- adult status from the event date; +- whether a separately authorized candidate reference is one of the complete + registered-parent references on that event; and +- the bounded set of registered parents as audience-scoped entity references. + +Preparation sends only the child's configured tracking ID. Extraction +validates zero, one, or ambiguous event cardinality and +returns only the facts required by the selected source. For the relationship +source those facts are a complete bounded parent-reference set. The +relationship derivation declares the child reference for returned-record +binding and the candidate-parent reference for exact membership. The +identification derivation converts each raw source +reference to an opaque `EntityReferenceSeed`; Rust then produces +audience-scoped references. Raw parent references do not leave the evaluation +pipeline. + +The fact schema also requires `relationship_set_complete: true`, an exact +reference namespace, and a versioned relationship-set contract identifier. +Derivation validates all three before it can return `false` or emit parent +references. Bundle review must establish that absence of each configured +parent slot means authoritatively absent, rather than omitted or unavailable. + +OpenCRVS event declarations are country-configured. The example parameterizes +the exact declaration field identifiers that hold stable parent references. +An operator must replace those illustrative field identifiers with fields +whose uniqueness, namespace, completeness, and parent-role meaning are +governed for that deployment. If the country configuration contains only +names and dates, it may use a separately reviewed deterministic attribute rule +and an accurately named concept, or it may expose a governed decision facade. +Evidence must not silently treat a fuzzy search hit as legal parentage. + +The reference deliberately calls the portable assertion +`registered-parent-relationship`. A jurisdiction may rename it to +`legal-parent-relationship` only when its law and data governance establish +that the configured record fields and exact matching rule carry that meaning. +Similarly, `registered parent` does not silently mean biological parent, +guardian, or current parental responsibility. + +Primary implementation references: + +- [OpenCRVS SearchQuery schema](https://github.com/opencrvs/opencrvs-core/blob/ff6a21ae39d16cc113714346ccf73bb76a23e2fb/packages/commons/src/events/EventIndex.ts) +- [OpenCRVS Event Search route](https://github.com/opencrvs/opencrvs-core/blob/ff6a21ae39d16cc113714346ccf73bb76a23e2fb/packages/events/src/router/event/index.ts) +- [Farajaland birth advanced-search configuration](https://github.com/opencrvs/opencrvs-farajaland/blob/4865495d28e3a62d8ee979503fb8139b41439c2c/src/events/birth/advancedSearch.ts) +- [OpenCRVS client query construction](https://github.com/opencrvs/opencrvs-core/blob/ff6a21ae39d16cc113714346ccf73bb76a23e2fb/packages/client/src/v2-events/features/events/Search/utils.ts) + +A signed negative is valid only after the child event was uniquely resolved, +the configured parent-reference fields were present and complete, and exact +membership returned false. Zero events, multiple events, absent relationship +fields, malformed declaration data, or a source that does not guarantee a +complete parent set stop before derivation. They never become `false`. + +OpenCRVS Event Search currently returns an EventIndex result that can include a +broader country-configured declaration. The reference's extended JSON Pointer +projection is client-side pruning before Rhai, not provider-side field +selection. The broader record therefore crosses the wire and enters bounded +JSON parsing even when extraction receives only the event date or two parent +references. This is honest `record-transformed` compatibility, not +field-projected acquisition. A governed decision endpoint or provider-side +result projection is preferable where available. + +The reference also reflects OpenCRVS's current client bootstrap, which places +the client identifier and secret in the token endpoint query string. This +placement applies only to the token request; Rust sends the resulting access +token to `/events/search` in the `Authorization: Bearer` header. Query-string +bootstrap can expose credentials to upstream URL logs, proxies, or tracing. +Use a safer provider-supported placement when available and require complete +token-URL stripping and redaction locally. External provider logging remains a +deployment risk that this adapter design cannot eliminate. + +For the simpler adult-status compatibility case, the same preparation ABI can +render the already tested tracking-ID request: + +```json +{ + "query": { + "type": "and", + "clauses": [ + { + "eventType": "birth", + "status": {"type": "exact", "term": "REGISTERED"}, + "trackingId": {"type": "exact", "term": ""} + } + ] + }, + "limit": 2, + "offset": 0 +} +``` + +The full deployment-shaped configuration and scripts are under +[`deployment-projects/opencrvs-family-evidence/`](deployment-projects/opencrvs-family-evidence/). + +## Required adapter tests + +Every preparation adapter should have tests in these groups. + +### Startup contract + +- The script compiles at startup. +- `prepare`, `extract`, and `derive` exist with the approved arities. +- The bundle contract version selects the supported adapter ABI; a source does + not declare an independent version unless coexistence is later required. +- Parameters and result schemas are closed. +- Function pointers, anonymous functions, closures, interpolated strings, raw + string literals, block or `if` expressions in operand position, and top-level + executable statements are rejected. + +### Exact preparation fixtures + +- Sanitized ordinary and hostile-character inputs map structurally to their + normalized `RequestParts` fixtures. +- Repeated query keys and order are preserved. +- Object-member order and fixture whitespace are ignored; query-pair order is + significant. +- The same input produces byte-equivalent normalized transport output + repeatedly. +- Missing or unknown roles, profiles, fields, or parameters fail closed. +- No failure includes a selector value. + +### Output boundary + +- URL, path, method, header, credential, retry, or next-request members fail. +- Excessive depth, string size, collection size, query-pair count, and body + size fail. +- Unsupported or non-JSON values fail. +- A preparation failure occurs after the durable access-attempt audit but + before credential acquisition and source access. + +### Encoding and injection + +- `&`, `=`, `%`, `+`, `/`, `:`, `,`, Unicode, empty strings, and CR/LF are + covered. +- Rust percent-encodes every query name and value exactly once. +- Selector content cannot change origin, path, method, headers, or the number + of requests. +- Credential fields cannot be overwritten through query or body data. + +### Execution and extraction + +- Exactly one request reaches the sanitized mock. +- Redirect, timeout, response-size, and concurrency limits remain Rust-owned. +- Zero, one, two, and provider totals above two map safely. +- No second page or response-provided URL is followed. +- Malformed envelopes, wrong container types, fractional counts, integers + outside the signed 64-bit range, and count inconsistencies fail as + `source_protocol_error` or before script invocation, as specified. +- Facts exist only on a unique match. +- Relationship negatives require schema-validated completeness, namespace, and + relationship-set contract facts; missing or mismatched facts stop without a + signed boolean. + +### Privacy and resource bounds + +- Preparation receives no credentials, requester identity, authority object, + audit handle, logger, filesystem, environment, clock, randomness, or network. +- Extraction receives no selectors or prepared request. +- Derivation receives only validated facts, its declared authorized selector + inputs, and the closed evaluation context. +- A derivation cannot access an authorized role or field omitted from its + `selectorInputs` declaration. +- Every invocation has fresh state. +- Operation, call-depth, collection, string, and output limits terminate + expensive scripts with a safe typed error. +- No test failure artifact prints raw selectors, source facts, credentials, or + tokens. + +Deployment-specific decisions remain bundle review inputs rather than open ABI +questions. In particular, operators must identify the exact provider fields +that carry stable and complete relationship references, document their legal +meaning, and encode that agreement in source parameters, the fact schema, +requirement parameters, and fixtures. diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md new file mode 100644 index 000000000..9579ae71b --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -0,0 +1,570 @@ +# Evidence Version 1 configuration reference + +Status: Implemented Version 1 configuration contract + +Evidence starts from two closed, startup-only inputs: + +1. `bundle/evidence.yaml` and its referenced bundle files define governed + evidence semantics and source authority. +2. `runtime.yaml` binds that bundle to one process, filesystem, listener, audit + destination, secret mount, and local TLS trust files. + +Both inputs are reviewed, validated completely before readiness, mounted +read-only, and immutable for the process lifetime. Evidence computes stable +bundle and runtime revisions at startup; audit events carry the governed bundle +revision. Runtime configuration is not an override layer. It cannot +change a source origin, request, credential kind, requirement, authority, +selector, disclosure rule, rate limit, signing policy, or audit fail-closed +policy. + +Unknown keys are rejected at every level. All names below are exact and +case-sensitive unless the field explicitly says otherwise. + +## What an adopter normally edits + +Most adopters should need to edit only: + +- deployment URIs, hosts, identifiers, purposes, authority tags, and selector + field names in `evidence.yaml`; +- one small preparation script and one extraction script per distinct provider + request/response shape; +- one derivation script per requirement; +- the closed parameter and fact schemas beside those scripts; +- sanitized fixtures; +- process-local paths and listener settings in `runtime.yaml`; and +- secret and private-CA files outside the project. + +Changing Rust, defining a source-product plugin, or adding a product-specific +configuration variant is not part of ordinary adoption. + +## Provider prerequisites + +Decide compatibility before authoring scripts. A Version 1 provider must: + +- expose one bounded lookup at one fixed origin over HTTPS, except numeric + loopback HTTP used only by deterministic local tests; +- accept one fixed `GET` or `POST` and return JSON with media type + `application/json` or `application/graphql-response+json`; +- let one response distinguish zero, one, and multiple matches, either through + a trustworthy total count plus at most one minimized result, or through a + caller-controlled hard result limit of at least two; +- not require page traversal, a response-provided next URL, a retry, or a + second evidence-data request to establish uniqueness; +- support a query/body/path lookup narrow enough that Evidence does not fetch + a broad candidate set; and +- provide the complete facts and relationship-set completeness needed by the + requirement, or expose a governed intermediary that does. + +An unbounded array without a trustworthy count or hard result limit is not +adaptable in Version 1. Neither is a provider that requires broad retrieval and +local candidate selection. Use or build a governed bounded-lookup facade +outside Evidence rather than weakening the one-request and no-matcher boundary. + +The hard result limit itself is governed adapter policy. Its value, such as +`resultLimit: 2` in the source's `adapterParameters`, is declared by the +reviewed source configuration and rendered into the request by that source's +preparation script. Rust enforces the generic one-request, projection, and +response bounds around it and holds no domain rule about a two-result ceiling. +The number is not a property of any source product; a different reviewed +configuration may declare a different limit as long as one response still +distinguishes zero, one, and multiple matches. + +## Governed bundle + +The governed file is `bundle/evidence.yaml`. + +### Top-level sections + +| Key | Required | Meaning | +|---|---|---| +| `version` | yes | Bundle schema version. Version 1 requires integer `1`. | +| `service` | yes | Evidence provider identity and trust domain. | +| `issuer` | yes | Issuer identity placed in evidence. | +| `authentication` | yes | Closed inbound OIDC access-token verification policy. | +| `audit` | yes | Audit format, pseudonymization key reference/version, and fail-closed policy. Storage location is runtime-owned. | +| `subjectBinding` | yes | Audience-scoped subject-binding key reference/version. | +| `rateLimits` | yes | Governed anti-enumeration and request limits. | +| `signing` | yes | Evidence/JWS format, algorithm, key references, validity, JWKS path, and rollover policy. | +| `responseFormats` | no | Response formats the whole deployment permits. Omission means `[signed-jws]`. Declare it explicitly in production bundles. | +| `selectorProfiles` | yes | Closed caller/grant/context selector shapes. | +| `sources` | yes | Fixed source authorities, transport policy, scripts, schemas, and bounds. | +| `authorityProfiles` | yes | Who may request which requirement, purpose, audience, roles, profiles, and value origins. | +| `requirements` | yes | Evidence semantics, source, derivation, concepts, fixtures, and disclosure family. | + +### Service, issuer, and inbound authentication + +| Section or key | Required | Meaning | +|---|---|---| +| `service.providerId` | yes | Technical Evidence provider URI placed in evidence. | +| `service.trustDomain` | yes | One operator-controlled trust-domain URI for the process. | +| `issuer.id` | yes | Legal issuer URI placed in evidence. Governance must authorize the provider to act for it. | +| `authentication.kind` | yes | Exactly `oidc-access-token`. | +| `authentication.issuer`, `authentication.jwksUri` | yes | Exact HTTPS token issuer and JWKS endpoint. Path-based issuers are supported. The fixed JWKS endpoint may resolve to a public or private HTTPS address; DNS is pinned for each fetch, ambient proxies are disabled, and cloud-metadata destinations remain prohibited. | +| `authentication.audiences` | yes | Non-empty exact audience allowlist. | +| `authentication.tokenTypes` | yes | Non-empty allowlist containing only `at+jwt` and/or `application/at+jwt`. | +| `authentication.algorithms` | yes | Non-empty allowlist containing only `EdDSA`, `ES256`, and/or `RS256`. No algorithm fallback is permitted. | +| `authentication.principalClaim` | yes | The only claim used for the principal. Its absence denies; `client_id`, `azp`, request data, and proxy headers are not fallbacks. | +| `authentication.requesterTagsClaim` | yes | Claim containing the requester tags matched against an authority profile. | +| `authentication.evidenceAudienceClaim` | yes | Claim containing the exact evidence audience. The public request cannot choose another audience. | +| `authentication.grantIdClaim`, `authentication.grantAuthorityClaim` | yes | Claims used only when an `authenticated-grant` origin is selected. The authority must equal the matched authority-profile id. | +| `authentication.actorClaim` | no | Optional verified actor claim. Omission does not enable a fallback actor source. | + +### Audit, subject binding, rates, and signing + +| Section | Required fields and rule | +|---|---| +| `audit` | `format: keyed-jsonl`, file-only `hashSecretRef`, positive `hashKeyVersion`, and `failClosed: true`. The referenced file contains at least 32 raw secret bytes. The runtime file owns storage location. | +| `subjectBinding` | File-only `secretRef` and positive `keyVersion`. The referenced file contains at least 32 raw secret bytes. Rust derives audience-and-purpose-scoped bindings over the complete canonical role/profile/value bundle, never per-field hashes. | +| `rateLimits` | Positive `requestsPerPrincipalPerMinute`, `burstPerPrincipal`, and `failedSelectorAttemptsPerPrincipalAuthorityPerMinute`. Raw selector values never become rate-limit labels. | +| `signing` | Exact keys are `format: flattened-jws-json`, `algorithm: EdDSA`, `activeKeyId`, file-only `activeKeyRef`, `retiredPublicJwkFiles`, fixed `jwksPath`, `maximumAssertionValiditySeconds`, and `verifierClockSkewSeconds`. Missing signing material fails readiness; there is no unsigned fallback. | +| `responseFormats` | Closed unique list of 1 or 2 entries drawn from `signed-jws` and `unsigned-json`. `signed-jws` must always be present; a bundle that omits it is rejected at startup. Unsigned output additionally requires the matched grant to permit it, and signing material must still be ready even for an unsigned response. | + +### Selector profiles + +Each `selectorProfiles.` declares `maximumAggregateBytes` and one exact +`fields` map of 1 through 16 deployment-defined fields. Field names are opaque +to Rust. A profile is not an identity type and possession of its values is not +authority. + +| Field type | Required declaration | +|---|---| +| `string` | `minimumBytes`, `maximumBytes` | +| `date` | Canonical `YYYY-MM-DD` | +| `integer` | Inclusive `minimum`, `maximum` within the safe integer bound | +| `boolean` | No additional keys | +| `controlled-code` | `codelist`, `codelistVersion`, `maximumBytes` | + +Alternative sufficient field sets and sets with an extra disambiguator are +different named profiles. Rust does no case folding, Unicode normalization, +transliteration, phonetics, tokenization, name-order parsing, partial-date +matching, fuzzy scoring, or candidate selection. + +### Authority profiles + +An authority profile has a `kind` of `statutory`, `organizational`, `consent`, +`delegated`, or `explicit-request`, non-empty `requesterTags`, and grants. +Each grant binds one exact `requirement`, `purpose`, +`audienceFrom: authenticated-requester`, an optional `responseFormats` list, +and the complete subject-role set. A grant's `responseFormats` follows the same +closed rule as the bundle-level list: 1 or 2 unique entries that must include +`signed-jws`, defaulting to `[signed-jws]` when omitted. Unsigned output +requires both the bundle and the one complete matched grant to permit it, so a +production grant that says nothing permits only signed JWS. +Every grant subject fixes `role`, `selectorProfile`, and one `valueOrigin`: + +- `request` requires values in the closed public request and prohibits + `valueClaims`; +- `authenticated-context` requires an exact field-to-verified-claim + `valueClaims` map and rejects caller values; and +- `authenticated-grant` requires the same exact map, rejects caller values, + and additionally requires the configured grant id and grant authority. The + authenticated authority value must equal the matched authority-profile id. + +Claim paths are resolved only from the strictly verified access token. A +caller-supplied grant reference, selector, consent reference, or approval value +cannot create authority. The runtime authorizes the principal, optional actor, +requirement revision, purpose, audience, authority profile, and every +role/profile/origin tuple as one decision before credentials or source access. + +A profile matches only when every tag in its `requesterTags` is present in the +verified claim, so adding a tag narrows the profile. Access is per requester +class rather than per client identity: two clients carrying the same tags have +the same access, and differentiated requirements, purposes, or `valueClaims` +are expressed by issuing different tags. To bind purpose to the token issuer +instead of the caller's choice, give each purpose its own tag and its own +profile. Exactly one authority path may match a request; two profiles covering +the same requirement, purpose, and subject tuple are denied at request time and +are not rejected at startup. + +### Requirements and concepts + +Each requirement declares these fields: + +| Key | Required | Meaning | +|---|---|---| +| `id`, `kind` | yes | Stable requirement URI and one of `criterion`, `information-requirement`, or `constraint`. | +| `source` | yes | One configured source id. Version 1 does not perform multi-source fulfillment. | +| `purposes` | yes | Closed purpose codes that authority grants may select. | +| `subjectRoles` | yes | Complete role set, `cardinality: one`, and permitted selector profile ids. Public subject array order is not semantic; roles are resolved uniquely and canonicalized to declaration order. | +| `referenceFrameworks`, `evidenceType` | yes | Governed legal/procedural framework URIs and the exact Evidence Type URI. | +| `observationTimezone` | no | Valid IANA timezone used for `legal_local_date` and `legal_local_time`. Omission uses UTC. Declare it explicitly whenever local legal time can affect a result. | +| `validitySeconds` | yes | Positive assertion lifetime no greater than the signing maximum. | +| `derivation` | yes | Script, optional minimized `selectorInputs`, and closed typed parameters. | +| `concepts` | yes | 1 through 16 exact outputs, each with `id`, `form`, `required`, and closed form-specific `constraints`. | +| `fixtures` | yes | Bundle-relative sanitized project fixture referenced by exactly one requirement. | +| `disclosureGuard.families` | yes | Non-empty reviewed disclosure-family URI set. Reuse across enabled requirements is rejected; distinct labels still require human combined-disclosure review. | +| `existenceDisclosure` | yes | Exactly `collapse-unresolved` in Version 1. | + +Supported concept forms are `boolean`, `controlled-code`, +`controlled-category`, `bounded-integer`, `bounded-decimal`, `date-bucket`, +`time-bucket`, `audience-scoped-entity-reference`, `controlled-code-list`, +`entity-reference-list`, and `reviewed-structured-value`. Constraint keys use +bundle camelCase, including `codelistVersion`, `maximumBytes`, `categoryScheme`, +`schemeVersion`, `maximumScale`, `bucketScheme`, `minimumItems`, `maximumItems`, +`maximumSerializedBytes`, and `unique`, with the exact set determined by the +selected form. Codelist declarations and reviewed structured schemas are +bundle-relative, closed, versioned artifacts validated at startup. + +`observed_at` is a runtime-supplied instant normalized to UTC. Rust resolves +the legal local date and time from `observationTimezone`; without the optional +field it uses UTC. Fixtures supply only `observed_at`, never derived local +values, so timezone boundary behavior uses the production path. + +Derivation parameters are limited to bounded strings, safe integers, booleans, +typed canonical decimals shaped as `{type: decimal, value: "..."}`, and arrays +of typed decimal bucket boundaries. Adapter parameters use their separately +closed JSON Schema and the narrower conversion described in +[`ADAPTER-API.md`](../ADAPTER-API.md#inputs). Neither parameter map may contain +secrets or runtime authority. + +### Source + +```yaml +sources: + source-a: + transport: http-json + baseUrl: https://registry.gov.example + posture: record-transformed + tlsTrustProfile: government-internal-pki + authentication: + kind: static-bearer + tokenRef: secret:file/source-token + request: {} + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/source-a-facts.schema.yaml +``` + +| Key | Required | Meaning | +|---|---|---| +| `transport` | yes | Exactly `http-json` in Version 1. | +| `baseUrl` | yes | Fixed HTTPS origin. No path, query, fragment, user information, wildcard, or runtime substitution. | +| `posture` | yes | `source-derived`, `field-projected`, or `record-transformed`, describing what crosses the source wire. | +| `tlsTrustProfile` | no | Logical profile name bound by `runtime.yaml`. Omission uses configured system roots only. | +| `authentication` | yes | One closed source-authentication profile below. | +| `request` | yes | One fixed evidence-data request plan. | +| `extractScript` | yes | Bundle-relative Rhai script implementing `extract/2`. | +| `factSchema` | yes | Bundle-relative closed JSON Schema for match facts. | + +### Source authentication + +All secret references are logical. Rust resolves them only after authorization, +durable access audit, and complete request-parts validation. No secret is passed +to Rhai. + +```yaml +# HTTP Basic +authentication: + kind: basic + usernameRef: secret:file/source-username + passwordRef: secret:file/source-password + +# Authorization: Bearer +authentication: + kind: static-bearer + tokenRef: secret:file/source-token + +# A provider-specific API-key header +authentication: + kind: static-api-key + headerName: X-API-Key + valueRef: secret:file/source-api-key + +# OAuth 2.0 client credentials +authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://auth.registry.gov.example/token + clientIdRef: secret:file/source-client-id + clientSecretRef: secret:file/source-client-secret + scope: recordsearch + credentialPlacement: basic-header + maximumCacheSeconds: 300 +``` + +`static-api-key.headerName` cannot be `Authorization`, `Host`, `Cookie`, +`Set-Cookie`, `Content-Length`, `Content-Type`, `Transfer-Encoding`, a +hop-by-hop header, forwarding/proxy header, or tracing header. Names are +validated as HTTP field names. Secret values are bounded and reject controls, +CR, and LF. + +OAuth `credentialPlacement` is one of `basic-header`, `form-body`, or the +deployment-residual `query-string`. Query-string placement requires complete +token URL/query redaction. Token redirects are denied and token responses are +bounded. The token request is credential bootstrap, not a second evidence-data +lookup. + +Secret files are byte strings, not base64 fields. Do not base64-encode the +audit or subject-binding key unless those encoded ASCII bytes are intentionally +the key. Generate independent random values of at least 32 bytes and store the +raw bytes in their owner-only files. Source usernames, passwords, tokens, +client ids, and client secrets use their provider-defined lexical form and the +runtime's generic secret bounds. + +For inbound access tokens, `tokenTypes: [at+jwt]` requires a protected JWT +header with `typ: at+jwt`; `application/at+jwt` requires that exact alternative. +A sanitized shape for the reference projects is: + +```json +{"alg":"EdDSA","kid":"deployment-key-id","typ":"at+jwt"} +{"iss":"https://identity.example","aud":"registry-evidence","exp":2000000000,"sub":"service-client","evidence_tags":["approved-requester"],"evidence_audience":"https://consumer.example"} +``` + +These are decoded shapes, not usable tokens. The configured issuer, audience, +algorithm, token type, principal claim, requester-tag claim, and evidence +audience claim must all match. Grant-derived selectors additionally require +the configured grant id and authority claims. + +### Request + +```yaml +request: + method: POST + path: /v1/search + fixedHeaders: + - {name: Accept, value: application/fhir+json} + - {name: X-API-Version, value: "2026-01"} + selectorInputs: + - role: subject + alternatives: + - {profile: record-reference-v1, fields: [record_reference]} + prepareScript: adapters/source-a-prepare.rhai + adapterParameters: {resultLimit: 2} + adapterParametersSchema: schemas/source-a-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 12 + maximumCollectionItems: 32 + maximumStringBytes: 512 + maximumNormalizedBytes: 8192 + projection: + - /total + - /results/*/status + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 +``` + +| Key | Required | Meaning | +|---|---|---| +| `method` | yes | Fixed `GET` or `POST`. | +| `path` | conditional | Fixed absolute path. Exactly one of `path` or `pathTemplate` is required. | +| `pathTemplate` | conditional | Fixed absolute path with complete-segment placeholders resolved by Rust. | +| `pathBindings` | with template | Closed placeholder-to-selector bindings. | +| `fixedHeaders` | no | Ordered non-secret constants. Names are unique after ASCII case folding. | +| `selectorInputs` | yes | Exact minimized authorized selector alternatives visible to `prepare`. | +| `prepareScript` | yes | Bundle-relative Rhai script implementing `prepare/2`. | +| `adapterParameters` | yes | Closed non-secret JSON parameters shared by preparation and extraction. `{}` is valid. | +| `adapterParametersSchema` | yes | Closed bundle-relative JSON Schema for those parameters. | +| `preparationLimits` | yes | Per-channel policy and stricter output bounds. | +| `projection` | yes | Non-empty Rust-enforced response allowlist defined by `ADAPTER-API.md`. | +| `redirects` | yes | Exactly `deny` in Version 1. | +| `timeoutMilliseconds` | yes | Positive source-request timeout within the global ceiling. | +| `maximumResponseBytes` | yes | Positive pre-projection response limit within the global ceiling. | +| `concurrencyLimit` | yes | Positive per-source request concurrency limit. | + +Fixed headers cannot set authentication, host/routing, cookies, body framing, +content length/type, connection, forwarding, proxy, or tracing headers. Rust +sets `Content-Type: application/json` when a JSON body is present and owns all +authentication headers. Scripts cannot add, remove, or change a header. + +Query components in `RequestParts` are lexical strings even if a provider +interprets them as numbers or booleans. Write query constants as `"2"` and +`"true"`. JSON body parameters retain JSON types, so body constants may be `2` +and `true`. Rust performs no implicit conversion. + +### Path templates + +Use a path template only when the provider lacks a safe query/body lookup: + +```yaml +request: + method: GET + pathTemplate: /api/records/{record_reference} + pathBindings: + record_reference: + role: subject + profile: record-reference-v1 + field: record_reference +``` + +Each placeholder occupies one complete path segment and has exactly one closed +binding. Rust reads the value directly from an already validated and authorized +selector. Scripts do not return path values. A value must be non-empty bounded +UTF-8 and cannot contain `/`, `\`, `%`, controls, `.` or `..`. Rust +percent-encodes it exactly once. Templates cannot contain a scheme, authority, +query, fragment, empty segment, or dot segment. Exact expanded-path fixtures +are required. + +### Preparation limits + +`query` and `jsonBody` are independently `required`, `allowed`, or `forbidden`. +The remaining keys are optional stricter limits beneath the ABI hard ceilings: + +- `maximumQueryPairs` +- `maximumQueryNameBytes` +- `maximumQueryValueBytes` +- `maximumJsonDepth` +- `maximumCollectionItems` +- `maximumStringBytes` +- `maximumNormalizedBytes` + +At least one output channel must be usable. `required` means non-empty. For a +JSON body, JSON `null` is absent; an empty object or array is present. + +### Requirement derivation selector inputs + +`requirements[].derivation.selectorInputs` is optional. Omission means the +derivation receives an empty selector map. When present, every alternative must +be an exact subset of the selected requirement's declared subject roles and +profiles. This is independent of `request.selectorInputs`. + +Rust supplies only that minimized map to `derive/3`. A relationship requirement +can retrieve a child record with a request-derived selector while comparing a +candidate reference obtained from an authenticated grant. The preparation +script never sees the candidate, and the derivation never sees the child's +lookup selector unless it explicitly declares it. + +## Referenced bundle artifacts + +All referenced paths are bundle-relative and captured in the immutable bundle +revision. Scripts end in `.rhai`. Parameter, fact, and reviewed-value schemas +are closed JSON Schema 2020-12 documents; fact and reviewed-value schemas close +every reachable object and bound every reachable string, array, and number. +Fixtures use the exact contract in [`FIXTURES.md`](FIXTURES.md). + +Codelists under `codelists/` use one of two closed YAML shapes: + +```yaml +# Exact code set +id: urn:gov:example:codelist:status +version: '1' +codes: [active, inactive] + +# Exact source-to-output mapping +id: urn:gov:example:codelist:region-map +version: '1' +entries: {SOURCE-A: REGION-NORTH, SOURCE-B: REGION-SOUTH} +allowed_outputs: [REGION-NORTH, REGION-SOUTH] +``` + +Each document has 1 through 4,096 unique bounded codes. A mapping output must +appear in `allowed_outputs`. Referencing configuration repeats the exact +artifact version and startup rejects a mismatch. Retired public keys live only +under `public-keys/` as public JWK JSON files; active private key material is a +secret and never a bundle artifact. + +## Runtime configuration + +`runtime.yaml` contains only process-local bindings: + +```yaml +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: /run/secrets/registry-evidence +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: + government-internal-pki: + caBundleFile: /etc/registry-evidence/ca/government-internal.pem +``` + +| Key | Required | Meaning and Version 1 bounds | +|---|---|---| +| `version` | yes | Literal integer `1`. | +| `bundleDirectory` | yes | Absolute path to the single governed bundle directory. No alternate, overlay, or fallback bundle exists. | +| `listener.bindHost` | yes | Numeric loopback, RFC 1918 private IPv4, or RFC 4193 unique-local IPv6 address, 2 through 64 bytes. Hostnames, public, unspecified, and multicast addresses are rejected. Production TLS terminates at the operator-controlled upstream. | +| `listener.port` | yes | TCP port 1 through 65535. | +| `listener.tlsTermination` | yes | Literal `operator-controlled-upstream`. | +| `listener.trustProxyIdentityHeaders` | yes | Literal `false`; proxy headers never supply authenticated identity or authority. | +| `listener.maximumRequestBytes` | yes | 1,024 through 1,048,576 bytes. | +| `listener.maximumConcurrentRequests` | yes | 1 through 4,096. | +| `listener.requestTimeoutMilliseconds` | yes | 1 through 30,000 milliseconds for admission, concurrency queueing, and request-body collection. Once protected evaluation starts, this timer does not cancel it; source and OIDC boundaries have their own bounds, and the runtime preserves fail-closed audit and release ordering. | +| `listener.shutdownGraceMilliseconds` | yes | 1 through 120,000 milliseconds. | +| `secretProviders.file.root` | yes | Absolute root for logical `secret:file/...` references. Only regular, non-symlink, owner-only files below this root are accepted. | +| `auditStorage.path` | yes | Absolute keyed-JSONL audit path on operator-owned durable storage. | +| `auditStorage.maximumFileBytes` | yes | 1,048,576 through 1,099,511,627,776 bytes. Reaching the closed bound fails audit writes and therefore fails closed. | +| `outboundTls.systemRoots` | yes | Literal `true`. | +| `outboundTls.trustProfiles` | yes | Closed map of at most 64 logical profile ids. It may be empty when no source names a private trust profile. | +| `outboundTls.trustProfiles..caBundleFile` | for each profile | Absolute path to one bounded PEM CA file. Profile names must exactly match bundle `tlsTrustProfile` references. | + +`bundleDirectory`, secret roots, audit destinations, and CA files must be +absolute paths. The runtime rejects symlinks, insecure ownership/modes, missing +required logical bindings, mutable files, and files outside the configured +roots according to the operator contract. + +A bundle source may name one `tlsTrustProfile`. The corresponding bounded PEM +file is loaded and validated at startup. Hostname verification and source-origin +checks remain mandatory. There is no `insecure`, `skipVerification`, or +`trustAll` setting. Changing a trust file requires restart and changes the +runtime digest. + +Version 1 has no application-level HTTP proxy and ignores `HTTP_PROXY`, +`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY`. Deployments needing mediated egress +use network routing or a local sidecar while preserving end-to-end source TLS +verification. This avoids ambient environment state silently redirecting +credentials to another authority. + +## Maintenance rules + +- Keep scripts small and provider-shaped. Extraction, not Rust, validates + provider count/collection consistency and maps it to the closed lookup union. +- Reuse one script across sources when the selector role and provider shape can + be parameterized without adding a string expression language. +- Do not add a declarative uniqueness or response-mapping DSL. Reviewed Rhai is + the escape hatch for API differences. +- Prefer literal map/array traversal. Dots in provider keys are literal. If a + deployment must parameterize a nested path, pass a bounded array of literal + segments and implement a bounded same-file helper. +- Keep one governed bundle per evidence policy revision and one runtime file per + environment. Never use environment variables or command arguments to + override governed fields. +- Run every fixture before accepting either input and again before deploying a + changed bundle, runtime file, script, schema, codelist, CA file, or secret + binding. + +## Authoring and promotion workflow + +Treat a deployment project like reviewed source code. Start with a copy of the +closest complete project, keep governed semantics under `bundle/`, and keep +environment paths in a separate `runtime.yaml`. While authoring, use only +synthetic responses and selectors. Add the smallest provider-shaped +`prepare/2`, `extract/2`, and requirement `derive/3` scripts, then add exact +positive, legitimate-false, boundary, unresolved, malformed-provider, +transport-failure, and privacy-canary cases. + +Run `evidence check` and every referenced `evidence evaluate` command before +requesting review. Review the complete bundle as one disclosure surface, not +scripts independently. Promote the same reviewed bundle bytes through staging +and production. Each environment may supply its own runtime file, secret +files, private CA, and signing key, but may not override governed fields. In +staging, verify OIDC claim shapes, source credentials, private trust, readiness, +one approved synthetic positive, one legitimate negative or unresolved case, +safe public failures, audit durability, and JWS verification before production +exposure. A provider API or governance change produces a newly reviewed bundle +revision and reruns the fixture matrix. + +After those checks, publish the static token-acquisition, legal context, +endpoint-trust, and verifier guidance for each approved consumer class using +the workflow in +[`OPERATOR-CONTRACT.md`](../../../OPERATOR-CONTRACT.md#discovery-of-available-evidence). +The consumer then calls authenticated `GET /v1/evidence-definitions` and uses +one returned complete requirement, purpose, concept, role, selector, and value +origin shape. The endpoint does not publish the whole bundle, source internals, +authority tags, secrets, selector values, or unrelated definitions. A change +to an offered contract changes the returned `configurationRevision` and needs +a coordinated rollout; clients do not infer alternatives from runtime errors. diff --git a/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md new file mode 100644 index 000000000..ae5154e2a --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md @@ -0,0 +1,198 @@ +# Evidence Version 1 project fixture contract + +Status: Implemented Version 1 executable fixture contract + +Project fixtures are sanitized inputs to the production bundle loader, script +runtime, request materializer, source projection, output gate, evidence +construction, signer, verifier, and privacy checks. Evaluation does not start +the HTTP server, authenticate a JWT, write audit, resolve source credentials, +or contact a provider. Those boundaries are covered by package and HTTP-path +tests. Fixtures are not illustrative prose. Unknown keys, unknown mutation +names, and an expectation the harness cannot execute fail fixture evaluation. + +Focused adapter directories may keep separate JSON input/output files. Complete +deployment projects use this YAML format so an adopter can review one case +matrix beside the requirement. + +## File shape + +```yaml +fixture: registry.evidence.reference.example/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: {} + derivationSelectorInputs: {} + expectedRequestParts: + query: [] + body: null + expectedTransport: + path: /v1/search + fixedHeaders: + - {name: Accept, value: application/json} +cases: [] +privacyExpectation: + evidenceContains: [] + evidenceExcludes: [] + diagnosticsExclude: [] +``` + +| Key | Required | Meaning | +|---|---|---| +| `fixture` | yes | Unique fixture contract identifier ending in `/v1`. | +| `synthetic_only` | yes | Must be literal `true`. Live values are forbidden. | +| `common` | yes | Deterministic inputs inherited by every case. | +| `cases` | yes | Non-empty ordered array with unique case ids. | +| `privacyExpectation` | yes | Tokens/canaries that must be present or absent at public and diagnostic boundaries. | + +`observed_at` is required and may be overridden by a case. Rust derives +`legal_local_date` and `legal_local_time` from that instant and the requirement's +configured IANA timezone exactly as it does in production. A fixture cannot +inject those derived values. Boundary cases choose an observation instant that +produces the required local date or time. + +`selectors` contains the complete authorized role map for the requirement. +`derivationSelectorInputs`, when present, states the exact minimized role map +the harness expects Rust to supply to `derive/3`. It must equal the bundle's +closed derivation selector-input declaration. Omit it when derivation receives +an empty map. + +`purpose` names which of the requirement's declared purposes a case exercises, +and may be stated in `common`, in a case, or in both. A requirement declaring +exactly one purpose may omit it. A requirement declaring more than one must +state it, so offline evaluation reaches every authorized purpose and none is +silently skipped. An omitted declaration is a fixture contract failure, not an +authorization denial. A declared purpose the requirement does not list is +denied exactly as the served path denies it. + +`common.expectedRequestParts` is compared structurally after ABI +normalization. Query-pair order is significant; JSON object order and source +whitespace are not. The harness then passes those parts and the resolved +authorized selectors through the production request materializer and compares +the path, encoded query, and JSON body. There is no case-level +`expectedRequestParts` override. + +`common.expectedTransport` contains the exact fixed path or expanded path and +the exact configured non-secret headers. Authentication and Rust-owned framing +headers are checked by dedicated redacted transport assertions, never copied +into fixture YAML. A case-level `expected.expectedTransport` may add an exact +encoded query and normalized body for an encoding-focused case. + +## Case input vocabulary + +Every case has a required `id` and exactly one of these seven tagged forms: + +- `response`: raw provider JSON returned by the local mock. Rust applies the + configured projection before extraction. The fixture never pre-projects or + synthesizes an envelope. +- `sourceFailure`: one closed mock failure, currently `timeout`, + `connection-refused`, `invalid-media-type`, `oversized`, or `malformed-json`. +- `bundleMutation`: one named startup mutation below. +- `requestMutation`: one named authorization/request mutation below. +- `derivationMutation`: one named script-output mutation below. +- `derivationParameterMutation`: one closed mutation of a disposable copy of + the requirement's derivation parameters before startup validation. +- `selectorOverrides`: one closed selector-input replacement that exercises + preparation rejection or exact transport materialization. + +The optional inputs shared by applicable forms are: + +- `observed_at`; +- `purpose`, which selects one of the requirement's declared purposes and + overrides any `common.purpose`. + +The closed mutation names used by these projects are: + +| Field and name | Exact harness action | +|---|---| +| `bundleMutation: duplicate-disclosure-family` | Give two enabled requirements the same unsafe disclosure family and require startup rejection. | +| `requestMutation: swap-subject-roles` | Exchange the child and candidate-parent role assignments without changing their profiles or origins. | +| `requestMutation: supply-grant-derived-candidate` | Add caller material for a role whose configured origin is the authenticated grant. | +| `derivationMutation: return-raw-reference` | Replace the disposable fixture derivation with one that returns the synthetic raw reference as a public value. | + +Mutation cases never alter the reviewed project files on disk. + +## Expected vocabulary + +Each case has a required `expected` object. Unknown keys and keys irrelevant to +the selected tagged form are rejected. Success, unresolved, and failure cases +must state their exact lookup, derivation, signing, and public outcome where +those stages apply. Omission is not treated as a wildcard. + +| Key | Value | Assertion | +|---|---|---| +| `lookup` | `match`, `no_match`, or `ambiguous` | Exact closed extraction outcome. | +| `facts` | JSON object | Exact complete fact object after fact-schema validation, never a partial match. | +| `value` | supported scalar | Exact value of the requirement's only concept. Use only for a one-concept requirement. | +| `entityReferenceCount` | integer | Exact number of audience-scoped entity references in the public value. | +| `rawReferencesDisclosed` | boolean | Whether any configured raw reference appears in evidence; these projects require `false`. | +| `signed` | boolean | Whether a flattened JWS success is returned. A valid `false` concept still requires `true`. | +| `publicProblem` | problem code | Exact safe public failure code. | +| `error` | adapter signal | Exact value-free internal fixture signal: `adapter_input_error`, `source_protocol_error`, or `derivation_input_error`. | +| `derivationRuns` | boolean | Whether `derive/3` is invoked. | +| `bundle` | `accepted` or `rejected` | Startup bundle result. | +| `outputGate` | `accepted` or `rejected` | Derived-value gate result. | +| `rejectedBefore` | `credential`, `source`, `derivation`, or `signing` | Latest boundary that must not be crossed. | +| `sourceRequestCount` | integer | Exact number of evidence-data requests. Version 1 permits only `0` or `1`. | +| `expectedTransport` | object | Exact expanded path, encoded query string, and normalized body bytes for a transport-focused case. | + +`expectedTransport` is accepted only for the `selectorOverrides` form. +Successful `response` cases require `lookup: match`, `derivationRuns: true`, +and `signed: true`. The harness creates a fresh in-memory Ed25519 key for the +evaluation, signs the constructed Evidence, and verifies the JWS and exact +payload policy. The private key is never read from deployment secrets, written +to disk, or included in output. Unresolved and failing cases require +`signed: false` and the exact `derivationRuns` value. + +Every `facts` expectation is exact. If a test cares about only two of four +facts, it must still list all four. This prevents fixtures from silently +accepting a new or leaked fact. + +## Error classes and public problems + +Fixtures distinguish unresolved evidence from a broken dependency or bundle +contract. Do not choose a public code based only on the stage where a value was +noticed. + +| Internal outcome or signal | Public result | Fixture use | +|---|---|---| +| `no_match` | `evidence_not_available`, HTTP 422 | Authoritative lookup found no unique record. | +| `ambiguous` | `evidence_not_available`, HTTP 422 | Authoritative lookup found multiple records; no candidate is selected. | +| Host-private `required_fact_missing` | `evidence_not_available`, HTTP 422 | A uniquely matched record legitimately lacks a requirement fact that the derivation marks required. | +| `adapter_input_error` | `service_unavailable`, HTTP 503 | Trusted preparation or its closed inputs violate the adapter contract. Credential acquisition and source access must not occur. | +| `source_protocol_error` | `dependency_unavailable`, HTTP 503 | The projected provider response violates its protocol, type, count, completeness, or fact-shape contract. | +| `derivation_input_error` | `evidence_not_available`, HTTP 422 | Matched facts, returned-subject binding, governed namespace/contract, or derivation parameters are inconsistent. A returned-child mismatch is this class. It collapses publicly with the unresolved classes so a caller cannot learn that a record exists, and it is never an authoritative no-match or a signed `false`. | +| Source transport failure | `dependency_unavailable`, HTTP 503 | Credential, concurrency, timeout, redirect, status, media type, size, JSON, projection, or source transport failure stopped the lookup. | +| Audit, signing, output-gate, or other script failure | `service_unavailable`, HTTP 503 | Evidence failed closed within the Evidence service or one of its required release dependencies. A script fault raised while derivation is running reports the internal `derivation_input_error` category and stays in this public class. | + +Each 503 class is pinned by the fixture's exact `publicProblem` expectation. +Both 503 codes share the safe title and disclose no provider, selector, fact, +script, or comparison detail. `no_match`, `ambiguous`, the host-private +required-value outcome, and derivation-input inconsistency over a uniquely +found record all collapse into the same 422 public shape, so a fixture can +never make the status code an existence oracle. A script must not throw +`source_protocol_error` merely to represent an ordinary missing optional fact, +and it must not convert malformed or incomplete provider data into `no_match`. + +## Harness-wide assertions + +The harness applies these assertions to every case whether or not they appear +under `expected`: + +- preparation is after authorization and durable access audit, and before + credential acquisition; +- no request-parts failure acquires credentials or reaches the source; +- zero or multiple provider results never run derivation or sign evidence; +- no case performs more than one evidence-data request, follows pagination, + redirects, retries, or response-provided URLs; +- an error, failed audit, failed output gate, or failed signing never returns an + unsigned success; +- expected facts and values are compared with redacted failure messages; +- selector values, source facts, supported values, secrets, and privacy canaries + never appear in test names, snapshots, panic output, logs, audit, metrics, + traces, HTTP problems, or diff diagnostics; and +- the privacy expectation is checked against the verified JWS payload and every + captured diagnostic sink. + +The harness may report the case id, stage, expected type, actual type, and a +value-free mismatch code. It must not print the mismatching protected value. diff --git a/products/evidence/reference/request-adapter/deployment-projects/README.md b/products/evidence/reference/request-adapter/deployment-projects/README.md new file mode 100644 index 000000000..f800f359c --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/README.md @@ -0,0 +1,88 @@ +# Deployment-shaped Evidence projects + +Status: Implemented Version 1 deployment references + +These projects show the complete immutable bundle an operator would deploy, +not only a connector fragment. They include runtime security settings, source +authentication references, authority grants, selector profiles, requirement +definitions, scripts, schemas, and sanitized contract cases. + +The projects use the Version 1 script contract: + +```text +prepare(source_required_selectors, adapter_parameters) -> RequestParts +extract(source_response, adapter_parameters) -> LookupResult +derive(facts, authorized_requirement_selectors, evaluation_context) + -> array +``` + +They are conformance inputs for the implemented product contract and runtime, +not pseudoconfiguration that silently falls back to fixed selector placements. +The Evidence package tests load both runtime files and complete bundle +configurations, capture every referenced artifact, and compile every script +through the production ABI. Offline evaluation executes every fixture case +through production request materialization, lookup, derivation, output +validation, Evidence construction, ephemeral signing, JWS verification, +privacy, and failure contracts. It intentionally does not authenticate a JWT, +resolve deployment credentials, write audit, start HTTP, or contact the source; +package and HTTP-path tests cover those runtime boundaries. An adopter must run +the same fixtures with `evidence evaluate` before deployment. + +## Projects + +- [`dhis2-adult-status/`](dhis2-adult-status/) resolves one tracked entity by + an exact reference and derives adult status from the configured date-of-birth + attribute. +- [`opencrvs-family-evidence/`](opencrvs-family-evidence/) resolves one + registered birth event and supports adult status, exact registered-parent + confirmation, and bounded registered-parent identification. + +Every hostname, issuer, identifier, and fixture value is synthetic. `.example` +hosts must be replaced during deployment. Secret files are referenced only by +logical names and must be independent, owner-only files beneath the configured +secret root. No credential value belongs in a bundle, command argument, test +fixture, snapshot, or diagnostic. + +Follow the [authoring and promotion workflow](CONFIG.md#authoring-and-promotion-workflow) +when adapting a project. Keep one reviewed governed bundle unchanged across +environments and bind each environment through its own runtime file and secret +mounts. + +## Security boundary + +Rust still owns authentication, authorization, durable access audit, fixed +transport authority, credentials, one evidence-data request, limits, output +validation, audience-scoped entity references, signing, and disclosure audit. +Scripts are reviewed and trusted but remain deterministic and unable to +perform I/O. + +Both projects declare `responseFormats: [signed-jws]` at the bundle level and +on every grant, so they release only signed flattened JWS. That is the +production-shaped default: unsigned output is a development convenience that a +deployment must enable deliberately in both places. + +Relationship derivation may compare one authorized candidate with a complete +relationship set from one uniquely resolved authoritative record. It may not +retrieve a broad candidate set, score candidates, choose a best match, or turn +an unresolved or partial result into `false`. + +The family project's `reference_namespace` and +`relationship_set_contract` facts come from closed source adapter parameters. +Comparing them with closed requirement parameters proves startup agreement +between two reviewed bundle sections. It does not prove that the provider +returned either value. Governance must separately establish that returned +references belong to the declared namespace and that the configured fields are +complete for the declared relationship contract. If a provider's namespace or +contract varies by record, extraction must derive and validate that value from +projected provider data instead of copying a bundle constant. + +Before copying either project, apply the +[provider prerequisites](CONFIG.md#provider-prerequisites). A source that cannot +distinguish zero, one, and multiple matches in one bounded request is not a +Version 1 integration even if its JSON can otherwise be mapped by Rhai. + +[`CONFIG.md`](CONFIG.md) defines the Version 1 configuration vocabulary, +ownership split, enumerations, and optionality. [`FIXTURES.md`](FIXTURES.md) +defines the executable fixture vocabulary and exact comparison rules. Those +two references are normative for these projects; the project READMEs explain +only deployment-specific choices. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md new file mode 100644 index 000000000..0d4f8c5a0 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md @@ -0,0 +1,59 @@ +# DHIS2 adult-status deployment project + +This is a complete, simple target bundle for a deployment that derives one +adult-status boolean from DHIS2 Tracker. + +The reviewed governance bundle is under `bundle/`. Process-local paths, +listener settings, and the private-CA file binding are in `runtime.yaml`. +Deployments review and mount both files read-only, but staging and production +may use different runtime files without changing evidence semantics. + +Before deployment, the operator changes only: + +- `.example` OIDC and DHIS2 hosts; +- the DHIS2 program, organisation unit, and date-of-birth attribute UIDs; +- issuer, provider, trust-domain, framework, evidence-type, and concept URIs; +- authority tags and purposes; +- the referenced secret files; and +- the runtime paths, listener binding, and private-CA bundle file. + +The source performs one page-one lookup for exactly one tracked-entity +reference with `pageSize=2`. A second page is never followed. That two-result +ceiling is governed adapter policy declared by this reviewed bundle and +rendered by its preparation script so one bounded response separates a unique +match from ambiguity. It is not a Rust domain rule and not a DHIS2 property. +Because the Tracker response can contain attributes beyond the one consumed by +the adapter, the source honestly declares `record-transformed` posture. +Extraction carries the returned `trackedEntity` only as a transient fact, and +the derivation requires its exact equality with the authorized subject +selector before evaluating the date of birth. A returned-record mismatch fails +closed as the internal `derivation_input_error` category and collapses publicly +into the same `evidence_not_available` problem as an unresolved lookup, so the +caller cannot learn that a record was found. It is never signed as either adult +or not adult. The raw tracked entity reference is never included in evidence. +`pageSize`, `page`, and `totalPages` are strings because they become lexical URL +query values. In contrast, the OpenCRVS project's JSON body keeps numeric and +boolean constants typed. + +Required secret files beneath `/run/secrets/registry-evidence`, each owned by +the service identity with mode `0600`, are: + +```text +signing-ed25519-private-jwk +audit-hmac-key +subject-binding-hmac-key +dhis2-username +dhis2-password +``` + +The audit and subject-binding files must contain independently generated raw +key material of at least 32 bytes each; they are not base64-decoded. The +signing file contains one private Ed25519 JWK. No secret value is stored in +this project. + +Author with synthetic fixtures first, then promote the same reviewed `bundle/` +bytes through staging and production. Bind environment-specific runtime paths, +credentials, private CA, and signing key in each environment. Staging must +verify the configured `at+jwt` header and claims, readiness, one approved +synthetic source lookup, audit durability, and JWS verification. See the +[authoring and promotion workflow](../CONFIG.md#authoring-and-promotion-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml index 55c4aa4ed..d4a1b75a6 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml @@ -37,6 +37,8 @@ signing: jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + selectorProfiles: tracked-entity-reference-v1: maximumAggregateBytes: 64 @@ -97,6 +99,7 @@ authorityProfiles: - requirement: urn:gov:example:requirement:adult-status:v1 purpose: benefit-eligibility audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: subject selectorProfile: tracked-entity-reference-v1 diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml index 6c4da5c84..185bc7014 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml @@ -90,7 +90,7 @@ cases: expected: lookup: match error: derivation_input_error - publicProblem: service_unavailable + publicProblem: evidence_not_available derivationRuns: true signed: false - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md new file mode 100644 index 000000000..8011a597b --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md @@ -0,0 +1,122 @@ +# OpenCRVS family-evidence deployment project + +Status: deployment-shaped for a country configuration with governed stable +parent references; not a generic OpenCRVS capability claim + +This complete target bundle uses one uniquely resolved registered birth event +for three separate minimum-disclosure requirements: + +- adult status from `dateOfEvent`; +- exact registered-parent confirmation against a separately authorized + candidate reference; and +- identification of the registered parents as one or two audience-scoped + entity references. + +The reviewed governance bundle is under `bundle/`. Process-local paths and +listener settings are in `runtime.yaml`. Deployments review and mount both +inputs read-only, but staging and production may use different runtime files +without changing evidence semantics. + +Both OpenCRVS sources reuse `birth-event-prepare.rhai`; the closed +`selectorRole` parameter selects the already validated `subject` or `child` +role. Extraction remains separate because the two sources validate and emit +different facts. + +The event lookup sends only the child tracking ID. Extraction retains the +returned tracking ID as a narrow fact, and each derivation requires it to equal +the independently authorized child selector before producing a value. The +candidate-parent reference comes from the authenticated grant and is supplied +only to the relationship derivation. Raw parent references enter transient extraction and +derivation but never production evidence, audit, logs, errors, metrics, traces, +or failure artifacts. Sanitized synthetic references are intentionally present +in local contract fixtures. + +## Deployment decisions + +Replace the separate `.example` OIDC, OpenCRVS authorization, and OpenCRVS +Event Search hosts and every `urn:gov:example:*` identifier. Confirm the exact +client capability or scope assigned by that deployment. In the family source, +replace these illustrative declaration field identifiers: + +```text +mother.personReference +father.personReference +``` + +They must name the target country configuration's complete set of stable, +opaque parent references from the same namespace used by the +`civil-person-reference-v1` candidate selector. If the OpenCRVS deployment +does not return such fields, this exact-reference configuration is not +deployable unchanged. + +The example's closed source contract also states that: + +- `urn:gov:example:opencrvs:registered-parent-set:v1` identifies the reviewed + country-specific relationship-set semantics; +- `urn:gov:example:opencrvs:person` is the shared candidate and source-reference + namespace; +- absence of either configured declaration field authoritatively means no + registered parent in that slot, rather than unreturned or unavailable data; + and +- at least one registered parent reference is required for this project. + +Those statements are trusted deployment governance, enforced through adapter +parameters, fact schema constants, and derivation checks. They are not inferred +from generic OpenCRVS behavior. + +A country may instead review a deterministic tuple comparison over returned +attributes, such as an authoritative identifier plus date of birth. That is a +different versioned derivation and selector profile. Fuzzy search, candidate +ranking, and silent fallback from missing identifiers to names are not part of +this example. + +The portable concepts say `registered parent`. Rename them to `legal parent` +only when the jurisdiction explicitly governs the configured record fields and +matching rule as proof of legal parentage. Registered parent, biological +parent, guardian, and current parental responsibility are not interchangeable. + +## Negative evidence + +The relationship derivation may return `false` only when all of these are +true: + +1. exactly one registered birth event was resolved; +2. its returned tracking ID exactly matches the authorized child selector; +3. the configured fields constitute the complete authoritative parent set; +4. every present reference is valid and references are unique; and +5. exact membership found no candidate match. + +No event, multiple events, missing declaration data, an empty parent set, a +wrong type, or a namespace mismatch stops without a signed negative. + +## OAuth and secrets + +This example retains OpenCRVS query-string client credential placement for +compatibility. It is a deployment residual because authorization-server, +proxy, or ingress URL logs may capture the token request. Prefer Basic-header +or form-body placement when the provider supports it. Locally, the complete +token URL, query, body, response, and debug output must be stripped or +redacted, redirects denied, and token responses bounded. + +Required secret files beneath `/run/secrets/registry-evidence`, each owned by +the service identity with mode `0600`, are: + +```text +signing-ed25519-private-jwk +audit-hmac-key +subject-binding-hmac-key +opencrvs-client-id +opencrvs-client-secret +``` + +The audit and subject-binding files must contain independently generated raw +key material of at least 32 bytes each; they are not base64-decoded. The +signing file contains one private Ed25519 JWK. No credential or live subject +identifier is stored in this project. + +Author with synthetic fixtures first, then promote the same reviewed `bundle/` +bytes through staging and production. Bind environment-specific runtime paths, +credentials, private CA, and signing key in each environment. Staging must +verify the configured `at+jwt` header and claims, OAuth bootstrap, readiness, +one approved synthetic source lookup, audit durability, and JWS verification. +See the [authoring and promotion workflow](../CONFIG.md#authoring-and-promotion-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index ebd7e7ffb..ff43259ac 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -37,6 +37,8 @@ signing: jwksPath: /.well-known/evidence/jwks.json maximumAssertionValiditySeconds: 86400 verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + selectorProfiles: opencrvs-tracking-id-v1: maximumAggregateBytes: 64 @@ -160,6 +162,7 @@ authorityProfiles: - requirement: urn:gov:example:requirement:adult-status-from-birth:v1 purpose: eligibility-assessment audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: subject selectorProfile: opencrvs-tracking-id-v1 @@ -167,6 +170,7 @@ authorityProfiles: - requirement: urn:gov:example:requirement:registered-parent-relationship:v1 purpose: family-relationship-verification audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: child selectorProfile: opencrvs-tracking-id-v1 @@ -179,6 +183,7 @@ authorityProfiles: - requirement: urn:gov:example:requirement:identify-registered-parents:v1 purpose: family-case-record audienceFrom: authenticated-requester + responseFormats: [signed-jws] subjects: - role: child selectorProfile: opencrvs-tracking-id-v1 From 60ae00a290b6b91446ce1c4b5b733cc3678dc5d7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:10:50 +0700 Subject: [PATCH 005/136] feat(mint): issue delegated tokens bound to one subject A registered client may now ask Mint for a token that is valid only for evidence about one named person. The delegation request rides inside the client's own signed assertion, in an `on_behalf_of` member, so the actor and the subject are covered by the client's signature. Mint validates both against the client's registration and mints them as claims. The containment is the resource server's: with the subject role's `valueOrigin` declared as `authenticated-context`, Evidence reads the selector from the token and refuses any request carrying selector values of its own. A client bug that puts the wrong person in the request body therefore cannot reach that person. Security review notes. This is authentication and authorization surface. - Delegation is opt-in per registration. A client with no `delegation` block cannot obtain a delegated token, and Mint refuses an actor outside the registered set. Every delegation failure collapses to `invalid_client`, like the existing failures, so the endpoint stays unusable as a probe. - The subject must carry the registration's subject fields exactly. A missing or extra field is refused rather than silently dropped. - `on_behalf_of` is Mint's own member, not RFC 8693 `act`: token exchange presents a subject's own credential, which a deployment without an IdP does not have. - Threat model. This defends against a buggy client, not a compromised one: a client holding its own signing key can ask for a token naming a different subject within the fields its registration permits. Closing that would mean resolving the subject from a server-side grant record. - Evidence confines an actor-bearing token to `kind: delegated` authority profiles but does not conversely require an actor to reach one. An undelegated token therefore matches such a grant and is stopped at selector resolution. Asserted in the tests and stated in both READMEs. `tests/delegated_subject_binding.rs` proves the property against Evidence's own entitlement match and selector resolution, over the same bundle the demonstration uses. `demo/` runs the whole thing against the real binaries with every request printed; its keys, certificates, and subjects are generated per run and synthetic. Signed-off-by: Jeremi Joslin --- crates/registry-mint/README.md | 80 ++- crates/registry-mint/demo/.gitignore | 3 + crates/registry-mint/demo/README.md | 235 +++++++ .../adapters/demo-source-prepare.rhai | 15 + .../evidence-bundle/adapters/demo-source.rhai | 19 + .../evidence-bundle/codelists/region-map.yaml | 6 + .../derivations/residence-region.rhai | 10 + .../demo/evidence-bundle/evidence.yaml | 101 +++ .../demo/evidence-bundle/fixtures/cases.yaml | 31 + .../schemas/adapter-parameters.schema.yaml | 7 + .../evidence-bundle/schemas/facts.schema.yaml | 5 + crates/registry-mint/demo/run.sh | 74 +++ .../registry-mint/demo/support/mock_source.py | 65 ++ .../registry-mint/demo/support/provision.py | 241 ++++++++ .../registry-mint/demo/support/tls_front.py | 62 ++ crates/registry-mint/demo/walkthrough.py | 299 +++++++++ crates/registry-mint/src/assertion.rs | 435 ++++++++++++- crates/registry-mint/src/clients.rs | 276 +++++++++ crates/registry-mint/src/config.rs | 43 +- crates/registry-mint/src/lib.rs | 8 + crates/registry-mint/src/server.rs | 126 +++- crates/registry-mint/src/token.rs | 310 +++++++++- .../tests/delegated_subject_binding.rs | 576 ++++++++++++++++++ 23 files changed, 2993 insertions(+), 34 deletions(-) create mode 100644 crates/registry-mint/demo/.gitignore create mode 100644 crates/registry-mint/demo/README.md create mode 100644 crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai create mode 100644 crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai create mode 100644 crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml create mode 100644 crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai create mode 100644 crates/registry-mint/demo/evidence-bundle/evidence.yaml create mode 100644 crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml create mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml create mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml create mode 100755 crates/registry-mint/demo/run.sh create mode 100644 crates/registry-mint/demo/support/mock_source.py create mode 100644 crates/registry-mint/demo/support/provision.py create mode 100644 crates/registry-mint/demo/support/tls_front.py create mode 100644 crates/registry-mint/demo/walkthrough.py create mode 100644 crates/registry-mint/tests/delegated_subject_binding.rs diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md index 5bebee4ae..776b56d8c 100644 --- a/crates/registry-mint/README.md +++ b/crates/registry-mint/README.md @@ -92,6 +92,8 @@ accessTokens: evidenceAudience: evidence_audience grantId: evidence_grant_id grantAuthority: evidence_authority + # Optional. Required only to issue delegated tokens; see below. + actor: evidence_actor clientAssertion: audience: https://mint.example.org/token maximumLifetimeSeconds: 300 @@ -133,6 +135,78 @@ Only public JWKs are accepted; a document carrying a private member is rejected. The load is all-or-nothing, so one malformed registration fails the whole load and a partially applied registry can never serve. +## Delegation: a token bound to one subject + +A caller can be issued a token that is valid only for evidence *about one named +person*. The point is containment rather than labelling: a client that loops +over the wrong list or reuses a request object cannot cross from one person to +another, because the subject is not a request parameter it controls. + +Three things have to line up. + +**The registration** says which agents this client may act as, and which +selector fields it may bind, at which claim paths: + +```yaml +clientId: appointment-scheduler +principal: service:appointment-scheduler +evidenceAudience: https://scheduler.example.org +requesterTags: [scheduling-agent] +delegation: + # Optional. Omitted means the client names its own actor. + actors: [urn:example:agent:appointment-scheduler] + subjectClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +keys: [...] +``` + +A client with no `delegation` block cannot obtain a delegated token at all. + +**The request** names the actor and the subject inside the client's own signed +assertion, in an `on_behalf_of` member: + +```json +{ + "iss": "appointment-scheduler", + "sub": "appointment-scheduler", + "aud": "https://mint.example.org/token", + "iat": 1785671511, "exp": 1785671631, "jti": "...", + "on_behalf_of": { + "actor": "urn:example:agent:appointment-scheduler", + "subject": {"given_name": "...", "family_name": "...", "birth_date": "..."} + } +} +``` + +Placing it inside the assertion is deliberate: the actor and the subject are +covered by the client's signature, so nothing between the client and Mint can +alter who the token is for. `on_behalf_of` is Mint's own member rather than RFC +8693 `act`, because token exchange presents a subject's own credential, which is +exactly what a deployment without an identity provider does not have. + +The subject must carry the registration's subject fields exactly: a missing +field or an extra one is refused, like every other delegation failure, as +`invalid_client`. + +**The resource server bundle** declares that subject role's `valueOrigin` as +`authenticated-context`, with `valueClaims` mirroring `subjectClaims` above. +That is what makes the property hold: Evidence reads the selector from the token +and rejects any request that carries selector values of its own. A request +naming a different person is refused for carrying values at all, not for +carrying the wrong ones. + +Two limits worth stating plainly. This defends against a *buggy* client, not a +*compromised* one: a client holding its own signing key can ask Mint for a token +naming a different subject, within the fields its registration permits. And +Evidence confines an actor-bearing token to `kind: delegated` authority profiles +but does not conversely require an actor to reach one, so an undelegated token +matches such a grant and is stopped when the subject cannot be resolved. + +[`demo/`](demo/) runs all of this end to end against the real binaries, with +every request printed before it is sent. + The registry is the one reloadable part of Mint. `SIGHUP` reloads it in place, keeping the previous registry if the new one does not load. Onboarding, offboarding, and caller key rotation therefore never restart the resource @@ -163,5 +237,7 @@ cargo test --locked -p registry-mint `tests/evidence_compatibility.rs` is the test that justifies the crate: it drives the real router over a real on-disk deployment and feeds the minted -token to Evidence's own authenticator. The dependency runs one way only. -Evidence does not depend on Mint. +token to Evidence's own authenticator. `tests/delegated_subject_binding.rs` +does the same for delegation, running Evidence's own entitlement match and +selector resolution over a token from the real Mint router. The dependency runs +one way only. Evidence does not depend on Mint. diff --git a/crates/registry-mint/demo/.gitignore b/crates/registry-mint/demo/.gitignore new file mode 100644 index 000000000..3b785b09b --- /dev/null +++ b/crates/registry-mint/demo/.gitignore @@ -0,0 +1,3 @@ +# The throwaway deployment `run.sh` provisions: keys, certificates, configs, +# audit log. Regenerated on every run, never committed. +.run/ diff --git a/crates/registry-mint/demo/README.md b/crates/registry-mint/demo/README.md new file mode 100644 index 000000000..792e7f6e4 --- /dev/null +++ b/crates/registry-mint/demo/README.md @@ -0,0 +1,235 @@ +# Delegated, subject-bound access: a runnable demonstration + +An agent needs to know which region one person lives in. It must not be able to +learn that about anybody else, even if the agent's own code is wrong. + +This directory runs that end to end against the real Mint and the real Evidence +binaries: a real client assertion, a real token, a real signed evidence +assertion, and a real refusal. + +```bash +crates/registry-mint/demo/run.sh +``` + +It needs `cargo`, `uv`, and `openssl`, binds four loopback ports (8080, 8090, +8092, 8443), and leaves its throwaway deployment in `demo/.run/` for inspection. +Everything it generates is disposable: fresh keys per run, synthetic people, a +private CA that exists for the lifetime of the demonstration. + +## What to read + +- [`walkthrough.py`](walkthrough.py) is the demonstration. Six steps, every + request printed before it is sent, with the reasoning next to it. +- [`evidence-bundle/evidence.yaml`](evidence-bundle/evidence.yaml) is the + policy. The security property is one line of it. +- Everything under [`support/`](support/) is deployment plumbing: key + generation, a TLS terminator, a stand-in registry source. None of it decides + anything. Read it only if you want to know why the demonstration needs a + certificate. + +## The mechanism, in one paragraph + +The client authenticates to Mint with a JWT it signs with its own key +(RFC 7523 `private_key_jwt`), so there is no shared secret and Mint holds only +public keys. The delegation request rides *inside* that signed JWT, in an +`on_behalf_of` member, which is what makes the actor and the subject +tamper-evident between the client and Mint. Mint checks both against the +client's registration and mints them as claims. The Evidence bundle then +declares that subject role's `valueOrigin` as `authenticated-context`, which +means Evidence reads the selector from those claims and **refuses any request +that carries selector values of its own**. That refusal is the containment: it +is not "you named the wrong person", it is "you do not get to name a person". + +## The four requests + +These are the requests the walkthrough sends, written as curl so they can be +read without running anything. `run.sh` stops its servers when it exits, so to +issue them by hand you would keep the deployment in `.run/` and start the four +processes yourself. + +### 1. The client assertion + +The client builds and signs this itself. Nothing between it and Mint can change +who the token is for. + +```json +{ + "iss": "scheduler", + "sub": "scheduler", + "aud": "https://localhost:8443/token", + "iat": 1785671511, + "exp": 1785671631, + "jti": "demo-1", + "on_behalf_of": { + "actor": "urn:example:demo:agent:appointment-scheduler", + "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} + } +} +``` + +`on_behalf_of` is Mint's own member, not RFC 8693 `act`. Token exchange presents +a subject's own credential, which is exactly what a deployment without an +identity provider does not have. + +### 2. The token request + +```bash +curl -sS --cacert crates/registry-mint/demo/.run/ca.pem https://localhost:8443/token \ + -d grant_type=client_credentials \ + -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ + --data-urlencode "client_assertion@/path/to/assertion.jwt" +``` + +Mint verifies the signature against the keys registered for `scheduler`, then +checks the delegation request against the same registration: + +```yaml +clientId: scheduler +principal: urn:example:demo:principal:scheduler +evidenceAudience: https://scheduler.demo.invalid +requesterTags: [demo-agent] +keys: [...] +delegation: + actors: [urn:example:demo:agent:appointment-scheduler] + subjectClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +``` + +That block is the whole authorization decision Mint makes: which agents this +client may act as, and which selector fields it may bind, at which claim paths. +Neither answer comes from the request. A client with no `delegation:` block +cannot obtain a delegated token at all. + +The resulting token: + +```json +{ + "iss": "https://localhost:8443", + "sub": "urn:example:demo:principal:scheduler", + "aud": "evidence.demo.invalid", + "client_id": "scheduler", + "evidence_tags": ["demo-agent"], + "evidence_audience": "https://scheduler.demo.invalid", + "evidence_actor": "urn:example:demo:agent:appointment-scheduler", + "identity": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, + "exp": 1785671811, "iat": 1785671511, "nbf": 1785671511, "jti": "01KZ..." +} +``` + +`evidence_actor` says who is acting. `identity.*` says who they are acting for. + +### 3. The evidence request + +```bash +curl -sS http://127.0.0.1:8080/v1/evidence \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "requirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1"}}] + }' +``` + +Note what is not in that body: the person. The bundle says where the subject +comes from instead. + +```yaml +authorityProfiles: + delegated-agent-v1: + kind: delegated + requesterTags: [demo-agent] + grants: + - requirement: urn:example:demo:requirement:residence-region:v1 + purpose: demo-routing + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-v1 + valueOrigin: authenticated-context # <- the security property + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +``` + +Evidence answers with a signed assertion whose payload carries a coarse region +and an opaque subject binding. The person's name and their residence code are in +neither the request nor the answer: + +```json +{ + "supportsRequirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "audience": "https://scheduler.demo.invalid", + "subjects": [{"role": "subject", "binding": "urn:evidence:subject:v1_lARwiBg..."}], + "supportedValues": [ + {"providesValueFor": "urn:example:demo:concept:residence-region", "value": "REGION-NORTH"} + ] +} +``` + +### 4. The same token, pointed at somebody else + +```bash +curl -sS http://127.0.0.1:8080/v1/evidence \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'Content-Type: application/json' \ + -d '{ + "requirement": "urn:example:demo:requirement:residence-region:v1", + "purpose": "demo-routing", + "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1", + "values": {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"}}}] + }' +``` + +```json +{"type": "https://registrystack.org/problems/evidence/invalid_selector", + "title": "Request is not valid", "status": 400, "code": "invalid_selector"} +``` + +There is no request this token can make about Kofi Mensah. The refusal is for +carrying selector values at all, not for carrying the wrong ones, so a client +bug that puts the wrong person in the body cannot reach that person. + +## What this does and does not defend against + +It defends against a **buggy** client. A client that loops over the wrong list, +reuses a request object, or confuses two sessions cannot cross from one person +to another, because the subject is not a request parameter it controls. + +It does not defend against a **compromised** client. A client holding its own +signing key can ask Mint for a token naming a different subject, within the +fields its registration permits. Closing that would mean Mint resolving the +subject from a server-side grant record rather than from the caller's request, +which is a larger change and is not what this builds. + +Two further limits worth stating plainly: + +- Evidence confines an *actor-bearing* token to `kind: delegated` authority + profiles, but it does not conversely require an actor to reach one. An + undelegated token therefore matches this grant and is stopped when the subject + cannot be resolved, rather than at the authority match. Nothing leaks either + way, but the two are not interchangeable: were this grant to gain a subject + role whose values come from the request, an undelegated token would reach it. +- The registry source still receives the person's identifying details. Data + minimization here is about what the *caller* learns, not about what the source + is asked. + +## Why the demonstration needs a certificate + +Evidence requires the token issuer and its key set to be HTTPS, with no +exception for loopback, and Mint expects TLS to be terminated upstream. Rather +than work around that, `run.sh` issues a throwaway CA and puts a small TLS +terminator in front of Mint, exactly where your ingress would sit. The CA is +trusted only by the demonstration's own Evidence process, through `SSL_CERT_FILE`. + +## The same property, as a test + +Steps 3 and 4 are also asserted in +[`tests/delegated_subject_binding.rs`](../tests/delegated_subject_binding.rs), +which loads this same bundle and runs Evidence's own authorization decision over +a token minted by the real Mint router. The demonstration and the test cannot +drift: they share `evidence-bundle/`, and the test's constants must match it. diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai new file mode 100644 index 000000000..691d7c164 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai @@ -0,0 +1,15 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{ + given_name: subject["values"]["given_name"], + family_name: subject["values"]["family_name"], + birth_date: subject["values"]["birth_date"] + }, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai new file mode 100644 index 000000000..e19794042 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai @@ -0,0 +1,19 @@ +fn extract(source_response, parameters) { + if !source_response.contains("total") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 { + throw("source_protocol_error"); + } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } + if !source_response.contains("official_residence_code") { + return #{outcome: "match", facts: #{}}; + } + if type_of(source_response["official_residence_code"]) != "string" { + throw("source_protocol_error"); + } + #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} +} diff --git a/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml b/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml new file mode 100644 index 000000000..e7bfbbf26 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml @@ -0,0 +1,6 @@ +id: urn:example:demo:codelist:region-map +version: '2026-01' +entries: + R-101: REGION-NORTH + R-201: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai b/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai new file mode 100644 index 000000000..ef3749040 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai @@ -0,0 +1,10 @@ +fn derive(facts, selectors, evaluation_context) { + let mapped = codelist_lookup( + evaluation_context.codelists["region-map"], + required(facts.official_residence_code, "required_fact_missing") + ); + [#{ + concept_id: "urn:example:demo:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + }] +} diff --git a/crates/registry-mint/demo/evidence-bundle/evidence.yaml b/crates/registry-mint/demo/evidence-bundle/evidence.yaml new file mode 100644 index 000000000..23504c1a4 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/evidence.yaml @@ -0,0 +1,101 @@ +# Demonstration Evidence bundle for delegated, subject-bound access. +# +# The one line that matters is `valueOrigin: authenticated-context` under +# `delegated-agent-v1`. It moves the subject out of the request body and into +# the access token: Evidence reads the selector from the claims listed in +# `valueClaims`, and refuses any request that carries selector values of its +# own. A token minted for one person therefore cannot be pointed at another. +# +# Synthetic identifiers only. Nothing here describes a real person or source. +version: 1 +service: {providerId: urn:example:demo:provider:evidence, trustDomain: urn:example:demo:trust-domain:delegation} +issuer: {id: urn:example:demo:issuer:authority} +authentication: + kind: oidc-access-token + # Mint's public origin. Evidence requires HTTPS for both the issuer and the + # key set, so the demonstration puts a TLS terminator in front of Mint exactly + # as a real deployment would. + issuer: https://localhost:8443 + audiences: [evidence.demo.invalid] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://localhost:8443/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority + # The claim Mint writes the delegated agent into. Its presence is what + # confines a token to `kind: delegated` authority profiles. + actorClaim: evidence_actor +audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} +subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} +rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} +signing: {format: flattened-jws-json, algorithm: EdDSA, activeKeyId: demo-evidence-key, activeKeyRef: secret:file/signing-key, retiredPublicJwkFiles: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} +selectorProfiles: + demographics-v1: + maximumAggregateBytes: 420 + fields: + given_name: {type: string, minimumBytes: 1, maximumBytes: 200} + family_name: {type: string, minimumBytes: 1, maximumBytes: 200} + birth_date: {type: date} +sources: + demo-source: + transport: http-json + # The stand-in registry source the demonstration runs on loopback. Evidence + # permits plain HTTP only for a numeric loopback host. + baseUrl: http://127.0.0.1:8092 + posture: field-projected + authentication: {kind: static-bearer, tokenRef: secret:file/source-token} + request: + method: POST + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] + selectorInputs: + - role: subject + alternatives: [{profile: demographics-v1, fields: [given_name, family_name, birth_date]}] + prepareScript: adapters/demo-source-prepare.rhai + adapterParameters: {requestedFields: [official_residence_code], resultLimit: 2} + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} + projection: [/total, /official_residence_code] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/demo-source.rhai + factSchema: schemas/facts.schema.yaml +authorityProfiles: + delegated-agent-v1: + kind: delegated + requesterTags: [demo-agent] + grants: + - requirement: urn:example:demo:requirement:residence-region:v1 + purpose: demo-routing + audienceFrom: authenticated-requester + subjects: + - role: subject + selectorProfile: demographics-v1 + valueOrigin: authenticated-context + valueClaims: + given_name: identity.given_name + family_name: identity.family_name + birth_date: identity.birth_date +requirements: + - id: urn:example:demo:requirement:residence-region:v1 + kind: information-requirement + source: demo-source + purposes: [demo-routing] + subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [demographics-v1]}] + referenceFrameworks: [urn:example:demo:framework:residence-region:v1] + evidenceType: urn:example:demo:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: {script: derivations/residence-region.rhai, parameters: {}} + concepts: + - id: urn:example:demo:concept:residence-region + form: controlled-code + required: true + constraints: {codelist: codelists/region-map.yaml, codelistVersion: '2026-01', maximumBytes: 32} + fixtures: fixtures/cases.yaml + disclosureGuard: {families: [urn:example:demo:disclosure-family:residence-region]} + existenceDisclosure: collapse-unresolved diff --git a/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml b/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml new file mode 100644 index 000000000..75c140ba3 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml @@ -0,0 +1,31 @@ +fixture: registry.mint.demo.delegation/v1 +synthetic_only: true +common: + observed_at: '2026-08-02T00:00:00Z' + selectors: + subject: + profile: demographics-v1 + values: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} + expectedRequestParts: + query: [] + body: + lookup: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} + fields: [official_residence_code] + limit: 2 + expectedTransport: + path: /v1/facts + fixedHeaders: [{name: Accept, value: application/json}] +cases: + - {id: positive, source: {total: 1, official_residence_code: R-101}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-unknown-code, source: {total: 1, official_residence_code: R-999}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: negative-overly-precise-output, injected_derivation: [{concept_id: urn:example:demo:concept:residence-region, value: R-101}], expected: output-gate-rejection} + - {id: boundary-other-coarse-region, source: {total: 1, official_residence_code: R-201}, expected_value: REGION-SOUTH, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence_not_available, derivation_runs: false, signed_success: false} + - {id: source-failure, source_failure: http-503, expected_public_problem: dependency_unavailable, signed_success: false} + - {id: anti-reconstruction, companion_bundle: geographic-overlap, expected: bundle-rejection} +privacy_expectation: + evidence_contains: [urn:example:demo:concept:residence-region, REGION-NORTH] + evidence_excludes: [official_residence_code, given_name, family_name, birth_date, R-101] + diagnostics_exclude: [Amara, Okafor, '1998-04-02', R-101, REGION-NORTH] diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..39fc57931 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [official_residence_code] + resultLimit: {const: 2} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml new file mode 100644 index 000000000..d3e45d7b6 --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [official_residence_code] +properties: + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/crates/registry-mint/demo/run.sh b/crates/registry-mint/demo/run.sh new file mode 100755 index 000000000..71de1e15b --- /dev/null +++ b/crates/registry-mint/demo/run.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Start a throwaway Mint and Evidence deployment on loopback, run the delegation +# walkthrough against them, then tear everything down. +# +# Nothing here is the demonstration. `walkthrough.py` is. +set -euo pipefail + +demo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +workspace="$(cd "${demo_dir}/../../.." && pwd)" +run_dir="${demo_dir}/.run" +log_dir="${run_dir}/logs" + +# The source's bearer token. Generated per run, exported to the two processes +# that need it, and never printed or passed as an argument. +DEMO_SOURCE_TOKEN="$(openssl rand -hex 24)" +export DEMO_SOURCE_TOKEN + +pids=() +cleanup() { + for pid in "${pids[@]:-}"; do + kill "${pid}" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_port() { + local port="$1" name="$2" + for _ in $(seq 1 100); do + if nc -z 127.0.0.1 "${port}" 2>/dev/null; then + return 0 + fi + sleep 0.1 + done + printf 'error: %s never listened on port %s\n' "${name}" "${port}" >&2 + printf 'see %s/%s.log\n' "${log_dir}" "${name}" >&2 + return 1 +} + +printf '== building mint and evidence\n' +cargo build --locked --manifest-path "${workspace}/Cargo.toml" \ + -p registry-mint -p registry-evidence --bins >/dev/null + +printf '== provisioning a throwaway deployment in %s\n' "${run_dir}" +uv run --quiet "${demo_dir}/support/provision.py" "${run_dir}" "${demo_dir}/evidence-bundle" \ + >/dev/null +mkdir -p "${log_dir}" + +printf '== starting the stand-in registry source, Mint, its TLS front, and Evidence\n' +uv run --quiet "${demo_dir}/support/mock_source.py" 8092 >"${log_dir}/source.log" 2>&1 & +pids+=("$!") + +"${workspace}/target/debug/mint" serve --config "${run_dir}/mint/mint.yaml" \ + >"${log_dir}/mint.log" 2>&1 & +pids+=("$!") +wait_for_port 8090 mint + +uv run --quiet "${demo_dir}/support/tls_front.py" 8443 8090 \ + "${run_dir}/tls.pem" "${run_dir}/tls.key" >"${log_dir}/tls.log" 2>&1 & +pids+=("$!") + +# Evidence fetches Mint's key set over HTTPS. SSL_CERT_FILE is how the demo's +# private CA becomes trusted for this process, and only this process. +SSL_CERT_FILE="${run_dir}/ca.pem" \ + "${workspace}/target/debug/evidence" --runtime "${run_dir}/evidence/runtime.yaml" serve \ + >"${log_dir}/evidence.log" 2>&1 & +pids+=("$!") + +wait_for_port 8092 source +wait_for_port 8443 tls +wait_for_port 8080 evidence + +printf '\n' +uv run --quiet "${demo_dir}/walkthrough.py" "${run_dir}" diff --git a/crates/registry-mint/demo/support/mock_source.py b/crates/registry-mint/demo/support/mock_source.py new file mode 100644 index 000000000..8dada5bd8 --- /dev/null +++ b/crates/registry-mint/demo/support/mock_source.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""A stand-in registry source for the delegation demonstration. + +Deployment plumbing, not part of the security story. Evidence has to call +*something* to answer a requirement; this is the smallest thing that answers. + +It serves one route, `POST /v1/facts`, and answers from a fixed synthetic table. +Everything it knows is invented. +""" + +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Synthetic people, synthetic residence codes. The bundle's codelist maps +# R-101 to REGION-NORTH and R-201 to REGION-SOUTH. +RECORDS = { + ("Amara", "Okafor", "1998-04-02"): "R-101", + ("Kofi", "Mensah", "1971-11-30"): "R-201", +} + +EXPECTED_BEARER = os.environ["DEMO_SOURCE_TOKEN"] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/v1/facts": + return self.reply(404, {"error": "not_found"}) + if self.headers.get("Authorization") != f"Bearer {EXPECTED_BEARER}": + return self.reply(401, {"error": "unauthorized"}) + + length = int(self.headers.get("Content-Length", "0")) + body = json.loads(self.rfile.read(length) or b"{}") + lookup = body.get("lookup", {}) + key = ( + lookup.get("given_name"), + lookup.get("family_name"), + lookup.get("birth_date"), + ) + + # Note what the source receives: the person's identifying details, and + # nothing about the requirement, the purpose, or the caller. + print(f"source <- lookup for {key[0]} {key[1]}", file=sys.stderr, flush=True) + + code = RECORDS.get(key) + if code is None: + return self.reply(200, {"total": 0}) + return self.reply(200, {"total": 1, "official_residence_code": code}) + + def reply(self, status, payload): + encoded = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format, *args): # noqa: A002 - the base class names it + pass # the one line printed in do_POST is the whole log we want + + +if __name__ == "__main__": + port = int(sys.argv[1]) + HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/crates/registry-mint/demo/support/provision.py b/crates/registry-mint/demo/support/provision.py new file mode 100644 index 000000000..23d42710c --- /dev/null +++ b/crates/registry-mint/demo/support/provision.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["cryptography>=42"] +# /// +"""Lay out a throwaway deployment of Mint and Evidence for the demonstration. + +Deployment plumbing, not part of the security story: keys, certificates, +configuration files, and file permissions. The walkthrough in `walkthrough.py` +is the part worth reading. + +Everything this writes is disposable and local. The keys are generated fresh on +every run and are worthless outside this directory. +""" + +import base64 +import datetime as dt +import json +import os +import secrets +import shutil +import stat +import sys +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.x509.oid import NameOID + +MINT_PORT = 8090 +TLS_PORT = 8443 +EVIDENCE_PORT = 8080 +SOURCE_PORT = 8092 + +MINT_ORIGIN = f"https://localhost:{TLS_PORT}" +AGENT = "urn:example:demo:agent:appointment-scheduler" + + +def b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def ed25519_jwk(kid: str) -> tuple[dict, dict]: + """Return (private JWK, public JWK) for a fresh Ed25519 key.""" + private = ed25519.Ed25519PrivateKey.generate() + x = b64( + private.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + ) + d = b64( + private.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + ) + public_jwk = {"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x} + return {**public_jwk, "d": d}, public_jwk + + +def write(path: Path, text: str, mode: int = 0o644) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(mode) + return path + + +def write_secret(path: Path, text: str) -> Path: + return write(path, text, 0o600) + + +def issue_tls_certificate(root: Path) -> None: + """A private CA and one `localhost` server certificate. + + Evidence insists the token issuer and its key set be HTTPS, with no + exception for loopback. That is the right default and the demonstration + respects it rather than working around it. + """ + now = dt.datetime.now(dt.timezone.utc) + ca_key = ed25519.Ed25519PrivateKey.generate() + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "registry-stack demo CA")]) + ca_certificate = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, None) + ) + + server_key = ed25519.Ed25519PrivateKey.generate() + server_certificate = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])) + .issuer_name(ca_name) + .public_key(server_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(minutes=5)) + .not_valid_after(now + dt.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + .sign(ca_key, None) + ) + + pem = serialization.Encoding.PEM + write(root / "ca.pem", ca_certificate.public_bytes(pem).decode()) + write(root / "tls.pem", server_certificate.public_bytes(pem).decode()) + write_secret( + root / "tls.key", + server_key.private_bytes( + pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode(), + ) + + +def provision_mint(root: Path) -> None: + mint = root / "mint" + signing_private, _ = ed25519_jwk("mint-key-1") + write_secret(mint / "secrets/signing.jwk", json.dumps(signing_private)) + + for client_id in ("scheduler", "service-desk"): + private, public = ed25519_jwk(f"{client_id}-key-1") + write_secret(root / f"client-keys/{client_id}.jwk", json.dumps(private)) + + # `scheduler` is the delegated caller. Its registration is the whole + # authorization decision Mint makes: which agents it may act as, and + # which selector fields it may bind, at which claim paths. + delegation = ( + "delegation:\n" + f" actors: [{AGENT}]\n" + " subjectClaims:\n" + " given_name: identity.given_name\n" + " family_name: identity.family_name\n" + " birth_date: identity.birth_date\n" + if client_id == "scheduler" + else "" + ) + write( + mint / f"clients/{client_id}.yaml", + f"clientId: {client_id}\n" + f"principal: urn:example:demo:principal:{client_id}\n" + f"evidenceAudience: https://{client_id}.demo.invalid\n" + "requesterTags: [demo-agent]\n" + f"keys: [{json.dumps(public)}]\n" + delegation, + ) + + write( + mint / "mint.yaml", + f"""version: 1 +issuer: {MINT_ORIGIN} +listener: {{address: 127.0.0.1, port: {MINT_PORT}}} +signing: + algorithm: EdDSA + activeKeyId: mint-key-1 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [evidence.demo.invalid] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: evidence_actor +clientAssertion: + audience: {MINT_ORIGIN}/token + algorithms: [EdDSA] +clients: + directory: clients +""", + ) + + +def provision_evidence(root: Path, bundle_source: Path) -> None: + evidence = root / "evidence" + + # Evidence refuses a bundle it could write to, so the copy is frozen. + bundle = evidence / "bundle" + shutil.copytree(bundle_source, bundle) + for path in sorted(bundle.rglob("*"), reverse=True): + path.chmod(0o555 if path.is_dir() else 0o444) + bundle.chmod(0o555) + + secret_root = evidence / "secrets" + secret_root.mkdir(parents=True, exist_ok=True) + secret_root.chmod(0o700) # Evidence refuses a group- or world-readable root + + signing_private, _ = ed25519_jwk("demo-evidence-key") + write_secret(secret_root / "signing-key", json.dumps(signing_private)) + write_secret(secret_root / "audit-hash-key", secrets.token_hex(32)) + write_secret(secret_root / "subject-binding-key", secrets.token_hex(32)) + write_secret(secret_root / "source-token", os.environ["DEMO_SOURCE_TOKEN"]) + + (evidence / "audit").mkdir(parents=True, exist_ok=True) + write( + evidence / "runtime.yaml", + f"""version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {EVIDENCE_PORT} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 5000 +secretProviders: + file: + root: {secret_root} +auditStorage: + path: {evidence / "audit/evidence.jsonl"} + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {{}} +""", + 0o444, # Evidence refuses a runtime file it could write to + ) + + +if __name__ == "__main__": + root = Path(sys.argv[1]).resolve() + bundle_source = Path(sys.argv[2]).resolve() + if root.exists(): + # Frozen directories need their write bit back before removal. + for path in sorted(root.rglob("*"), reverse=True): + path.chmod(path.stat().st_mode | stat.S_IWUSR) + shutil.rmtree(root) + root.mkdir(parents=True) + + issue_tls_certificate(root) + provision_mint(root) + provision_evidence(root, bundle_source) + print(f"provisioned {root}") diff --git a/crates/registry-mint/demo/support/tls_front.py b/crates/registry-mint/demo/support/tls_front.py new file mode 100644 index 000000000..22e3b275f --- /dev/null +++ b/crates/registry-mint/demo/support/tls_front.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""A TLS terminator in front of Mint, for the delegation demonstration. + +Deployment plumbing, not part of the security story. Mint speaks plain HTTP and +expects an operator-controlled TLS front, and Evidence refuses a non-HTTPS token +issuer, so the demonstration supplies one. In production this is your ingress. + +Forwards verbatim to Mint on loopback. It adds nothing and inspects nothing. +""" + +import http.client +import ssl +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self.forward("GET", None) + + def do_POST(self): + length = int(self.headers.get("Content-Length", "0")) + self.forward("POST", self.rfile.read(length)) + + def forward(self, method, body): + upstream = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=10) + headers = { + name: value + for name, value in self.headers.items() + if name.lower() not in ("host", "connection") + } + upstream.request(method, self.path, body=body, headers=headers) + response = upstream.getresponse() + payload = response.read() + + self.send_response(response.status) + for name, value in response.getheaders(): + if name.lower() not in ("transfer-encoding", "connection", "content-length"): + self.send_header(name, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + upstream.close() + + def log_message(self, format, *args): # noqa: A002 - the base class names it + pass + + +if __name__ == "__main__": + listen_port, UPSTREAM_PORT, certificate, key = ( + int(sys.argv[1]), + int(sys.argv[2]), + sys.argv[3], + sys.argv[4], + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certificate, key) + server = ThreadingHTTPServer(("127.0.0.1", listen_port), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + server.serve_forever() diff --git a/crates/registry-mint/demo/walkthrough.py b/crates/registry-mint/demo/walkthrough.py new file mode 100644 index 000000000..48cc09bf7 --- /dev/null +++ b/crates/registry-mint/demo/walkthrough.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["cryptography>=42", "requests>=2.31"] +# /// +"""Delegated, subject-bound access, end to end, in four requests. + +An agent needs to know which region one person lives in. It must not be able to +learn that about anyone else, even if the agent's own code is wrong. + + 1. The client signs a request for a token, naming the agent it is acting as + and the person it is acting for. + 2. Mint checks that request against the client's registration and issues a + token carrying both. + 3. The client asks Evidence for evidence, and does not name the person. + 4. The client tries to name a different person, and cannot. + +Every request below is printed before it is sent. Run it with: + + crates/registry-mint/demo/run.sh +""" + +import base64 +import json +import sys +from pathlib import Path + +import requests +from cryptography.hazmat.primitives.asymmetric import ed25519 + +MINT = "https://localhost:8443" +EVIDENCE = "http://127.0.0.1:8080" + +REQUIREMENT = "urn:example:demo:requirement:residence-region:v1" +PURPOSE = "demo-routing" +AGENT = "urn:example:demo:agent:appointment-scheduler" + +AMARA = {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} +KOFI = {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"} + + +# -------------------------------------------------------------------------- +# Step 1: the client assertion. +# +# The client authenticates to Mint with a JWT it signs with its own key +# (RFC 7523 `private_key_jwt`). There is no shared secret to leak, and Mint +# holds only public keys. +# +# The delegation request rides *inside* that JWT, in `on_behalf_of`. That +# placement is the point: the actor and the subject are covered by the client's +# signature, so nothing between the client and Mint can alter who the token is +# for. +# -------------------------------------------------------------------------- + + +def build_client_assertion(client_id, private_key, jti, on_behalf_of=None): + claims = { + "iss": client_id, + "sub": client_id, + "aud": f"{MINT}/token", + "iat": now(), + "exp": now() + 120, + "jti": jti, # Mint refuses a second assertion with the same jti + } + if on_behalf_of is not None: + claims["on_behalf_of"] = on_behalf_of + + announce("the client assertion the client is about to sign", claims) + return sign_jwt({"alg": "EdDSA", "typ": "JWT", "kid": private_key["kid"]}, claims, + private_key) + + +# -------------------------------------------------------------------------- +# Step 2: the token request. +# +# Mint verifies the signature against the keys registered for this client, then +# checks the delegation request against the same registration: is this an actor +# the client may act as, and are these exactly the selector fields it may bind? +# Neither answer comes from the request. +# -------------------------------------------------------------------------- + + +def request_token(assertion): + form = { + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + } + announce(f"POST {MINT}/token", {**form, "client_assertion": ""}) + return requests.post(f"{MINT}/token", data=form, verify=CA, timeout=10) + + +# -------------------------------------------------------------------------- +# Step 3: the evidence request. +# +# Note what is *not* in this body: the person. The bundle declares the subject's +# `valueOrigin` as `authenticated-context`, so Evidence reads the selector out +# of the token's claims and refuses to read it from the request. +# -------------------------------------------------------------------------- + + +def request_evidence(token, subject_values=None): + selector = {"profile": "demographics-v1"} + if subject_values is not None: + selector["values"] = subject_values + body = { + "requirement": REQUIREMENT, + "purpose": PURPOSE, + "subjects": [{"role": "subject", "selector": selector}], + } + announce(f"POST {EVIDENCE}/v1/evidence", body, header="Authorization: Bearer ") + return requests.post( + f"{EVIDENCE}/v1/evidence", + json=body, + headers={"Authorization": f"Bearer {token}"}, + timeout=30, + ) + + +def main(run_dir): + global CA + CA = str(run_dir / "ca.pem") + scheduler = load_jwk(run_dir / "client-keys/scheduler.jwk") + service_desk = load_jwk(run_dir / "client-keys/service-desk.jwk") + + heading("1. The client asks Mint for a token to act for one person") + assertion = build_client_assertion( + "scheduler", + scheduler, + "demo-1", + on_behalf_of={"actor": AGENT, "subject": AMARA}, + ) + response = request_token(assertion) + expect(response, 200) + token = response.json()["access_token"] + + heading("2. What Mint put in the token") + claims = decode_jwt_claims(token) + show(claims) + note( + "`evidence_actor` says who is acting. `identity.*` says who they are acting for.", + "Both were checked against the client's registration, not taken on trust.", + ) + + heading("3. The client asks Evidence for evidence, naming no one") + response = request_evidence(token) + expect(response, 200) + show(decode_evidence(response.json())) + note( + "Evidence resolved the subject from the token, called the source, and", + "returned a coarse region. The person's name and their residence code", + "are in neither the request nor the answer. The subject appears only as", + "an opaque binding that cannot be reversed into a name.", + ) + + heading("4. The same token, pointed at somebody else") + response = request_evidence(token, subject_values=KOFI) + expect(response, 400) + show(response.json()) + note( + "This is the containment. A bug in the client that puts the wrong person", + "in the request body does not reach that person: Evidence refuses the", + "request for carrying selector values at all, not for carrying the wrong", + "ones. There is no request this token can make about Kofi Mensah.", + ) + + heading("5. Mint refuses what it was not asked to allow") + for jti, description, client_id, key, on_behalf_of in ( + ( + "demo-wrong-actor", + "an actor this client may not act as", + "scheduler", + scheduler, + {"actor": "urn:example:demo:agent:someone-else", "subject": AMARA}, + ), + ( + "demo-undelegated", + "a client with no delegation in its registration", + "service-desk", + service_desk, + {"actor": AGENT, "subject": AMARA}, + ), + ( + "demo-extra-field", + "a subject carrying a field the registration does not bind", + "scheduler", + scheduler, + {"actor": AGENT, "subject": {**AMARA, "national_id": "synthetic-1"}}, + ), + ): + response = request_token( + build_client_assertion(client_id, key, jti, on_behalf_of=on_behalf_of) + ) + expect(response, 401) + print(f" refused: {description} -> {response.status_code} {response.json()}\n") + + heading("6. And an undelegated token cannot use the delegated grant") + response = request_token(build_client_assertion("service-desk", service_desk, "demo-plain")) + expect(response, 200) + plain_token = response.json()["access_token"] + note( + "This token is valid and carries the same requester tag. It simply has no", + "`evidence_actor` and no `identity.*`, so there is no subject to resolve.", + ) + response = request_evidence(plain_token) + expect(response, 400) + show(response.json()) + note( + "Worth being precise about the shape of this refusal. Evidence confines", + "an actor-bearing token to `kind: delegated` authority profiles, but it", + "does not require an actor to reach one. So this token matches the grant", + "and is stopped when the subject cannot be resolved, rather than at the", + "authority match. Nothing leaks either way.", + ) + + print("\nAll six steps behaved as described.") + + +# -------------------------------------------------------------------------- +# Below here is only formatting and JWT mechanics. Nothing decides anything. +# -------------------------------------------------------------------------- + + +def now(): + import time + + return int(time.time()) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def unb64url(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def load_jwk(path: Path) -> dict: + return json.loads(path.read_text()) + + +def sign_jwt(header: dict, claims: dict, private_jwk: dict) -> str: + key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"])) + signing_input = ".".join( + b64url(json.dumps(part, separators=(",", ":")).encode()) for part in (header, claims) + ) + return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" + + +def decode_jwt_claims(token: str) -> dict: + """Read the claims without verifying. Evidence verifies; this only shows.""" + return json.loads(unb64url(token.split(".")[1])) + + +def decode_evidence(assertion: dict) -> dict: + """Show the signed evidence assertion's payload rather than its base64. + + A verifier would check the signature over `protected` and `payload` against + Evidence's published key set. This only makes the answer legible. + """ + return { + "protected": json.loads(unb64url(assertion["protected"])), + "payload": json.loads(unb64url(assertion["payload"])), + "signature": assertion["signature"][:16] + "...", + } + + +def heading(text): + print(f"\n{'=' * 76}\n{text}\n{'=' * 76}") + + +def announce(what, payload, header=None): + print(f"\n {what}") + if header: + print(f" {header}") + for line in json.dumps(payload, indent=2).splitlines(): + print(f" {line}") + print() + + +def show(payload): + for line in json.dumps(payload, indent=2).splitlines(): + print(f" {line}") + + +def note(*lines): + print() + for line in lines: + print(f" -> {line}") + + +def expect(response, status): + if response.status_code != status: + print(f"\nunexpected {response.status_code}: {response.text}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main(Path(sys.argv[1]).resolve()) diff --git a/crates/registry-mint/src/assertion.rs b/crates/registry-mint/src/assertion.rs index c58db3671..6ed81be25 100644 --- a/crates/registry-mint/src/assertion.rs +++ b/crates/registry-mint/src/assertion.rs @@ -22,10 +22,11 @@ use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, Toke use serde_json::Value; use crate::{ - clients::{ClientRegistry, RegisteredClient}, + clients::{ClientRegistry, Delegation, RegisteredClient}, config::ClientAssertionConfig, error::TokenError, replay::{ReplayCache, ReplayError}, + ON_BEHALF_OF_CLAIM, }; /// Bounds chosen so a hostile caller cannot make Mint allocate before any @@ -35,6 +36,9 @@ const MAX_HEADER_BYTES: usize = 8 * 1024; const MAX_CLAIMS_BYTES: usize = 8 * 1024; const MAX_CLIENT_ID_BYTES: usize = 256; const MAX_JTI_BYTES: usize = 256; +/// Evidence rejects an actor longer than this, and a selector value longer than +/// this could not satisfy any selector profile. +const MAX_DELEGATION_VALUE_BYTES: usize = 512; /// Tolerance for clock difference between a caller and Mint. Applied to the /// assertion's own `exp` and `nbf`, not to the tokens Mint issues. @@ -50,6 +54,44 @@ struct AssertionPreflight { claims: Value, } +/// A delegation request that the registry permits, ready to be minted. +/// +/// Every value here came from the signed assertion and was then checked against +/// the client's registration: the actor against its permitted set, and the +/// subject against the exact selector fields it declared. Nothing unbounded or +/// undeclared survives into this type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedDelegation { + actor: String, + /// Selector field to its value, keyed exactly as the registration declared. + subject: BTreeMap, +} + +impl ResolvedDelegation { + /// Crate-internal because the type's meaning is that its contents already + /// passed [`build_delegation`]; nothing outside this crate may assert one. + pub(crate) fn new(actor: String, subject: BTreeMap) -> Self { + Self { actor, subject } + } + + #[must_use] + pub fn actor(&self) -> &str { + &self.actor + } + + #[must_use] + pub fn subject(&self) -> &BTreeMap { + &self.subject + } +} + +/// An authenticated client, and the delegation it authenticated for. +#[derive(Clone, Debug)] +pub struct AuthenticatedClient { + pub client: Arc, + pub delegation: Option, +} + /// Authenticates client assertions against a registry snapshot. /// /// One verifier is built per registered client at construction time, each bound @@ -129,12 +171,15 @@ impl ClientAuthenticator { /// Authenticate a client assertion and return the client it proves. /// /// The returned client is the registry entry, which is where all authority - /// is read from. Nothing from the assertion payload is carried forward. + /// is read from. The one thing carried forward from the assertion payload + /// is the delegation request, and only after the registry has confirmed + /// that this client may delegate, to that actor, over exactly those subject + /// fields. pub async fn authenticate( &self, assertion: &str, now: i64, - ) -> Result, TokenError> { + ) -> Result { let preflight = preflight(assertion)?; let client_id = asserted_client_id(&preflight.claims)?; @@ -196,6 +241,10 @@ impl ClientAuthenticator { return Err(TokenError::invalid_client("assertion is not yet issued")); } + // Resolved before the assertion is spent so a rejected delegation does + // not burn the caller's jti. + let delegation = resolve_delegation(client, &verified.claims.extra)?; + let jti = verified .claims .extra @@ -223,7 +272,99 @@ impl ClientAuthenticator { ReplayError::Poisoned => TokenError::server_error("replay cache poisoned"), })?; - Ok(Arc::clone(client)) + Ok(AuthenticatedClient { + client: Arc::clone(client), + delegation, + }) + } +} + +/// Reconcile the delegation the assertion asks for with the one the registry +/// permits. +/// +/// Both directions fail closed. A client with no registered delegation cannot +/// obtain an actor or a bound subject by asking for one, and a client that *is* +/// registered for delegation cannot obtain an ordinary unbounded token by +/// omitting the request. The second half is what stops a delegated caller +/// quietly widening its own reach. +fn resolve_delegation( + client: &RegisteredClient, + claims: &serde_json::Map, +) -> Result, TokenError> { + let requested = claims.get(ON_BEHALF_OF_CLAIM); + match (client.delegation(), requested) { + (None, None) => Ok(None), + (None, Some(_)) => Err(TokenError::invalid_client( + "client is not registered to act on behalf of a subject", + )), + (Some(_), None) => Err(TokenError::invalid_client( + "a delegated client must name the actor and subject it acts for", + )), + (Some(registered), Some(requested)) => Ok(Some(build_delegation(registered, requested)?)), + } +} + +fn build_delegation( + registered: &Delegation, + requested: &Value, +) -> Result { + let invalid = |reason: &'static str| TokenError::invalid_client(reason); + + let requested = requested + .as_object() + .ok_or_else(|| invalid("the delegation request is malformed"))?; + // An unrecognized member would be silently dropped, leaving the caller + // believing it constrained something it did not. + if requested.len() != 2 || !requested.contains_key("actor") { + return Err(invalid("the delegation request is malformed")); + } + + let actor = requested + .get("actor") + .and_then(Value::as_str) + .ok_or_else(|| invalid("the delegation request is malformed"))?; + if actor.trim().is_empty() || actor.len() > MAX_DELEGATION_VALUE_BYTES { + return Err(invalid("the delegated actor is not bounded")); + } + if !registered.permits_actor(actor) { + return Err(invalid("the client may not act as this actor")); + } + + let subject = requested + .get("subject") + .and_then(Value::as_object) + .ok_or_else(|| invalid("the delegation request is malformed"))?; + // Exactly the declared fields: a missing one would leave the resource + // server unable to resolve the subject, and an extra one would be minted + // nowhere while looking to the caller as though it had been honoured. + if subject.len() != registered.subject_claims.len() { + return Err(invalid( + "the delegated subject does not match its registration", + )); + } + let mut resolved = BTreeMap::new(); + for field in registered.subject_claims.keys() { + let value = subject + .get(field) + .ok_or_else(|| invalid("the delegated subject does not match its registration"))?; + resolved.insert(field.clone(), bounded_selector_value(value)?); + } + + Ok(ResolvedDelegation::new(actor.to_owned(), resolved)) +} + +/// The value shapes a resource server can read back out as a selector value. +fn bounded_selector_value(value: &Value) -> Result { + match value { + Value::String(text) if !text.is_empty() && text.len() <= MAX_DELEGATION_VALUE_BYTES => { + Ok(value.clone()) + } + Value::Bool(_) => Ok(value.clone()), + // Only integers survive a JSON round trip into a selector value. + Value::Number(number) if number.is_i64() => Ok(value.clone()), + _ => Err(TokenError::invalid_client( + "a delegated subject value is not a bounded string, integer, or boolean", + )), } } @@ -292,7 +433,7 @@ fn asserted_client_id(claims: &Value) -> Result<&str, TokenError> { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::config::Algorithm; use serde_json::json; @@ -345,10 +486,21 @@ mod tests { } fn registry_with(clients: &[(&str, &Value)]) -> Arc { + registry_of( + &clients + .iter() + .map(|(id, key)| (*id, *key, "")) + .collect::>(), + ) + } + + /// Each entry is a client id, its public key, and any extra registration + /// lines (a `delegation:` block, in these tests). + fn registry_of(clients: &[(&str, &Value, &str)]) -> Arc { let directory = tempfile::tempdir().expect("temp dir"); - for (client_id, public) in clients { + for (client_id, public, extra) in clients { let document = format!( - "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [tag-{client_id}]\nkeys: [{public}]\n" + "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [tag-{client_id}]\nkeys: [{public}]\n{extra}" ); std::fs::write(directory.path().join(format!("{client_id}.yaml")), document) .expect("write client registration"); @@ -372,12 +524,13 @@ mod tests { let authenticator = authenticator(registry_with(&[("client-a", &public)])); let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - let client = authenticator + let authenticated = authenticator .authenticate(&assertion, NOW) .await .expect("valid assertion authenticates"); - assert_eq!(client.client_id(), "client-a"); - assert_eq!(client.principal(), "urn:example:client-a"); + assert_eq!(authenticated.client.client_id(), "client-a"); + assert_eq!(authenticated.client.principal(), "urn:example:client-a"); + assert_eq!(authenticated.delegation, None); } /// The core security property. Client A holds a real, registered key. It @@ -627,6 +780,268 @@ mod tests { assert_eq!(error, TokenError::invalid_client("assertion is malformed")); } + const DELEGATION: &str = "delegation:\n actors: [urn:example:agent-one, urn:example:agent-two]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + + fn on_behalf_of(jti: &str, request: Value) -> Value { + let mut claims = assertion_claims("client-a", jti); + claims[ON_BEHALF_OF_CLAIM] = request; + claims + } + + fn subject_request() -> Value { + json!({ + "actor": "urn:example:agent-one", + "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, + }) + } + + /// A delegated assertion carries the actor and the subject; both survive + /// only because the registration named them. + #[tokio::test] + async fn a_delegated_assertion_resolves_the_actor_and_subject_it_names() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + + let authenticated = authenticator + .authenticate(&assertion, NOW) + .await + .expect("a permitted delegation authenticates"); + let delegation = authenticated.delegation.expect("delegation resolved"); + assert_eq!(delegation.actor(), "urn:example:agent-one"); + assert_eq!( + delegation.subject(), + &BTreeMap::from([ + ("birth_date".to_owned(), json!("1998-04-02")), + ("given_name".to_owned(), json!("Amara")), + ]) + ); + } + + /// Asking is not enough. Delegation is a property of the registration. + #[tokio::test] + async fn a_client_with_no_registered_delegation_cannot_ask_for_one() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_with(&[("client-a", &public)])); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an undelegated client is refused"); + assert_eq!( + error, + TokenError::invalid_client("client is not registered to act on behalf of a subject") + ); + } + + /// The other direction, and the one that is easy to miss: if omitting the + /// request produced an ordinary token, a delegated caller could widen its + /// own reach from one subject to every subject by leaving out a claim. + #[tokio::test] + async fn a_delegated_client_cannot_widen_itself_by_omitting_the_request() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a delegated client must name its subject"); + assert_eq!( + error, + TokenError::invalid_client( + "a delegated client must name the actor and subject it acts for" + ) + ); + } + + #[tokio::test] + async fn an_actor_outside_the_registered_set_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + let mut request = subject_request(); + request["actor"] = json!("urn:example:agent-three"); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); + + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an unregistered actor is refused"); + assert_eq!( + error, + TokenError::invalid_client("the client may not act as this actor") + ); + } + + /// Without an `actors` list the client names its own actor, so the actor is + /// an audit label rather than a bound. The subject binding is unaffected. + #[tokio::test] + async fn an_open_actor_list_still_binds_the_subject() { + let (private, public) = test_key(1); + let open = "delegation:\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + let authenticator = authenticator(registry_of(&[("client-a", &public, open)])); + let mut request = subject_request(); + request["actor"] = json!("urn:example:anything"); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); + + let authenticated = authenticator + .authenticate(&assertion, NOW) + .await + .expect("any actor is permitted"); + let delegation = authenticated.delegation.expect("delegation resolved"); + assert_eq!(delegation.actor(), "urn:example:anything"); + assert_eq!(delegation.subject().len(), 2); + } + + /// A missing field would leave the resource server unable to resolve the + /// subject; an extra one would be minted nowhere while looking to the caller + /// as though it had been honoured. + #[tokio::test] + async fn the_subject_must_carry_exactly_the_registered_fields() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut missing = subject_request(); + missing["subject"] = json!({"given_name": "Amara"}); + let mut extra = subject_request(); + extra["subject"] = json!({ + "given_name": "Amara", + "birth_date": "1998-04-02", + "national_id": "some-identifier", + }); + let mut renamed = subject_request(); + renamed["subject"] = json!({"given_name": "Amara", "family_name": "Okafor"}); + + for (index, request) in [missing, extra, renamed].into_iter().enumerate() { + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a mismatched subject is refused"); + assert_eq!( + error, + TokenError::invalid_client("the delegated subject does not match its registration") + ); + } + } + + /// Only the shapes a resource server can read back out as a selector value. + #[tokio::test] + async fn a_subject_value_that_is_not_a_selector_value_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + for (index, value) in [ + json!(null), + json!(""), + json!(1.5), + json!(["Amara"]), + json!({"value": "Amara"}), + json!("x".repeat(513)), + ] + .into_iter() + .enumerate() + { + let mut request = subject_request(); + request["subject"] = json!({"given_name": value, "birth_date": "1998-04-02"}); + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("an unusable subject value is refused"); + assert_eq!( + error, + TokenError::invalid_client( + "a delegated subject value is not a bounded string, integer, or boolean" + ) + ); + } + + // Integers and booleans are selector values, so they are accepted. + let mut numeric = subject_request(); + numeric["subject"] = json!({"given_name": 42, "birth_date": true}); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-ok", numeric)); + assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); + } + + /// A malformed request must not cost the caller its `jti`: the delegation is + /// reconciled before the assertion is spent, so correcting the request and + /// retrying works. + #[tokio::test] + async fn a_refused_delegation_does_not_spend_the_assertion() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut wrong = subject_request(); + wrong["actor"] = json!("urn:example:agent-three"); + let refused = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", wrong)); + assert!(authenticator.authenticate(&refused, NOW).await.is_err()); + + // Same jti, corrected request. + let corrected = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); + assert!(authenticator.authenticate(&corrected, NOW).await.is_ok()); + } + + #[tokio::test] + async fn a_structurally_malformed_delegation_request_is_refused() { + let (private, public) = test_key(1); + let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); + + let mut unknown_member = subject_request(); + unknown_member["scope"] = json!("everything"); + let mut no_subject = subject_request(); + no_subject + .as_object_mut() + .expect("request object") + .remove("subject"); + + for (index, request) in [ + json!("urn:example:agent-one"), + json!([{"actor": "urn:example:agent-one"}]), + json!({"subject": {"given_name": "Amara", "birth_date": "1998-04-02"}}), + json!({"actor": "urn:example:agent-one", "subject": "Amara"}), + unknown_member, + no_subject, + ] + .into_iter() + .enumerate() + { + let assertion = sign_assertion( + &private, + "JWT", + &on_behalf_of(&format!("jti-{index}"), request), + ); + let error = authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a malformed delegation request is refused"); + assert_eq!( + error, + TokenError::invalid_client("the delegation request is malformed") + ); + } + + let mut blank_actor = subject_request(); + blank_actor["actor"] = json!(" "); + let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-blank", blank_actor)); + assert_eq!( + authenticator + .authenticate(&assertion, NOW) + .await + .expect_err("a blank actor is refused"), + TokenError::invalid_client("the delegated actor is not bounded") + ); + } + #[tokio::test] async fn an_access_token_type_is_not_accepted_as_a_client_assertion() { let (private, public) = test_key(1); diff --git a/crates/registry-mint/src/clients.rs b/crates/registry-mint/src/clients.rs index 79a674c79..4c43e30c6 100644 --- a/crates/registry-mint/src/clients.rs +++ b/crates/registry-mint/src/clients.rs @@ -35,6 +35,12 @@ const MAX_TAGS: usize = 32; const MAX_KEYS_PER_CLIENT: usize = 8; const MAX_CLIENT_FILE_BYTES: u64 = 256 * 1024; const MAX_CLIENTS: usize = 4_096; +/// Evidence permits at most this many fields in one selector profile, so a +/// larger subject could never satisfy one. +const MAX_SUBJECT_FIELDS: usize = 16; +const MAX_DELEGATED_ACTORS: usize = 64; +/// The longest claim path Evidence will resolve. +const MAX_CLAIM_PATH_BYTES: usize = 512; /// JWK members that only ever appear in private keys. const PRIVATE_JWK_MEMBERS: [&str; 7] = ["d", "p", "q", "dp", "dq", "qi", "k"]; @@ -64,6 +70,38 @@ pub struct Grant { pub authority: String, } +/// Permission for a client to obtain tokens bound to one delegated actor acting +/// for one subject. +/// +/// This is what makes a delegated token narrower than an ordinary one rather +/// than merely differently labelled. The subject's selector values are minted +/// into the token at [`Delegation::subject_claims`], which must mirror the +/// `valueClaims` of the matching `authenticated-context` entitlement in the +/// resource server's bundle. The resource server then reads the subject from +/// the token and refuses any request that carries its own selector values, so a +/// token issued for one subject cannot reach another. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Delegation { + /// A closed set of actor identities this client may act as. Omitted means + /// the client names its own actor, bounded only by length. + #[serde(default)] + pub actors: Option>, + /// Selector field name to the claim path its value is minted at. + pub subject_claims: BTreeMap, +} + +impl Delegation { + /// Whether `actor` is one this client may act as. + #[must_use] + pub fn permits_actor(&self, actor: &str) -> bool { + match &self.actors { + Some(actors) => actors.iter().any(|permitted| permitted == actor), + None => true, + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct ClientDocument { @@ -73,6 +111,8 @@ struct ClientDocument { requester_tags: Vec, #[serde(default)] grant: Option, + #[serde(default)] + delegation: Option, keys: Vec, } @@ -85,6 +125,7 @@ pub struct RegisteredClient { evidence_audience: String, requester_tags: Vec, grant: Option, + delegation: Option, jwks: JwkSet, } @@ -114,6 +155,14 @@ impl RegisteredClient { self.grant.as_ref() } + /// The delegation this client is registered for, if any. A client with no + /// delegation may never obtain a token carrying an actor or a bound + /// subject. + #[must_use] + pub fn delegation(&self) -> Option<&Delegation> { + self.delegation.as_ref() + } + /// The public keys registered for this client, and nothing else. This is /// the set an assertion from this client is verified against. #[must_use] @@ -136,6 +185,10 @@ impl fmt::Debug for RegisteredClient { &format_args!("[{} redacted]", self.requester_tags.len()), ) .field("grant", &self.grant.as_ref().map(|_| "[redacted]")) + .field( + "delegation", + &self.delegation.as_ref().map(|_| "[redacted]"), + ) .field("keys", &self.jwks.keys.len()) .finish() } @@ -271,6 +324,10 @@ fn build_client( } } + if let Some(delegation) = &document.delegation { + validate_delegation(delegation, &invalid)?; + } + let jwks = build_public_jwks(document.keys, &invalid)?; Ok(RegisteredClient { @@ -279,10 +336,97 @@ fn build_client( evidence_audience: document.evidence_audience, requester_tags: document.requester_tags, grant: document.grant, + delegation: document.delegation, jwks, }) } +fn validate_delegation( + delegation: &Delegation, + invalid: &impl Fn(&'static str) -> ClientRegistryError, +) -> Result<(), ClientRegistryError> { + if let Some(actors) = &delegation.actors { + // An empty list reads as "no restriction" but means "nothing may be + // requested", so it is refused rather than silently interpreted. + if actors.is_empty() || actors.len() > MAX_DELEGATED_ACTORS { + return Err(invalid("between 1 and 64 delegated actors are required")); + } + for actor in actors { + if actor.trim().is_empty() || actor.len() > MAX_PRINCIPAL_BYTES { + return Err(invalid("delegated actors must be 1..=512 bytes")); + } + } + if actors.iter().collect::>().len() != actors.len() { + return Err(invalid("delegated actors must be unique")); + } + } + + if delegation.subject_claims.is_empty() || delegation.subject_claims.len() > MAX_SUBJECT_FIELDS + { + return Err(invalid("between 1 and 16 subject claims are required")); + } + for (field, path) in &delegation.subject_claims { + if !valid_selector_field(field) { + return Err(invalid("subject claim field names are invalid")); + } + if !valid_claim_path(path) { + return Err(invalid("subject claim paths are invalid")); + } + } + + // Two fields minted at one path would leave whichever came last in place, + // so the resource server would read one field's value for both. + let paths = delegation.subject_claims.values().collect::>(); + if paths.len() != delegation.subject_claims.len() { + return Err(invalid("subject claim paths must be unique")); + } + + // Minting builds nested objects, so `identity` and `identity.given_name` + // cannot both hold a value. Sorting groups any prefix with what it + // prefixes, so comparing neighbours is enough. + let mut segmented = paths + .iter() + .map(|path| path.split('.').collect::>()) + .collect::>(); + segmented.sort_unstable(); + if segmented + .windows(2) + .any(|pair| pair[1].starts_with(&pair[0])) + { + return Err(invalid( + "subject claim paths must not nest inside one another", + )); + } + Ok(()) +} + +/// The selector field name grammar Evidence enforces on bundle selector +/// profiles. A field Mint accepts but Evidence rejects could never resolve. +fn valid_selector_field(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + +/// The claim path grammar Evidence resolves tokens against. +fn valid_claim_path(value: &str) -> bool { + if value.is_empty() || value.len() > MAX_CLAIM_PATH_BYTES { + return false; + } + value.split('.').all(|segment| { + let bytes = segment.as_bytes(); + !bytes.is_empty() + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) +} + fn build_public_jwks( keys: Vec, invalid: &impl Fn(&'static str) -> ClientRegistryError, @@ -485,6 +629,138 @@ keys: )); } + /// A registration with a `delegation` block, whose subject claims mirror + /// the `valueClaims` of an `authenticated-context` entitlement. + fn delegated(body: &str) -> String { + format!("{CLIENT_A}delegation:\n{body}") + } + + const SUBJECT_CLAIMS: &str = + " subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + + #[test] + fn a_delegated_registration_declares_its_actors_and_subject_claims() { + let text = delegated(&format!( + " actors: [urn:example:agent:scheduling]\n{SUBJECT_CLAIMS}" + )); + let registry = load_one(&text).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let delegation = client.delegation().expect("client-a may delegate"); + + assert_eq!( + delegation.subject_claims, + BTreeMap::from([ + ("given_name".to_owned(), "identity.given_name".to_owned()), + ("birth_date".to_owned(), "identity.birth_date".to_owned()), + ]) + ); + assert!(delegation.permits_actor("urn:example:agent:scheduling")); + assert!(!delegation.permits_actor("urn:example:agent:other")); + + // Ordinary registrations stay undelegated, which is what makes an + // assertion asking to delegate refusable. + assert_eq!( + load_one(CLIENT_A) + .expect("registry loads") + .get("client-a") + .expect("client-a is registered") + .delegation(), + None + ); + } + + #[test] + fn an_omitted_actor_list_permits_any_actor_the_client_names() { + let registry = load_one(&delegated(SUBJECT_CLAIMS)).expect("registry loads"); + let client = registry.get("client-a").expect("client-a is registered"); + let delegation = client.delegation().expect("client-a may delegate"); + + assert_eq!(delegation.actors, None); + assert!(delegation.permits_actor("urn:example:agent:anything")); + } + + #[test] + fn an_actor_list_must_be_non_empty_bounded_and_unique() { + assert_eq!( + load_error(&delegated(&format!(" actors: []\n{SUBJECT_CLAIMS}"))), + invalid("between 1 and 64 delegated actors are required") + ); + assert_eq!( + load_error(&delegated(&format!(" actors: [a, a]\n{SUBJECT_CLAIMS}"))), + invalid("delegated actors must be unique") + ); + assert_eq!( + load_error(&delegated(&format!(" actors: ['']\n{SUBJECT_CLAIMS}"))), + invalid("delegated actors must be 1..=512 bytes") + ); + } + + #[test] + fn subject_claim_paths_must_use_the_resource_servers_claim_path_grammar() { + for path in ["identity..given_name", "1identity.given_name", "", "a.b!c"] { + let text = delegated(&format!(" subjectClaims:\n given_name: \"{path}\"\n")); + assert_eq!( + load_error(&text), + invalid("subject claim paths are invalid"), + "path {path:?} must be rejected" + ); + } + } + + #[test] + fn subject_claim_field_names_follow_the_selector_profile_grammar() { + for field in ["Given_Name", "1given", "given name", ""] { + let text = delegated(&format!( + " subjectClaims:\n \"{field}\": identity.given_name\n" + )); + assert_eq!( + load_error(&text), + invalid("subject claim field names are invalid"), + "field {field:?} must be rejected" + ); + } + } + + /// Minting builds nested objects, so a path and a path inside it cannot + /// both hold a value. + #[test] + fn subject_claim_paths_must_be_unique_and_must_not_nest() { + let text = delegated( + " subjectClaims:\n given_name: identity.name\n family_name: identity.name\n", + ); + assert_eq!( + load_error(&text), + invalid("subject claim paths must be unique") + ); + + let text = delegated( + " subjectClaims:\n given_name: identity\n family_name: identity.family_name\n", + ); + assert_eq!( + load_error(&text), + invalid("subject claim paths must not nest inside one another") + ); + } + + #[test] + fn a_delegation_must_bind_at_least_one_subject_claim() { + // Delegation with no subject binding would be an actor label on an + // otherwise unbounded token. + let text = delegated(" subjectClaims: {}\n"); + assert_eq!( + load_error(&text), + invalid("between 1 and 16 subject claims are required") + ); + + let fields = (0..17) + .map(|index| format!(" field_{index}: identity.f{index}\n")) + .collect::(); + assert_eq!( + load_error(&delegated(&format!(" subjectClaims:\n{fields}"))), + invalid("between 1 and 16 subject claims are required") + ); + } + #[test] fn debug_output_redacts_client_authority() { let registry = load_one(CLIENT_A).expect("registry loads"); diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs index ef21f823c..6c732ce7b 100644 --- a/crates/registry-mint/src/config.rs +++ b/crates/registry-mint/src/config.rs @@ -125,18 +125,24 @@ pub struct ClaimNames { pub evidence_audience: String, pub grant_id: String, pub grant_authority: String, + /// The delegated actor identity, present only where a deployment issues + /// delegated tokens. Omitting it is what stops a registry entry that + /// declares delegation from ever being served. + #[serde(default)] + pub actor: Option, } impl ClaimNames { fn validate(&self) -> Result<(), ConfigError> { - let names = [ + let mut names = vec![ self.principal.as_str(), self.requester_tags.as_str(), self.evidence_audience.as_str(), self.grant_id.as_str(), self.grant_authority.as_str(), ]; - for name in names { + names.extend(self.actor.as_deref()); + for name in &names { if name.trim().is_empty() || name.len() > 128 { return Err(ConfigError::Invalid("claim names must be 1..=128 bytes")); } @@ -444,6 +450,39 @@ clients: } } + #[test] + fn the_actor_claim_is_optional_and_obeys_every_other_claim_name_rule() { + // A deployment that never delegates names no actor claim at all. + assert!(sample_config().access_tokens.claims.actor.is_none()); + + let with_actor = |name: &str| { + VALID.replace( + "grantAuthority: evidence_authority", + &format!("grantAuthority: evidence_authority\n actor: {name}"), + ) + }; + + let config = load_from(&with_actor("evidence_actor")).expect("an actor claim is accepted"); + assert_eq!( + config.access_tokens.claims.actor.as_deref(), + Some("evidence_actor") + ); + + // Reusing another authority claim would let the actor overwrite it. + assert_eq!( + load_error(&with_actor("evidence_tags")), + ConfigError::Invalid("claim names must be distinct") + ); + assert_eq!( + load_error(&with_actor("client_id")), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims") + ); + assert_eq!( + load_error(&with_actor("\"\"")), + ConfigError::Invalid("claim names must be 1..=128 bytes") + ); + } + #[test] fn version_must_be_one_and_audiences_must_be_present() { let text = VALID.replace("version: 1", "version: 2"); diff --git a/crates/registry-mint/src/lib.rs b/crates/registry-mint/src/lib.rs index 0870b4be2..a8e589978 100644 --- a/crates/registry-mint/src/lib.rs +++ b/crates/registry-mint/src/lib.rs @@ -54,3 +54,11 @@ pub const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; /// Media type of the minted access tokens. pub const ACCESS_TOKEN_TYP: &str = "at+jwt"; + +/// The client assertion member naming the actor and subject a delegated token +/// is requested for. +/// +/// This is Mint's own member, not RFC 8693 `act`: token exchange presents a +/// subject's own credential, which is precisely what a deployment without an +/// identity provider does not have. +pub const ON_BEHALF_OF_CLAIM: &str = "on_behalf_of"; diff --git a/crates/registry-mint/src/server.rs b/crates/registry-mint/src/server.rs index 2fd245cc9..d948d8c8e 100644 --- a/crates/registry-mint/src/server.rs +++ b/crates/registry-mint/src/server.rs @@ -56,6 +56,8 @@ pub enum ServiceError { Minter(#[from] MinterError), #[error("the client registry could not be loaded: {0}")] Registry(#[from] ClientRegistryError), + #[error("client {0} cannot be served: {1}")] + Delegation(String, &'static str), } /// The whole serving state: an immutable minter over a reloadable registry. @@ -86,6 +88,7 @@ impl MintService { pub fn load(config: MintConfig) -> Result { let minter = TokenMinter::new(&config)?; let registry = Arc::new(ClientRegistry::load(&config.clients.directory)?); + check_delegations(®istry, minter.claims())?; let replay = Arc::new(ReplayCache::new( config.client_assertion.replay_cache_entries, )); @@ -107,6 +110,9 @@ impl MintService { /// dropped into the directory must not silently revoke every caller. pub fn reload_clients(&self) -> Result { let registry = Arc::new(ClientRegistry::load(&self.config.clients.directory)?); + // Checked on every reload, not only at startup: a registration dropped + // into the directory later must clear the same bar. + check_delegations(®istry, self.minter.claims())?; let count = registry.len(); let authenticator = Arc::new(ClientAuthenticator::new( registry, @@ -160,10 +166,10 @@ impl MintService { // Cloned out of the lock so a concurrent reload cannot block here. let authenticator = self.authenticator(); - let client = authenticator + let authenticated = authenticator .authenticate(&request.client_assertion, now) .await?; - let token = self.minter.mint(&client, now).await?; + let token = self.minter.mint(&authenticated, now).await?; serde_json::to_vec(&token) .map(|body| json_response(StatusCode::OK, JSON_MEDIA_TYPE, body)) @@ -171,6 +177,53 @@ impl MintService { } } +/// Refuse a registry whose delegations this configuration cannot express. +/// +/// The registry and the claim-name configuration are loaded independently, so +/// this is the only place their agreement can be established. A disagreement +/// caught here is an operator error at startup or reload; caught at the first +/// token request instead, it would be an outage for one caller and a token +/// missing its actor for another. +fn check_delegations( + registry: &ClientRegistry, + claims: &crate::config::ClaimNames, +) -> Result<(), ServiceError> { + // The claims Mint writes itself. A subject minted over one of these would + // replace authority the registry, not the caller, is supposed to decide. + let mut reserved = vec!["iss", "aud", "exp", "iat", "nbf", "jti", "client_id", "sub"]; + reserved.push(claims.principal.as_str()); + reserved.push(claims.requester_tags.as_str()); + reserved.push(claims.evidence_audience.as_str()); + reserved.push(claims.grant_id.as_str()); + reserved.push(claims.grant_authority.as_str()); + reserved.extend(claims.actor.as_deref()); + + for client_id in registry.client_ids() { + let client = registry + .get(client_id) + .expect("client id came from this registry"); + let Some(delegation) = client.delegation() else { + continue; + }; + if claims.actor.is_none() { + return Err(ServiceError::Delegation( + client_id.to_owned(), + "it declares a delegation but no actor claim name is configured", + )); + } + for path in delegation.subject_claims.values() { + let root = path.split('.').next().unwrap_or(path); + if reserved.contains(&root) { + return Err(ServiceError::Delegation( + client_id.to_owned(), + "a subject claim path would overwrite an authority claim", + )); + } + } + } + Ok(()) +} + fn build_metadata(config: &MintConfig) -> Value { let issuer = config.issuer.trim_end_matches('/'); let algorithms = { @@ -456,6 +509,75 @@ mod tests { } } + fn registry_with(extra: &str) -> ClientRegistry { + let directory = tempfile::tempdir().expect("temp dir"); + let public = crate::assertion::tests::test_key(1).1; + std::fs::write( + directory.path().join("client-a.yaml"), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [tag-a]\nkeys: [{public}]\n{extra}"), + ) + .expect("write client registration"); + ClientRegistry::load(directory.path()).expect("registry loads") + } + + fn claim_names() -> crate::config::ClaimNames { + crate::config::tests::sample_config().access_tokens.claims + } + + const DELEGATION: &str = "delegation:\n subjectClaims:\n given_name: identity.given_name\n"; + + /// The registry and the claim-name configuration are loaded independently, + /// so a delegation with nowhere to mint its actor has to be caught here or + /// not at all. + #[test] + fn a_delegation_without_a_configured_actor_claim_refuses_to_load() { + let mut claims = claim_names(); + claims.actor = None; + let error = check_delegations(®istry_with(DELEGATION), &claims) + .expect_err("an unconfigured actor claim must refuse the registry"); + assert!(matches!(error, ServiceError::Delegation(client, _) if client == "client-a")); + + claims.actor = Some("evidence_actor".to_owned()); + assert!(check_delegations(®istry_with(DELEGATION), &claims).is_ok()); + } + + /// A subject path rooted at a claim Mint writes itself would let the caller + /// choose authority the registry is supposed to decide. + #[test] + fn a_subject_path_rooted_at_an_authority_claim_refuses_to_load() { + let mut claims = claim_names(); + claims.actor = Some("evidence_actor".to_owned()); + + for root in [ + "iss", + "sub", + "jti", + "client_id", + claims.requester_tags.as_str(), + claims.evidence_audience.as_str(), + claims.grant_id.as_str(), + claims.grant_authority.as_str(), + "evidence_actor", + ] { + let registry = registry_with(&format!( + "delegation:\n subjectClaims:\n given_name: {root}.given_name\n" + )); + assert!( + check_delegations(®istry, &claims).is_err(), + "a subject path rooted at {root} must be refused" + ); + } + } + + /// A registry with no delegations is unaffected by the actor claim either + /// way, so an existing deployment does not have to configure one. + #[test] + fn an_undelegated_registry_loads_without_an_actor_claim() { + let mut claims = claim_names(); + claims.actor = None; + assert!(check_delegations(®istry_with(""), &claims).is_ok()); + } + #[test] fn metadata_describes_the_endpoints_a_client_needs() { let config = crate::config::tests::sample_config(); diff --git a/crates/registry-mint/src/token.rs b/crates/registry-mint/src/token.rs index 310a029ea..41137ed5c 100644 --- a/crates/registry-mint/src/token.rs +++ b/crates/registry-mint/src/token.rs @@ -1,10 +1,22 @@ //! Access token minting. //! //! Every authority claim written here is read from the server-side client -//! registry. Nothing is copied from the client assertion. That is what makes a -//! caller's private key a proof of *identity* rather than a licence to assert -//! whatever it likes: the caller chooses which registry entry it authenticates -//! as, and the registry chooses what that entry may say. +//! registry. That is what makes a caller's private key a proof of *identity* +//! rather than a licence to assert whatever it likes: the caller chooses which +//! registry entry it authenticates as, and the registry chooses what that entry +//! may say. +//! +//! A delegated token is the one case where values reach a token from the +//! caller, and they arrive already reconciled against the registration by +//! [`crate::assertion`]: the actor is one the client may act as, and the +//! subject holds exactly the selector fields the registration declared, minted +//! at exactly the claim paths it declared. The registry still fixes the shape; +//! the caller only fills it in. +//! +//! Minting the subject into the token is what bounds a delegated token to one +//! person. A resource server configured to read that subject from the token +//! refuses any request carrying its own selector values, so a token issued for +//! one subject cannot be turned toward another, however the caller misbehaves. use std::path::Path; @@ -15,7 +27,8 @@ use serde_json::{json, Map, Value}; use thiserror::Error; use crate::{ - clients::{contains_private_material, RegisteredClient}, + assertion::AuthenticatedClient, + clients::{contains_private_material, Delegation, RegisteredClient}, config::{Algorithm, ClaimNames, MintConfig}, error::TokenError, secretfile::{self, SecretFileError}, @@ -121,12 +134,19 @@ impl TokenMinter { &self.issuer } + /// The claim names this minter writes authority into. + #[must_use] + pub fn claims(&self) -> &ClaimNames { + &self.claims + } + /// Mint an access token carrying the registry's authority for `client`. pub async fn mint( &self, - client: &RegisteredClient, + authenticated: &AuthenticatedClient, now: i64, ) -> Result { + let client: &RegisteredClient = &authenticated.client; let expires_at = now + self.lifetime_seconds; let mut claims = Map::new(); claims.insert("iss".to_owned(), Value::String(self.issuer.clone())); @@ -183,6 +203,22 @@ impl TokenMinter { ); } + if let Some(delegation) = &authenticated.delegation { + let registered = client.delegation().ok_or_else(|| { + TokenError::server_error("a delegation was resolved for an undelegated client") + })?; + // Startup refuses a registry that declares delegation without a + // configured actor claim, so reaching here means the two disagree. + let actor_claim = self.claims.actor.as_ref().ok_or_else(|| { + TokenError::server_error("no actor claim is configured for delegated tokens") + })?; + claims.insert( + actor_claim.clone(), + Value::String(delegation.actor().to_owned()), + ); + write_subject_claims(&mut claims, registered, delegation)?; + } + let header = json!({ "alg": self.algorithm.as_header_value(), "typ": ACCESS_TOKEN_TYP, @@ -207,6 +243,46 @@ impl TokenMinter { } } +/// Write each subject selector value at the claim path its registration +/// declared, creating the intermediate objects the path implies. +/// +/// The registration's paths were checked at load time to be well formed, +/// unique, and non-nesting, so no write here can overwrite another. Anything +/// that would still collide is a bug rather than a caller's doing, and is +/// refused rather than allowed to overwrite an authority claim. +fn write_subject_claims( + claims: &mut Map, + registered: &Delegation, + delegation: &crate::assertion::ResolvedDelegation, +) -> Result<(), TokenError> { + let collision = + || TokenError::server_error("a subject claim path collides with an authority claim"); + + for (field, path) in ®istered.subject_claims { + let value = delegation + .subject() + .get(field) + .ok_or_else(|| TokenError::server_error("a resolved subject field is missing"))?; + + let mut segments = path.split('.').peekable(); + let mut current = &mut *claims; + while let Some(segment) = segments.next() { + if segments.peek().is_none() { + if current.contains_key(segment) { + return Err(collision()); + } + current.insert(segment.to_owned(), value.clone()); + break; + } + let entry = current + .entry(segment.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + current = entry.as_object_mut().ok_or_else(collision)?; + } + } + Ok(()) +} + fn encode_json(value: &Value) -> Result { let bytes = serde_json::to_vec(value) .map_err(|_| TokenError::server_error("a token component could not be serialized"))?; @@ -272,6 +348,13 @@ mod tests { } fn fixture(grant: Option<&str>) -> Fixture { + build_fixture(grant, "", "") + } + + /// `registration` appends lines to the client registration, `claim` appends + /// lines to the configured claim names. Both are how the delegation tests + /// reach a shape the plain fixture does not have. + fn build_fixture(grant: Option<&str>, registration: &str, claim: &str) -> Fixture { let directory = tempfile::tempdir().expect("temp dir"); let root = directory.path(); fs::create_dir_all(root.join("clients")).expect("client dir"); @@ -286,13 +369,12 @@ mod tests { .unwrap_or_default(); fs::write( root.join("clients/client-a.yaml"), - format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n", ed25519_key(1, "client-a-1").1), + format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n{registration}", ed25519_key(1, "client-a-1").1), ) .expect("write client"); let config_path = root.join("mint.yaml"); - fs::write( - &config_path, + let mut document = String::from( r#" version: 1 issuer: https://mint.example.org @@ -310,14 +392,18 @@ accessTokens: evidenceAudience: evidence_audience grantId: evidence_grant_id grantAuthority: evidence_authority -clientAssertion: +"#, + ); + document.push_str(claim); + document.push_str( + r#"clientAssertion: audience: https://mint.example.org/token algorithms: [EdDSA] clients: directory: clients "#, - ) - .expect("write config"); + ); + fs::write(&config_path, document).expect("write config"); let config = MintConfig::load(&config_path).expect("config loads"); let registry = ClientRegistry::load(&config.clients.directory).expect("registry loads"); @@ -329,6 +415,13 @@ clients: } } + fn undelegated(client: &std::sync::Arc) -> AuthenticatedClient { + AuthenticatedClient { + client: std::sync::Arc::clone(client), + delegation: None, + } + } + fn decode_claims(token: &str) -> Value { let segment = token.split('.').nth(1).expect("token has a claims segment"); serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("claims decode")) @@ -345,7 +438,11 @@ clients: async fn minted_claims_come_from_the_registry() { let fixture = fixture(None); let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture.minter.mint(client, NOW).await.expect("token mints"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); let claims = decode_claims(&minted.access_token); assert_eq!(claims["iss"], json!("https://mint.example.org")); @@ -371,7 +468,11 @@ clients: async fn the_header_names_the_active_key_and_access_token_type() { let fixture = fixture(None); let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture.minter.mint(client, NOW).await.expect("token mints"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); assert_eq!( decode_header(&minted.access_token), @@ -386,7 +487,7 @@ clients: let claims = decode_claims( &without .minter - .mint(client, NOW) + .mint(&undelegated(client), NOW) .await .expect("token mints") .access_token, @@ -399,7 +500,7 @@ clients: let claims = decode_claims( &with .minter - .mint(client, NOW) + .mint(&undelegated(client), NOW) .await .expect("token mints") .access_token, @@ -412,14 +513,187 @@ clients: async fn every_token_carries_a_distinct_identifier() { let fixture = fixture(None); let client = fixture.registry.get("client-a").expect("client registered"); - let first = fixture.minter.mint(client, NOW).await.expect("token mints"); - let second = fixture.minter.mint(client, NOW).await.expect("token mints"); + let first = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + let second = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); let first_jti = decode_claims(&first.access_token)["jti"].clone(); let second_jti = decode_claims(&second.access_token)["jti"].clone(); assert_ne!(first_jti, second_jti); } + const DELEGATION: &str = + "delegation:\n actors: [urn:example:agent-one]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; + const ACTOR_CLAIM: &str = " actor: evidence_actor\n"; + + fn delegated_fixture() -> Fixture { + build_fixture(None, DELEGATION, ACTOR_CLAIM) + } + + fn delegation(subject: &[(&str, Value)]) -> crate::assertion::ResolvedDelegation { + crate::assertion::ResolvedDelegation::new( + "urn:example:agent-one".to_owned(), + subject + .iter() + .map(|(field, value)| ((*field).to_owned(), value.clone())) + .collect(), + ) + } + + fn delegated( + client: &std::sync::Arc, + subject: &[(&str, Value)], + ) -> AuthenticatedClient { + AuthenticatedClient { + client: std::sync::Arc::clone(client), + delegation: Some(delegation(subject)), + } + } + + /// The subject is minted at exactly the claim paths the registration + /// declared, which is what lets a resource server read it back out as the + /// selector it will not accept from the request body. + #[tokio::test] + async fn a_delegated_token_carries_the_actor_and_the_subject_at_their_declared_paths() { + let fixture = delegated_fixture(); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint( + &delegated( + client, + &[ + ("given_name", json!("Amara")), + ("birth_date", json!("1998-04-02")), + ], + ), + NOW, + ) + .await + .expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert_eq!(claims["evidence_actor"], json!("urn:example:agent-one")); + assert_eq!( + claims["identity"], + json!({"given_name": "Amara", "birth_date": "1998-04-02"}) + ); + // The registry's own authority is unchanged by the delegation. + assert_eq!(claims["sub"], json!("urn:example:client-a")); + assert_eq!(claims["client_id"], json!("client-a")); + } + + /// An ordinary token from the same minter carries neither, so a resource + /// server reading the subject from the token has nothing to read. + #[tokio::test] + async fn an_undelegated_token_carries_no_actor_and_no_subject() { + let fixture = delegated_fixture(); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint(&undelegated(client), NOW) + .await + .expect("token mints"); + + let claims = decode_claims(&minted.access_token); + assert!(claims.get("evidence_actor").is_none()); + assert!(claims.get("identity").is_none()); + } + + /// Startup refuses a registry that declares delegation without a configured + /// actor claim, so this can only be reached by a bug. It must fail rather + /// than mint a token whose subject no resource server can attribute. + #[tokio::test] + async fn minting_a_delegation_without_a_configured_actor_claim_is_a_server_error() { + let fixture = build_fixture(None, DELEGATION, ""); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint( + &delegated( + client, + &[ + ("given_name", json!("Amara")), + ("birth_date", json!("1998-04-02")), + ], + ), + NOW, + ) + .await + .expect_err("an unconfigured actor claim must not mint"); + assert_eq!( + error, + TokenError::server_error("no actor claim is configured for delegated tokens") + ); + } + + #[tokio::test] + async fn a_delegation_resolved_for_an_undelegated_client_is_a_server_error() { + let fixture = build_fixture(None, "", ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) + .await + .expect_err("an undelegated client must not mint a delegation"); + assert_eq!( + error, + TokenError::server_error("a delegation was resolved for an undelegated client") + ); + } + + /// Two fields under one path prefix have to nest into one object rather than + /// the second overwriting the first. + #[tokio::test] + async fn subject_claims_sharing_a_path_prefix_nest_into_one_object() { + let deep = "delegation:\n subjectClaims:\n given_name: subject.identity.given_name\n region: subject.residence.region\n"; + let fixture = build_fixture(None, deep, ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let minted = fixture + .minter + .mint( + &delegated( + client, + &[("given_name", json!("Amara")), ("region", json!("north"))], + ), + NOW, + ) + .await + .expect("token mints"); + + assert_eq!( + decode_claims(&minted.access_token)["subject"], + json!({"identity": {"given_name": "Amara"}, "residence": {"region": "north"}}) + ); + } + + /// The startup check refuses a subject path rooted at an authority claim, so + /// this is unreachable in a loaded server. If it were ever reached, the + /// delegation must not be allowed to overwrite the authority. + #[tokio::test] + async fn a_subject_path_colliding_with_an_authority_claim_is_a_server_error() { + let colliding = + "delegation:\n subjectClaims:\n given_name: evidence_audience.given_name\n"; + let fixture = build_fixture(None, colliding, ACTOR_CLAIM); + let client = fixture.registry.get("client-a").expect("client registered"); + let error = fixture + .minter + .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) + .await + .expect_err("a colliding subject path must not mint"); + assert_eq!( + error, + TokenError::server_error("a subject claim path collides with an authority claim") + ); + } + #[test] fn the_published_key_set_carries_public_material_only() { let fixture = fixture(None); diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs new file mode 100644 index 000000000..9ba57ca79 --- /dev/null +++ b/crates/registry-mint/tests/delegated_subject_binding.rs @@ -0,0 +1,576 @@ +//! End-to-end proof that a delegated token Mint issues is bound to one subject. +//! +//! The claim is narrow and worth stating precisely: a client that is registered +//! for delegation asks Mint for a token *for a named person*, and the resulting +//! token can only ever produce evidence about that person. Not because the +//! client is well behaved, but because Evidence reads the subject out of the +//! token and refuses to read it from the request at all. +//! +//! Nothing here stubs a boundary. The Mint router is the real one, the bundle is +//! the demonstration bundle under `demo/evidence-bundle`, and the authorization +//! decision is Evidence's own `match_entitlement` and `resolve_selectors`. + +use std::{collections::BTreeMap, fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; + +use axum_test::TestServer; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence::{ + auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}, + bundle::Bundle, + config::{AuthorityKind, ValueOrigin}, + model::{EvidenceRequest, RequestedSelector, RequestedSubject, SelectorValue}, + selector::{authorize_and_resolve, AuthorizationError, ResolvedSelectorValue}, +}; +use registry_mint::{ + config::MintConfig, + server::{build_app, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, ON_BEHALF_OF_CLAIM, +}; +use registry_platform_crypto::PrivateJwk; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; +use serde_json::{json, Value}; + +/// These four must agree with `demo/evidence-bundle/evidence.yaml`. If they ever +/// drift, this test is the thing that says so. +const ISSUER: &str = "https://localhost:8443"; +const EVIDENCE_AUDIENCE: &str = "evidence.demo.invalid"; +const ACTOR_CLAIM: &str = "evidence_actor"; +const REQUIREMENT: &str = "urn:example:demo:requirement:residence-region:v1"; +const PURPOSE: &str = "demo-routing"; +const ASSERTION_AUDIENCE: &str = "https://localhost:8443/token"; + +const AGENT: &str = "urn:example:demo:agent:appointment-scheduler"; + +fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let d = URL_SAFE_NO_PAD.encode(seed_bytes); + let kid = format!("key-{seed}"); + let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); + let private_document = + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); + let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); + (private, public, private_document) +} + +struct Deployment { + _directory: tempfile::TempDir, + service: Arc, +} + +/// A Mint deployment whose claim names, issuer, and audience are the ones the +/// demonstration bundle expects. +fn deployment() -> Deployment { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, _, signing_document) = key_pair(9); + let signing_path = root.join("secrets/signing.jwk"); + fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); + fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) + .expect("restrict signing key"); + + // The delegated caller: it may act as one agent, over exactly three + // selector fields, minted at exactly the paths the bundle reads. + let (_, scheduler_public, _) = key_pair(1); + fs::write( + root.join("clients/scheduler.yaml"), + format!( + "clientId: scheduler\nprincipal: urn:example:demo:principal:scheduler\nevidenceAudience: https://scheduler.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{scheduler_public}]\ndelegation:\n actors: [{AGENT}]\n subjectClaims:\n given_name: identity.given_name\n family_name: identity.family_name\n birth_date: identity.birth_date\n" + ), + ) + .expect("write delegated client"); + + // The same authority, without delegation. Its tokens carry no actor. + let (_, desk_public, _) = key_pair(2); + fs::write( + root.join("clients/service-desk.yaml"), + format!( + "clientId: service-desk\nprincipal: urn:example:demo:principal:service-desk\nevidenceAudience: https://service-desk.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{desk_public}]\n" + ), + ) + .expect("write undelegated client"); + + let config_path = root.join("mint.yaml"); + fs::write( + &config_path, + format!( + r#" +version: 1 +issuer: {ISSUER} +listener: {{address: 127.0.0.1, port: 0}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [{EVIDENCE_AUDIENCE}] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: {ACTOR_CLAIM} +clientAssertion: + audience: {ASSERTION_AUDIENCE} + algorithms: [EdDSA] +clients: + directory: clients +"# + ), + ) + .expect("write config"); + + let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); + let service = Arc::new(MintService::load(config).expect("the deployment loads")); + Deployment { + _directory: directory, + service, + } +} + +fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { + let kid = private.kid.clone().expect("the test key has a kid"); + let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); + let encode = |value: &Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) + }; + let signing_input = format!("{}.{}", encode(&header), encode(claims)); + let signature = + registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) +} + +fn assertion_claims(client_id: &str, jti: &str) -> Value { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + json!({ + "iss": client_id, + "sub": client_id, + "aud": ASSERTION_AUDIENCE, + "iat": now, + "exp": now + 120, + "jti": jti, + }) +} + +/// The delegation request, carried inside the client's own signed assertion so +/// the values are covered by the client's signature rather than travelling as +/// unauthenticated form parameters. +fn delegated_claims(jti: &str, subject: &[(&str, &str)]) -> Value { + let mut claims = assertion_claims("scheduler", jti); + claims[ON_BEHALF_OF_CLAIM] = json!({ + "actor": AGENT, + "subject": subject + .iter() + .map(|(field, value)| ((*field).to_owned(), json!(value))) + .collect::>(), + }); + claims +} + +fn token_form(assertion: &str) -> Vec<(String, String)> { + vec![ + ( + "grant_type".to_owned(), + GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), + ), + ( + "client_assertion_type".to_owned(), + CLIENT_ASSERTION_TYPE.to_owned(), + ), + ("client_assertion".to_owned(), assertion.to_owned()), + ] +} + +async fn mint_token(http: &TestServer, assertion: &str) -> String { + let response = http.post("/token").form(&token_form(assertion)).await; + response.assert_status_ok(); + response.json::()["access_token"] + .as_str() + .expect("the response carries an access token") + .to_owned() +} + +/// Evidence's authenticator over the key set Mint published, configured exactly +/// as the demonstration bundle configures it. +fn evidence_authenticator(jwks: &Value) -> Authenticator { + let key_set: jsonwebtoken::jwk::JwkSet = + serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); + let verifier_config = TokenVerifierConfig::access_token_profile( + ISSUER.to_owned(), + vec![EVIDENCE_AUDIENCE.to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ); + Authenticator::new( + Arc::new(TokenVerifier::new( + verifier_config, + Arc::new(JwksFetcher::new_static( + key_set, + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: Some(ACTOR_CLAIM.to_owned()), + }, + ) +} + +struct LoadedBundle { + _directory: tempfile::TempDir, + bundle: Bundle, +} + +/// Evidence refuses a writable bundle, so the demonstration bundle is copied to +/// a temporary root and frozen before loading. +fn demo_bundle() -> LoadedBundle { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path().join("bundle"); + fs::create_dir(&root).expect("create bundle root"); + copy_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/evidence-bundle"), + &root, + ); + make_read_only(&root); + let bundle = Bundle::load(&root).expect("the demonstration bundle loads"); + LoadedBundle { + _directory: directory, + bundle, + } +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).expect("the demonstration bundle is readable") { + let entry = entry.expect("bundle entry is readable"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("entry type is readable").is_dir() { + fs::create_dir(&destination).expect("bundle directory is copied"); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("bundle file is copied"); + } + } +} + +fn make_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("copied bundle is readable") { + let entry = entry.expect("bundle entry is readable"); + let child = entry.path(); + if entry.file_type().expect("entry type is readable").is_dir() { + make_read_only(&child); + fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) + .expect("bundle directory is immutable"); + } else { + fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) + .expect("bundle file is immutable"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("bundle root is immutable"); +} + +/// The request a delegated caller sends: it names the requirement, the purpose, +/// and the *shape* of the subject, and carries no selector values at all. +fn subject_bound_request() -> EvidenceRequest { + EvidenceRequest { + requirement: REQUIREMENT.to_owned(), + purpose: PURPOSE.to_owned(), + subjects: vec![RequestedSubject { + role: "subject".to_owned(), + selector: RequestedSelector { + profile: "demographics-v1".to_owned(), + values: None, + }, + }], + } +} + +fn resolved_values( + authorization: ®istry_evidence::selector::ResolvedAuthorization, +) -> BTreeMap { + authorization + .subjects + .iter() + .flat_map(|subject| subject.fields.iter()) + .map(|field| { + let value = match &field.value { + ResolvedSelectorValue::String(value) => value.clone(), + ResolvedSelectorValue::Date(value) => value.to_string(), + other => format!("{other:?}"), + }; + (field.name.clone(), value) + }) + .collect() +} + +async fn context_for(http: &TestServer, jwks: &Value, assertion: &str) -> AuthenticatedContext { + let token = mint_token(http, assertion).await; + evidence_authenticator(jwks) + .authenticate(&token) + .await + .expect("Evidence accepts a token Mint issued") +} + +/// The whole point, in one test: the subject the client named to Mint is the +/// subject Evidence resolves, and the client never names it again. +#[tokio::test] +async fn a_delegated_token_authorizes_evidence_about_exactly_its_own_subject() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let context = context_for(&http, &jwks, &assertion).await; + assert_eq!(context.actor(), Some(AGENT)); + + let authorization = authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) + .expect("a delegated token authorizes its own subject"); + + assert_eq!(authorization.authority_kind, AuthorityKind::Delegated); + assert_eq!(authorization.subjects.len(), 1); + assert_eq!( + authorization.subjects[0].value_origin, + ValueOrigin::AuthenticatedContext + ); + assert_eq!( + resolved_values(&authorization), + BTreeMap::from([ + ("given_name".to_owned(), "Amara".to_owned()), + ("family_name".to_owned(), "Okafor".to_owned()), + ("birth_date".to_owned(), "1998-04-02".to_owned()), + ]) + ); +} + +/// The containment Jeremi asked for. A client holding a token for one person +/// cannot reach a second person by putting their details in the request: the +/// request is refused for carrying selector values at all, so there is no +/// version of this request that reaches a different subject. +#[tokio::test] +async fn a_delegated_token_cannot_be_pointed_at_a_different_subject() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(1); + let assertion = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let context = context_for(&http, &jwks, &assertion).await; + + let mut request = subject_bound_request(); + request.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Kofi".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Mensah".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1971-11-30".to_owned()), + ), + ])); + + let error = authorize_and_resolve(&loaded.bundle, &request, &context) + .expect_err("a request carrying its own selector values is refused"); + assert_eq!(error, AuthorizationError::Selector); + + // And repeating the caller's own subject does not help either: the refusal + // is for supplying values, not for supplying the wrong ones. + let mut echoed = subject_bound_request(); + echoed.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Amara".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Okafor".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1998-04-02".to_owned()), + ), + ])); + assert_eq!( + authorize_and_resolve(&loaded.bundle, &echoed, &context) + .expect_err("supplying values is refused even when they match"), + AuthorizationError::Selector + ); +} + +/// Two tokens from the same client and the same key resolve to two different +/// people, so the binding is a property of the token rather than of the client. +#[tokio::test] +async fn each_token_carries_its_own_subject() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + let (private, _, _) = key_pair(1); + + let first = sign_assertion( + &private, + &delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ), + ); + let second = sign_assertion( + &private, + &delegated_claims( + "jti-2", + &[ + ("given_name", "Kofi"), + ("family_name", "Mensah"), + ("birth_date", "1971-11-30"), + ], + ), + ); + + let first = context_for(&http, &jwks, &first).await; + let second = context_for(&http, &jwks, &second).await; + + let resolve = |context: &AuthenticatedContext| { + resolved_values( + &authorize_and_resolve(&loaded.bundle, &subject_bound_request(), context) + .expect("each delegated token authorizes its own subject"), + ) + }; + assert_eq!(resolve(&first)["given_name"], "Amara"); + assert_eq!(resolve(&second)["given_name"], "Kofi"); +} + +/// A token with no actor cannot produce evidence from the subject-bound grant, +/// because there is nowhere for the subject to come from: the grant reads it +/// from claims the token does not carry, and refuses to read it from the +/// request. +/// +/// Note the shape of the refusal. Evidence confines an *actor-bearing* token to +/// `kind: delegated` profiles, but it does not conversely require an actor to +/// reach one, so this token matches the grant and is stopped at selector +/// resolution rather than at entitlement matching. Nothing leaks either way, but +/// the two are not interchangeable: were this grant to gain a subject role whose +/// values come from the request, an undelegated token would reach it. +#[tokio::test] +async fn an_undelegated_token_cannot_use_the_delegated_grant() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + let jwks = http.get("/.well-known/jwks.json").await.json::(); + let loaded = demo_bundle(); + + let (private, _, _) = key_pair(2); + let assertion = sign_assertion(&private, &assertion_claims("service-desk", "jti-1")); + let context = context_for(&http, &jwks, &assertion).await; + assert_eq!(context.actor(), None); + + assert_eq!( + authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) + .expect_err("an undelegated token has no subject to resolve"), + AuthorizationError::Selector + ); + + // Supplying the subject in the request does not rescue it: the grant refuses + // request-borne selector values from any caller. + let mut request = subject_bound_request(); + request.subjects[0].selector.values = Some(BTreeMap::from([ + ( + "given_name".to_owned(), + SelectorValue::String("Amara".to_owned()), + ), + ( + "family_name".to_owned(), + SelectorValue::String("Okafor".to_owned()), + ), + ( + "birth_date".to_owned(), + SelectorValue::String("1998-04-02".to_owned()), + ), + ])); + assert_eq!( + authorize_and_resolve(&loaded.bundle, &request, &context) + .expect_err("an undelegated token cannot name a subject either"), + AuthorizationError::Selector + ); +} + +/// Mint refuses the request before it ever becomes a token: the actor is not one +/// this registration may act as. +#[tokio::test] +async fn mint_refuses_an_actor_the_registration_does_not_permit() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(1); + let mut claims = delegated_claims( + "jti-1", + &[ + ("given_name", "Amara"), + ("family_name", "Okafor"), + ("birth_date", "1998-04-02"), + ], + ); + claims[ON_BEHALF_OF_CLAIM]["actor"] = json!("urn:example:demo:agent:someone-else"); + let assertion = sign_assertion(&private, &claims); + + let response = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); +} + +/// The other direction: an undelegated registration cannot obtain a subject +/// binding by asking for one. +#[tokio::test] +async fn mint_refuses_a_delegation_from_an_undelegated_client() { + let deployment = deployment(); + let http = TestServer::new(build_app(Arc::clone(&deployment.service))); + + let (private, _, _) = key_pair(2); + let mut claims = assertion_claims("service-desk", "jti-1"); + claims[ON_BEHALF_OF_CLAIM] = json!({ + "actor": AGENT, + "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, + }); + let assertion = sign_assertion(&private, &claims); + + let response = http.post("/token").form(&token_form(&assertion)).await; + assert_eq!(response.status_code(), 401); + assert_eq!(response.json::(), json!({"error": "invalid_client"})); +} From b2e282266780dd0141d349cb6f8df9f3edc0f27f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:15:51 +0700 Subject: [PATCH 006/136] fix(mint): carry requestNonce in the delegation test and demo Evidence's request model gained a required caller correlation nonce. The delegation test uses the offline constant, since the nonce never reaches authorization, which is what that test exercises. The demo generates a fresh one per request. Signed-off-by: Jeremi Joslin --- crates/registry-mint/demo/README.md | 6 +++++- crates/registry-mint/demo/walkthrough.py | 9 +++++++++ crates/registry-mint/tests/delegated_subject_binding.rs | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/registry-mint/demo/README.md b/crates/registry-mint/demo/README.md index 792e7f6e4..1d26ba6df 100644 --- a/crates/registry-mint/demo/README.md +++ b/crates/registry-mint/demo/README.md @@ -127,13 +127,16 @@ curl -sS http://127.0.0.1:8080/v1/evidence \ -H "Authorization: Bearer ${TOKEN}" \ -H 'Content-Type: application/json' \ -d '{ + "requestNonce": "<32 random bytes, base64url, unpadded>", "requirement": "urn:example:demo:requirement:residence-region:v1", "purpose": "demo-routing", "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1"}}] }' ``` -Note what is not in that body: the person. The bundle says where the subject +Note what is not in that body: the person. `requestNonce` is a caller +correlation value, echoed into the assertion and kept away from authorization, +sources, and audit. The bundle says where the subject comes from instead. ```yaml @@ -178,6 +181,7 @@ curl -sS http://127.0.0.1:8080/v1/evidence \ -H "Authorization: Bearer ${TOKEN}" \ -H 'Content-Type: application/json' \ -d '{ + "requestNonce": "<32 random bytes, base64url, unpadded>", "requirement": "urn:example:demo:requirement:residence-region:v1", "purpose": "demo-routing", "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1", diff --git a/crates/registry-mint/demo/walkthrough.py b/crates/registry-mint/demo/walkthrough.py index 48cc09bf7..088466ddc 100644 --- a/crates/registry-mint/demo/walkthrough.py +++ b/crates/registry-mint/demo/walkthrough.py @@ -22,6 +22,7 @@ import base64 import json +import secrets import sys from pathlib import Path @@ -104,6 +105,9 @@ def request_evidence(token, subject_values=None): if subject_values is not None: selector["values"] = subject_values body = { + # A caller-generated correlation value. Evidence echoes it into the + # assertion and keeps it away from authorization, sources, and audit. + "requestNonce": request_nonce(), "requirement": REQUIREMENT, "purpose": PURPOSE, "subjects": [{"role": "subject", "selector": selector}], @@ -231,6 +235,11 @@ def b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() +def request_nonce() -> str: + """32 random bytes, base64url without padding. Evidence rejects anything else.""" + return b64url(secrets.token_bytes(32)) + + def unb64url(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs index 9ba57ca79..355cf912b 100644 --- a/crates/registry-mint/tests/delegated_subject_binding.rs +++ b/crates/registry-mint/tests/delegated_subject_binding.rs @@ -282,6 +282,9 @@ fn make_read_only(path: &Path) { /// and the *shape* of the subject, and carries no selector values at all. fn subject_bound_request() -> EvidenceRequest { EvidenceRequest { + // The nonce is a caller correlation value that never reaches + // authorization, which is the only thing under test here. + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), requirement: REQUIREMENT.to_owned(), purpose: PURPOSE.to_owned(), subjects: vec![RequestedSubject { From 52cfb58f40128cfcd662ad510a778312229d8e06 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:35:31 +0700 Subject: [PATCH 007/136] feat(mint): obtain a development token from the client side `mint token` signs a client assertion with the caller's own key and presents it to a running token endpoint. Getting an assertion right by hand is fiddly and getting it wrong yields an opaque `invalid_client`. Security review notes: - The subcommand authenticates; it does not decide. There is deliberately no path that signs an access token with Mint's signing key, which would be a way to obtain authority without authenticating inside the binary whose purpose is to make authority depend on authentication. Anything it obtains, the same client could have obtained over the wire. - The caller's key file gets the same guarantees as Mint's own signing key, through `secretfile::read_owner_only`. - Delegation subjects come from `--subject-file`, never from flags: they are a person's identifying details, and command lines are visible to every process on the host and land in shell history. - Logs move to stderr for this subcommand so stdout carries the access token alone. Also closes a claim-shadowing gap in `ClaimNames::validate`. Minting writes the configured claim names last, so a principal claim named `aud` produced a token whose audience was the principal and which still verified; the same held for any non-principal claim named `sub`. Both are now refused at configuration load, with tests over every reserved name. Signed-off-by: Jeremi Joslin --- AGENTS.md | 6 + Cargo.lock | 1 + crates/registry-mint/Cargo.toml | 1 + crates/registry-mint/README.md | 47 +++ crates/registry-mint/src/caller.rs | 332 ++++++++++++++++++++++ crates/registry-mint/src/config.rs | 59 +++- crates/registry-mint/src/lib.rs | 1 + crates/registry-mint/src/main.rs | 193 ++++++++++++- crates/registry-mint/tests/token_cli.rs | 363 ++++++++++++++++++++++++ 9 files changed, 995 insertions(+), 8 deletions(-) create mode 100644 crates/registry-mint/src/caller.rs create mode 100644 crates/registry-mint/tests/token_cli.rs diff --git a/AGENTS.md b/AGENTS.md index 555b58512..72684659c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,11 @@ Registry Manifest describes sources portably; Relay is its consumer in code (Notary does not depend on the manifest crates). `registry-platform-*` crates are shared primitives. `registryctl` is adopter tooling. +Registry Mint is a supporting service, not a fourth pattern: it issues the +access tokens a resource server such as Evidence verifies, for deployments with +no identity provider. The dependency runs one way only. Mint's tests drive +Evidence's authenticator; Evidence does not depend on Mint. + ## Repository map | Area | Owns | @@ -23,6 +28,7 @@ are shared primitives. `registryctl` is adopter tooling. | `crates/registry-relay` | Protected read APIs (Relay) | | `crates/registry-notary*` | Evidence gateway: server, core, client, source adapters, worker harness (Notary) | | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | +| `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | | `crates/registry-platform-*` | Shared primitives: audit, authcommon, cache, config, crypto, httpsec, httputil, oid4vci, oidc, ops, pdp, replay, sdjwt, sts, testing | | `crates/registryctl` | Adopter tooling | diff --git a/Cargo.lock b/Cargo.lock index fbf3db49c..c98a223e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,6 +5447,7 @@ dependencies = [ "registry-platform-canonical-json", "registry-platform-crypto", "registry-platform-oidc", + "reqwest 0.12.28", "rustix", "serde", "serde_json", diff --git a/crates/registry-mint/Cargo.toml b/crates/registry-mint/Cargo.toml index ecf75df10..b75cea858 100644 --- a/crates/registry-mint/Cargo.toml +++ b/crates/registry-mint/Cargo.toml @@ -25,6 +25,7 @@ jsonwebtoken.workspace = true registry-platform-canonical-json.workspace = true registry-platform-crypto.workspace = true registry-platform-oidc.workspace = true +reqwest.workspace = true rustix.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md index 776b56d8c..0f126df91 100644 --- a/crates/registry-mint/README.md +++ b/crates/registry-mint/README.md @@ -229,6 +229,50 @@ socket. Mint serves plain HTTP and expects to sit behind TLS termination it does not manage. +## Getting a token in development + +```bash +mint token --url https://mint.example.org/token \ + --client-id scheduler --key ./dev/scheduler.jwk +``` + +It prints the access token on stdout and nothing else, so `TOKEN=$(mint token +...)` is the whole usage. Diagnostics go to stderr; `--verbose` prints the +endpoint's full response instead. + +This is a *client* tool. It signs a client assertion with the caller's own key +and presents it to a running endpoint, exactly as an adopter's client library +would. It reads no server configuration and never touches Mint's signing key. +There is deliberately no subcommand that signs an access token directly: that +would be a way to obtain authority without authenticating, inside the binary +whose purpose is to make authority depend on authentication. Anything `mint +token` can obtain, the same client could have obtained over the wire. + +The key file gets the same treatment as Mint's own signing key: a regular file, +owned by you, unreadable by group and other, reached without traversing a +symlink. + +For a delegated token: + +```bash +mint token --url https://mint.example.org/token \ + --client-id scheduler --key ./dev/scheduler.jwk \ + --actor urn:example:agent:appointment-scheduler \ + --subject-file ./dev/subject.json +``` + +`--subject-file` holds a flat JSON object of selector fields +(`{"given_name": "Amara", "birth_date": "1998-04-02"}`). It is a file rather +than repeated flags because those are a real person's identifying details, and +command lines are visible to every process on the host and land in shell +history. + +Two more flags matter in development. `--audience` overrides the assertion +audience, which defaults to `--url`; they differ when the endpoint is reached +over loopback but configured with its public URL. `--ca-certificate` trusts a +PEM bundle in addition to the system roots, for a deployment behind a private +CA. + ## Verify a change ```bash @@ -241,3 +285,6 @@ token to Evidence's own authenticator. `tests/delegated_subject_binding.rs` does the same for delegation, running Evidence's own entitlement match and selector resolution over a token from the real Mint router. The dependency runs one way only. Evidence does not depend on Mint. + +`tests/token_cli.rs` runs `mint token` against a real `mint serve` as two +processes, which is the only place the stdout contract can be observed. diff --git a/crates/registry-mint/src/caller.rs b/crates/registry-mint/src/caller.rs new file mode 100644 index 000000000..7a18ddff4 --- /dev/null +++ b/crates/registry-mint/src/caller.rs @@ -0,0 +1,332 @@ +//! The caller's half of the protocol: building a client assertion. +//! +//! This is what a client does, not what Mint does. It holds no server state, +//! reads no server configuration, and touches no signing key of Mint's. It +//! signs with the *caller's* own private key, exactly as an adopter's client +//! library would, and produces an assertion that the token endpoint then +//! verifies on its own terms. +//! +//! That separation is deliberate and worth stating plainly, because the obvious +//! alternative is a subcommand that signs an access token directly with Mint's +//! signing key. That would be a way to obtain authority without authenticating, +//! inside the binary whose entire purpose is to make authority depend on +//! authentication. There is no such path here and there should never be one. +//! +//! Getting an assertion right by hand is fiddly (exact claims, a fresh `jti`, a +//! lifetime inside the configured bound) and getting it wrong yields an opaque +//! `invalid_client`. A first-party builder removes that guesswork and doubles as +//! executable documentation of the format. + +use std::collections::BTreeMap; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_crypto::{PrivateJwk, SigningAlgorithm}; +use serde_json::{json, Map, Value}; + +use crate::ON_BEHALF_OF_CLAIM; + +/// Refusals that happen before anything is signed. +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub enum AssertionError { + #[error("{0}")] + Invalid(&'static str), + #[error("the assertion could not be signed: {0}")] + Signing(String), +} + +/// What a caller is asking for. +/// +/// `actor` and `subject` are the delegation request. Mint requires them +/// together or not at all, and refuses a registered delegated client that omits +/// them, so this refuses the halfway states here rather than spending a network +/// round trip to be told. +#[derive(Debug)] +pub struct AssertionRequest<'a> { + pub client_id: &'a str, + /// The token endpoint's configured `clientAssertion.audience`. + pub audience: &'a str, + pub lifetime_seconds: i64, + pub actor: Option<&'a str>, + pub subject: Option>, +} + +/// Build and sign one client assertion. +/// +/// `now` is a Unix timestamp, passed in rather than read so that the claim +/// arithmetic is testable. +pub fn sign_client_assertion( + key: &PrivateJwk, + request: &AssertionRequest<'_>, + now: i64, +) -> Result { + if request.client_id.trim().is_empty() { + return Err(AssertionError::Invalid("a client id is required")); + } + if request.audience.trim().is_empty() { + return Err(AssertionError::Invalid("an assertion audience is required")); + } + // Mint bounds the assertion lifetime and applies 30 seconds of clock skew + // either way; anything outside this is a caller error, not a policy choice. + if !(1..=300).contains(&request.lifetime_seconds) { + return Err(AssertionError::Invalid( + "the assertion lifetime must be 1..=300 seconds", + )); + } + + let mut claims = Map::new(); + claims.insert("iss".to_owned(), json!(request.client_id)); + claims.insert("sub".to_owned(), json!(request.client_id)); + claims.insert("aud".to_owned(), json!(request.audience)); + claims.insert("iat".to_owned(), json!(now)); + claims.insert("exp".to_owned(), json!(now + request.lifetime_seconds)); + // Every assertion is single use. A caller-chosen `jti` would make a repeat + // an accident waiting to happen, so it is generated here and never reused. + claims.insert("jti".to_owned(), json!(ulid::Ulid::new().to_string())); + + match (request.actor, &request.subject) { + (None, None) => {} + (Some(actor), Some(subject)) => { + if actor.trim().is_empty() { + return Err(AssertionError::Invalid("the actor must not be empty")); + } + if subject.is_empty() { + return Err(AssertionError::Invalid( + "the subject must name at least one selector field", + )); + } + claims.insert( + ON_BEHALF_OF_CLAIM.to_owned(), + json!({"actor": actor, "subject": subject}), + ); + } + _ => { + return Err(AssertionError::Invalid( + "a delegation needs both an actor and a subject", + )) + } + } + + let algorithm = match key + .algorithm() + .map_err(|error| AssertionError::Signing(error.to_string()))? + { + SigningAlgorithm::EdDsa => "EdDSA", + SigningAlgorithm::Es256 => "ES256", + SigningAlgorithm::Rs256 => "RS256", + }; + let header = json!({ + "alg": algorithm, + "typ": "JWT", + "kid": key + .kid + .as_deref() + .ok_or(AssertionError::Invalid("the signing key needs a kid"))?, + }); + + let encode = |value: &Value| -> Result { + serde_json::to_vec(value) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|error| AssertionError::Signing(error.to_string())) + }; + let signing_input = format!("{}.{}", encode(&header)?, encode(&Value::Object(claims))?); + let signature = registry_platform_crypto::sign(signing_input.as_bytes(), key) + .map_err(|error| AssertionError::Signing(error.to_string()))?; + Ok(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_800_000_000; + + fn key() -> PrivateJwk { + let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + PrivateJwk::parse( + &json!({ + "kty": "OKP", + "crv": "Ed25519", + "kid": "caller-key-1", + "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(signing.to_bytes()), + }) + .to_string(), + ) + .expect("the test key parses") + } + + fn request<'a>(client_id: &'a str, audience: &'a str) -> AssertionRequest<'a> { + AssertionRequest { + client_id, + audience, + lifetime_seconds: 120, + actor: None, + subject: None, + } + } + + fn claims_of(assertion: &str) -> Value { + let payload = assertion.split('.').nth(1).expect("a payload segment"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).expect("base64url")) + .expect("claims parse") + } + + #[test] + fn an_assertion_carries_exactly_what_the_token_endpoint_requires() { + let assertion = sign_client_assertion( + &key(), + &request("scheduler", "https://mint.example.org/token"), + NOW, + ) + .expect("the assertion signs"); + + let claims = claims_of(&assertion); + assert_eq!(claims["iss"], json!("scheduler")); + assert_eq!(claims["sub"], json!("scheduler")); + assert_eq!(claims["aud"], json!("https://mint.example.org/token")); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!(claims["exp"], json!(NOW + 120)); + assert!(claims.get(ON_BEHALF_OF_CLAIM).is_none()); + + let header: Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(assertion.split('.').next().expect("a header segment")) + .expect("base64url"), + ) + .expect("header parses"); + assert_eq!(header["alg"], json!("EdDSA")); + assert_eq!(header["typ"], json!("JWT")); + assert_eq!(header["kid"], json!("caller-key-1")); + } + + /// Reusing a `jti` is refused by Mint as a replay, so the builder must never + /// produce the same one twice even when called with an identical request. + #[test] + fn every_assertion_gets_its_own_jti() { + let key = key(); + let request = request("scheduler", "https://mint.example.org/token"); + let first = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); + let second = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); + + assert_ne!(first["jti"], second["jti"]); + assert!(first["jti"].as_str().is_some_and(|jti| !jti.is_empty())); + } + + #[test] + fn a_delegation_request_rides_inside_the_signed_claims() { + let subject = BTreeMap::from([ + ("given_name".to_owned(), json!("Amara")), + ("birth_date".to_owned(), json!("1998-04-02")), + ]); + let assertion = sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + subject: Some(subject), + ..request("scheduler", "https://mint.example.org/token") + }, + NOW, + ) + .expect("the assertion signs"); + + let claims = claims_of(&assertion); + assert_eq!( + claims[ON_BEHALF_OF_CLAIM], + json!({ + "actor": "urn:example:agent:scheduler", + "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, + }) + ); + } + + /// Mint requires the actor and subject together, and refuses a registered + /// delegated client that sends neither. Answering here saves a round trip + /// that could only ever return an opaque `invalid_client`. + #[test] + fn half_a_delegation_is_refused_before_anything_is_signed() { + let audience = "https://mint.example.org/token"; + let expected = || AssertionError::Invalid("a delegation needs both an actor and a subject"); + + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + ..request("scheduler", audience) + }, + NOW, + ), + Err(expected()) + ); + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), + ..request("scheduler", audience) + }, + NOW, + ), + Err(expected()) + ); + } + + #[test] + fn empty_and_out_of_range_inputs_are_refused() { + let audience = "https://mint.example.org/token"; + assert_eq!( + sign_client_assertion(&key(), &request(" ", audience), NOW), + Err(AssertionError::Invalid("a client id is required")) + ); + assert_eq!( + sign_client_assertion(&key(), &request("scheduler", ""), NOW), + Err(AssertionError::Invalid("an assertion audience is required")) + ); + for lifetime in [0, -1, 301] { + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + lifetime_seconds: lifetime, + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid( + "the assertion lifetime must be 1..=300 seconds" + )), + "lifetime {lifetime} must be refused" + ); + } + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some(" "), + subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid("the actor must not be empty")) + ); + assert_eq!( + sign_client_assertion( + &key(), + &AssertionRequest { + actor: Some("urn:example:agent:scheduler"), + subject: Some(BTreeMap::new()), + ..request("scheduler", audience) + }, + NOW, + ), + Err(AssertionError::Invalid( + "the subject must name at least one selector field" + )) + ); + } +} diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs index 6c732ce7b..baf1a8209 100644 --- a/crates/registry-mint/src/config.rs +++ b/crates/registry-mint/src/config.rs @@ -153,14 +153,26 @@ impl ClaimNames { if unique.len() != names.len() { return Err(ConfigError::Invalid("claim names must be distinct")); } - // These are written by Mint itself and must not be overridable. + // These are written by Mint itself and must not be overridable. Minting + // writes the registered claims last, so any of these reused as a claim + // name would silently replace what Mint decided with what the registry + // did: an `aud` shadow yields a token whose audience is the principal + // and which still verifies. for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { - if names.iter().skip(1).any(|name| *name == reserved) { + if names.contains(&reserved) { return Err(ConfigError::Invalid( "authority claim names must not shadow registered JWT claims", )); } } + // `sub` is the exception. It always carries the principal, so naming + // the principal claim `sub` rewrites the same value and is the default; + // any other claim named `sub` would replace the principal. + if names.iter().skip(1).any(|name| *name == "sub") { + return Err(ConfigError::Invalid( + "authority claim names must not shadow registered JWT claims", + )); + } Ok(()) } } @@ -450,6 +462,49 @@ clients: } } + #[test] + fn the_principal_claim_is_bound_by_the_same_rule_as_the_others() { + // Minting writes the registered claims after the JWT ones, so a + // principal named for a reserved claim would overwrite it. `aud` is the + // one that matters most: the token would carry the principal as its + // audience and still verify. + for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id"] { + let text = VALID.replace("principal: sub", &format!("principal: {reserved}")); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "principal {reserved} must be rejected" + ); + } + } + + #[test] + fn only_the_principal_may_be_named_sub() { + // `sub` always carries the principal, so naming the principal claim + // `sub` is the default and merely rewrites the same value. + load_from(VALID).expect("principal may be named sub"); + + // Any other claim named `sub` would replace the principal with its own + // value, which for requester tags is not even a string. The principal + // moves off `sub` first, so this is the shadowing rule answering rather + // than the distinctness rule. + let renamed = VALID.replace("principal: sub", "principal: evidence_principal"); + for field in [ + "requesterTags: evidence_tags", + "evidenceAudience: evidence_audience", + "grantId: evidence_grant_id", + "grantAuthority: evidence_authority", + ] { + let name = field.split(':').next().expect("a claim field name"); + let text = renamed.replace(field, &format!("{name}: sub")); + assert_eq!( + load_error(&text), + ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), + "{name} must not be named sub" + ); + } + } + #[test] fn the_actor_claim_is_optional_and_obeys_every_other_claim_name_rule() { // A deployment that never delegates names no actor claim at all. diff --git a/crates/registry-mint/src/lib.rs b/crates/registry-mint/src/lib.rs index a8e589978..6192a18c1 100644 --- a/crates/registry-mint/src/lib.rs +++ b/crates/registry-mint/src/lib.rs @@ -38,6 +38,7 @@ compile_error!( ); pub mod assertion; +pub mod caller; pub mod clients; pub mod config; pub mod error; diff --git a/crates/registry-mint/src/main.rs b/crates/registry-mint/src/main.rs index 83f41f383..397bd7120 100644 --- a/crates/registry-mint/src/main.rs +++ b/crates/registry-mint/src/main.rs @@ -1,10 +1,17 @@ //! The `mint` binary. //! -//! Two subcommands: `check` validates a deployment without opening a socket, -//! and `serve` runs the token endpoint. `SIGHUP` reloads the client registry in +//! Three subcommands. `check` validates a deployment without opening a socket +//! and `serve` runs the token endpoint; `SIGHUP` reloads the client registry in //! place so onboarding a caller never restarts the service. +//! +//! `token` is the odd one out: it is a *caller* tool, not an operator one. It +//! reads no server configuration and never touches Mint's signing key. It signs +//! a client assertion with the caller's own key and presents it to a running +//! token endpoint, which then decides on its own terms. Obtaining a token still +//! requires authenticating, in the CLI exactly as over the wire. use std::{ + collections::BTreeMap, path::{Path, PathBuf}, process::ExitCode, sync::Arc, @@ -12,9 +19,13 @@ use std::{ use clap::{Parser, Subcommand}; use registry_mint::{ + caller::{sign_client_assertion, AssertionRequest}, config::MintConfig, + secretfile, server::{serve, MintService}, + CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, }; +use serde_json::Value; #[derive(Debug, Parser)] #[command(name = "mint", about = "Registry Stack token issuer", version)] @@ -35,18 +46,66 @@ enum Command { #[arg(long, env = "MINT_CONFIG")] config: PathBuf, }, + /// Obtain an access token from a running token endpoint, as a client would. + /// + /// This authenticates. It signs a client assertion with the caller's own + /// key and posts it; the endpoint decides. Nothing here can produce a token + /// the same request over the wire would not have produced. + Token { + /// The token endpoint, for example `https://mint.example.org/token`. + #[arg(long)] + url: String, + /// The `clientId` this caller is registered under. + #[arg(long)] + client_id: String, + /// The caller's private JWK. Must be owner-only and not a symlink. + #[arg(long)] + key: PathBuf, + /// The endpoint's configured `clientAssertion.audience`. Defaults to + /// `--url`, which is the usual configuration. + #[arg(long)] + audience: Option, + /// Request a delegated token for this actor. Requires `--subject-file`. + #[arg(long)] + actor: Option, + /// A JSON object of subject selector fields, for the actor to act for. + /// + /// A file rather than repeated flags on purpose: these are a real + /// person's identifying details, and command lines are visible to every + /// process on the host and land in shell history. + #[arg(long)] + subject_file: Option, + /// Assertion lifetime in seconds. + #[arg(long, default_value_t = 120)] + lifetime_seconds: i64, + /// Trust this PEM certificate bundle in addition to the system roots, + /// for a development deployment behind a private CA. + #[arg(long)] + ca_certificate: Option, + /// Print the full endpoint response instead of the access token alone. + #[arg(long)] + verbose: bool, + }, } fn main() -> ExitCode { - tracing_subscriber::fmt() + let cli = Cli::parse(); + + // `token` writes the access token to stdout and nothing else, so its + // diagnostics go to stderr and the caller can pipe the token straight into + // whatever needs it. The services keep structured logs on stdout. + let logs = tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) - .json() - .init(); + .json(); + if matches!(cli.command, Command::Token { .. }) { + logs.with_writer(std::io::stderr).init(); + } else { + logs.init(); + } - let cli = Cli::parse(); match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(message) => { @@ -84,9 +143,131 @@ fn run(cli: Cli) -> Result<(), String> { .map_err(|error| format!("the listener failed: {error}")) }) } + Command::Token { + url, + client_id, + key, + audience, + actor, + subject_file, + lifetime_seconds, + ca_certificate, + verbose, + } => { + // The caller's key gets the same file guarantees as Mint's own: + // a regular file, owned by this user, unreadable by anyone else, + // and reached without traversing a symlink. + let key = secretfile::read_owner_only(&key) + .map_err(|error| format!("the client key could not be read: {error}"))?; + let key = registry_platform_crypto::PrivateJwk::parse(&key) + .map_err(|error| format!("the client key is not a usable private JWK: {error}"))?; + + let subject = subject_file.as_deref().map(read_subject).transpose()?; + let assertion = sign_client_assertion( + &key, + &AssertionRequest { + client_id: &client_id, + audience: audience.as_deref().unwrap_or(&url), + lifetime_seconds, + actor: actor.as_deref(), + subject, + }, + now_seconds()?, + ) + .map_err(|error| format!("the client assertion could not be built: {error}"))?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("the async runtime could not start: {error}"))?; + let response = + runtime.block_on(request_token(&url, &assertion, ca_certificate.as_deref()))?; + + if verbose { + println!("{response}"); + } else { + let token = response + .get("access_token") + .and_then(Value::as_str) + .ok_or_else(|| "the endpoint returned no access token".to_owned())?; + println!("{token}"); + } + Ok(()) + } } } +/// Read the delegation subject: a flat JSON object of selector fields. +fn read_subject(path: &Path) -> Result, String> { + let bytes = std::fs::read(path) + .map_err(|error| format!("the subject file could not be read: {error}"))?; + let subject: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("the subject file is not JSON: {error}"))?; + let Value::Object(fields) = subject else { + return Err("the subject file must hold a JSON object of selector fields".to_owned()); + }; + // Selector values are scalars. Rejecting anything else here names the + // problem, where the endpoint could only answer `invalid_client`. + for (name, value) in &fields { + if value.is_object() || value.is_array() || value.is_null() { + return Err(format!("the subject field `{name}` must be a scalar value")); + } + } + Ok(fields.into_iter().collect()) +} + +async fn request_token( + url: &str, + assertion: &str, + ca_certificate: Option<&Path>, +) -> Result { + let mut client = reqwest::Client::builder(); + if let Some(path) = ca_certificate { + let pem = std::fs::read(path) + .map_err(|error| format!("the CA certificate could not be read: {error}"))?; + for certificate in reqwest::Certificate::from_pem_bundle(&pem) + .map_err(|error| format!("the CA certificate could not be parsed: {error}"))? + { + client = client.add_root_certificate(certificate); + } + } + let client = client + .build() + .map_err(|error| format!("the HTTP client could not be built: {error}"))?; + + let response = client + .post(url) + .form(&[ + ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), + ("client_assertion_type", CLIENT_ASSERTION_TYPE), + ("client_assertion", assertion), + ]) + .send() + .await + .map_err(|error| format!("the token request failed: {error}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("the token response could not be read: {error}"))?; + if !status.is_success() { + // OAuth error bodies name the failure and carry no token, so echoing + // one is the useful thing to do. + return Err(format!( + "the endpoint refused the request ({status}): {body}" + )); + } + serde_json::from_str(&body).map_err(|error| format!("the token response is not JSON: {error}")) +} + +fn now_seconds() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .map_err(|_| "the system clock is before the Unix epoch".to_owned()) +} + fn load(config: &Path) -> Result { let config = MintConfig::load(config) .map_err(|error| format!("the configuration could not be loaded: {error}"))?; diff --git a/crates/registry-mint/tests/token_cli.rs b/crates/registry-mint/tests/token_cli.rs new file mode 100644 index 000000000..352590a35 --- /dev/null +++ b/crates/registry-mint/tests/token_cli.rs @@ -0,0 +1,363 @@ +//! `mint token` against a real `mint serve`, as two processes. +//! +//! The point of the subcommand is that it is an ordinary client: it proves who +//! it is and the endpoint decides. Testing it at the process boundary is what +//! shows that. It also pins the output contract the subcommand exists for, that +//! stdout carries the access token and nothing else, which no in-process test +//! of the builder could observe. + +use std::{ + fs, + io::ErrorKind, + net::{TcpListener, TcpStream}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::{json, Value}; + +const ISSUER: &str = "https://mint.example.org"; +const ASSERTION_AUDIENCE: &str = "https://mint.example.org/token"; +const ACTOR: &str = "urn:example:agent:scheduler"; + +/// Deterministic Ed25519 material, so a test knows which identity signed what. +fn key_pair(seed: u8) -> (Value, Value) { + let seed_bytes = [seed; 32]; + let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); + let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); + let kid = format!("key-{seed}"); + ( + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}), + json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, + "d": URL_SAFE_NO_PAD.encode(seed_bytes)}), + ) +} + +/// A running `mint serve`, killed when the test drops it however it ends. +struct Server { + _directory: tempfile::TempDir, + child: Child, + port: u16, + root: PathBuf, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Server { + fn token_url(&self) -> String { + format!("http://127.0.0.1:{}/token", self.port) + } + + fn caller_key(&self, client_id: &str) -> PathBuf { + self.root.join(format!("{client_id}.jwk")) + } +} + +fn write_owner_only(path: &Path, contents: &str) { + fs::write(path, contents).expect("write secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("restrict secret"); +} + +/// Write a deployment, start the binary on it, and wait for the listener. +fn server() -> Server { + let directory = tempfile::tempdir().expect("temp dir"); + let root = directory.path().to_path_buf(); + fs::create_dir(root.join("secrets")).expect("create secrets directory"); + fs::create_dir(root.join("clients")).expect("create clients directory"); + + let (_, signing_private) = key_pair(9); + write_owner_only( + &root.join("secrets/signing.jwk"), + &signing_private.to_string(), + ); + + // `listener.port: 0` would leave the test unable to find the port, so an + // ephemeral one is reserved and released. The window is a test-only risk. + let port = TcpListener::bind("127.0.0.1:0") + .expect("reserve a port") + .local_addr() + .expect("the reserved port") + .port(); + + let (scheduler_public, scheduler_private) = key_pair(1); + write_owner_only(&root.join("scheduler.jwk"), &scheduler_private.to_string()); + fs::write( + root.join("clients/scheduler.yaml"), + format!( + "clientId: scheduler +principal: urn:example:principal:scheduler +evidenceAudience: https://scheduler.example.org +requesterTags: [scheduler] +keys: [{scheduler_public}] +delegation: + actors: [{ACTOR}] + subjectClaims: + given_name: identity.given_name + birth_date: identity.birth_date +" + ), + ) + .expect("write scheduler registration"); + + let (reporter_public, reporter_private) = key_pair(2); + write_owner_only(&root.join("reporter.jwk"), &reporter_private.to_string()); + fs::write( + root.join("clients/reporter.yaml"), + format!( + "clientId: reporter +principal: urn:example:principal:reporter +evidenceAudience: https://reporter.example.org +requesterTags: [reporter] +keys: [{reporter_public}] +" + ), + ) + .expect("write reporter registration"); + + let config = root.join("mint.yaml"); + fs::write( + &config, + format!( + "version: 1 +issuer: {ISSUER} +listener: {{address: 127.0.0.1, port: {port}}} +signing: + algorithm: EdDSA + activeKeyId: key-9 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [evidence.example.org] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority + actor: evidence_actor +clientAssertion: + audience: {ASSERTION_AUDIENCE} + algorithms: [EdDSA] +clients: + directory: clients +" + ), + ) + .expect("write config"); + + let child = Command::new(env!("CARGO_BIN_EXE_mint")) + .arg("serve") + .arg("--config") + .arg(&config) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("the mint binary starts"); + + let server = Server { + _directory: directory, + child, + port, + root, + }; + wait_for_listener(port); + server +} + +fn wait_for_listener(port: u16) { + let deadline = Instant::now() + Duration::from_secs(20); + loop { + match TcpStream::connect(("127.0.0.1", port)) { + Ok(_) => return, + Err(error) if error.kind() == ErrorKind::ConnectionRefused => { + assert!( + Instant::now() < deadline, + "the token endpoint never accepted" + ); + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => panic!("the token endpoint could not be reached: {error}"), + } + } +} + +fn mint_token(server: &Server, client_id: &str, extra: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_mint")); + command + .arg("token") + .arg("--url") + .arg(server.token_url()) + // The endpoint is reached over loopback but its configured assertion + // audience is its public URL, which is the deployment shape behind a + // TLS terminator and the reason the flag exists. + .arg("--audience") + .arg(ASSERTION_AUDIENCE) + .arg("--client-id") + .arg(client_id) + .arg("--key") + .arg(server.caller_key(client_id)) + .args(extra); + command.output().expect("the mint binary runs") +} + +fn claims_of(token: &str) -> Value { + let segments: Vec<&str> = token.split('.').collect(); + assert_eq!(segments.len(), 3, "an access token has three segments"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1]).expect("base64url")) + .expect("claims parse") +} + +fn stdout_of(output: &Output) -> String { + assert!( + output.status.success(), + "the command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()).expect("stdout is UTF-8") +} + +#[test] +fn the_subcommand_obtains_a_token_the_endpoint_agreed_to_issue() { + let server = server(); + let output = mint_token(&server, "reporter", &[]); + let stdout = stdout_of(&output); + + // Exactly one line, so `TOKEN=$(mint token ...)` is the whole usage. + assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); + let claims = claims_of(stdout.trim()); + + assert_eq!(claims["iss"], json!(ISSUER)); + assert_eq!(claims["sub"], json!("urn:example:principal:reporter")); + assert_eq!(claims["client_id"], json!("reporter")); + assert_eq!(claims["evidence_tags"], json!(["reporter"])); + assert_eq!( + claims["evidence_audience"], + json!("https://reporter.example.org") + ); + // The authority came from the registry, not from anything the caller sent. + assert!(claims.get("evidence_actor").is_none()); +} + +#[test] +fn a_delegated_token_carries_the_actor_and_the_subject_from_the_registry_paths() { + let server = server(); + let subject = server.root.join("subject.json"); + fs::write( + &subject, + json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + ACTOR, + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + let claims = claims_of(stdout_of(&output).trim()); + + assert_eq!(claims["evidence_actor"], json!(ACTOR)); + assert_eq!( + claims["identity"], + json!({"given_name": "Amara", "birth_date": "1998-04-02"}), + "the claim paths are the registry's, not the request's" + ); +} + +/// The subcommand authenticates; it does not decide. An actor the registration +/// does not permit must be refused by the endpoint, with no token printed. +#[test] +fn an_unregistered_actor_is_refused_by_the_endpoint() { + let server = server(); + let subject = server.root.join("other-subject.json"); + fs::write( + &subject, + json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + "urn:example:agent:not-registered", + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "a refusal must print no token: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid_client"), + "the refusal should be reported: {stderr}" + ); +} + +/// A subject file that is not a flat object of scalars is a caller mistake with +/// an opaque server-side answer, so it is named locally instead. +#[test] +fn a_malformed_subject_file_is_refused_before_the_request() { + let server = server(); + let subject = server.root.join("nested-subject.json"); + fs::write( + &subject, + json!({"identity": {"given_name": "Amara"}}).to_string(), + ) + .expect("write subject file"); + + let output = mint_token( + &server, + "scheduler", + &[ + "--actor", + ACTOR, + "--subject-file", + subject.to_str().expect("a UTF-8 path"), + ], + ); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("must be a scalar value"), + "the mistake should be named: {stderr}" + ); +} + +/// A key file anyone else can read is a key that should be assumed leaked. +#[test] +fn a_group_readable_client_key_is_refused() { + let server = server(); + let key = server.caller_key("reporter"); + fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).expect("loosen the key file"); + + let output = mint_token(&server, "reporter", &[]); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("client key could not be read"), + "the refusal should name the key file: {stderr}" + ); +} From ad8fc5028df40f2e32310960a565a1995ee0914b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:50:59 +0700 Subject: [PATCH 008/136] docs(mint): diagram the delegated evidence flow Adds a sequence diagram to the demonstration README, and corrects the walkthrough's summary, which counted four requests for a script that sends nine across six steps. Signed-off-by: Jeremi Joslin --- crates/registry-mint/demo/README.md | 35 ++++++++++++++++++++++++ crates/registry-mint/demo/walkthrough.py | 5 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/registry-mint/demo/README.md b/crates/registry-mint/demo/README.md index 1d26ba6df..b478d0797 100644 --- a/crates/registry-mint/demo/README.md +++ b/crates/registry-mint/demo/README.md @@ -40,6 +40,41 @@ means Evidence reads the selector from those claims and **refuses any request that carries selector values of its own**. That refusal is the containment: it is not "you named the wrong person", it is "you do not get to name a person". +## The flow + +```mermaid +sequenceDiagram + autonumber + participant Client + participant Mint as Registry Mint + participant Evidence + participant Source as Registry source + + Note over Client: Signs a client assertion with its own private key.
The delegation request rides inside that signature. + Client->>Mint: POST /token, assertion carrying on_behalf_of + Mint->>Mint: Verify the signature against the keys registered for scheduler + Mint->>Mint: Check the actor and the subject fields against that registration + Mint-->>Client: Access token with evidence_actor and identity.* claims + + Note over Client,Evidence: The person is named nowhere in the request below. + Client->>Evidence: POST /v1/evidence, requirement and purpose only + Evidence->>Evidence: Match the delegated authority profile + Evidence->>Evidence: Read the selector from the token (valueOrigin: authenticated-context) + Evidence->>Source: One fixed-authority lookup, for that person only + Source-->>Evidence: Registry record + Evidence-->>Client: Signed assertion: coarse region, opaque subject binding + + Note over Client,Evidence: The containment, with the same valid token. + Client->>Evidence: POST /v1/evidence carrying selector values for someone else + Evidence--xClient: 400 invalid_selector +``` + +Two properties are visible in the shape of that diagram. Nothing the client +sends after step 1 names a person, and the only arrow that reaches the registry +source is the one Evidence draws for the subject its own authority context +resolved. The final refusal is not a lookup that failed; it is a request that +was never allowed to describe anybody. + ## The four requests These are the requests the walkthrough sends, written as curl so they can be diff --git a/crates/registry-mint/demo/walkthrough.py b/crates/registry-mint/demo/walkthrough.py index 088466ddc..81ea29b85 100644 --- a/crates/registry-mint/demo/walkthrough.py +++ b/crates/registry-mint/demo/walkthrough.py @@ -3,7 +3,7 @@ # requires-python = ">=3.11" # dependencies = ["cryptography>=42", "requests>=2.31"] # /// -"""Delegated, subject-bound access, end to end, in four requests. +"""Delegated, subject-bound access, end to end. An agent needs to know which region one person lives in. It must not be able to learn that about anyone else, even if the agent's own code is wrong. @@ -15,6 +15,9 @@ 3. The client asks Evidence for evidence, and does not name the person. 4. The client tries to name a different person, and cannot. +Two further steps show the refusals that hold that shape up: what Mint will not +issue, and what a token without a delegation cannot reach. + Every request below is printed before it is sent. Run it with: crates/registry-mint/demo/run.sh From 6e44d429c453dbdc374940b2859f71163c19c983 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 19:51:58 +0700 Subject: [PATCH 009/136] feat(evidence): serve the generated OpenAPI document Version 1 generated the public contract as a release artifact but never published it from the running service. Add GET /openapi.json, returning the same bytes the contract gate reproduces, so an adopter can point tooling at a deployment instead of tracking the repository artifact. Security review: the route is unauthenticated, like /health, /ready, and the public JWKS. Its payload is a process-constant built by the same generator as products/evidence/generated/registry-evidence.openapi.json, so it names no definition, bundle revision, authority, principal, or source, and it reaches no runtime state or dependency. It is therefore not a discovery oracle, and the requester-scoped catalog stays authenticated at GET /v1/evidence-definitions. Enforcement is the handler itself, which reads no token and holds no state, and is pinned by openapi_route_serves_the_generated_contract_without_authentication_or_source_access: it asserts no source request, an empty audit, and no bundle revision in the served bytes. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/README.md | 6 ++ crates/registry-evidence/src/contracts.rs | 63 +++++++++++++- crates/registry-evidence/src/runtime_tests.rs | 46 ++++++++++ crates/registry-evidence/src/server.rs | 19 ++++- products/evidence/IMPLEMENTATION.md | 3 +- products/evidence/OPERATOR-CONTRACT.md | 10 ++- products/evidence/README.md | 13 +-- .../acceptance-test-traceability.yaml | 13 +-- .../generated/registry-evidence.openapi.json | 85 +++++++++++++++++++ 9 files changed, 241 insertions(+), 17 deletions(-) diff --git a/crates/registry-evidence/README.md b/crates/registry-evidence/README.md index 8b63841b7..921183af6 100644 --- a/crates/registry-evidence/README.md +++ b/crates/registry-evidence/README.md @@ -21,10 +21,16 @@ the native HTTP service: POST /v1/evidence GET /v1/evidence-definitions GET /health +GET /openapi.json GET /ready GET /.well-known/evidence/jwks.json ``` +`GET /openapi.json` returns the generated public contract as +`application/openapi+json`. It is unauthenticated and byte-identical to the +released artifact under `products/evidence/generated/`, so it describes no +deployment, definition, or authority. + `POST /v1/evidence` requires a `requestNonce`: the canonical unpadded base64url encoding of exactly 32 random bytes, freshly generated per request. The runtime echoes it into the Evidence payload and covers it by the signature. It is never diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index 68bb5722f..d6dd433c1 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -69,6 +69,7 @@ const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ ), ]; +static SERVED_OPENAPI: OnceLock> = OnceLock::new(); static REQUEST_VALIDATOR: OnceLock> = OnceLock::new(); static EVIDENCE_VALIDATOR: OnceLock> = OnceLock::new(); static DEFINITIONS_VALIDATOR: OnceLock> = @@ -148,6 +149,27 @@ pub fn write_documents(output: &Path) -> Result<(), ContractGenerationError> { Ok(()) } +/// The generated OpenAPI document the running service publishes. +/// +/// It is built once from the same generator as [`documents`], so the served +/// description is the committed release artifact and cannot drift from it. +pub(crate) fn served_openapi_document() -> Option<&'static str> { + SERVED_OPENAPI + .get_or_init(|| { + pretty_json(&openapi_document( + &request_schema(), + &evidence_schema(), + &definitions_schema(), + &jws_schema(), + &unsigned_envelope_schema(), + &problem_schema(), + &jwks_schema(), + )) + .ok() + }) + .as_deref() +} + /// Validate an inbound public request against the exact generated Version 1 schema. pub(crate) fn request_contract_accepts(value: &Value) -> Result { contract_validator(&REQUEST_VALIDATOR, request_schema) @@ -1060,6 +1082,26 @@ fn openapi_document( } } }, + "/openapi.json": { + "get": { + "operationId": "getOpenApi", + "summary": "Fetch this OpenAPI document", + "description": "The generated public contract for this service. This route is intentionally unauthenticated: the served bytes are the released generated artifact and describe no deployment, definition, or authority.", + "security": [], + "responses": { + "200": { + "description": "The generated Version 1 OpenAPI document", + "headers": response_headers(None), + "content": {"application/openapi+json": {"schema": {"type": "object"}}} + }, + "503": { + "description": "The document could not be produced", + "headers": response_headers(None), + "content": problem_content(&["service_unavailable"]) + } + } + } + }, "/.well-known/evidence/jwks.json": { "get": { "operationId": "getEvidenceJwks", @@ -1143,7 +1185,7 @@ mod tests { } #[test] - fn openapi_has_only_the_five_version_one_routes_and_exact_success_media() { + fn openapi_has_only_the_version_one_routes_and_exact_success_media() { let document = openapi_document( &request_schema(), &evidence_schema(), @@ -1159,11 +1201,21 @@ mod tests { [ "/.well-known/evidence/jwks.json", "/health", + "/openapi.json", "/ready", "/v1/evidence", "/v1/evidence-definitions" ] ); + assert!( + document["paths"]["/openapi.json"]["get"]["responses"]["200"]["content"] + ["application/openapi+json"] + .is_object() + ); + assert_eq!( + document["paths"]["/openapi.json"]["get"]["security"], + json!([]) + ); assert!( document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] ["application/jose+json"] @@ -1239,6 +1291,15 @@ mod tests { ); } + #[test] + fn the_served_openapi_document_is_the_generated_release_artifact() { + let generated = documents().expect("generated contracts build"); + assert_eq!( + served_openapi_document().expect("served OpenAPI document builds"), + generated[OPENAPI_FILE] + ); + } + #[test] fn jws_and_problem_schemas_are_closed() { let jws = jws_schema(); diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 550b9641c..bb58eaf6b 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -517,6 +517,52 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda } } +#[tokio::test] +async fn openapi_route_serves_the_generated_contract_without_authentication_or_source_access() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let document = http.get("/openapi.json").await; + document.assert_status_ok(); + assert_eq!(document.header("content-type"), "application/openapi+json"); + assert_eq!(document.header("cache-control"), "no-store"); + + // The served bytes are the committed release artifact, not a second + // hand-maintained description of the same routes. + let generated = crate::contracts::documents().expect("generated contracts build"); + assert_eq!(document.text(), generated[crate::contracts::OPENAPI_FILE]); + + // The document is static public material: it names no definition, reveals + // no deployment revision, and reaches no source. + let served = document.json::(); + assert_eq!(served["openapi"], json!("3.1.0")); + assert!(served["paths"]["/openapi.json"]["get"].is_object()); + assert!(!document + .text() + .contains(&fixture.runtime.bundle().revision().to_string())); + assert!(fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // Only GET is in the contract; anything else joins the closed unknown-route + // problem response. + let rejected = http.post("/openapi.json").await; + assert_eq!( + rejected.status_code(), + ProblemCode::MalformedRequest.status() + ); + assert_eq!( + rejected.json::()["code"], + json!(ProblemCode::MalformedRequest.code()) + ); + + let audit = fs::read_to_string(&fixture.audit_path).expect("durable audit is readable"); + assert!(audit.is_empty()); +} + #[tokio::test] async fn discovery_requires_authentication_and_returns_no_unentitled_definitions() { let fixture = acceptance_runtime().await; diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 5ade65db9..dd12ca5be 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -39,7 +39,7 @@ use ulid::Ulid; use crate::{ config::{ListenerConfig, ResponseFormat}, - contracts::request_contract_accepts, + contracts::{request_contract_accepts, served_openapi_document}, model::{request_nonce_is_canonical, EvidenceRequest}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeFailure}, @@ -49,6 +49,7 @@ use crate::{ const JSON_MEDIA_TYPE: &str = "application/json"; const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; +const OPENAPI_MEDIA_TYPE: &str = "application/openapi+json"; const RETRY_AFTER_SECONDS: &str = "1"; #[derive(Clone)] @@ -62,7 +63,7 @@ struct ServerState { evaluation_time: Option>, } -/// Build the five-route Version 1 application from one immutable runtime. +/// Build the complete Version 1 application from one immutable runtime. #[cfg(test)] pub(crate) fn build_app(runtime: Arc) -> Router { build_app_with_tracker(runtime).0 @@ -105,6 +106,7 @@ fn build_app_with_tracker_at( .route("/v1/evidence", post(create_evidence)) .route("/v1/evidence-definitions", get(discover_evidence)) .route("/health", get(health)) + .route("/openapi.json", get(openapi)) .route("/ready", get(ready)) .route("/.well-known/evidence/jwks.json", get(jwks)) .fallback(unknown_route) @@ -452,6 +454,19 @@ async fn health() -> Response { static_json_response(StatusCode::OK, r#"{"status":"ok"}"#) } +/// Publish the generated public contract. The document is static release +/// material, so this route takes no credential and reaches no dependency. +async fn openapi() -> Response { + match served_openapi_document() { + Some(document) => bytes_response( + StatusCode::OK, + OPENAPI_MEDIA_TYPE, + document.as_bytes().to_vec(), + ), + None => problem_response(ProblemCode::ServiceUnavailable, &operation_id()), + } +} + async fn ready(State(state): State>) -> Response { let operation = operation_id(); match tokio::time::timeout(state.request_timeout, state.runtime.ready()).await { diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 138004d70..8fcbe1319 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -568,7 +568,8 @@ the privacy and trust invariants. minimized, audited path without a second evaluator or signing fallback. - Add request and response limits, safe problem responses, per-principal rate controls, dependency timeouts, and shutdown behavior. -- Generate JSON Schema and OpenAPI from code and add drift checks. +- Generate JSON Schema and OpenAPI from code and add drift checks, and publish + the generated OpenAPI document unauthenticated at `GET /openapi.json`. - Test all four acceptance definitions through the real router and HTTP client while multiple definitions are enabled in one process. diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 9775a8552..e467eace7 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -138,8 +138,8 @@ The publication workflow is: Version one does not implement a public, cross-requester, searchable, mutable, or federated catalog, a registration editor, or a `describe` CLI command. -`/health`, `/ready`, public problems, and JWKS never reveal enabled definitions -or selector profiles. +`/health`, `/ready`, `/openapi.json`, public problems, and JWKS never reveal +enabled definitions or selector profiles. ## Requester authority and purpose @@ -432,10 +432,16 @@ The native operations are: GET /v1/evidence-definitions POST /v1/evidence GET /health +GET /openapi.json GET /ready GET /.well-known/evidence/jwks.json ``` +`GET /openapi.json` publishes the generated public contract as +`application/openapi+json`. It carries no credential requirement because the +served bytes are the released generated artifact: the same document shipped in +`products/evidence/generated/`, independent of the deployed bundle. + A successful `GET /v1/evidence-definitions` response uses `application/json` and the closed requester-scoped definition schema. It requires the same strict Bearer authentication profile and per-principal request budget as evidence diff --git a/products/evidence/README.md b/products/evidence/README.md index d92d57719..496cd69ee 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -86,12 +86,13 @@ Discovery performs no provider request and creates no evidence-data audit event. Metadata never grants authority; `POST /v1/evidence` authenticates and authorizes the complete tuple again. -The generated OpenAPI defines both operations. Operators still publish static -onboarding material through their API catalog, developer portal, configuration -repository, or bilateral process for token acquisition, human descriptions, -legal context, endpoint trust, and verifier policy. The public JWKS at -`/.well-known/evidence/jwks.json` supplies verification keys only. The complete -contract and change rules are in +The generated OpenAPI defines both operations, and the running service publishes +that same document unauthenticated at `GET /openapi.json`. Operators still +publish static onboarding material through their API catalog, developer portal, +configuration repository, or bilateral process for token acquisition, human +descriptions, legal context, endpoint trust, and verifier policy. The public +JWKS at `/.well-known/evidence/jwks.json` supplies verification keys only. The +complete contract and change rules are in [the operator contract](OPERATOR-CONTRACT.md#discovery-of-available-evidence). ## Requesting evidence diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index 69ae2b72d..7f3f30c48 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -162,7 +162,7 @@ entries: tests: - {file: crates/registry-evidence/tests/deployment_projects.rs, name: reference_deployment_projects_execute_the_closed_fixture_contract} - {file: crates/registry-evidence/src/bundle.rs, name: deployment_reference_projects_are_complete_compilable_bundles} - - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_version_one_routes_and_exact_success_media} note: >- The mapped tests prove the positive half only: source-product behavior is reached through governed bundles and scripts without editing Rust, and the @@ -175,12 +175,15 @@ entries: tests: - {file: crates/registry-evidence/src/contracts.rs, name: every_json_schema_is_valid_draft_2020_12} - {file: crates/registry-evidence/src/contracts.rs, name: openapi_document_is_valid_utoipa_model} - - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_version_one_routes_and_exact_success_media} - {file: crates/registry-evidence/src/contracts.rs, name: schemas_accept_the_exact_public_wire_shapes} + - {file: crates/registry-evidence/src/contracts.rs, name: the_served_openapi_document_is_the_generated_release_artifact} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: openapi_route_serves_the_generated_contract_without_authentication_or_source_access} note: >- These tests pin the generated contract content and its agreement with the - public Rust wire types. Byte-for-byte reproduction against the committed - artifacts under products/evidence/generated/ is proven by + public Rust wire types, including the document the running service + publishes at GET /openapi.json. Byte-for-byte reproduction against the + committed artifacts under products/evidence/generated/ is proven by products/evidence/scripts/check-contracts.sh, not by a cargo test. - id: acceptance-row-29 summary: Every declared Supported Value form rejects wrong scalar types, unknown codes, invalid entity references, excessive precision, oversized strings and lists, prohibited duplicates, and wrong cardinalities, and each valid form survives Evidence construction, JWS serialization, and verification without type loss. @@ -414,4 +417,4 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_requires_authentication_and_returns_no_unentitled_definitions} - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_omits_an_authority_shape_that_the_runtime_would_deny_as_ambiguous} - {file: crates/registry-evidence/src/runtime_tests.rs, name: discovery_uses_the_bounded_per_principal_request_budget} - - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_five_version_one_routes_and_exact_success_media} + - {file: crates/registry-evidence/src/contracts.rs, name: openapi_has_only_the_version_one_routes_and_exact_success_media} diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index 793aa3e08..bbce6b652 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -1294,6 +1294,91 @@ "summary": "Report process liveness without dependency access" } }, + "/openapi.json": { + "get": { + "description": "The generated public contract for this service. This route is intentionally unauthenticated: the served bytes are the released generated artifact and describe no deployment, definition, or authority.", + "operationId": "getOpenApi", + "responses": { + "200": { + "content": { + "application/openapi+json": { + "schema": { + "type": "object" + } + } + }, + "description": "The generated Version 1 OpenAPI document", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "enum": [ + "service_unavailable" + ], + "type": "string" + }, + "status": { + "enum": [ + 503 + ], + "type": "integer" + }, + "title": { + "enum": [ + "Service temporarily unavailable" + ], + "type": "string" + }, + "type": { + "enum": [ + "https://registrystack.org/problems/evidence/service_unavailable" + ], + "type": "string" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "The document could not be produced", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + } + } + } + }, + "security": [], + "summary": "Fetch this OpenAPI document" + } + }, "/ready": { "get": { "operationId": "getReadiness", From 06a209f22e14ffa07f45d5065f9d5ebf35598caf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 20:45:28 +0700 Subject: [PATCH 010/136] docs(evidence): bound version one delegation against the deferred profile Signed-off-by: Jeremi Joslin --- products/evidence/CONCEPT.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 66ba7434b..f96d12acf 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -128,7 +128,7 @@ Version one is not: - an OOTS Evidence Broker, Data Service Directory, Semantic Repository, Preview Space, or AS4 Access Point; - a replacement for source-system access control. -Document evidence, holder credentials, transaction-bound replay protection, OOTS execution, public or federated catalogs, multi-source fulfillment, source-planning scripts, and delegated-agent access are explicitly deferred. The closed requester-scoped definition response is not a catalog or authorization source. +Document evidence, holder credentials, transaction-bound replay protection, OOTS execution, public or federated catalogs, multi-source fulfillment, source-planning scripts, and the delegated-agent grant profile of section 15.3 are explicitly deferred. Deferring that profile does not defer the optional delegated actor identity of section 8.1: version one carries an actor in the authenticated authority context and authorizes it there, but consumes no agent grant record and exposes no agent-facing operations. The closed requester-scoped definition response is not a catalog or authorization source. ## 5. Design principles @@ -427,6 +427,8 @@ types in the Evidence core. Evidence consumes an authenticated authority context. Its basis may be statutory authority, organizational authority, consent, delegation, or an OOTS explicit request. A per-request grant reference is optional because statutory flows may derive authority from the requester and configured procedure. Evidence does not issue, manage, revoke, or infer that authority. +Where the basis is delegation, version one carries the actor identity in that context and confines an actor-bearing request to authority paths declared `delegated`. It does not resolve a delegating principal, enforce call constraints, or consume an agent grant record; section 15.3 covers those. + A caller-supplied consent or approval reference never creates authority by itself. ### 8.5 Existence disclosure From 76f97da7e8cf4388ce7140f698ffe550081fcd9f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 22:09:42 +0700 Subject: [PATCH 011/136] feat(evidence): land version one runtime and professional-licence definition Rename the reference deployment project dhis2-adult-status -> dhis2-tracker-evidence and add the professional-licence-status acceptance definition beside adult-status, both served from the same DHIS2 tracker source. Bring in the version one runtime: request nonce end-to-end, response-format negotiation with unsigned envelope, strict offline verifier, source transport pinning, and observability. Security-sensitive: touches authentication (auth.rs), audit (audit.rs), source transport pinning (source.rs), and verifier policy. Reviewed for data minimization; reference deployment-project names stay test-only and the production code path remains source-neutral. Signed-off-by: Jeremi Joslin --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/registry-evidence/Cargo.toml | 1 + crates/registry-evidence/README.md | 8 +- crates/registry-evidence/src/audit.rs | 78 +- crates/registry-evidence/src/auth.rs | 16 + crates/registry-evidence/src/bundle.rs | 2 +- crates/registry-evidence/src/config.rs | 143 ++- crates/registry-evidence/src/contracts.rs | 12 +- crates/registry-evidence/src/lib.rs | 1 + crates/registry-evidence/src/main.rs | 59 +- crates/registry-evidence/src/observability.rs | 367 ++++++++ crates/registry-evidence/src/rhai_runtime.rs | 7 + crates/registry-evidence/src/runtime.rs | 13 +- crates/registry-evidence/src/runtime_tests.rs | 863 +++++++++++++++++- crates/registry-evidence/src/server.rs | 178 +++- crates/registry-evidence/src/source.rs | 7 +- crates/registry-evidence/tests/cli.rs | 32 + .../tests/deployment_projects.rs | 35 +- products/evidence/FIRST-CURL-TEST.md | 19 +- products/evidence/IMPLEMENTATION.md | 3 +- products/evidence/OPERATOR-CONTRACT.md | 55 ++ products/evidence/PERFORMANCE.md | 123 +++ products/evidence/README.md | 6 +- products/evidence/contracts/README.md | 3 + .../evidence/contracts/runtime.schema.yaml | 15 + .../contracts/security-invariant-matrix.yaml | 16 +- .../contracts/security-test-traceability.yaml | 12 + .../evidence/contracts/source-contract.yaml | 2 + .../generated/registry-evidence.openapi.json | 133 +++ .../deployment-projects/FIXTURES.md | 5 + .../deployment-projects/README.md | 7 +- .../dhis2-adult-status/README.md | 59 -- .../dhis2-adult-status/bundle/evidence.yaml | 133 --- .../dhis2-tracker-evidence/README.md | 127 +++ .../adapters/adult-status-extract.rhai} | 35 +- .../bundle/adapters/prepare.rhai | 0 .../professional-licence-extract.rhai | 171 ++++ .../codelists/licence-expiry-categories.yaml | 3 + .../bundle/derivations/adult-status.rhai | 0 .../derivations/professional-licence.rhai | 36 + .../bundle/evidence.yaml | 242 +++++ .../bundle/fixtures/adult-status-cases.yaml} | 50 +- .../fixtures/professional-licence-cases.yaml | 297 ++++++ ...ult-status-adapter-parameters.schema.yaml} | 0 .../schemas/adult-status-facts.schema.yaml} | 0 ...nal-licence-adapter-parameters.schema.yaml | 27 + .../professional-licence-facts.schema.yaml | 9 + .../runtime.yaml | 0 .../request-adapter/dhis2-tracker/source.yaml | 5 +- 50 files changed, 3125 insertions(+), 293 deletions(-) create mode 100644 crates/registry-evidence/src/observability.rs create mode 100644 products/evidence/PERFORMANCE.md delete mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md delete mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status/bundle/adapters/extract.rhai => dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai} (58%) rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status => dhis2-tracker-evidence}/bundle/adapters/prepare.rhai (100%) create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/codelists/licence-expiry-categories.yaml rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status => dhis2-tracker-evidence}/bundle/derivations/adult-status.rhai (100%) create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/professional-licence.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status/bundle/fixtures/cases.yaml => dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml} (60%) create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml => dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml} (100%) rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status/bundle/schemas/facts.schema.yaml => dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml} (100%) create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml rename products/evidence/reference/request-adapter/deployment-projects/{dhis2-adult-status => dhis2-tracker-evidence}/runtime.yaml (100%) diff --git a/Cargo.lock b/Cargo.lock index c98a223e8..5a2affa27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5385,6 +5385,7 @@ dependencies = [ "tower", "tower-http 0.7.0", "tracing", + "tracing-subscriber", "ulid", "url", "utoipa", diff --git a/Cargo.toml b/Cargo.toml index f93cad094..c760cbf02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,7 +130,7 @@ proptest = { version = "1" } rand_core = { version = "0.6", features = ["std"] } rcgen = { version = "0.13", default-features = false, features = ["ring", "zeroize"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } -rhai = { version = "1.25.1", features = ["sync", "serde"] } +rhai = { version = "=1.25.1", features = ["sync", "serde"] } rustix = { version = "1", features = ["fs", "process"] } rustls-native-certs = { version = "0.8.4" } ryu-js = { version = "=1.0.3" } diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index c7027109a..500fd5547 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -48,6 +48,7 @@ tokio.workspace = true tower.workspace = true tower-http.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true ulid.workspace = true url.workspace = true utoipa.workspace = true diff --git a/crates/registry-evidence/README.md b/crates/registry-evidence/README.md index 921183af6..2616cc318 100644 --- a/crates/registry-evidence/README.md +++ b/crates/registry-evidence/README.md @@ -11,11 +11,15 @@ The `evidence` binary takes a runtime file and one subcommand: evidence --runtime check evidence --runtime evaluate --fixture evidence --runtime serve +evidence verify --jws --jwks --policy [--at ] ``` `check` validates and compiles the complete immutable bundle. `evaluate` runs -one bundle-owned fixture without source or credential access. `serve` starts -the native HTTP service: +one bundle-owned fixture without source or credential access. `verify` +re-verifies a stored signed response offline against a pinned trusted JWKS +file and a complete relying-procedure policy document, reporting cryptographic +authenticity separately from current validity; it needs no runtime file and +never touches the network. `serve` starts the native HTTP service: ```text POST /v1/evidence diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 65c315bc0..36e779247 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -113,7 +113,6 @@ pub struct EvidenceAuditEvent { } impl EvidenceAuditEvent { - #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] pub fn new( operation: String, @@ -963,6 +962,83 @@ mod tests { assert!(!log.ready().await, "readiness detects chain tampering"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_appends_extend_one_keyed_chain_without_forking() { + // Every evaluation shares one `EvidenceAuditLog` through an `Arc`, so many + // requests can append at once. The keyed chain must serialize each event's + // prev-hash read with its durable write: if two appends observed the same + // tail hash in parallel they would fork the chain and surface as a + // `ChainForkDetected` error (a spurious 503) or a broken linkage. Drive a + // burst of concurrent appends across worker threads and prove each one + // succeeds and the resulting chain still verifies end to end. + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 256 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + const CONCURRENCY: usize = 16; + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let log = Arc::clone(&log); + handles.push(tokio::spawn(async move { + let event = event(log.as_ref()); + log.append(event).await + })); + } + for handle in handles { + handle + .await + .expect("append task joins") + .expect("a concurrent append never forks the keyed chain"); + } + + assert!( + log.ready().await, + "the chain verifies after concurrent appends" + ); + assert_eq!( + log.sink.full_verifications.load(Ordering::Relaxed), + 1, + "concurrent appends extend the chain incrementally without rescanning it" + ); + let lines = std::fs::read_to_string(&path) + .expect("audit reads") + .lines() + .count(); + assert_eq!( + lines, CONCURRENCY, + "every concurrent append is durably recorded exactly once" + ); + + // Release the single-writer sink lock before reopening: the sink holds an + // exclusive lock for one writer per file, so a fresh reader can only + // re-verify the chain once this handle is dropped. + drop(log); + + // A fresh reader re-verifies the whole keyed chain from disk, proving the + // prev-hash linkage stayed consistent under concurrent appends. + let reopened = EvidenceAuditLog::initialize( + &path, + 256 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a chain grown under concurrency verifies on restart"); + assert!( + reopened.ready().await, + "the reopened chain verifies end to end" + ); + } + #[tokio::test] async fn restart_verifies_a_nonempty_keyed_chain_before_accepting_appends() { let directory = tempfile::tempdir().expect("temporary directory"); diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index da88369b7..6c36b87c1 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -154,8 +154,15 @@ pub enum AuthenticationError { Verification, #[error("required authenticated context is missing or invalid")] Context, + #[error("access token is bound to a sender proof this profile cannot validate")] + SenderConstrained, } +/// RFC 7800 confirmation claim. Its presence means the authorization server +/// issued a token that is only valid when presented with a matching proof of +/// possession, such as DPoP or mutual TLS. +const CONFIRMATION_CLAIM: &str = "cnf"; + impl Authenticator { /// Build the one strict resource-server profile from the loaded bundle. pub fn from_config(config: &AuthenticationConfig) -> Self { @@ -230,6 +237,15 @@ impl Authenticator { serde_json::to_value(verified.claims).map_err(|_| AuthenticationError::Context)?; let claims_object = claims.as_object().ok_or(AuthenticationError::Context)?; + // Version one validates no proof of possession. Treating a + // sender-constrained token as an ordinary bearer would silently discard + // the constraint the authorization server issued it under and make a + // stolen token replayable for its whole lifetime, so the profile denies + // rather than downgrades. + if claims_object.contains_key(CONFIRMATION_CLAIM) { + return Err(AuthenticationError::SenderConstrained); + } + let principal = required_direct_string( claims_object, &self.claims.principal_claim, diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index 56d933a6f..ad8f1b8c3 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -1965,7 +1965,7 @@ mod tests { fn deployment_reference_projects_are_complete_compilable_bundles() { let projects_root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../products/evidence/reference/request-adapter/deployment-projects"); - for project in ["dhis2-adult-status", "opencrvs-family-evidence"] { + for project in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { let project_root = projects_root.join(project); RuntimeConfig::parse_yaml( &fs::read(project_root.join("runtime.yaml")).expect("read reference runtime"), diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index ceea97345..ce799f44c 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -697,6 +697,10 @@ pub struct RuntimeConfig { pub version: u8, pub bundle_directory: String, pub listener: ListenerConfig, + /// Optional operator-only metrics listener. Absent means the deployment + /// serves no metrics endpoint at all, which is the default posture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics_listener: Option, pub secret_providers: RuntimeSecretProviders, pub audit_storage: AuditStorageConfig, pub outbound_tls: OutboundTlsConfig, @@ -720,6 +724,9 @@ impl RuntimeConfig { } validate_absolute_path(&self.bundle_directory)?; self.listener.validate()?; + if let Some(metrics) = &self.metrics_listener { + metrics.validate(&self.listener)?; + } self.secret_providers.validate()?; self.audit_storage.validate()?; self.outbound_tls.validate() @@ -812,20 +819,7 @@ pub struct ListenerConfig { impl ListenerConfig { fn validate(&self) -> Result<(), ConfigError> { - if self.bind_host.len() < 2 || self.bind_host.len() > 64 { - return invalid("listener bindHost length is invalid"); - } - let ip: IpAddr = self - .bind_host - .parse() - .map_err(|_| ConfigError::Invalid("listener bindHost must be a private numeric IP"))?; - let private = match ip { - IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(), - IpAddr::V6(ip) => ip.is_loopback() || is_unique_local(ip), - }; - if !private || ip.is_unspecified() || ip.is_multicast() { - return invalid("listener bindHost must be loopback or private"); - } + validate_private_bind_host(&self.bind_host)?; if self.trust_proxy_identity_headers { return invalid("proxy identity headers must not be trusted"); } @@ -856,6 +850,52 @@ impl ListenerConfig { } } +/// Operator-only telemetry listener. +/// +/// It is a separate binding rather than a route on the evidence listener so +/// that reaching the counters requires reaching a different socket. It carries +/// no request limits of its own: it serves one static rendering of in-process +/// counters, reads no request body, and touches no source or signing material. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MetricsListenerConfig { + pub bind_host: String, + pub port: u16, +} + +impl MetricsListenerConfig { + fn validate(&self, evidence_listener: &ListenerConfig) -> Result<(), ConfigError> { + validate_private_bind_host(&self.bind_host)?; + // Sharing the evidence binding would publish the counters on the + // listener the public contract describes, which is the separation this + // block exists to enforce. + if self.bind_host == evidence_listener.bind_host && self.port == evidence_listener.port { + return invalid("metricsListener must not share the evidence listener binding"); + } + Ok(()) + } +} + +/// Accept only numeric loopback, RFC 1918 private IPv4, and RFC 4193 +/// unique-local IPv6 bindings. Every listener this service opens is an +/// operator-network listener; TLS and exposure are upstream concerns. +fn validate_private_bind_host(bind_host: &str) -> Result<(), ConfigError> { + if bind_host.len() < 2 || bind_host.len() > 64 { + return invalid("listener bindHost length is invalid"); + } + let ip: IpAddr = bind_host + .parse() + .map_err(|_| ConfigError::Invalid("listener bindHost must be a private numeric IP"))?; + let private = match ip { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(), + IpAddr::V6(ip) => ip.is_loopback() || is_unique_local(ip), + }; + if !private || ip.is_unspecified() || ip.is_multicast() { + return invalid("listener bindHost must be loopback or private"); + } + Ok(()) +} + fn is_unique_local(ip: Ipv6Addr) -> bool { ip.octets()[0] & 0xfe == 0xfc } @@ -3371,7 +3411,7 @@ mod tests { include_bytes!("../../../products/evidence/fixtures/acceptance/residence-region/evidence.yaml").as_slice(), include_bytes!("../../../products/evidence/fixtures/conformance/selectors/evidence.yaml").as_slice(), include_bytes!("../../../products/evidence/fixtures/conformance/supported-values/evidence.yaml").as_slice(), - include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml").as_slice(), include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml").as_slice(), ] { assert!(validator.is_valid(&bundle_contract_instance(yaml))); @@ -3720,7 +3760,7 @@ outboundTls: let validator = runtime_contract_validator(); assert!(validator.is_valid(&bundle_contract_instance(valid))); for reference in [ - include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml").as_slice(), + include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml").as_slice(), include_bytes!("../../../products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/runtime.yaml").as_slice(), ] { assert!(validator.is_valid(&bundle_contract_instance(reference))); @@ -3771,6 +3811,77 @@ outboundTls: } } + /// The metrics listener is opt-in operator surface. It must be absent + /// unless an operator asks for it, must obey the same private-address rule + /// as the evidence listener, and must not be able to reuse the evidence + /// binding, which would publish counters on the contract listener. + #[test] + fn the_optional_metrics_listener_is_absent_by_default_and_stays_operator_private() { + let base = r#" +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: {root: /run/secrets/registry-evidence} +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +outboundTls: + systemRoots: true + trustProfiles: {} +"#; + let validator = runtime_contract_validator(); + let default = RuntimeConfig::parse_yaml(base.as_bytes()).expect("closed runtime parses"); + assert!( + default.metrics_listener.is_none(), + "a deployment that asked for no metrics listener must not get one" + ); + + let configured = format!("{base}metricsListener:\n bindHost: 127.0.0.1\n port: 9090\n"); + assert!(validator.is_valid(&bundle_contract_instance(configured.as_bytes()))); + let parsed = + RuntimeConfig::parse_yaml(configured.as_bytes()).expect("metrics listener parses"); + let metrics = parsed + .metrics_listener + .expect("the configured metrics listener is retained"); + assert_eq!(metrics.bind_host, "127.0.0.1"); + assert_eq!(metrics.port, 9090); + + for rejected_host in ["evidence.internal", "0.0.0.0", "8.8.8.8", "ff02::1"] { + let candidate = + format!("{base}metricsListener:\n bindHost: {rejected_host}\n port: 9090\n"); + assert!( + RuntimeConfig::parse_yaml(candidate.as_bytes()).is_err(), + "metrics listener accepted prohibited bindHost {rejected_host}" + ); + } + + // Reusing the evidence binding would put the counters on the listener + // the public contract describes. + let shared = format!("{base}metricsListener:\n bindHost: 127.0.0.1\n port: 8080\n"); + assert!(matches!( + RuntimeConfig::parse_yaml(shared.as_bytes()), + Err(ConfigError::Invalid( + "metricsListener must not share the evidence listener binding" + )) + )); + + // The block is closed like every other level of the document. + let unknown = format!( + "{base}metricsListener:\n bindHost: 127.0.0.1\n port: 9090\n path: /telemetry\n" + ); + assert!(RuntimeConfig::parse_yaml(unknown.as_bytes()).is_err()); + assert!(!validator.is_valid(&bundle_contract_instance(unknown.as_bytes()))); + } + #[test] fn path_templates_headers_and_projection_fail_closed() { let bindings: OrderedMap = serde_norway::from_str( diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index d6dd433c1..d8eb7e490 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -41,6 +41,9 @@ const UNSIGNED_ENVELOPE_SCHEMA_ID: &str = const PROBLEM_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/problem-v1.json"; const JWKS_SCHEMA_ID: &str = "https://registrystack.org/schemas/evidence/jwks-v1.json"; const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; +/// Shape of the server-minted operation identifier, shared by the response +/// header and the problem member so the two cannot describe different values. +const OPERATION_PATTERN: &str = "^[0-9A-HJKMNP-TV-Z]{26}$"; const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ ("malformed_request", 400, "Request is not valid"), ("invalid_selector", 400, "Request is not valid"), @@ -620,7 +623,7 @@ fn problem_schema() -> Value { "response_format_not_acceptable", "evidence_not_available", "rate_limited", "dependency_unavailable", "service_unavailable" ]}, - "operation": {"type": "string", "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"} + "operation": {"type": "string", "pattern": OPERATION_PATTERN} }, "$comment": "Problem members are a closed safe shape. No request, authority, source, script, supported-value, subject-binding, candidate, or credential detail is returned." }); @@ -814,6 +817,13 @@ fn response_headers(extra: Option<(&str, Value)>) -> Value { "schema": {"type": "string", "enum": ["no-store"]} }), ); + headers.insert( + "X-Request-Id".to_string(), + json!({ + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": {"type": "string", "pattern": OPERATION_PATTERN} + }), + ); if let Some((name, header)) = extra { headers.insert(name.to_string(), header); } diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs index ea55d5305..8f2c1b3ae 100644 --- a/crates/registry-evidence/src/lib.rs +++ b/crates/registry-evidence/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod contracts; pub mod kernel; pub mod model; +pub mod observability; pub mod problem; pub mod rate_limit; pub mod rhai_runtime; diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 9ab514450..ce80462d9 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -186,11 +186,21 @@ async fn run(cli: Cli) -> Result { Ok(ExitCode::SUCCESS) } Command::Serve => { + install_operational_logging(); let runtime = Arc::new( EvidenceRuntime::initialize(&cli.runtime) .await .map_err(runtime_initialization_error)?, ); + tracing::info!( + target: "registry_evidence::startup", + bundle_revision = runtime.bundle().revision(), + runtime_revision = runtime.runtime_revision(), + bind_host = runtime.runtime_config().listener.bind_host, + port = runtime.runtime_config().listener.port, + metrics = runtime.runtime_config().metrics_listener.is_some(), + "evidence service starting" + ); server::serve(runtime, shutdown_signal()) .await .map_err(|_| CommandError::Cli(CliError("service failed")))?; @@ -280,6 +290,24 @@ fn compile_source_plans_with_runtime( Ok(plans) } +/// Install the operational log subscriber for the serving process. +/// +/// Records are line-delimited JSON on stdout so a collector can read them +/// without a parsing convention of its own. `EVIDENCE_LOG` selects verbosity +/// and defaults to `info`, which is the level the request boundary emits at. +/// Offline commands print their own result and install nothing, so no command +/// gains log output it did not have. +fn install_operational_logging() { + let filter = tracing_subscriber::EnvFilter::try_from_env("EVIDENCE_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .with_current_span(false) + .with_span_list(false) + .init(); +} + /// Resolve on the first operator stop signal. /// /// A service manager and a container runtime both stop a process with @@ -822,6 +850,7 @@ async fn evaluate_reference_fixture( "lookup", "facts", "value", + "values", "entityReferenceCount", "rawReferencesDisclosed", "signed", @@ -835,6 +864,11 @@ async fn evaluate_reference_fixture( "expectedTransport", ], )?; + // A case states either the one concept value or the complete concept map, + // never both, so neither expectation can weaken the other. + if expected.contains_key("value") && expected.contains_key("values") { + return Err(CliError("reference fixture states two value expectations")); + } let forms = [ "response", "sourceFailure", @@ -1091,6 +1125,24 @@ async fn validate_reference_response( return Err(CliError("reference scalar value did not match")); } } + if let Some(exact) = expected.get("values") { + let exact = exact + .as_object() + .ok_or(CliError("reference concept map is invalid"))?; + if values.as_slice().len() != exact.len() { + return Err(CliError("reference concept value did not match")); + } + for (concept, expected_value) in exact { + let disclosed = values + .as_slice() + .iter() + .find(|value| value.provides_value_for == *concept) + .ok_or(CliError("reference concept value did not match"))?; + if public_json(&disclosed.value)? != *expected_value { + return Err(CliError("reference concept value did not match")); + } + } + } if let Some(count) = expected.get("entityReferenceCount").and_then(Value::as_u64) { let actual = match values.as_slice() { [value] => match &value.value { @@ -1273,6 +1325,7 @@ fn validate_reference_expectation_keys( "lookup", "facts", "value", + "values", "entityReferenceCount", "rawReferencesDisclosed", "signed", @@ -2632,7 +2685,7 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn offline_cli_evaluates_every_reference_deployment_fixture() { - for project in ["dhis2-adult-status", "opencrvs-family-evidence"] { + for project in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { let directory = tempfile::tempdir().expect("temporary bundle"); let source = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../products/evidence/reference/request-adapter/deployment-projects") @@ -2643,7 +2696,7 @@ mod tests { let bundle = Arc::new(Bundle::load(directory.path()).expect("reference bundle loads")); let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); - let outbound_tls: OutboundTlsConfig = if project == "dhis2-adult-status" { + let outbound_tls: OutboundTlsConfig = if project == "dhis2-tracker-evidence" { serde_norway::from_str( "systemRoots: true\ntrustProfiles:\n government-internal-pki:\n caBundleFile: /etc/registry-evidence/ca/government-internal.pem\n", ) @@ -2654,7 +2707,7 @@ mod tests { trust_profiles: Default::default(), } }; - let ca_bundles = if project == "dhis2-adult-status" { + let ca_bundles = if project == "dhis2-tracker-evidence" { let certificate = rcgen::generate_simple_self_signed( vec!["tracker.dhis2.gov.example".to_owned()], diff --git a/crates/registry-evidence/src/observability.rs b/crates/registry-evidence/src/observability.rs new file mode 100644 index 000000000..978c305a5 --- /dev/null +++ b/crates/registry-evidence/src/observability.rs @@ -0,0 +1,367 @@ +//! Version 1 operational telemetry for the Evidence HTTP boundary. +//! +//! Operational records describe service health and performance only. The +//! reviewed field set is route template, operation identifier, duration, +//! status category, and safe internal error category; request bodies, selector +//! profiles or values, source responses, Supported Values, credentials, +//! tokens, authority grants, and script inputs are outside it. Both the log +//! record and the metric series below are built from that same closed set, so +//! neither can widen without a review of this module. + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use axum::{ + body::Body, + extract::{MatchedPath, State}, + http::{header::CONTENT_TYPE, HeaderName, HeaderValue, Method, Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use ulid::Ulid; + +use crate::problem::ProblemCode; + +/// Correlation identifier returned to the caller on every response. +/// +/// Requests carry no inbound correlation value: the listener contract fixes +/// `trustProxyIdentityHeaders` to false, so a client-supplied identifier would +/// let a caller choose the key its own records are filed under. +pub(crate) const CORRELATION_HEADER: &str = "x-request-id"; + +/// Target of the per-request operational record. +pub(crate) const REQUEST_LOG_TARGET: &str = "registry_evidence::request"; + +/// Route label used when no route template matched the request. +const UNMATCHED_ROUTE: &str = "unmatched"; + +/// Error label used when a response carries no problem code. +const NO_ERROR: &str = "none"; + +const METRICS_MEDIA_TYPE: &str = "text/plain; version=0.0.4"; + +/// Upper bounds, in seconds, of the request duration histogram. +const DURATION_BUCKETS: [f64; 9] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]; + +/// The request-scoped correlation identifier, minted once at the boundary. +/// +/// Handlers read it from the request extensions rather than minting their own, +/// so the problem body, the audit record, the operational log record, and the +/// response header all name the same operation. +#[derive(Clone)] +pub(crate) struct OperationId(Arc); + +impl OperationId { + fn new() -> Self { + Self(Ulid::new().to_string().into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +/// Read the boundary-minted identifier for this request. +/// +/// The observation layer wraps every route including both fallbacks, so the +/// extension is always present. A handler reached without one would otherwise +/// report an operation that correlates with nothing, so the missing case mints +/// a fresh identifier rather than reporting an empty one. +pub(crate) fn operation_id(extensions: &axum::http::Extensions) -> String { + extensions.get::().map_or_else( + || OperationId::new().as_str().to_owned(), + |id| id.0.to_string(), + ) +} + +/// Coarse outcome class. Operational records report the class, never the exact +/// status, because the exact status of a denial is part of the closed public +/// problem contract rather than an operational signal. +#[derive(Clone, Copy)] +enum StatusCategory { + Success, + ClientError, + ServerError, +} + +impl StatusCategory { + fn of(status: StatusCode) -> Self { + if status.is_server_error() { + Self::ServerError + } else if status.is_client_error() { + Self::ClientError + } else { + Self::Success + } + } + + const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::ClientError => "client_error", + Self::ServerError => "server_error", + } + } +} + +/// Observe one request: mint its identifier, serve it, then publish the +/// reviewed operational fields to the log and the metric registry. +pub(crate) async fn observe( + State(metrics): State>, + mut request: Request, + next: Next, +) -> Response { + let operation = OperationId::new(); + let route = route_template(&request); + let method = normalized_method(request.method()); + request.extensions_mut().insert(operation.clone()); + + let started = Instant::now(); + let mut response = next.run(request).await; + let elapsed = started.elapsed(); + + let status = StatusCategory::of(response.status()); + let error = response + .extensions() + .get::() + .map_or(NO_ERROR, |code| code.code()); + response.headers_mut().insert( + HeaderName::from_static(CORRELATION_HEADER), + HeaderValue::from_str(operation.as_str()) + .expect("a Crockford base32 identifier is a valid header value"), + ); + + metrics.record(route, method, status, error, elapsed); + tracing::info!( + target: REQUEST_LOG_TARGET, + route, + operation = operation.as_str(), + duration_ms = duration_milliseconds(elapsed), + status = status.as_str(), + error, + "evidence request served" + ); + response +} + +/// Resolve the matched route template. +/// +/// Only templates the router registered are reported. An unrouted request +/// reports a single fixed label rather than its requested path, which keeps +/// both the log field and the metric label set bounded by the route table and +/// prevents a caller from writing arbitrary text into either. +fn route_template(request: &Request) -> &'static str { + let Some(matched) = request.extensions().get::() else { + return UNMATCHED_ROUTE; + }; + crate::server::ROUTE_TEMPLATES + .iter() + .find(|template| **template == matched.as_str()) + .copied() + .unwrap_or(UNMATCHED_ROUTE) +} + +/// Fold the method into the closed set the route table can serve, so an +/// arbitrary request verb cannot create a metric series. +fn normalized_method(method: &Method) -> &'static str { + match *method { + Method::GET => "GET", + Method::POST => "POST", + Method::HEAD => "HEAD", + Method::OPTIONS => "OPTIONS", + _ => "other", + } +} + +fn duration_milliseconds(elapsed: Duration) -> u64 { + u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX) +} + +/// In-process request counters and duration histogram. +/// +/// Series are keyed only by the closed label set above, so the registry is +/// bounded by the route table regardless of traffic and needs no eviction. +#[derive(Default)] +pub(crate) struct Metrics { + series: Mutex>, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct SeriesKey { + route: &'static str, + method: &'static str, + status: &'static str, + error: &'static str, +} + +#[derive(Default)] +struct Series { + requests: u64, + duration_sum: f64, + bucket_counts: [u64; DURATION_BUCKETS.len()], +} + +impl Metrics { + fn record( + &self, + route: &'static str, + method: &'static str, + status: StatusCategory, + error: &'static str, + elapsed: Duration, + ) { + let key = SeriesKey { + route, + method, + status: status.as_str(), + error, + }; + let seconds = elapsed.as_secs_f64(); + let mut series = self + .series + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = series.entry(key).or_default(); + entry.requests += 1; + entry.duration_sum += seconds; + for (count, bound) in entry.bucket_counts.iter_mut().zip(DURATION_BUCKETS) { + if seconds <= bound { + *count += 1; + } + } + } + + /// Render the Prometheus text exposition of the current registry. + pub(crate) fn render(&self) -> String { + let series = self + .series + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut body = String::new(); + body.push_str( + "# HELP evidence_http_requests_total Requests served by the Evidence boundary.\n", + ); + body.push_str("# TYPE evidence_http_requests_total counter\n"); + for (key, value) in series.iter() { + body.push_str(&format!( + "evidence_http_requests_total{{{}}} {}\n", + labels(key), + value.requests + )); + } + body.push_str( + "# HELP evidence_http_request_duration_seconds Request duration at the Evidence boundary.\n", + ); + body.push_str("# TYPE evidence_http_request_duration_seconds histogram\n"); + for (key, value) in series.iter() { + for (count, bound) in value.bucket_counts.iter().zip(DURATION_BUCKETS) { + body.push_str(&format!( + "evidence_http_request_duration_seconds_bucket{{{},le=\"{bound}\"}} {count}\n", + labels(key) + )); + } + body.push_str(&format!( + "evidence_http_request_duration_seconds_bucket{{{},le=\"+Inf\"}} {}\n", + labels(key), + value.requests + )); + body.push_str(&format!( + "evidence_http_request_duration_seconds_sum{{{}}} {}\n", + labels(key), + value.duration_sum + )); + body.push_str(&format!( + "evidence_http_request_duration_seconds_count{{{}}} {}\n", + labels(key), + value.requests + )); + } + body + } +} + +fn labels(key: &SeriesKey) -> String { + format!( + "route=\"{}\",method=\"{}\",status=\"{}\",error=\"{}\"", + key.route, key.method, key.status, key.error + ) +} + +/// Build the metrics application. +/// +/// It is a separate application on a separate listener: the served counters +/// are operator material, and the public evidence contract does not describe +/// them. Every other path on this listener is unserved rather than delegated +/// back to the evidence routes. +pub(crate) fn metrics_app(metrics: Arc) -> Router { + Router::new() + .route("/metrics", get(render_metrics)) + .fallback(metrics_route_absent) + .with_state(metrics) +} + +async fn render_metrics(State(metrics): State>) -> Response { + let mut response = (StatusCode::OK, metrics.render()).into_response(); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static(METRICS_MEDIA_TYPE)); + response +} + +async fn metrics_route_absent() -> Response { + StatusCode::NOT_FOUND.into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn series_labels_stay_bounded_by_the_closed_route_and_method_sets() { + // An arbitrary verb and an unrouted path must not each create a series. + let metrics = Metrics::default(); + for method in [ + Method::from_bytes(b"PATCH").expect("a valid method"), + Method::from_bytes(b"BREW").expect("a valid method"), + ] { + metrics.record( + UNMATCHED_ROUTE, + normalized_method(&method), + StatusCategory::ClientError, + ProblemCode::MalformedRequest.code(), + Duration::from_millis(1), + ); + } + let rendered = metrics.render(); + assert_eq!( + rendered + .matches("evidence_http_requests_total{route=\"unmatched\",method=\"other\"") + .count(), + 1, + "unrecognized methods collapse onto one series" + ); + assert!(rendered.contains("status=\"client_error\",error=\"malformed_request\"} 2\n")); + } + + #[test] + fn duration_buckets_are_cumulative_and_carry_an_infinite_bound() { + let metrics = Metrics::default(); + metrics.record( + "/health", + "GET", + StatusCategory::Success, + NO_ERROR, + Duration::from_millis(30), + ); + let rendered = metrics.render(); + assert!(rendered.contains("le=\"0.025\"} 0\n")); + assert!(rendered.contains("le=\"0.05\"} 1\n")); + assert!(rendered.contains("le=\"+Inf\"} 1\n")); + assert!(rendered.contains("evidence_http_request_duration_seconds_count{route=\"/health\",method=\"GET\",status=\"success\",error=\"none\"} 1\n")); + } +} diff --git a/crates/registry-evidence/src/rhai_runtime.rs b/crates/registry-evidence/src/rhai_runtime.rs index 5755f788b..e894630ae 100644 --- a/crates/registry-evidence/src/rhai_runtime.rs +++ b/crates/registry-evidence/src/rhai_runtime.rs @@ -1121,6 +1121,13 @@ fn decode_derived_value(value: Dynamic) -> Result Result { validate_top_level_functions(source)?; let mut insertions = review_script_bytes(source)?; diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 31e3870c4..bcc6db097 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -811,7 +811,12 @@ impl EvidenceRuntime { } }; let evidence_id = format!("urn:ulid:{}", ulid::Ulid::new()); - let issued_at = evaluation_time.unwrap_or_else(Utc::now); + // `issued_at` is read after the source round-trip, so a backward wall-clock + // adjustment between it and `observed_at` could otherwise make `issued_at` + // precede `observed_at` and fail evidence construction. Clamp the wall-clock + // read so issuance never predates observation; an injected evaluation time + // keeps both stamps equal. + let issued_at = evaluation_time.unwrap_or_else(|| Utc::now().max(observed_at)); let evidence = match self.kernel.construct_evidence( &request.requirement, values, @@ -875,10 +880,14 @@ impl EvidenceRuntime { let bytes = match bytes { Ok(bytes) => bytes, Err(error) => { + // Serialization of the already-signed artifact failed; + // record it with the same decision as the unsigned path + // so the audit taxonomy for release-serialization is one + // class regardless of format. self.append_failure( &material, operation, - AuditDecision::SigningFailure, + AuditDecision::EvaluationFailure, "release-serialization", &source_id, &adapter_id, diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index bb58eaf6b..b81a8238d 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -7,7 +7,7 @@ use std::{ atomic::{AtomicUsize, Ordering}, Arc, }, - time::Duration, + time::{Duration, Instant}, }; use async_trait::async_trait; @@ -16,6 +16,7 @@ use axum_test::TestServer; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_platform_audit::{verify_jsonl_lines_with_hasher, AuditChainHasher, AuditHashSecret}; use registry_platform_crypto::{ sign, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, SigningProvider, @@ -30,6 +31,11 @@ use wiremock::{ }; use crate::{ + audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind as AuditAuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, + ResponseProtection, + }, auth::{AuthenticationClaimsConfig, Authenticator}, config::ResponseFormat, contracts::evidence_contract_accepts, @@ -37,9 +43,10 @@ use crate::{ Evidence, EvidenceDefinitions, EvidenceRequest, EvidenceSelectorField, FlattenedJws, PublicValue, RequestedSelector, RequestedSubject, SelectorValue, UnsignedEvidenceEnvelope, }, + observability::{metrics_app, CORRELATION_HEADER, REQUEST_LOG_TARGET}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeInitializationError}, - server::{build_app, build_app_at_for_test, serve_listener_for_test}, + server::{build_app, build_app_at_for_test, build_app_with_metrics, serve_listener_for_test}, signing::EvidenceSigner, verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, EVIDENCE_UNSIGNED_MEDIA_TYPE, @@ -56,6 +63,8 @@ const BASIC_USER: &str = "source-user-canary"; const BASIC_PASSWORD: &str = "source-password-canary"; const PARENT_REFERENCE: &str = "synthetic-parent-reference-001"; const NON_PARENT_REFERENCE: &str = "synthetic-non-parent-reference-003"; +const LOG_TOKEN_CANARY: &str = "operational-log-token-canary"; +const LOG_SELECTOR_CANARY: &str = "operational-log-selector-canary"; struct AcceptanceRuntime { _temporary: TempDir, @@ -517,6 +526,385 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda } } +/// One identifier is minted per request at the boundary and stays the same +/// everywhere an operator can observe it. Without a response header the +/// identifier the problem body reports is the only copy the caller ever sees, +/// so a support report cannot be joined to a server-side record. +#[tokio::test] +async fn every_response_carries_the_request_scoped_correlation_identifier() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + let health = http.get("/health").await; + health.assert_status_ok(); + let first = correlation_id(&health); + + // A rejected request reports one identifier, not one per error site. + let denied = http.get("/v1/evidence-definitions").await; + assert_eq!(denied.status_code(), axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(correlation_id(&denied), denied.json::()["operation"]); + + // An unrouted request correlates on the same terms. + let unknown = http.get("/absent").await; + assert_eq!( + correlation_id(&unknown), + unknown.json::()["operation"] + ); + + // Identifiers are request-scoped, never process-scoped. + let second = http.get("/health").await; + assert_ne!(first, correlation_id(&second)); +} + +/// Section 12 fixes exactly what an operational record may contain. Anything +/// outside that set is a disclosure the record has never been reviewed for, so +/// the field set is asserted whole rather than field by field. +#[test] +fn operational_logs_carry_only_the_reviewed_fields_and_disclose_no_value() { + let emitted = capture_evidence_logs(|| async { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + + http.get("/health").await.assert_status_ok(); + + // The body parses and its selector values are held in memory before + // authentication rejects the request, so this exercises the disclosure + // path rather than an early parse failure. + let rejected = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {LOG_TOKEN_CANARY}")) + .json(&request( + "urn:example:fixture:requirement:adult-status:v1", + "fixture-eligibility", + vec![requested_subject( + "subject", + "person-demographics-v1", + Some([ + ("given_name", LOG_SELECTOR_CANARY), + ("family_name", "Diallo"), + ("birth_date", "2000-01-01"), + ]), + )], + )) + .await; + assert_eq!( + rejected.status_code(), + axum::http::StatusCode::UNAUTHORIZED, + "the canary token is not a verifiable access token" + ); + }); + + let served: Vec<&Value> = emitted + .iter() + .filter(|record| record["target"] == json!(REQUEST_LOG_TARGET)) + .collect(); + assert_eq!(served.len(), 2, "one operational record per served request"); + + for record in &served { + let fields = record["fields"] + .as_object() + .expect("an operational record carries fields"); + assert_eq!( + fields.keys().map(String::as_str).collect::>(), + BTreeSet::from([ + "message", + "route", + "operation", + "duration_ms", + "status", + "error" + ]) + ); + assert!(fields["duration_ms"].is_u64()); + assert!(!fields["operation"] + .as_str() + .expect("the operation identifier is a string") + .is_empty()); + } + + // The route template, never the requested path, and a status category + // rather than a code. + assert_eq!(served[0]["fields"]["route"], json!("/health")); + assert_eq!(served[0]["fields"]["status"], json!("success")); + assert_eq!(served[0]["fields"]["error"], json!("none")); + assert_eq!(served[1]["fields"]["route"], json!("/v1/evidence")); + assert_eq!(served[1]["fields"]["status"], json!("client_error")); + assert_eq!(served[1]["fields"]["error"], json!("authentication_failed")); + + // No token, selector value, purpose, or requirement identity reaches an + // operational record this crate emits. + let raw = serde_json::to_string(&emitted).expect("captured records serialize"); + for canary in [ + LOG_TOKEN_CANARY, + LOG_SELECTOR_CANARY, + "Diallo", + "2000-01-01", + "fixture-eligibility", + "adult-status", + ] { + assert!( + !raw.contains(canary), + "an operational log disclosed {canary}" + ); + } +} + +/// Counters describe traffic. They must say how the boundary behaved without +/// naming what any request asked for, and their label set must stay bounded by +/// the route table rather than by anything a caller can send. +#[tokio::test] +async fn metrics_report_bounded_series_without_disclosing_request_content() { + let fixture = acceptance_runtime().await; + let (app, metrics) = build_app_with_metrics(Arc::clone(&fixture.runtime)); + let http = TestServer::new(app); + + http.get("/health").await.assert_status_ok(); + http.get("/health").await.assert_status_ok(); + let denied = http.get("/v1/evidence-definitions").await; + assert_eq!(denied.status_code(), axum::http::StatusCode::UNAUTHORIZED); + http.get("/absent-path-canary").await; + + let exposition = TestServer::new(metrics_app(Arc::clone(&metrics))); + let rendered = exposition.get("/metrics").await; + rendered.assert_status_ok(); + assert_eq!(rendered.header("content-type"), "text/plain; version=0.0.4"); + let body = rendered.text(); + + assert!(body.contains( + "evidence_http_requests_total{route=\"/health\",method=\"GET\",status=\"success\",error=\"none\"} 2\n" + )); + assert!(body.contains( + "evidence_http_requests_total{route=\"/v1/evidence-definitions\",method=\"GET\",status=\"client_error\",error=\"authentication_failed\"} 1\n" + )); + assert!(body.contains("evidence_http_request_duration_seconds_count{route=\"/health\"")); + + // An unrouted request is one fixed label, never the path the caller chose. + assert!(body.contains("route=\"unmatched\"")); + assert!(!body.contains("absent-path-canary")); + for canary in privacy_canaries() { + assert!(!body.contains(canary), "metrics disclosed {canary}"); + } + + // The metrics application answers for metrics only; it is not a second + // way to reach the evidence routes. + for path in ["/health", "/v1/evidence-definitions", "/openapi.json"] { + assert_eq!( + exposition.get(path).await.status_code(), + axum::http::StatusCode::NOT_FOUND, + "the metrics listener served {path}" + ); + } +} + +/// The metrics endpoint is opt-in deployment surface on its own socket. The +/// evidence listener must not gain a metrics route, and the two listeners must +/// share one lifecycle so shutdown leaves nothing behind. +#[tokio::test] +async fn a_configured_metrics_listener_serves_beside_the_evidence_listener() { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let evidence_port = reserved_port(); + let metrics_port = reserved_port(); + let mut document = + fs::read_to_string(&prepared.runtime_path).expect("runtime configuration is readable"); + replace_exact( + &mut document, + "port: 8080", + &format!("port: {evidence_port}"), + 1, + ); + document.push_str(&format!( + "metricsListener:\n bindHost: 127.0.0.1\n port: {metrics_port}\n" + )); + // The prepared document is already read-only, as deployment requires, so + // this variant is written before the runtime captures it. + make_file_writable(&prepared.runtime_path); + fs::write(&prepared.runtime_path, &document).expect("runtime configuration is rewritten"); + make_file_read_only(&prepared.runtime_path); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the runtime initializes with a metrics listener"), + ); + + let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); + let server = tokio::spawn(crate::server::serve(runtime, async move { + let _ = stopped.await; + })); + let client = reqwest::Client::new(); + let evidence = format!("http://127.0.0.1:{evidence_port}"); + let telemetry = format!("http://127.0.0.1:{metrics_port}"); + await_ready(&client, &format!("{evidence}/health")).await; + + let exposition = client + .get(format!("{telemetry}/metrics")) + .send() + .await + .expect("the metrics listener answers"); + assert_eq!(exposition.status(), reqwest::StatusCode::OK); + assert!(exposition + .text() + .await + .expect("the exposition is readable") + .contains("evidence_http_requests_total{route=\"/health\"")); + + // The evidence listener gained no metrics route of its own. + let on_evidence_listener = client + .get(format!("{evidence}/metrics")) + .send() + .await + .expect("the evidence listener answers"); + assert_eq!( + on_evidence_listener.status(), + reqwest::StatusCode::BAD_REQUEST + ); + assert_eq!( + on_evidence_listener + .json::() + .await + .expect("problem body")["code"], + json!("malformed_request") + ); + + let _ = stop.send(()); + server + .await + .expect("the service task joins") + .expect("the service stops cleanly"); + + // One shutdown closes both sockets. + assert!(client + .get(format!("{telemetry}/metrics")) + .send() + .await + .is_err()); + assert!(client + .get(format!("{evidence}/health")) + .send() + .await + .is_err()); +} + +/// Reserve an ephemeral port and release it, so a test can name a port in +/// configuration that the operating system has just confirmed is free. +fn reserved_port() -> u16 { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port is available"); + listener + .local_addr() + .expect("the reserved socket has an address") + .port() +} + +async fn await_ready(client: &reqwest::Client, url: &str) { + for _ in 0..100 { + if let Ok(response) = client.get(url).send().await { + if response.status().is_success() { + return; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the evidence listener never became reachable at {url}"); +} + +fn correlation_id(response: &axum_test::TestResponse) -> Value { + json!(response + .header(CORRELATION_HEADER) + .to_str() + .expect("the correlation header is ASCII")) +} + +/// Collect every record this crate emits while `body` runs. +/// +/// The subscriber is thread-local and the request future is driven to +/// completion on this thread, so records emitted from the detached evaluation +/// task land in the same buffer. +/// +/// Earlier tests serve requests with no subscriber installed, which caches the +/// request boundary's callsite as permanently uninteresting and drops the +/// global maximum level to off. A thread-local subscriber does not undo that on +/// its own, so one subscriber is installed process-wide on first use and routes +/// each record to the buffer of the thread that emitted it. Tests running +/// concurrently on other threads have no buffer and their records are dropped. +fn capture_evidence_logs(body: F) -> Vec +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + static INSTALLED: std::sync::Once = std::sync::Once::new(); + INSTALLED.call_once(|| { + tracing::subscriber::set_global_default( + tracing_subscriber::fmt() + .json() + .with_max_level(tracing::Level::INFO) + .with_writer(CapturedLogs) + .finish(), + ) + .expect("this test binary installs no other subscriber"); + }); + + let buffer = Arc::new(std::sync::Mutex::new(Vec::::new())); + CAPTURED_LOGS.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&buffer))); + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a single-threaded executor builds"); + executor.block_on(body()); + CAPTURED_LOGS.with(|slot| *slot.borrow_mut() = None); + + let raw = buffer + .lock() + .expect("the log buffer is not poisoned") + .clone(); + String::from_utf8(raw) + .expect("operational records are UTF-8") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record["target"] + .as_str() + .is_some_and(|target| target.starts_with("registry_evidence")) + }) + .collect() +} + +thread_local! { + /// The buffer `capture_evidence_logs` is filling on this thread, if any. + static CAPTURED_LOGS: std::cell::RefCell>>>> = + const { std::cell::RefCell::new(None) }; +} + +#[derive(Clone)] +struct CapturedLogs; + +impl std::io::Write for CapturedLogs { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + // A record emitted outside a capture window belongs to a test that is + // not inspecting logs, so it is deliberately discarded rather than + // written anywhere. + let _ = CAPTURED_LOGS.try_with(|slot| { + if let Some(sink) = slot.borrow().as_ref() { + sink.lock() + .expect("the log buffer is not poisoned") + .extend_from_slice(buffer); + } + }); + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLogs { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + self.clone() + } +} + #[tokio::test] async fn openapi_route_serves_the_generated_contract_without_authentication_or_source_access() { let fixture = acceptance_runtime().await; @@ -1047,6 +1435,39 @@ async fn missing_principal_never_falls_back_to_client_id_or_azp() { assert!(!audit.contains("fallback-authorized-party-canary")); } +/// A confirmation claim binds the token to a sender-provided proof this +/// profile does not validate. Accepting it as an ordinary bearer would +/// silently discard the constraint the authorization server issued it under, +/// so the only safe outcome is denial. +#[tokio::test] +async fn sender_constrained_tokens_are_denied_rather_than_downgraded() { + for confirmation in [ + json!({"jkt": "sender-constraint-canary"}), + json!({"x5t#S256": "sender-constraint-canary"}), + json!({"jwk": {"kty": "OKP", "crv": "Ed25519", "x": "sender-constraint-canary"}}), + ] { + let fixture = acceptance_runtime().await; + let token = access_token(Some(json!({"cnf": confirmation}))); + let error = fixture + .runtime + .evaluate("operation-sender-constrained", &token, &adult_request()) + .await + .expect_err("a confirmation-bound token is denied"); + assert_eq!(error.problem(), ProblemCode::AuthenticationFailed); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "a sender-constrained token cannot acquire source credentials" + ); + let audit = fs::read_to_string(&fixture.audit_path).expect("audit is readable"); + assert!(!audit.contains("sender-constraint-canary")); + } +} + #[tokio::test] async fn disclosure_audit_failure_prevents_signed_response_release() { let fixture = acceptance_runtime().await; @@ -2677,6 +3098,115 @@ async fn every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_ver ); } +/// Many simultaneous evaluations must leave exactly one verifiable audit chain: +/// two records per released assertion, no forked or interleaved hash links, and +/// one distinct evidence identity per request. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_evidence_requests_keep_one_verifiable_audit_chain() { + let load = LoadFixture::start().await; + let outcomes = load.run(LOAD_CONCURRENCY, LOAD_CONCURRENCY).await; + assert_eq!( + outcomes.released(), + LOAD_CONCURRENCY, + "every admitted concurrent request releases evidence; observed {:?}", + outcomes.status_counts() + ); + + let audit = load.shutdown().await; + assert_eq!( + audit.matches("\"phase\":\"access-attempt\"").count(), + LOAD_CONCURRENCY + ); + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + LOAD_CONCURRENCY + ); + assert_eq!( + released_evidence_ids(&audit).len(), + LOAD_CONCURRENCY, + "concurrent releases must not share an evidence identity" + ); + + let verification = verify_jsonl_lines_with_hasher(audit.lines(), &acceptance_audit_hasher()) + .expect("the concurrently written audit chain verifies under the deployment key"); + assert_eq!(verification.records, LOAD_CONCURRENCY * 2); +} + +/// Report sustained request throughput against the two candidate ceilings, so +/// the dominant one is attributed from measurement rather than argument. +/// +/// This asserts only correctness invariants. The rates themselves are reported, +/// never asserted: they are properties of the host filesystem and core count, +/// and a threshold here would be a flake generator. Read the report, record the +/// numbers with the hardware they came from, and compare across changes. +/// +/// ```text +/// cargo test -p registry-evidence --lib -- --ignored --nocapture soak_reports +/// ``` +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "opt-in throughput soak; rates are host-specific and reported, not asserted"] +async fn soak_reports_request_throughput_against_the_audit_ceiling() { + let load = LoadFixture::start().await; + + // Warm the OAuth-free source path, JWKS cache, and connection pool so + // first-request costs do not land inside the measured window. + let _ = load.run(LOAD_CONCURRENCY, LOAD_CONCURRENCY).await; + + let outcomes = load.run(SOAK_REQUESTS, LOAD_CONCURRENCY).await; + let released = outcomes.released(); + let request_rate = released as f64 / outcomes.elapsed.as_secs_f64(); + + let source_rate = load.measure_source_rate(SOURCE_PROBE_REQUESTS).await; + let append_rate = load.measure_audit_append_rate(AUDIT_PROBE_APPENDS).await; + // Every released assertion writes an access-attempt record before source + // access and a disclosure-release record after it. + let audit_ceiling = append_rate / 2.0; + + let percentiles = outcomes.latency_percentiles(); + println!( + "\n=== Evidence throughput report ===\n\ + host : {} logical cores\n\ + load : {SOAK_REQUESTS} requests, {LOAD_CONCURRENCY} concurrent\n\ + released : {released} in {:.2}s\n\ + \n\ + observed request : {request_rate:.0} rps\n\ + audit ceiling : {audit_ceiling:.0} rps ({append_rate:.0} appends/s / 2 records per request)\n\ + mock source floor : {source_rate:.0} rps\n\ + \n\ + latency p50/p95/p99 : {:.1} / {:.1} / {:.1} ms\n\ + audit share of ceiling: {:.0}%\n\ + ==================================\n", + std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get), + outcomes.elapsed.as_secs_f64(), + percentiles.0.as_secs_f64() * 1000.0, + percentiles.1.as_secs_f64() * 1000.0, + percentiles.2.as_secs_f64() * 1000.0, + if audit_ceiling > 0.0 { + request_rate / audit_ceiling * 100.0 + } else { + 0.0 + }, + ); + + assert_eq!( + released, + SOAK_REQUESTS, + "sustained load must not shed requests; observed {:?}", + outcomes.status_counts() + ); + + let audit = load.shutdown().await; + let expected_releases = SOAK_REQUESTS + LOAD_CONCURRENCY; + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + expected_releases + ); + assert_eq!(released_evidence_ids(&audit).len(), expected_releases); + let verification = verify_jsonl_lines_with_hasher(audit.lines(), &acceptance_audit_hasher()) + .expect("the audit chain written under sustained load verifies"); + assert_eq!(verification.records, expected_releases * 2); +} + fn acceptance_case_time( case: &serde_json::Map, common: &serde_json::Map, @@ -3434,3 +3964,332 @@ fn make_writable(path: &Path) { } } } + +// Concurrency and throughput harness. + +/// Simultaneous virtual clients. Held below the fixture listener's 64 admitted +/// request slots so admission queueing never reads as a throughput limit. +const LOAD_CONCURRENCY: usize = 32; + +/// Requests issued by one measured soak window. +const SOAK_REQUESTS: usize = 512; + +/// Direct source calls used to establish the mock-source floor. +const SOURCE_PROBE_REQUESTS: usize = 256; + +/// Appends used to measure the audit sink ceiling on the same filesystem. +const AUDIT_PROBE_APPENDS: usize = 200; + +/// Probe ceiling. The probe writes a small fraction of this. +const AUDIT_PROBE_MAXIMUM_BYTES: u64 = 64 * 1024 * 1024; + +/// The acceptance runtime behind a real TCP listener, driven by real HTTP +/// clients. Load is applied over sockets rather than through an in-process +/// router so admission, the detached evaluation task, and connection handling +/// are all measured. +struct LoadFixture { + _temporary: TempDir, + _source: MockServer, + source_origin: String, + audit_path: PathBuf, + probe_directory: PathBuf, + address: std::net::SocketAddr, + client: reqwest::Client, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, + runtime: Arc, +} + +/// One measured load window. +struct LoadOutcomes { + statuses: Vec, + latencies: Vec, + elapsed: Duration, +} + +impl LoadOutcomes { + fn released(&self) -> usize { + self.statuses + .iter() + .filter(|status| **status == 200) + .count() + } + + fn status_counts(&self) -> BTreeMap { + let mut counts = BTreeMap::new(); + for status in &self.statuses { + *counts.entry(*status).or_insert(0_usize) += 1; + } + counts + } + + /// Nearest-rank p50, p95, and p99 over the whole window. + fn latency_percentiles(&self) -> (Duration, Duration, Duration) { + if self.latencies.is_empty() { + return (Duration::ZERO, Duration::ZERO, Duration::ZERO); + } + let mut sorted = self.latencies.clone(); + sorted.sort_unstable(); + let at = |fraction: f64| { + let rank = (fraction * sorted.len() as f64).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] + }; + (at(0.50), at(0.95), at(0.99)) + } +} + +impl LoadFixture { + async fn start() -> Self { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + mount_unmetered_adult_source(&prepared.server).await; + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the load fixture runtime initializes"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the load listener binds"); + let address = listener + .local_addr() + .expect("the load listener has an address"); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn({ + let runtime = Arc::clone(&runtime); + async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + } + }); + + let client = reqwest::Client::builder() + .pool_max_idle_per_host(LOAD_CONCURRENCY) + .timeout(Duration::from_secs(30)) + .build() + .expect("the load client builds"); + + Self { + source_origin: prepared.server.uri(), + probe_directory: prepared + .audit_path + .parent() + .expect("the audit path has a parent") + .to_path_buf(), + audit_path: prepared.audit_path, + _temporary: prepared.temporary, + _source: prepared.server, + address, + client, + shutdown, + serving, + runtime, + } + } + + /// Issue `total` adult-status requests across `concurrency` clients. + /// + /// Each request carries its own principal so the per-principal token bucket + /// never becomes the thing under measurement. Tokens are signed up front, + /// outside the timed window. + async fn run(&self, total: usize, concurrency: usize) -> LoadOutcomes { + let tokens: Arc> = Arc::new( + (0..total) + .map(|index| access_token_for(&format!("load-principal-{index:06}"), None)) + .collect(), + ); + let body = + Arc::new(serde_json::to_vec(&adult_request()).expect("the load request serializes")); + let endpoint = Arc::new(format!("http://{}/v1/evidence", self.address)); + + let started = Instant::now(); + let mut workers = Vec::with_capacity(concurrency); + for worker in 0..concurrency { + let client = self.client.clone(); + let tokens = Arc::clone(&tokens); + let body = Arc::clone(&body); + let endpoint = Arc::clone(&endpoint); + workers.push(tokio::spawn(async move { + let mut results = Vec::new(); + let mut index = worker; + while index < tokens.len() { + let attempt = Instant::now(); + let response = client + .post(endpoint.as_str()) + .header("authorization", format!("Bearer {}", tokens[index])) + .header("content-type", "application/json") + .body(body.as_ref().clone()) + .send() + .await + .expect("the load request completes"); + let status = response.status().as_u16(); + // Drain the body so the connection returns to the pool. + let _ = response + .bytes() + .await + .expect("the load response body reads"); + results.push((status, attempt.elapsed())); + index += concurrency; + } + results + })); + } + + let mut statuses = Vec::with_capacity(total); + let mut latencies = Vec::with_capacity(total); + for worker in workers { + for (status, latency) in worker.await.expect("a load worker completes") { + statuses.push(status); + latencies.push(latency); + } + } + LoadOutcomes { + statuses, + latencies, + elapsed: started.elapsed(), + } + } + + /// Call the mock source directly to establish the harness floor: no result + /// above this rate is attributable to Evidence. + async fn measure_source_rate(&self, requests: usize) -> f64 { + let endpoint = Arc::new(format!("{}/v1/facts", self.source_origin)); + let started = Instant::now(); + let mut workers = Vec::with_capacity(LOAD_CONCURRENCY); + for worker in 0..LOAD_CONCURRENCY { + let client = self.client.clone(); + let endpoint = Arc::clone(&endpoint); + workers.push(tokio::spawn(async move { + let mut index = worker; + while index < requests { + let response = client + .post(endpoint.as_str()) + .header("accept", "application/json") + .header("authorization", format!("Bearer {BEARER}")) + .json(&adult_source_request()) + .send() + .await + .expect("the source probe request completes"); + let _ = response.bytes().await.expect("the source probe body reads"); + index += LOAD_CONCURRENCY; + } + })); + } + for worker in workers { + worker.await.expect("a source probe worker completes"); + } + requests as f64 / started.elapsed().as_secs_f64() + } + + /// Append through a real keyed sink on the same filesystem as the runtime's + /// own audit file. Appends are serialized by the chain, so this is a + /// sequential measurement by construction, not by choice of harness. + async fn measure_audit_append_rate(&self, appends: usize) -> f64 { + let path = self.probe_directory.join("audit-throughput-probe.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + AUDIT_PROBE_MAXIMUM_BYTES, + b"audit-hash-secret-canary-32-bytes-minimum".to_vec(), + 1, + ) + .await + .expect("the audit throughput probe initializes"); + + let started = Instant::now(); + for index in 0..appends { + log.append(audit_probe_event(index)) + .await + .expect("the audit throughput probe appends"); + } + appends as f64 / started.elapsed().as_secs_f64() + } + + /// Stop the listener, drain detached evaluations, and return the audit file. + async fn shutdown(self) -> String { + let _ = self.shutdown.send(()); + self.serving + .await + .expect("the load listener task completes") + .expect("the load listener shuts down cleanly"); + drop(self.runtime); + fs::read_to_string(&self.audit_path).expect("the load audit file is readable") + } +} + +/// The adult source mock without a call-count expectation, so one mount serves +/// a whole load window. +async fn mount_unmetered_adult_source(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/facts")) + .and(header("accept", "application/json")) + .and(header("authorization", format!("Bearer {BEARER}").as_str())) + .and(body_json(adult_source_request())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .mount(server) + .await; +} + +fn adult_source_request() -> Value { + json!({ + "lookup": { + "given_name": "Amina", + "family_name": "Diallo", + "birth_date": "2000-01-01" + }, + "fields": ["date_of_birth"], + "limit": 2 + }) +} + +/// A valid access-attempt event shaped like the ones the runtime writes, so the +/// probe measures the real serialization, hashing, and fsync path. +fn audit_probe_event(index: usize) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + format!("audit-throughput-probe-{index:012}"), + AuditPhase::AccessAttempt, + "urn:example:fixture:requirement:adult-status:v1".to_owned(), + "audit-throughput-probe".to_owned(), + "fixture-eligibility".to_owned(), + "hmac-sha256:v1:audit-throughput-probe-requester".to_owned(), + AuditAuthority { + kind: AuditAuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "person-demographics-v1".to_owned(), + selector_bundle_pseudonym: None, + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 0, + ) +} + +fn acceptance_audit_hasher() -> AuditChainHasher { + AuditChainHasher::keyed( + AuditHashSecret::new(b"audit-hash-secret-canary-32-bytes-minimum".to_vec()) + .expect("the acceptance audit secret is accepted"), + ) +} + +/// Distinct evidence identities across every disclosure-release record. +fn released_evidence_ids(audit: &str) -> BTreeSet { + audit + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("an audit line is JSON")) + .filter_map(|envelope| { + envelope + .get("record")? + .get("evidenceId")? + .as_str() + .map(str::to_owned) + }) + .collect() +} diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index dd12ca5be..34654d4b8 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -26,7 +26,7 @@ use axum::{ }, HeaderMap, HeaderValue, Request, StatusCode, }, - middleware::{from_fn, Next}, + middleware::{from_fn, from_fn_with_state, Next}, response::{IntoResponse, Response}, routing::{get, post}, Router, @@ -35,17 +35,37 @@ use registry_platform_crypto::parse_json_strict; use registry_platform_httpsec::CspBuilder; use serde::Serialize; use tokio::{net::TcpListener, sync::Semaphore}; -use ulid::Ulid; use crate::{ config::{ListenerConfig, ResponseFormat}, contracts::{request_contract_accepts, served_openapi_document}, model::{request_nonce_is_canonical, EvidenceRequest}, + observability::{self, operation_id, Metrics}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeFailure}, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; +const EVIDENCE_ROUTE: &str = "/v1/evidence"; +const DEFINITIONS_ROUTE: &str = "/v1/evidence-definitions"; +const HEALTH_ROUTE: &str = "/health"; +const OPENAPI_ROUTE: &str = "/openapi.json"; +const READY_ROUTE: &str = "/ready"; +const JWKS_ROUTE: &str = "/.well-known/evidence/jwks.json"; + +/// Every route template this listener registers. +/// +/// Operational telemetry labels requests with a member of this set or with a +/// single fixed unmatched label, so a caller cannot introduce a label value. +pub(crate) const ROUTE_TEMPLATES: [&str; 6] = [ + EVIDENCE_ROUTE, + DEFINITIONS_ROUTE, + HEALTH_ROUTE, + OPENAPI_ROUTE, + READY_ROUTE, + JWKS_ROUTE, +]; + const JSON_MEDIA_TYPE: &str = "application/json"; const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; @@ -77,14 +97,24 @@ pub(crate) fn build_app_at_for_test( build_app_with_tracker_at(runtime, Some(evaluation_time)).0 } -fn build_app_with_tracker(runtime: Arc) -> (Router, EvaluationTracker) { +/// Build the application together with the registry its observation layer +/// feeds, so a test can read the counters a request produced. +#[cfg(test)] +pub(crate) fn build_app_with_metrics(runtime: Arc) -> (Router, Arc) { + let (app, _evaluations, metrics) = build_app_with_tracker_at(runtime, None); + (app, metrics) +} + +fn build_app_with_tracker( + runtime: Arc, +) -> (Router, EvaluationTracker, Arc) { build_app_with_tracker_at(runtime, None) } fn build_app_with_tracker_at( runtime: Arc, evaluation_time: Option>, -) -> (Router, EvaluationTracker) { +) -> (Router, EvaluationTracker, Arc) { #[cfg(not(test))] let _ = evaluation_time; let listener = &runtime.runtime_config().listener; @@ -103,16 +133,21 @@ fn build_app_with_tracker_at( }); let routes = Router::new() - .route("/v1/evidence", post(create_evidence)) - .route("/v1/evidence-definitions", get(discover_evidence)) - .route("/health", get(health)) - .route("/openapi.json", get(openapi)) - .route("/ready", get(ready)) - .route("/.well-known/evidence/jwks.json", get(jwks)) + .route(EVIDENCE_ROUTE, post(create_evidence)) + .route(DEFINITIONS_ROUTE, get(discover_evidence)) + .route(HEALTH_ROUTE, get(health)) + .route(OPENAPI_ROUTE, get(openapi)) + .route(READY_ROUTE, get(ready)) + .route(JWKS_ROUTE, get(jwks)) .fallback(unknown_route) .method_not_allowed_fallback(unknown_route) .with_state(state); - (response_layers(routes), evaluations) + let metrics = Arc::new(Metrics::default()); + ( + response_layers(routes, Arc::clone(&metrics)), + evaluations, + metrics, + ) } #[derive(Clone, Default)] @@ -165,13 +200,16 @@ impl Drop for ActiveEvaluation { } } -fn response_layers(routes: Router) -> Router { +fn response_layers(routes: Router, metrics: Arc) -> Router { routes .layer(from_fn(add_no_store)) .layer(registry_platform_httpsec::corp_conditional()) .layer( registry_platform_httpsec::security_headers(CspBuilder::restrictive()).without_hsts(), ) + // Outermost, so that every response including both fallbacks carries a + // correlation identifier and produces exactly one operational record. + .layer(from_fn_with_state(metrics, observability::observe)) } /// Bind the configured private listener and serve until graceful shutdown. @@ -180,14 +218,39 @@ where F: Future + Send + 'static, { let listener_config = runtime.runtime_config().listener.clone(); - let bind_ip = listener_config - .bind_host - .parse::() - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let address = SocketAddr::new(bind_ip, listener_config.port); - let listener = TcpListener::bind(address).await?; - let (app, evaluations) = build_app_with_tracker(runtime); - let result = serve_listener(listener, app, &listener_config, shutdown).await; + let metrics_config = runtime.runtime_config().metrics_listener.clone(); + let listener = bind(&listener_config.bind_host, listener_config.port).await?; + let (app, evaluations, metrics) = build_app_with_tracker(runtime); + + // Both listeners are bound before either serves, so a misconfigured + // metrics binding fails startup instead of leaving a service that reports + // healthy while publishing no telemetry. + let metrics_listener = match &metrics_config { + Some(config) => Some(bind(&config.bind_host, config.port).await?), + None => None, + }; + let (stop_metrics, metrics_stopped) = tokio::sync::watch::channel(()); + let metrics_server = metrics_listener.map(|listener| { + let mut stopped = metrics_stopped; + tokio::spawn(async move { + axum::serve(listener, observability::metrics_app(metrics)) + .with_graceful_shutdown(async move { + // A dropped sender also ends the wait, so the metrics + // listener cannot outlive a failed evidence listener. + let _ = stopped.changed().await; + }) + .await + }) + }); + + let result = serve_listener(listener, app, &listener_config, async move { + shutdown.await; + drop(stop_metrics); + }) + .await; + if let Some(server) = metrics_server { + let _ = server.await; + } // A disconnected client can cause axum to drop its handler future. The // admitted evaluation itself is owned by a detached task, so the server @@ -196,6 +259,13 @@ where result } +async fn bind(bind_host: &str, port: u16) -> io::Result { + let ip = bind_host + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + TcpListener::bind(SocketAddr::new(ip, port)).await +} + /// Serve a pre-bound listener and drain client-bound handlers on shutdown. /// /// The grace duration is an operational target, not a cancellation boundary. @@ -250,7 +320,7 @@ where F: Future + Send + 'static, { let listener_config = runtime.runtime_config().listener.clone(); - let (app, evaluations) = build_app_with_tracker(runtime); + let (app, evaluations, _metrics) = build_app_with_tracker(runtime); let result = serve_listener(listener, app, &listener_config, shutdown).await; evaluations.wait_idle().await; result @@ -269,7 +339,7 @@ async fn create_evidence( } async fn create_evidence_negotiated(state: Arc, request: Request) -> Response { - let operation = operation_id(); + let operation = operation_id(request.extensions()); let started = Instant::now(); let access_token = match bearer_token(request.headers()) { @@ -399,7 +469,7 @@ async fn discover_evidence( State(state): State>, request: Request, ) -> Response { - let operation = operation_id(); + let operation = operation_id(request.extensions()); let started = Instant::now(); let access_token = match bearer_token(request.headers()) { Ok(token) => token.to_owned(), @@ -456,35 +526,41 @@ async fn health() -> Response { /// Publish the generated public contract. The document is static release /// material, so this route takes no credential and reaches no dependency. -async fn openapi() -> Response { +async fn openapi(request: Request) -> Response { match served_openapi_document() { Some(document) => bytes_response( StatusCode::OK, OPENAPI_MEDIA_TYPE, document.as_bytes().to_vec(), ), - None => problem_response(ProblemCode::ServiceUnavailable, &operation_id()), + None => problem_response( + ProblemCode::ServiceUnavailable, + &operation_id(request.extensions()), + ), } } -async fn ready(State(state): State>) -> Response { - let operation = operation_id(); +async fn ready(State(state): State>, request: Request) -> Response { + let operation = operation_id(request.extensions()); match tokio::time::timeout(state.request_timeout, state.runtime.ready()).await { Ok(true) => static_json_response(StatusCode::OK, r#"{"status":"ready"}"#), Ok(false) | Err(_) => problem_response(ProblemCode::ServiceUnavailable, &operation), } } -async fn jwks(State(state): State>) -> Response { - let operation = operation_id(); +async fn jwks(State(state): State>, request: Request) -> Response { + let operation = operation_id(request.extensions()); match serialize_response(StatusCode::OK, JWKS_MEDIA_TYPE, state.runtime.jwks()) { Some(response) => response, None => problem_response(ProblemCode::ServiceUnavailable, &operation), } } -async fn unknown_route() -> Response { - problem_response(ProblemCode::MalformedRequest, &operation_id()) +async fn unknown_route(request: Request) -> Response { + problem_response( + ProblemCode::MalformedRequest, + &operation_id(request.extensions()), + ) } async fn add_no_store(request: Request, next: Next) -> Response { @@ -572,6 +648,10 @@ fn problem_response(code: ProblemCode, operation: &str) -> Response { let body = code.body(operation); let mut response = serialize_response(code.status(), PROBLEM_MEDIA_TYPE, &body) .unwrap_or_else(|| empty_response(StatusCode::INTERNAL_SERVER_ERROR)); + // The observation layer reads the code from here rather than from the + // response body, so the operational record names the same reviewed error + // category the caller was given without reparsing public bytes. + response.extensions_mut().insert(code); if code == ProblemCode::AuthenticationFailed { response.headers_mut().insert( axum::http::header::WWW_AUTHENTICATE, @@ -611,10 +691,6 @@ fn empty_response(status: StatusCode) -> Response { (status, Body::empty()).into_response() } -fn operation_id() -> String { - Ulid::new().to_string() -} - #[cfg(test)] mod tests { use super::*; @@ -826,8 +902,25 @@ mod tests { #[test] fn operation_ids_meet_the_audit_contract() { - let operation = operation_id(); - assert!((16..=128).contains(&operation.len())); + // The public `operation` field is frozen to a 26-character Crockford + // Base32 ULID by the generated problem schema (^[0-9A-HJKMNP-TV-Z]{26}$). + // Pin the producer to that exact shape so a future change (for example + // swapping ULID for a hyphenated UUID) fails loudly here instead of + // silently breaking the frozen contract. + let operation = operation_id(&axum::http::Extensions::new()); + assert_operation_contract(&operation); + } + + fn assert_operation_contract(operation: &str) { + const CROCKFORD_UPPER: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + assert_eq!(operation.len(), 26, "operation id is a 26-character ULID"); + assert!( + operation + .bytes() + .all(|byte| CROCKFORD_UPPER.contains(&byte)), + "operation id uses only Crockford Base32 uppercase symbols" + ); + // 26 lies inside the frozen 16..=128 audit-operation length range. assert!(!operation.bytes().any(|byte| byte.is_ascii_whitespace())); } @@ -835,6 +928,7 @@ mod tests { async fn response_layers_apply_no_store_and_security_headers_without_hsts() { let app = response_layers( Router::new().route("/probe", get(|| async { StatusCode::NO_CONTENT })), + Arc::new(Metrics::default()), ); let response = app .oneshot( @@ -862,6 +956,16 @@ mod tests { .headers() .get("strict-transport-security") .is_none()); + // The identifier the caller can quote back is produced by the boundary + // itself, so it must meet the same frozen shape as the audit field. + assert_operation_contract( + response + .headers() + .get(crate::observability::CORRELATION_HEADER) + .expect("every response carries a correlation identifier") + .to_str() + .expect("the correlation header is ASCII"), + ); } #[tokio::test] diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index e634837e4..464d4347a 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -1160,10 +1160,9 @@ async fn parse_token_response( response: reqwest::Response, expected_scope: Option<&str>, ) -> Result<(ProtectedToken, Duration), SourceError> { - reject_response_status(&response).map_err(|error| match error { - SourceError::Timeout => SourceError::Timeout, - _ => SourceError::Credential, - })?; + // `reject_response_status` classifies only redirect/status outcomes, never a + // timeout, so every rejection here is a credential-exchange failure. + reject_response_status(&response).map_err(|_| SourceError::Credential)?; if response_media_type(&response).map_err(|_| SourceError::Credential)? != JSON_MEDIA_TYPE { return Err(SourceError::Credential); } diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 087cad26d..1dc7a1b7b 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -302,6 +302,19 @@ fn serve_stops_on_sigterm_and_restarts_on_an_archived_audit_chain() { stop(&mut restarted); assert!(archive.is_file(), "the archived chain was disturbed"); + + // Rollback is the same stop, rename, start sequence in reverse: the new + // chain is set aside and the archived chain resumes at the original path. + let superseded = deployment.path("audit-superseded.jsonl"); + fs::rename(&first, &superseded).expect("set the new chain aside"); + fs::rename(&archive, &first).expect("restore the archived chain"); + let mut rolled_back = deployment.serve(); + wait_until_ready(port); + stop(&mut rolled_back); + assert!( + superseded.is_file(), + "the superseded chain was disturbed during rollback" + ); deployment.unseal(); } @@ -400,6 +413,25 @@ fn verify_rejects_a_policy_document_with_an_unknown_field() { ); } +#[test] +fn verify_rejects_a_verification_instant_that_is_not_strict_utc() { + let stored = StoredResponse::stage(&fixture_evidence(), &fixture_evidence(), &fixture_policy()); + + for at in ["2026-08-02T12:00:00+02:00", "2026-08-02", CANARY] { + let output = stored.verify(Some(at)); + assert_eq!(output.status.code(), Some(1), "verify accepted {at:?}"); + assert!( + output.stdout.is_empty(), + "verify printed an unusable instant" + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, "evidence: verification instant is not strict RFC 3339 UTC\n", + "unexpected verification diagnostic" + ); + } +} + /// Assert one closed verification failure: exit 1, the chosen instant, the /// expected remaining stdout, only the closed class on stderr, and no leaked /// document value on either stream. diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs index eb90b7be2..0ade51b7c 100644 --- a/crates/registry-evidence/tests/deployment_projects.rs +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -100,6 +100,8 @@ struct Expected { facts: Option, #[serde(default)] value: Option, + #[serde(default)] + values: Option, #[serde(default, rename = "entityReferenceCount")] entity_reference_count: Option, #[serde(default, rename = "rawReferencesDisclosed")] @@ -192,7 +194,7 @@ impl Drop for LoadedProject { #[tokio::test] async fn reference_deployment_projects_execute_the_closed_fixture_contract() { - for project_name in ["dhis2-adult-status", "opencrvs-family-evidence"] { + for project_name in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { let project = load_project(project_name); let signer = fixture_signer().await; for requirement in &project.bundle.config.requirements { @@ -324,6 +326,11 @@ fn validate_contract_shape(project_name: &str, fixture: &FixtureContract) { "{project_name}/{}: case input form is not closed", case.id ); + assert!( + !(case.expected.value.is_some() && case.expected.values.is_some()), + "{project_name}/{}: a case states either one concept value or the complete concept map", + case.id + ); } } @@ -894,6 +901,30 @@ fn assert_values( "{label}: scalar value mismatch" ); } + // A requirement disclosing more than one concept states every concept it + // discloses, so a new or leaked concept cannot pass unnoticed. + if let Some(expected_values) = &expected.values { + let expected_values = expected_values + .as_object() + .unwrap_or_else(|| panic!("{label}: expected concept map is not an object")); + assert_eq!( + values.len(), + expected_values.len(), + "{label}: concept value count mismatch" + ); + for (concept_id, expected_value) in expected_values { + let value = values + .iter() + .find(|value| &value.provides_value_for == concept_id) + .unwrap_or_else(|| panic!("{label}: expected concept is absent")); + let actual = serde_json::to_value(&value.value) + .unwrap_or_else(|_| panic!("{label}: concept value encoding failed")); + assert!( + same_json(expected_value, &actual), + "{label}: concept value mismatch" + ); + } + } if let Some(expected_count) = expected.entity_reference_count { assert_eq!(values.len(), 1, "{label}: reference concept count mismatch"); let actual_count = match &values[0].value { @@ -1129,6 +1160,8 @@ fn assert_privacy(project_name: &str, expectation: &PrivacyExpectation, payloads "request preparation failed", "exact facts mismatch", "scalar value mismatch", + "concept value mismatch", + "expected concept is absent", "signed evidence verification failed", ]; for prohibited in &expectation.diagnostics_exclude { diff --git a/products/evidence/FIRST-CURL-TEST.md b/products/evidence/FIRST-CURL-TEST.md index a2f9a8749..e253c0768 100644 --- a/products/evidence/FIRST-CURL-TEST.md +++ b/products/evidence/FIRST-CURL-TEST.md @@ -165,7 +165,18 @@ confirms both audit events are durable, shuts down, and ends with: PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200, its JWS verified, adult-status was true, minimization held, and both audit events were durable. ``` -Anything else is not a pass. Do not paste `session.env` or its bearer token +If you also ran the optional unsigned variant, its `response-unsigned.json` +output is present, so the harness additionally verifies that leg and ends with +the four-audit-event form instead: + +```text +PASS: authenticated discovery listed four safe request shapes, Evidence returned HTTP 200 in both formats, the JWS verified, the unsigned envelope was self-identifying and rejected by the JWS verifier, adult-status was true, minimization held, and all four audit events were durable. +``` + +Either `PASS:` line is a full pass; which one you see depends only on whether the +optional unsigned leg ran (two audit events for the signed leg alone, four when +the unsigned leg also ran). Anything else is not a pass. Do not paste +`session.env` or its bearer token into chat. The responses are retained at `products/evidence/.first-curl/definitions.json`, `products/evidence/.first-curl/response.json`, and, if you ran the optional @@ -198,12 +209,6 @@ not for Version 1 completion: with HTTPS OIDC JWKS and the file-secret boundary; - run the Evidence server against one bounded DHIS2 demo record and one bounded OpenCRVS demo record using `.env`, then verify each returned JWS; -- bind the remaining authenticated definition-discovery acceptance requirement - to executable traceability so every numbered requirement maps to a test; -- make `existenceDisclosure` and the fixed JWKS path explicit enforced - invariants rather than decorative configuration; -- document audit capacity, rotation/restart, and independent chain files, and - bind the Unix single-link secret check to security traceability; - rerun the final Evidence package, contract, neutrality, generated-artifact, and ignored live-source gates on one stable revision; - stage the exact Evidence scope. Workspace-wide gates remain outside the diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 8fcbe1319..46e9a957f 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -64,12 +64,13 @@ products/evidence/ scripts/ ``` -The binary exposes three commands: +The binary exposes four commands: ```text evidence serve evidence check evidence evaluate --fixture +evidence verify --jws --jwks --policy ``` Do not create client, worker, adapter, policy, credential, or interoperability diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index e467eace7..273613b20 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -46,6 +46,13 @@ context and independently enforces requirement, purpose, subject authority, selector, audience, disclosure, signing, and audit rules. Unsigned headers or caller request fields never substitute for authenticated authority. +Version 1 accepts bearer tokens only. A token carrying a proof-of-possession +confirmation claim is denied rather than accepted as an ordinary bearer, because +Evidence validates no sender proof and accepting one would discard the +constraint the authorization server issued the token under. An authorization +server that binds tokens to DPoP keys or client certificates must issue Evidence +clients unbound tokens. + ## Governed bundle and operator runtime The operator supplies one atomic bundle containing the approved YAML, @@ -272,6 +279,27 @@ selector profile identifiers and values, source requests and responses, authority grants, Rhai inputs, credentials, tokens, and disclosed values are excluded from logs, metrics, traces, snapshots, panics, and errors. +The serving process writes those records as line-delimited JSON on standard +output, one per served request, and `EVIDENCE_LOG` selects verbosity with a +default of `info`. Offline commands print their own result and emit no +operational records. Every response, including responses to unrouted paths, +carries the request's operation identifier in `X-Request-Id`; it is minted by +Evidence and never taken from an inbound header, so a caller reporting a +problem can quote an identifier the operator can find without disclosing +anything about the request. + +Telemetry is off by default. Setting `metricsListener` in `runtime.yaml` serves +`GET /metrics` in Prometheus text format on a second private binding, which must +differ from the evidence listener binding and is subject to the same +loopback-or-private-address rule. The evidence listener never serves `/metrics`, +and the metrics listener never serves evidence. Series carry only the registered +route template, request method, status category, and reviewed problem code, so +series cardinality is bounded by the deployed contract and cannot grow with +caller input. A path that matches no route is counted as `unmatched` and a +method outside the served set as `other`, so a caller cannot write a label +value. Operators should still reach this listener only from their own network, +since request rates per route are operational information. + The operator owns audit retention, backup, restore, access control, key rotation, and chain verification for the selected durable sink. A deployment profile may require more reviewed metadata or retention, but it cannot silently @@ -454,8 +482,35 @@ No-match and ambiguous outcomes are publicly indistinguishable by default. Source, signing, and dependency failures use stable safe problem codes and do not reflect protected inputs. Signing failure returns a safe transient failure. +Every authorization refusal collapses to one generic `not_authorized` problem +(code `n`) with HTTP 403 and reveals no layer detail: a principal outside the +bundle audience, a requirement no matched grant permits, an authority the grant +does not carry, and an unsigned-envelope request the bundle or grant does not +allow all return the same body. This is deliberate; the response is not an +oracle for which check failed. Because the wire response is intentionally +uninformative, operators debug a 403 from trusted local state, not from the +response. Confirm, in order: the Bearer principal is in the deployed bundle's +audience; a grant matches the requested requirement, purpose, and subject +roles; the grant carries the claimed authority; and, only for an unsigned +request, both the bundle and that grant permit +`application/vnd.registrystack.evidence-unsigned+json`. The keyed audit chain +records the refusal phase for after-the-fact diagnosis; the caller never sees +it. + ## Verification and release limit +A relying party or operator re-verifies a stored signed response offline with +`evidence verify --jws --jwks --policy [--at ]`. +The pinned JWKS file is the complete trust set and the policy document carries +every expectation from independent trusted state: the retained request nonce, +the expected role-bound subject bindings, and the expected output contract, +under +[`contracts/verification-policy.schema.yaml`](contracts/verification-policy.schema.yaml). +The command performs no network access, reports cryptographic authenticity +separately from current validity, and exits 0 only when both hold; an +authentic but expired response exits 3. Every failed policy comparison reports +one generic class so verification is not an oracle. + Operators must verify a candidate revision with the applicable phase and final commands in [AGENTS.md](AGENTS.md). Public-demo source tests are optional, ignored, read-only, local, and non-gating. They may run only after deterministic diff --git a/products/evidence/PERFORMANCE.md b/products/evidence/PERFORMANCE.md new file mode 100644 index 000000000..ee05e13d5 --- /dev/null +++ b/products/evidence/PERFORMANCE.md @@ -0,0 +1,123 @@ +# Evidence Performance + +Status: Measured baseline and deferred work, not a Version 1 contract +Date: 2026-08-02 + +## Purpose + +Evidence trades request throughput for audit durability. This file records what +that trade costs, how it was measured, and the one change that would recover +most of the cost without weakening the guarantee. Nothing here is a Version 1 +commitment. Throughput is not a Definition of Done row and is not a `CONCEPT.md` +non-goal; it is ordinary engineering work that has been deliberately deferred. + +## The guarantee that sets the ceiling + +The security invariant matrix requires that the disclosure-release record be +durably accepted before the response bytes reach the caller, and that the access +record be durably accepted before source access. `DurableJsonlSink` implements +this by calling `sync_all` inside the append, and `ChainState::append` holds the +chain mutex across that call so hash links cannot interleave. + +The result is two serialized disk barriers per successful request. + +This is Evidence-specific. The shared `JsonlFileSink` used by Notary, and +Relay's file sink, both end their append at `write_all` plus `flush` and never +call `sync_all`, so their records sit in the page cache and are lost on power +failure. Evidence is the only one of the three that survives that failure, and +the ceiling below is the price of it. + +## Measured baseline + +Measured with `soak_reports_request_throughput_against_the_audit_ceiling` in +`crates/registry-evidence/src/runtime_tests.rs`. Two release-profile runs, 512 +requests at 32 concurrent, against a local mock source: + +| | run 1 | run 2 | +|---|---|---| +| Observed request rate | 122 rps | 161 rps | +| Measured audit ceiling | 131 rps | 163 rps | +| Mock source floor | 57,461 rps | 77,256 rps | +| Latency p50 / p95 / p99 | 260 / 293 / 308 ms | 196 / 212 / 225 ms | +| Share of audit ceiling | 93% | 99% | + +Host: Apple M5 Max, 18 logical cores, APFS, macOS. + +Attribution is unambiguous. The source served roughly 450 times faster than the +observed request rate, so it contributes nothing. Observed throughput sits at 93 +to 99 percent of what the audit chain can sustain. Little's law agrees from the +other side: 32 concurrent divided by 161 rps is 199 ms against a measured 196 ms +p50, so nearly all request latency is queueing on the audit mutex rather than +work. + +Per-append cost is 3.1 to 3.8 ms. + +### Why this number is pessimistic + +On macOS, `File::sync_all` issues `F_FULLFSYNC`, a true device write barrier. On +Linux the same call is an ordinary `fsync`, which on NVMe is far cheaper. **These +figures are a macOS floor and must be re-measured on the target Linux host +before they are quoted as production numbers or used to justify the work below.** + +## Horizontal scaling works today + +`DurableJsonlSink::open` takes an exclusive `flock`, so one process owns one +audit path. Nothing else is shared between requests. N processes with N distinct +audit paths therefore give N times the throughput with no code change. Only +vertical throughput is capped. + +## Deferred work: group commit + +The lever for vertical throughput is batching the barrier, not removing it. + +Today each append takes the chain mutex, writes, and fsyncs alone. Under +concurrency the appends already queue, so the records that queue behind an +in-flight barrier could be written and covered by a single subsequent barrier. +One fsync would then serve many records instead of one. + +Properties that must survive the change: + +- durability before release: an append resolves only after the barrier that + covers its own bytes has completed, so no caller receives evidence ahead of + its durable record; +- chain ordering: records are hash-linked in the order they were chained, and + the on-disk order matches; +- fail-closed: a failed barrier fails every append it covers, and none of them + may report success; +- fork detection: the pinned-path, fingerprint, and tail checks in + `DurableJsonlSink::write` still bracket the batched write. + +Expected gain is roughly the batch size, bounded by concurrent arrivals, so it +scales with load rather than helping a single idle request. + +### Preconditions + +1. Re-measure on the target Linux host. If the Linux ceiling already clears the + deployment's required rate, do not do this work. +2. Treat it as a security-sensitive change to audit integrity. It needs explicit + review notes and focused negative tests for each property above, per the + root `AGENTS.md` rules and the phase-3 invariant discipline in + `products/evidence/AGENTS.md`. + +## Regression baseline + +Two tests cover this area: + +- `concurrent_evidence_requests_keep_one_verifiable_audit_chain` runs in CI. It + drives simultaneous evaluations and asserts one verifiable chain, two records + per release, and a distinct evidence identity per request. Any interleaving or + forking under concurrency fails it. +- `soak_reports_request_throughput_against_the_audit_ceiling` is `#[ignore]` and + opt-in. It reports rates and asserts only correctness, because throughput + thresholds are host properties and would otherwise be a source of flakes: + +```bash +cargo test -p registry-evidence --lib --release -- --ignored --nocapture soak_reports +``` + +Record the host alongside any figure taken from it. + +## Not measured + +Notary's sink was read, not benchmarked. The claim that it is materially faster +on this axis is inference from the absent `sync_all`, not a measurement. diff --git a/products/evidence/README.md b/products/evidence/README.md index 496cd69ee..9af47bc4e 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -113,8 +113,10 @@ missing `Accept`, `*/*`, or the exact `application/jose+json` all select it. The exact `application/vnd.registrystack.evidence-unsigned+json` selects a visibly unsigned envelope, and only when both the immutable bundle and the one complete matched grant permit that format; otherwise the request is refused with the -ordinary `not_authorized` problem before credentials or source access, without -revealing which layer refused. A duplicate, combined, parameterized, weighted, +ordinary `not_authorized` problem (HTTP 403) before credentials or source +access, without revealing which layer refused. Every authorization refusal +shares this one generic 403, so it is never an oracle for which check failed. A +duplicate, combined, parameterized, weighted, or unknown `Accept` returns the `response_format_not_acceptable` problem with HTTP 406 before source access. Unsigned output is transport-authenticated convenience data for development and for consumers that cannot process JWS. It diff --git a/products/evidence/contracts/README.md b/products/evidence/contracts/README.md index 1f829de3f..9a17276a0 100644 --- a/products/evidence/contracts/README.md +++ b/products/evidence/contracts/README.md @@ -17,6 +17,9 @@ The normative source set is: `jws-profile.yaml`: public discovery, request, the required `requestNonce` and its echo in the Evidence payload, response-format negotiation, payload, signing, rotation, and strict verifier rules; +- `verification-policy.schema.yaml`: the closed all-required relying-procedure + policy document consumed by the offline `evidence verify` command, its frozen + command surface, exit codes, and no-network rule; - `problem-contract.yaml`: safe public failures, the `response_format_not_acceptable` negotiation failure, and existence-collapse rules; diff --git a/products/evidence/contracts/runtime.schema.yaml b/products/evidence/contracts/runtime.schema.yaml index 5d728d057..9746cdb16 100644 --- a/products/evidence/contracts/runtime.schema.yaml +++ b/products/evidence/contracts/runtime.schema.yaml @@ -25,6 +25,20 @@ properties: maximumConcurrentRequests: {type: integer, minimum: 1, maximum: 4096} requestTimeoutMilliseconds: {type: integer, minimum: 1, maximum: 30000} shutdownGraceMilliseconds: {type: integer, minimum: 1, maximum: 120000} + metricsListener: + type: object + additionalProperties: false + required: [bindHost, port] + description: Optional operator-only telemetry listener serving GET /metrics. Absent means the deployment serves no metrics endpoint, which is the default. It is a separate binding from the evidence listener and is not described by the public evidence contract. + properties: + bindHost: + type: string + minLength: 2 + maxLength: 64 + description: Numeric loopback, RFC 1918 private IPv4, or RFC 4193 unique-local IPv6 address. Unspecified, multicast, public, and hostname values are prohibited. + x-runtime-validation: Parsed as an IP address and accepted only when Rust classifies it as loopback, private IPv4, or unique-local IPv6. + port: {type: integer, minimum: 1, maximum: 65535} + x-runtime-validation: Rejected at startup when bindHost and port together repeat the evidence listener binding. secretProviders: type: object additionalProperties: false @@ -67,6 +81,7 @@ ownership: allowed: - bundle directory - listener binding and process limits + - optional operator metrics listener binding - file-secret root - audit path and rotation bound - logical private-CA file bindings diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index d2ceb6ff4..6ac12fafd 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -157,6 +157,16 @@ invariants: threat: A relying party accepts substituted subjects or outputs under a valid signature, or leaks which hidden comparison failed. enforcement: Verifier policy comparison after signature and schema verification with a single generic policy error and a separate current-validity result. negative_test: sec-verifier-independent-expectations + - id: V1-I32 + rule: An access token carrying a proof-of-possession confirmation claim is denied, never accepted as an ordinary bearer token. + threat: A stolen sender-constrained token replays for its full lifetime because the profile silently discards a constraint it does not validate. + enforcement: Verified-claim rejection of the RFC 7800 confirmation claim before any authenticated context is constructed. + negative_test: sec-sender-constrained-token-denied + - id: V1-I33 + rule: Operational telemetry is off by default, served only on a separate operator-private listener, and carries no caller-supplied or protected value in any label or series. + threat: A metrics surface becomes a public existence oracle, or per-request labels reconstruct selectors, requirement usage, or registry membership through unbounded series cardinality. + enforcement: Optional runtime metricsListener bound to a private address distinct from the evidence listener, serving only GET /metrics; series labels are drawn from the closed registered route-template set, a fixed method set, a fixed status category, and the reviewed problem-code set, never from request content. + negative_test: sec-telemetry-bounded-and-operator-private cross_cutting: config_trust: threat: A missing, writable, or unreviewed bundle is treated as trusted configuration. @@ -188,7 +198,7 @@ cross_cutting: negative_test: sec-request-preparation-closed runtime_ownership_split: threat: An environment-specific runtime file silently changes governed authorization, disclosure, source authority, signing, or audit policy. - enforcement: Closed runtime.yaml accepts only process-local listener, path, secret-root, audit-storage, and logical private-CA bindings and has an independent immutable digest. + enforcement: Closed runtime.yaml accepts only process-local listener, optional metrics-listener, path, secret-root, audit-storage, and logical private-CA bindings and has an independent immutable digest. negative_test: sec-runtime-cannot-override-governed-bundle outbound_tls_and_proxy: threat: A mutable or untrusted CA or ambient proxy redirects credentials and protected source queries to another authority. @@ -218,4 +228,8 @@ cross_cutting: threat: A hostile or defective reviewed script consumes unbounded execution inside the shared process. enforcement: The engine's normative operation ceiling terminates the invocation with a closed value-free error. negative_test: sec-script-operation-exhaustion + reserved_header_aliases: + threat: A configured fixed or API-key header name collides with an authorization, framing, routing, cookie, forwarding, proxy, or tracing header, including case variants and known infrastructure aliases. + enforcement: One closed ASCII-case-insensitive deny set shared by startup configuration validation and source plan compilation, with prefix families denied before exact names and both checks running before any credential is resolved. + negative_test: sec-reserved-header-aliases-closed fixture_index: ../fixtures/conformance/coverage-matrix.yaml diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index d175a54c7..0d08ad824 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -10,6 +10,13 @@ entries: tests: [{file: crates/registry-evidence/src/config.rs, name: a_shared_disclosure_family_rejects_the_complete_bundle}] - id: sec-missing-principal-no-fallback tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: missing_principal_never_falls_back_to_client_id_or_azp}] + - id: sec-sender-constrained-token-denied + tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: sender_constrained_tokens_are_denied_rather_than_downgraded}] + - id: sec-telemetry-bounded-and-operator-private + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: metrics_report_bounded_series_without_disclosing_request_content} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: a_configured_metrics_listener_serves_beside_the_evidence_listener} + - {file: crates/registry-evidence/src/config.rs, name: the_optional_metrics_listener_is_absent_by_default_and_stays_operator_private} - id: sec-no-entitlement-union tests: [{file: crates/registry-evidence/src/config.rs, name: complete_authority_paths_cannot_be_unioned_across_partial_grants}] - id: sec-selector-possession-no-authority @@ -50,6 +57,7 @@ entries: - {file: crates/registry-evidence/src/rhai_runtime.rs, name: derived_value_debug_redacts_every_value_carrier} - {file: crates/registry-evidence/src/rhai_runtime.rs, name: request_parts_debug_redacts_query_and_body_values} - {file: crates/registry-evidence/src/runtime_tests.rs, name: runtime_output_gate_rejects_every_fixture_injected_derivation_without_release} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: operational_logs_carry_only_the_reviewed_fields_and_disclose_no_value} - id: sec-unresolved-public-collapse tests: - {file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships} @@ -183,3 +191,7 @@ entries: tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: configured_jwks_path_is_mechanically_the_served_route}] - id: sec-script-operation-exhaustion tests: [{file: crates/registry-evidence/src/rhai_runtime.rs, name: operation_exhaustion_terminates_a_hostile_script_with_a_value_free_error}] + - id: sec-reserved-header-aliases-closed + tests: + - {file: crates/registry-evidence/src/config.rs, name: path_templates_headers_and_projection_fail_closed} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index ba61b50bb..1d2139a13 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -18,6 +18,7 @@ ownership: prohibited: source, origin, method, path, headers, authentication, credentials, TLS, proxy, redirect, retry, pagination, concurrency, or request-count authority evidence_data_request: count: Exactly one per evaluation after successful authorization, durable access audit, and complete RequestParts validation. + retry: The HTTP client disables transport retry explicitly, so a connection failure yields exactly one connection attempt and no second request. owner: Core runtime. method_allowlist: [GET, POST] request_media_types: [application/json] @@ -139,6 +140,7 @@ authentication: - Evidence-request processing resolves credentials only after authorization, access audit, preparation, and RequestParts validation. - Missing or invalid credentials fail closed without an evidence-data request. tls_and_proxy: + backend: The evidence-data and OAuth token clients select the rustls TLS backend explicitly, so workspace-wide dependency feature unification that enables an alternate backend elsewhere cannot change which backend these clients use. system_roots: Supported when runtime outboundTls.systemRoots is true. private_ca: A governed logical tlsTrustProfile must have exactly one regular, read-only, captured, bounded, valid runtime CA file binding before readiness; missing, extra, symlinked, mutable, and malformed bindings fail startup. verification: Fixed-origin and hostname verification remain mandatory; insecure, skip-verification, and trust-all modes do not exist. diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index bbce6b652..d0bb9c101 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -1258,6 +1258,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } @@ -1287,6 +1294,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } @@ -1317,6 +1331,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1371,6 +1392,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } @@ -1401,6 +1429,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1455,6 +1490,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } @@ -1509,6 +1551,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1612,6 +1661,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1683,6 +1739,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1746,6 +1809,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1809,6 +1879,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1872,6 +1949,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -1943,6 +2027,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -2046,6 +2137,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } @@ -2080,6 +2178,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -2134,6 +2239,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -2196,6 +2308,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -2258,6 +2377,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } }, @@ -2312,6 +2438,13 @@ ], "type": "string" } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } } } } diff --git a/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md index ae5154e2a..b539a66af 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md +++ b/products/evidence/reference/request-adapter/deployment-projects/FIXTURES.md @@ -124,6 +124,7 @@ those stages apply. Omission is not treated as a wildcard. | `lookup` | `match`, `no_match`, or `ambiguous` | Exact closed extraction outcome. | | `facts` | JSON object | Exact complete fact object after fact-schema validation, never a partial match. | | `value` | supported scalar | Exact value of the requirement's only concept. Use only for a one-concept requirement. | +| `values` | concept map | Exact complete set of disclosed concepts, keyed by concept id. Use for a requirement disclosing more than one concept. | | `entityReferenceCount` | integer | Exact number of audience-scoped entity references in the public value. | | `rawReferencesDisclosed` | boolean | Whether any configured raw reference appears in evidence; these projects require `false`. | | `signed` | boolean | Whether a flattened JWS success is returned. A valid `false` concept still requires `true`. | @@ -148,6 +149,10 @@ Every `facts` expectation is exact. If a test cares about only two of four facts, it must still list all four. This prevents fixtures from silently accepting a new or leaked fact. +`value` and `values` are mutually exclusive, and `values` is exact in the same +way: it states every concept the requirement discloses, so a new or leaked +concept cannot pass unnoticed. + ## Error classes and public problems Fixtures distinguish unresolved evidence from a broken dependency or bundle diff --git a/products/evidence/reference/request-adapter/deployment-projects/README.md b/products/evidence/reference/request-adapter/deployment-projects/README.md index f800f359c..5aa19c71c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/README.md @@ -30,9 +30,10 @@ the same fixtures with `evidence evaluate` before deployment. ## Projects -- [`dhis2-adult-status/`](dhis2-adult-status/) resolves one tracked entity by - an exact reference and derives adult status from the configured date-of-birth - attribute. +- [`dhis2-tracker-evidence/`](dhis2-tracker-evidence/) resolves one tracked + entity by an exact reference and supports adult status from the configured + date-of-birth attribute and professional licence status as an active-licence + boolean plus a bounded expiry category. - [`opencrvs-family-evidence/`](opencrvs-family-evidence/) resolves one registered birth event and supports adult status, exact registered-parent confirmation, and bounded registered-parent identification. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md deleted file mode 100644 index 0d4f8c5a0..000000000 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# DHIS2 adult-status deployment project - -This is a complete, simple target bundle for a deployment that derives one -adult-status boolean from DHIS2 Tracker. - -The reviewed governance bundle is under `bundle/`. Process-local paths, -listener settings, and the private-CA file binding are in `runtime.yaml`. -Deployments review and mount both files read-only, but staging and production -may use different runtime files without changing evidence semantics. - -Before deployment, the operator changes only: - -- `.example` OIDC and DHIS2 hosts; -- the DHIS2 program, organisation unit, and date-of-birth attribute UIDs; -- issuer, provider, trust-domain, framework, evidence-type, and concept URIs; -- authority tags and purposes; -- the referenced secret files; and -- the runtime paths, listener binding, and private-CA bundle file. - -The source performs one page-one lookup for exactly one tracked-entity -reference with `pageSize=2`. A second page is never followed. That two-result -ceiling is governed adapter policy declared by this reviewed bundle and -rendered by its preparation script so one bounded response separates a unique -match from ambiguity. It is not a Rust domain rule and not a DHIS2 property. -Because the Tracker response can contain attributes beyond the one consumed by -the adapter, the source honestly declares `record-transformed` posture. -Extraction carries the returned `trackedEntity` only as a transient fact, and -the derivation requires its exact equality with the authorized subject -selector before evaluating the date of birth. A returned-record mismatch fails -closed as the internal `derivation_input_error` category and collapses publicly -into the same `evidence_not_available` problem as an unresolved lookup, so the -caller cannot learn that a record was found. It is never signed as either adult -or not adult. The raw tracked entity reference is never included in evidence. -`pageSize`, `page`, and `totalPages` are strings because they become lexical URL -query values. In contrast, the OpenCRVS project's JSON body keeps numeric and -boolean constants typed. - -Required secret files beneath `/run/secrets/registry-evidence`, each owned by -the service identity with mode `0600`, are: - -```text -signing-ed25519-private-jwk -audit-hmac-key -subject-binding-hmac-key -dhis2-username -dhis2-password -``` - -The audit and subject-binding files must contain independently generated raw -key material of at least 32 bytes each; they are not base64-decoded. The -signing file contains one private Ed25519 JWK. No secret value is stored in -this project. - -Author with synthetic fixtures first, then promote the same reviewed `bundle/` -bytes through staging and production. Bind environment-specific runtime paths, -credentials, private CA, and signing key in each environment. Staging must -verify the configured `at+jwt` header and claims, readiness, one approved -synthetic source lookup, audit durability, and JWS verification. See the -[authoring and promotion workflow](../CONFIG.md#authoring-and-promotion-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml deleted file mode 100644 index d4a1b75a6..000000000 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/evidence.yaml +++ /dev/null @@ -1,133 +0,0 @@ -version: 1 -service: - providerId: urn:gov:example:evidence:dhis2 - trustDomain: urn:gov:example:trust-domain:social-protection -issuer: - id: urn:gov:example:issuer:population-authority -authentication: - kind: oidc-access-token - issuer: https://identity.gov.example - audiences: [registry-evidence] - tokenTypes: [at+jwt] - algorithms: [EdDSA] - jwksUri: https://identity.gov.example/.well-known/jwks.json - principalClaim: sub - requesterTagsClaim: evidence_tags - evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority -audit: - format: keyed-jsonl - hashSecretRef: secret:file/audit-hmac-key - hashKeyVersion: 1 - failClosed: true -subjectBinding: - secretRef: secret:file/subject-binding-hmac-key - keyVersion: 1 -rateLimits: - requestsPerPrincipalPerMinute: 60 - burstPerPrincipal: 10 - failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 -signing: - format: flattened-jws-json - algorithm: EdDSA - activeKeyId: evidence-signing-2026-01 - activeKeyRef: secret:file/signing-ed25519-private-jwk - retiredPublicJwkFiles: [] - jwksPath: /.well-known/evidence/jwks.json - maximumAssertionValiditySeconds: 86400 - verifierClockSkewSeconds: 30 -responseFormats: [signed-jws] - -selectorProfiles: - tracked-entity-reference-v1: - maximumAggregateBytes: 64 - fields: - record_reference: {type: string, minimumBytes: 11, maximumBytes: 64} -sources: - population-tracker: - transport: http-json - baseUrl: https://dhis2.gov.example - posture: record-transformed - tlsTrustProfile: government-internal-pki - authentication: - kind: basic - usernameRef: secret:file/dhis2-username - passwordRef: secret:file/dhis2-password - request: - method: GET - path: /api/tracker/trackedEntities - fixedHeaders: - - {name: Accept, value: application/json} - selectorInputs: - - role: subject - alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} - prepareScript: adapters/prepare.rhai - adapterParameters: - program: Prg00000001 - organisationUnit: Org00000001 - providerFields: trackedEntity,attributes[attribute,value] - pageSize: "2" - page: "1" - totalPages: "true" - dateOfBirthAttribute: Dob00000001 - adapterParametersSchema: schemas/adapter-parameters.schema.yaml - preparationLimits: - query: required - jsonBody: forbidden - maximumQueryPairs: 8 - maximumQueryNameBytes: 64 - maximumQueryValueBytes: 1024 - maximumNormalizedBytes: 4096 - projection: - - /pager/total - - /trackedEntities/*/trackedEntity - - /trackedEntities/*/attributes/*/attribute - - /trackedEntities/*/attributes/*/value - redirects: deny - timeoutMilliseconds: 3000 - maximumResponseBytes: 65536 - concurrencyLimit: 8 - extractScript: adapters/extract.rhai - factSchema: schemas/facts.schema.yaml -authorityProfiles: - eligibility-caseworker-v1: - kind: statutory - requesterTags: [eligibility-caseworker] - grants: - - requirement: urn:gov:example:requirement:adult-status:v1 - purpose: benefit-eligibility - audienceFrom: authenticated-requester - responseFormats: [signed-jws] - subjects: - - role: subject - selectorProfile: tracked-entity-reference-v1 - valueOrigin: request -requirements: - - id: urn:gov:example:requirement:adult-status:v1 - kind: criterion - source: population-tracker - purposes: [benefit-eligibility] - subjectRoles: - - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} - referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] - evidenceType: urn:gov:example:evidence-type:adult-status:v1 - observationTimezone: Asia/Bangkok - validitySeconds: 86400 - derivation: - script: derivations/adult-status.rhai - selectorInputs: - - role: subject - alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} - parameters: {minimum_age_years: 18} - concepts: - - id: urn:gov:example:concept:adult-status - form: boolean - required: true - constraints: {} - fixtures: fixtures/cases.yaml - disclosureGuard: - families: [urn:gov:example:disclosure-family:adult-status] - existenceDisclosure: collapse-unresolved diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md new file mode 100644 index 000000000..1735cacb7 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/README.md @@ -0,0 +1,127 @@ +# DHIS2 Tracker deployment project + +This complete target bundle uses two DHIS2 Tracker programmes for two separate +minimum-disclosure requirements: + +- adult status from the configured date-of-birth attribute; and +- professional licence status as an active-licence boolean plus a bounded + expiry category. + +The two requirements are independent. Each has its own source, extraction +script, fact schema, derivation, authority profile, purpose, validity period, +disclosure family, and fixtures. A caller authorized for one learns nothing +about the other. + +The reviewed governance bundle is under `bundle/`. Process-local paths, +listener settings, and the private-CA file binding are in `runtime.yaml`. +Deployments review and mount both files read-only, but staging and production +may use different runtime files without changing evidence semantics. + +Before deployment, the operator changes only: + +- `.example` OIDC and DHIS2 hosts; +- the DHIS2 programme, organisation unit, attribute, and programme-stage UIDs; +- issuer, provider, trust-domain, framework, evidence-type, and concept URIs; +- authority tags and purposes; +- the referenced secret files; and +- the runtime paths, listener binding, and private-CA bundle file. + +## Shared lookup shape + +Both sources reuse `adapters/prepare.rhai`. It reads only the closed +`program`, `organisationUnit`, `providerFields`, `pageSize`, `page`, and +`totalPages` parameters, so the same reviewed preparation renders both +requests and neither requirement can widen the other's query. Extraction stays +separate because the two sources validate and emit different facts. + +Each source performs one page-one lookup for exactly one tracked-entity +reference with `pageSize=2`. A second page is never followed. That two-result +ceiling is governed adapter policy declared by this reviewed bundle and +rendered by its preparation script so one bounded response separates a unique +match from ambiguity. It is not a Rust domain rule and not a DHIS2 property. +Both extractions require the complete `page`, `pageSize`, `total`, and +`pageCount` pager, check that the page count agrees with the total and the page +size, and check that the returned collection agrees with the total. A truncated +or inconsistent pager is a protocol failure rather than a silently smaller +result set. + +Because a Tracker response can carry attributes and enrollments beyond the ones +consumed by an adapter, both sources honestly declare `record-transformed` +posture. Extraction carries the returned `trackedEntity` only as a transient +fact, and each derivation requires its exact equality with the authorized +subject selector before evaluating anything else. A returned-record mismatch +fails closed as the internal `derivation_input_error` category and collapses +publicly into the same `evidence_not_available` problem as an unresolved +lookup, so the caller cannot learn that a record was found. The raw tracked +entity reference is never included in evidence. + +`pageSize`, `page`, and `totalPages` are strings because they become lexical +URL query values. In contrast, the OpenCRVS project's JSON body keeps numeric +and boolean constants typed. + +## Adult status + +One tracked entity is resolved by exact reference and one boolean is derived +from the configured date-of-birth attribute against the requirement's +`minimum_age_years`. The date of birth is never disclosed. A record whose +configured attribute is absent, duplicated, or not a string is unresolved or a +protocol failure, never a signed `false`. + +## Professional licence status + +The licence source projects the nested +`enrollments[program,status,events[programStage,status]]` shape. Extraction +selects the single enrollment in the configured licence programme, rejects a +duplicate enrollment in that programme as a protocol failure, and reads the +restriction programme stage from that enrollment's events. A restriction is +recorded only when an event in the configured stage carries the configured +completed status; a scheduled or otherwise incomplete restriction event is not +a restriction in force. + +The derivation signs an active-licence boolean and an expiry category. The +boolean requires the configured active enrollment state, no recorded +restriction, and a legal local date inside the validity window. The category +comes from `bucket_number` over the closed `expiry_buckets` parameter, so a +verifier learns how soon the licence lapses without learning the date. A +validity window that ends before it starts is an inconsistent record and is +rejected as `derivation_input_error` rather than signed as expired. + +The `absentRestrictionEventMeansUnrestricted` adapter parameter is a +governance declaration, not a convenience default. When the deployment sets it +`true`, as here, an enrollment recording no restriction event at all is read as +unrestricted, and that reading is reviewed bundle policy rather than an +inference from DHIS2 behavior. When a deployment cannot make that statement it +sets the parameter `false`, extraction omits `restriction_recorded`, the fact +schema rejects the incomplete fact set, and the requirement is unresolved. +Evidence has no third state to sign: a missing signal must either be governed +into a definite reading or stop the assertion. + +## Secrets + +Required secret files beneath `/run/secrets/registry-evidence`, each owned by +the service identity with mode `0600`, are: + +```text +signing-ed25519-private-jwk +audit-hmac-key +subject-binding-hmac-key +dhis2-username +dhis2-password +``` + +Both sources authenticate with the same credential files. A deployment whose +licence programme is served by a separate DHIS2 instance or a separate service +account gives that source its own `baseUrl` and its own secret references. + +The audit and subject-binding files must contain independently generated raw +key material of at least 32 bytes each; they are not base64-decoded. The +signing file contains one private Ed25519 JWK. No secret value is stored in +this project. + +Author with synthetic fixtures first, then promote the same reviewed `bundle/` +bytes through staging and production. Bind environment-specific runtime paths, +credentials, private CA, and signing key in each environment. Staging must +verify the configured `at+jwt` header and claims, readiness, one approved +synthetic source lookup per requirement, audit durability, and JWS +verification. See the +[authoring and promotion workflow](../CONFIG.md#authoring-and-promotion-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai similarity index 58% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai index 18f2e4a8e..23bba942f 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai @@ -1,23 +1,44 @@ +// The provider reports the full pager only because the prepared request asks +// for it. Requiring every declared pager field, and requiring the page count +// to agree with the total, keeps a truncated or inconsistent envelope from +// being read as an authoritative no-match or unique match. fn extract(source_response, parameters) { if !source_response.contains("pager") || !source_response.contains("trackedEntities") || type_of(source_response["pager"]) != "map" || - type_of(source_response["trackedEntities"]) != "array" || - !source_response["pager"].contains("total") || - type_of(source_response["pager"]["total"]) != "i64" || - source_response["pager"]["total"] < 0 || - source_response["trackedEntities"].len > 2 { + type_of(source_response["trackedEntities"]) != "array" { throw("source_protocol_error"); } - let total = source_response["pager"]["total"]; + let pager = source_response["pager"]; let records = source_response["trackedEntities"]; + if pager.len() != 4 || + !pager.contains("page") || + !pager.contains("pageSize") || + !pager.contains("total") || + !pager.contains("pageCount") || + type_of(pager["page"]) != "i64" || + type_of(pager["pageSize"]) != "i64" || + type_of(pager["total"]) != "i64" || + type_of(pager["pageCount"]) != "i64" || + pager["page"] != 1 || + pager["pageSize"] != 2 || + pager["total"] < 0 || + records.len > 2 { + throw("source_protocol_error"); + } + + let total = pager["total"]; + if pager["pageCount"] != (total + pager["pageSize"] - 1) / pager["pageSize"] { + throw("source_protocol_error"); + } + if total == 0 { if records.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } if total > 1 { - if records.len < 2 { throw("source_protocol_error"); } + if records.len != 2 { throw("source_protocol_error"); } return #{outcome: "ambiguous"}; } if records.len != 1 { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/prepare.rhai similarity index 100% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/adapters/prepare.rhai rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/prepare.rhai diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai new file mode 100644 index 000000000..547d3a98c --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai @@ -0,0 +1,171 @@ +// The licence register nests the governing state one level deeper than the +// population register: the tracked entity carries the licence validity dates, +// its enrollment in the licence programme carries the licence state, and that +// enrollment's events carry any recorded practice restriction. Extraction reads +// exactly that nesting and nothing else. + +fn licence_enrollments(record, parameters) { + let selected = []; + if !record.contains("enrollments") { + return selected; + } + if type_of(record["enrollments"]) != "array" { + throw("source_protocol_error"); + } + for enrollment in record["enrollments"] { + if type_of(enrollment) != "map" || + !enrollment.contains("program") || + type_of(enrollment["program"]) != "string" { + throw("source_protocol_error"); + } + if enrollment["program"] == parameters["program"] { + selected.push(enrollment); + } + } + selected +} + +// An enrollment recording no restriction event at all is read as unrestricted +// only because the deployment declares that reading. A deployment whose +// programme does not record restrictions positively sets the declaration to +// false, and the absent fact leaves the requirement unresolved instead of +// silently asserting an unrestricted licence. +fn unrecorded_restriction(parameters) { + if parameters["absentRestrictionEventMeansUnrestricted"] { + return false; + } + () +} + +fn restriction_recorded(enrollment, parameters) { + if !enrollment.contains("events") { + return unrecorded_restriction(parameters); + } + if type_of(enrollment["events"]) != "array" { + throw("source_protocol_error"); + } + let stage_present = false; + for event in enrollment["events"] { + if type_of(event) != "map" || + !event.contains("programStage") || + !event.contains("status") || + type_of(event["programStage"]) != "string" || + type_of(event["status"]) != "string" { + throw("source_protocol_error"); + } + if event["programStage"] == parameters["restrictionStage"] { + if event["status"] == parameters["completedEventStatus"] { + return true; + } + stage_present = true; + } + } + if stage_present { + return false; + } + unrecorded_restriction(parameters) +} + +fn extract(source_response, parameters) { + if !source_response.contains("pager") || + !source_response.contains("trackedEntities") || + type_of(source_response["pager"]) != "map" || + type_of(source_response["trackedEntities"]) != "array" { + throw("source_protocol_error"); + } + + let pager = source_response["pager"]; + let records = source_response["trackedEntities"]; + if pager.len() != 4 || + !pager.contains("page") || + !pager.contains("pageSize") || + !pager.contains("total") || + !pager.contains("pageCount") || + type_of(pager["page"]) != "i64" || + type_of(pager["pageSize"]) != "i64" || + type_of(pager["total"]) != "i64" || + type_of(pager["pageCount"]) != "i64" || + pager["page"] != 1 || + pager["pageSize"] != 2 || + pager["total"] < 0 || + records.len > 2 { + throw("source_protocol_error"); + } + + let total = pager["total"]; + if pager["pageCount"] != (total + pager["pageSize"] - 1) / pager["pageSize"] { + throw("source_protocol_error"); + } + + if total == 0 { + if records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if total > 1 { + if records.len != 2 { throw("source_protocol_error"); } + return #{outcome: "ambiguous"}; + } + if records.len != 1 { throw("source_protocol_error"); } + + let record = records[0]; + if !record.contains("trackedEntity") || + type_of(record["trackedEntity"]) != "string" || + !record.contains("attributes") || + type_of(record["attributes"]) != "array" { + throw("source_protocol_error"); + } + + let valid_from = (); + let valid_until = (); + let from_matches = 0; + let until_matches = 0; + for attribute in record["attributes"] { + if type_of(attribute) != "map" || + !attribute.contains("attribute") || + !attribute.contains("value") { + throw("source_protocol_error"); + } + if attribute["attribute"] == parameters["validFromAttribute"] { + if type_of(attribute["value"]) != "string" { + throw("source_protocol_error"); + } + valid_from = attribute["value"]; + from_matches += 1; + } + if attribute["attribute"] == parameters["validUntilAttribute"] { + if type_of(attribute["value"]) != "string" { + throw("source_protocol_error"); + } + valid_until = attribute["value"]; + until_matches += 1; + } + } + if from_matches > 1 || until_matches > 1 { + throw("source_protocol_error"); + } + + let facts = #{record_reference: record["trackedEntity"]}; + if from_matches == 1 { facts["valid_from"] = valid_from; } + if until_matches == 1 { facts["valid_until"] = valid_until; } + + // Two enrollments in one programme leave no reviewed rule for which one + // governs the licence, so the record is not read at all. + let enrollments = licence_enrollments(record, parameters); + if enrollments.len > 1 { + throw("source_protocol_error"); + } + if enrollments.len == 1 { + let enrollment = enrollments[0]; + if !enrollment.contains("status") || + type_of(enrollment["status"]) != "string" { + throw("source_protocol_error"); + } + facts["licence_state"] = enrollment["status"]; + let restricted = restriction_recorded(enrollment, parameters); + if type_of(restricted) == "bool" { + facts["restriction_recorded"] = restricted; + } + } + + #{outcome: "match", facts: facts} +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/codelists/licence-expiry-categories.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/codelists/licence-expiry-categories.yaml new file mode 100644 index 000000000..6f7b99b56 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/codelists/licence-expiry-categories.yaml @@ -0,0 +1,3 @@ +id: urn:gov:example:scheme:professional-licence-expiry +version: '1' +codes: [expired, within-30-days, within-90-days, later] diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/adult-status.rhai similarity index 100% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/derivations/adult-status.rhai rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/adult-status.rhai diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/professional-licence.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/professional-licence.rhai new file mode 100644 index 000000000..2b2d90f69 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/derivations/professional-licence.rhai @@ -0,0 +1,36 @@ +fn derive(facts, selectors, evaluation_context) { + if facts["record_reference"] != + selectors["subject"]["values"]["record_reference"] { + throw("derivation_input_error"); + } + let starts = parse_date(required(facts["valid_from"], "required_fact_missing")); + let ends = parse_date(required(facts["valid_until"], "required_fact_missing")); + let state = required(facts["licence_state"], "required_fact_missing"); + let restricted = required(facts["restriction_recorded"], "required_fact_missing"); + // A validity window that ends before it starts is an inconsistent record + // rather than an expired licence. + if compare_dates(ends, starts) < 0 { + throw("derivation_input_error"); + } + let current = evaluation_context["legal_local_date"]; + let active = + state == evaluation_context["parameters"]["active_state"] && + !restricted && + compare_dates(current, starts) >= 0 && + compare_dates(current, ends) <= 0; + let remaining_days = integer_to_decimal(days_between(current, ends)); + let category = bucket_number( + remaining_days, + evaluation_context["parameters"]["expiry_buckets"] + ); + [ + #{ + concept_id: "urn:gov:example:concept:professional-licence-active", + value: active + }, + #{ + concept_id: "urn:gov:example:concept:professional-licence-expiry-category", + value: category + } + ] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml new file mode 100644 index 000000000..ea76bbb1a --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml @@ -0,0 +1,242 @@ +version: 1 +service: + providerId: urn:gov:example:evidence:dhis2 + trustDomain: urn:gov:example:trust-domain:social-protection +issuer: + id: urn:gov:example:issuer:population-authority +authentication: + kind: oidc-access-token + issuer: https://identity.gov.example + audiences: [registry-evidence] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://identity.gov.example/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: evidence-signing-2026-01 + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + +selectorProfiles: + tracked-entity-reference-v1: + maximumAggregateBytes: 64 + fields: + record_reference: {type: string, minimumBytes: 11, maximumBytes: 64} +sources: + population-tracker: + transport: http-json + baseUrl: https://dhis2.gov.example + posture: record-transformed + tlsTrustProfile: government-internal-pki + authentication: + kind: basic + usernameRef: secret:file/dhis2-username + passwordRef: secret:file/dhis2-password + request: + method: GET + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + prepareScript: adapters/prepare.rhai + adapterParameters: + program: Prg00000001 + organisationUnit: Org00000001 + providerFields: trackedEntity,attributes[attribute,value] + pageSize: "2" + page: "1" + totalPages: "true" + dateOfBirthAttribute: Dob00000001 + adapterParametersSchema: schemas/adult-status-adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + projection: + - /pager/page + - /pager/pageSize + - /pager/total + - /pager/pageCount + - /trackedEntities/*/trackedEntity + - /trackedEntities/*/attributes/*/attribute + - /trackedEntities/*/attributes/*/value + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/adult-status-extract.rhai + factSchema: schemas/adult-status-facts.schema.yaml + professional-licence-register: + transport: http-json + baseUrl: https://dhis2.gov.example + posture: record-transformed + tlsTrustProfile: government-internal-pki + authentication: + kind: basic + usernameRef: secret:file/dhis2-username + passwordRef: secret:file/dhis2-password + request: + method: GET + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + prepareScript: adapters/prepare.rhai + adapterParameters: + program: Prg00000002 + organisationUnit: Org00000001 + providerFields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] + pageSize: "2" + page: "1" + totalPages: "true" + validFromAttribute: Vfr00000001 + validUntilAttribute: Vun00000001 + restrictionStage: Rst00000001 + completedEventStatus: COMPLETED + absentRestrictionEventMeansUnrestricted: true + adapterParametersSchema: schemas/professional-licence-adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + projection: + - /pager/page + - /pager/pageSize + - /pager/total + - /pager/pageCount + - /trackedEntities/*/trackedEntity + - /trackedEntities/*/attributes/*/attribute + - /trackedEntities/*/attributes/*/value + - /trackedEntities/*/enrollments/*/program + - /trackedEntities/*/enrollments/*/status + - /trackedEntities/*/enrollments/*/events/*/programStage + - /trackedEntities/*/enrollments/*/events/*/status + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/professional-licence-extract.rhai + factSchema: schemas/professional-licence-facts.schema.yaml +authorityProfiles: + eligibility-caseworker-v1: + kind: statutory + requesterTags: [eligibility-caseworker] + grants: + - requirement: urn:gov:example:requirement:adult-status:v1 + purpose: benefit-eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: tracked-entity-reference-v1 + valueOrigin: request + licence-verifier-v1: + kind: statutory + requesterTags: [licence-verifier] + grants: + - requirement: urn:gov:example:requirement:professional-licence-status:v1 + purpose: professional-registration-verification + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: tracked-entity-reference-v1 + valueOrigin: request +requirements: + - id: urn:gov:example:requirement:adult-status:v1 + kind: criterion + source: population-tracker + purposes: [benefit-eligibility] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} + referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] + evidenceType: urn:gov:example:evidence-type:adult-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 86400 + derivation: + script: derivations/adult-status.rhai + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + parameters: {minimum_age_years: 18} + concepts: + - id: urn:gov:example:concept:adult-status + form: boolean + required: true + constraints: {} + fixtures: fixtures/adult-status-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:adult-status] + existenceDisclosure: collapse-unresolved + - id: urn:gov:example:requirement:professional-licence-status:v1 + kind: criterion + source: professional-licence-register + purposes: [professional-registration-verification] + subjectRoles: + - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} + referenceFrameworks: [urn:gov:example:framework:professional-practice-licence:v1] + evidenceType: urn:gov:example:evidence-type:professional-licence-status:v1 + observationTimezone: Asia/Bangkok + validitySeconds: 43200 + derivation: + script: derivations/professional-licence.rhai + selectorInputs: + - role: subject + alternatives: + - {profile: tracked-entity-reference-v1, fields: [record_reference]} + parameters: + active_state: ACTIVE + expiry_buckets: + - {minimumInclusive: {type: decimal, value: '-365000'}, maximumExclusive: {type: decimal, value: '0'}, code: expired} + - {minimumInclusive: {type: decimal, value: '0'}, maximumExclusive: {type: decimal, value: '31'}, code: within-30-days} + - {minimumInclusive: {type: decimal, value: '31'}, maximumExclusive: {type: decimal, value: '91'}, code: within-90-days} + - {minimumInclusive: {type: decimal, value: '91'}, maximumExclusive: {type: decimal, value: '365001'}, code: later} + concepts: + - {id: urn:gov:example:concept:professional-licence-active, form: boolean, required: true, constraints: {}} + - id: urn:gov:example:concept:professional-licence-expiry-category + form: controlled-category + required: true + constraints: + categoryScheme: urn:gov:example:scheme:professional-licence-expiry + schemeVersion: '1' + maximumBytes: 32 + codelist: codelists/licence-expiry-categories.yaml + fixtures: fixtures/professional-licence-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:professional-licence] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml similarity index 60% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml index 185bc7014..06d74bcc1 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/fixtures/cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml @@ -27,7 +27,7 @@ common: cases: - id: positive response: - pager: {page: 1, pageSize: 2, total: 1} + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} trackedEntities: - trackedEntity: Tei00000001 attributes: @@ -41,7 +41,7 @@ cases: value: true - id: negative-false-is-success response: - pager: {page: 1, pageSize: 2, total: 1} + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} trackedEntities: - trackedEntity: Tei00000001 attributes: [{attribute: Dob00000001, value: "2010-01-01"}] @@ -53,7 +53,7 @@ cases: signed: true - id: boundary-on response: - pager: {page: 1, pageSize: 2, total: 1} + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} trackedEntities: - trackedEntity: Tei00000001 attributes: [{attribute: Dob00000001, value: "2008-08-02"}] @@ -65,25 +65,57 @@ cases: value: true - id: missing-fact response: - pager: {page: 1, pageSize: 2, total: 1} + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} trackedEntities: - trackedEntity: Tei00000001 attributes: [{attribute: Oth00000001, value: SOURCE-UNRELATED-CANARY}] expected: {publicProblem: evidence_not_available, derivationRuns: false, signed: false} - id: no-match - response: {pager: {page: 1, pageSize: 2, total: 0}, trackedEntities: []} + response: {pager: {page: 1, pageSize: 2, total: 0, pageCount: 0}, trackedEntities: []} expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false} - id: ambiguous response: - pager: {page: 1, pageSize: 2, total: 2} + pager: {page: 1, pageSize: 2, total: 2, pageCount: 1} trackedEntities: - {trackedEntity: Tei00000001, attributes: []} - {trackedEntity: Tei00000002, attributes: []} expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} - - {id: fractional-total, response: {pager: {total: 1.5}, trackedEntities: []}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} + # A tracker page whose envelope is truncated, inconsistent, or wrongly typed is + # never read as an authoritative no-match, unique match, or duplicate. + - id: truncated-pager + response: {pager: {page: 1, pageSize: 2, total: 1}, trackedEntities: [{trackedEntity: Tei00000001, attributes: []}]} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: pager-count-mismatch + response: {pager: {page: 1, pageSize: 2, total: 1, pageCount: 2}, trackedEntities: [{trackedEntity: Tei00000001, attributes: []}]} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: total-collection-mismatch + response: {pager: {page: 1, pageSize: 2, total: 1, pageCount: 1}, trackedEntities: []} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: fractional-total + response: {pager: {page: 1, pageSize: 2, total: 1.5, pageCount: 1}, trackedEntities: []} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + # One tracked entity carrying the configured attribute twice, or carrying it + # with a non-string value, is a provider or configuration fault rather than a + # record that legitimately lacks a date of birth. + - id: duplicate-configured-attribute + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Dob00000001, value: "2000-01-01"} + - {attribute: Dob00000001, value: "2010-01-01"} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: non-string-attribute-value + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [{attribute: Dob00000001, value: 20000101}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} - id: returned-subject-mismatch response: - pager: {page: 1, pageSize: 2, total: 1} + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} trackedEntities: - trackedEntity: Tei00000002 attributes: [{attribute: Dob00000001, value: "2000-01-01"}] @@ -94,6 +126,8 @@ cases: derivationRuns: true signed: false - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + # An expired provider session answers a JSON request with an HTML sign-in page. + - {id: source-html-sign-in-page, sourceFailure: invalid-media-type, expected: {publicProblem: dependency_unavailable, signed: false}} - id: hostile-reference selectorOverrides: subject: diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml new file mode 100644 index 000000000..2d16f67ce --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml @@ -0,0 +1,297 @@ +fixture: registry.evidence.reference.dhis2-professional-licence/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + subject: + profile: tracked-entity-reference-v1 + values: {record_reference: Tei00000001} + derivationSelectorInputs: + subject: + profile: tracked-entity-reference-v1 + values: {record_reference: Tei00000001} + expectedRequestParts: + query: + - {name: program, value: Prg00000002} + - {name: orgUnits, value: Org00000001} + - {name: trackedEntities, value: Tei00000001} + - {name: fields, value: "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]"} + - {name: pageSize, value: "2"} + - {name: page, value: "1"} + - {name: totalPages, value: "true"} + body: null + expectedTransport: + path: /api/tracker/trackedEntities + fixedHeaders: + - {name: Accept, value: application/json} +cases: + - id: positive + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-20"} + - {attribute: Oth00000001, value: LICENCE-UNRELATED-CANARY} + enrollments: + - program: Prg00000002 + status: ACTIVE + events: + - {programStage: Rev00000001, status: COMPLETED} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: ACTIVE + valid_from: "2025-01-01" + valid_until: "2026-08-20" + restriction_recorded: false + values: + urn:gov:example:concept:professional-licence-active: true + urn:gov:example:concept:professional-licence-expiry-category: within-30-days + # A completed restriction event withholds the active licence without changing + # the enrollment state, so the two signals are read together. + - id: restricted-licence-is-not-active + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-12-31"} + enrollments: + - program: Prg00000002 + status: ACTIVE + events: + - {programStage: Rst00000001, status: COMPLETED} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: ACTIVE + valid_from: "2025-01-01" + valid_until: "2026-12-31" + restriction_recorded: true + values: + urn:gov:example:concept:professional-licence-active: false + urn:gov:example:concept:professional-licence-expiry-category: later + # A restriction event that exists but is not completed is a restriction that + # has not been recorded, not a restriction in force. + - id: scheduled-restriction-is-not-recorded + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-09-15"} + enrollments: + - program: Prg00000002 + status: ACTIVE + events: + - {programStage: Rst00000001, status: SCHEDULE} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: ACTIVE + valid_from: "2025-01-01" + valid_until: "2026-09-15" + restriction_recorded: false + values: + urn:gov:example:concept:professional-licence-active: true + urn:gov:example:concept:professional-licence-expiry-category: within-90-days + - id: negative-cancelled-enrollment-is-success + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-10-01"} + enrollments: + - {program: Prg00000002, status: CANCELLED, events: []} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: CANCELLED + valid_from: "2025-01-01" + valid_until: "2026-10-01" + restriction_recorded: false + values: + urn:gov:example:concept:professional-licence-active: false + urn:gov:example:concept:professional-licence-expiry-category: within-90-days + - id: boundary-last-valid-day + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-02"} + enrollments: + - {program: Prg00000002, status: ACTIVE, events: []} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: ACTIVE + valid_from: "2025-01-01" + valid_until: "2026-08-02" + restriction_recorded: false + values: + urn:gov:example:concept:professional-licence-active: true + urn:gov:example:concept:professional-licence-expiry-category: within-30-days + - id: boundary-first-expired-day + observed_at: "2026-08-03T00:00:00Z" + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-02"} + enrollments: + - {program: Prg00000002, status: ACTIVE, events: []} + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: Tei00000001 + licence_state: ACTIVE + valid_from: "2025-01-01" + valid_until: "2026-08-02" + restriction_recorded: false + values: + urn:gov:example:concept:professional-licence-active: false + urn:gov:example:concept:professional-licence-expiry-category: expired + # A tracked entity with no enrollment in the licence programme legitimately + # carries no licence state, which is unresolved rather than a denied licence. + - id: missing-fact + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-20"} + enrollments: + - {program: Prg00000001, status: ACTIVE, events: []} + expected: {publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: no-match + response: {pager: {page: 1, pageSize: 2, total: 0, pageCount: 0}, trackedEntities: []} + expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - id: ambiguous + response: + pager: {page: 1, pageSize: 2, total: 2, pageCount: 1} + trackedEntities: + - {trackedEntity: Tei00000001, attributes: [], enrollments: []} + - {trackedEntity: Tei00000002, attributes: [], enrollments: []} + expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + # Two enrollments in one programme leave no reviewed rule for which one + # governs the licence, and an event without a status cannot be read at all. + - id: duplicate-programme-enrollment + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-20"} + enrollments: + - {program: Prg00000002, status: ACTIVE, events: []} + - {program: Prg00000002, status: CANCELLED, events: []} + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: event-without-status + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-20"} + enrollments: + - program: Prg00000002 + status: ACTIVE + events: [{programStage: Rst00000001}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - id: truncated-pager + response: + pager: {page: 1, pageSize: 2, total: 1} + trackedEntities: [{trackedEntity: Tei00000001, attributes: [], enrollments: []}] + expected: {error: source_protocol_error, derivationRuns: false, signed: false} + # A validity window that ends before it starts is an inconsistent record, and + # it collapses publicly with the unresolved classes rather than signing false. + - id: inverted-validity-window + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000001 + attributes: + - {attribute: Vfr00000001, value: "2026-12-01"} + - {attribute: Vun00000001, value: "2026-01-01"} + enrollments: + - {program: Prg00000002, status: ACTIVE, events: []} + expected: + lookup: match + error: derivation_input_error + publicProblem: evidence_not_available + derivationRuns: true + signed: false + - id: returned-subject-mismatch + response: + pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + trackedEntities: + - trackedEntity: Tei00000002 + attributes: + - {attribute: Vfr00000001, value: "2025-01-01"} + - {attribute: Vun00000001, value: "2026-08-20"} + enrollments: + - {program: Prg00000002, status: ACTIVE, events: []} + expected: + lookup: match + error: derivation_input_error + publicProblem: evidence_not_available + derivationRuns: true + signed: false + - {id: source-failure, sourceFailure: connection-refused, expected: {publicProblem: dependency_unavailable, signed: false}} + - id: hostile-reference + selectorOverrides: + subject: + values: {record_reference: "X&fields=*%0D%0AInjected:yes"} + expected: + sourceRequestCount: 1 + expectedTransport: + path: /api/tracker/trackedEntities + query: "program=Prg00000002&orgUnits=Org00000001&trackedEntities=X%26fields%3D%2A%250D%250AInjected%3Ayes&fields=trackedEntity%2Cattributes%5Battribute%2Cvalue%5D%2Cenrollments%5Bprogram%2Cstatus%2Cevents%5BprogramStage%2Cstatus%5D%5D&pageSize=2&page=1&totalPages=true" + body: null + - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} +privacyExpectation: + evidenceContains: + - urn:gov:example:concept:professional-licence-active + - urn:gov:example:concept:professional-licence-expiry-category + evidenceExcludes: + - licence_state + - valid_from + - valid_until + - restriction_recorded + - record_reference + - tracked-entity-reference-v1 + - Tei00000001 + - Tei00000002 + - ACTIVE + - CANCELLED + diagnosticsExclude: [Tei00000001, Tei00000002, "2026-08-20", ACTIVE, LICENCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml similarity index 100% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/adapter-parameters.schema.yaml rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml similarity index 100% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/bundle/schemas/facts.schema.yaml rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml new file mode 100644 index 000000000..62a6b4dac --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml @@ -0,0 +1,27 @@ +type: object +additionalProperties: false +required: + - program + - organisationUnit + - providerFields + - pageSize + - page + - totalPages + - validFromAttribute + - validUntilAttribute + - restrictionStage + - completedEventStatus + - absentRestrictionEventMeansUnrestricted +properties: + program: {type: string, minLength: 1, maxLength: 128} + organisationUnit: {type: string, minLength: 1, maxLength: 128} + providerFields: + const: "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]" + pageSize: {const: "2"} + page: {const: "1"} + totalPages: {const: "true"} + validFromAttribute: {type: string, minLength: 1, maxLength: 128} + validUntilAttribute: {type: string, minLength: 1, maxLength: 128} + restrictionStage: {type: string, minLength: 1, maxLength: 128} + completedEventStatus: {type: string, minLength: 1, maxLength: 32} + absentRestrictionEventMeansUnrestricted: {type: boolean} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml new file mode 100644 index 000000000..4c75f07df --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml @@ -0,0 +1,9 @@ +type: object +additionalProperties: false +required: [record_reference, licence_state, valid_from, valid_until, restriction_recorded] +properties: + record_reference: {type: string, minLength: 11, maxLength: 64} + licence_state: {type: string, minLength: 1, maxLength: 32} + valid_from: {type: string, format: date} + valid_until: {type: string, format: date} + restriction_recorded: {type: boolean} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml similarity index 100% rename from products/evidence/reference/request-adapter/deployment-projects/dhis2-adult-status/runtime.yaml rename to products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/runtime.yaml diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml index 787e09750..49cc065e0 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml +++ b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml @@ -1,5 +1,5 @@ # Focused executable DHIS2 adapter fragment. For a deployable governed bundle -# and runtime binding, use deployment-projects/dhis2-adult-status/. +# and runtime binding, use deployment-projects/dhis2-tracker-evidence/. transport: http-json baseUrl: https://tracker.example.invalid posture: record-transformed @@ -39,7 +39,10 @@ request: maximumQueryValueBytes: 1024 maximumNormalizedBytes: 8192 projection: + - /pager/page + - /pager/pageSize - /pager/total + - /pager/pageCount - /trackedEntities/*/trackedEntity - /trackedEntities/*/attributes/*/attribute - /trackedEntities/*/attributes/*/value From 3dd4b0b884ed2039e0806ac7d87ac3797e856ff0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 22:13:15 +0700 Subject: [PATCH 012/136] feat(evidence): add SD-JWT VC response format Serialize the stateless Evidence assertion as an SD-JWT VC (application/dc+sd-jwt, typ dc+sd-jwt) beside the JWS form, using the registry-platform-sdjwt serialization primitive. Amend the version one boundary to permit SD-JWT serialization solely for this response format. Security-sensitive: touches signing (signing.rs) and verification (verifier.rs). Response-format only; no credential issuance, OID4VCI, PDP, holder binding, or federation. Signed-off-by: Jeremi Joslin --- .gitignore | 1 + AGENTS.md | 10 +- Cargo.lock | 1 + crates/registry-evidence/Cargo.toml | 1 + crates/registry-evidence/src/audit.rs | 16 +- crates/registry-evidence/src/config.rs | 5 +- crates/registry-evidence/src/contracts.rs | 67 ++- crates/registry-evidence/src/lib.rs | 5 + crates/registry-evidence/src/main.rs | 68 ++- crates/registry-evidence/src/model.rs | 50 ++ crates/registry-evidence/src/runtime.rs | 63 +- crates/registry-evidence/src/runtime_tests.rs | 558 +++++++++++++++++- crates/registry-evidence/src/sdjwt_vc.rs | 500 ++++++++++++++++ crates/registry-evidence/src/selector.rs | 1 + crates/registry-evidence/src/server.rs | 40 +- crates/registry-evidence/src/signing.rs | 108 ++++ crates/registry-evidence/src/verifier.rs | 553 ++++++++++++++++- crates/registry-evidence/tests/cli.rs | 169 ++++++ .../tests/security_contract_traceability.rs | 78 +++ .../tests/selector_conformance.rs | 1 + products/evidence/AGENTS.md | 9 +- products/evidence/CONCEPT.md | 94 ++- products/evidence/IMPLEMENTATION.md | 13 +- products/evidence/OPERATOR-CONTRACT.md | 76 ++- products/evidence/README.md | 47 +- products/evidence/SD-JWT-VC-DEMO.md | 257 ++++++++ products/evidence/SOURCE-TESTING.md | 6 +- products/evidence/contracts/README.md | 26 +- .../contracts/audit-event.schema.yaml | 6 +- .../evidence/contracts/bundle.schema.yaml | 9 +- .../evidence/contracts/request.schema.yaml | 21 + .../evidence/contracts/sd-jwt-vc-profile.yaml | 159 +++++ .../contracts/security-invariant-matrix.yaml | 16 + .../contracts/security-test-traceability.yaml | 27 + .../contracts/verification-policy.schema.yaml | 8 +- .../fixtures/conformance/coverage-matrix.yaml | 1 + .../fixtures/conformance/sd-jwt-vc-cases.yaml | 47 ++ .../generated/evidence-request-v1.schema.json | 43 +- .../generated/registry-evidence.openapi.json | 111 +++- .../deployment-projects/CONFIG.md | 11 +- products/evidence/scripts/sd-jwt-vc-demo.sh | 209 +++++++ 41 files changed, 3403 insertions(+), 88 deletions(-) create mode 100644 crates/registry-evidence/src/sdjwt_vc.rs create mode 100644 products/evidence/SD-JWT-VC-DEMO.md create mode 100644 products/evidence/contracts/sd-jwt-vc-profile.yaml create mode 100644 products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml create mode 100755 products/evidence/scripts/sd-jwt-vc-demo.sh diff --git a/.gitignore b/.gitignore index 631d658e8..1ccb102e4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ __pycache__/ # Evidence local operator material. Never commit credentials or curl artifacts. /products/evidence/.env /products/evidence/.first-curl/ +/products/evidence/.sd-jwt-vc-demo/ diff --git a/AGENTS.md b/AGENTS.md index 72684659c..2ed8bfd71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,13 +42,15 @@ Evidence's authenticator; Evidence does not depend on Mint. Evidence work must remain independent from `registry-notary*`. Do not copy or depend on Notary product abstractions merely because both products use the word evidence. In particular, Evidence version one does not inherit credential -issuance, OID4VCI, SD-JWT, PDP, replay, federation, worker, or document -subsystems. +issuance lifecycle, OID4VCI, PDP, replay, federation, worker, or document +subsystems. Evidence serializes the same stateless assertion as an SD-JWT VC +response format under its own frozen profile; that is a second encoding of one +response, never a credential lifecycle. The implementation is one `registry-evidence` crate and one `evidence` binary. It may reuse narrowly applicable `registry-platform-*` -primitives such as audit, crypto, OIDC, HTTP security, and testing. It must not -depend on `registry-notary*`. +primitives such as audit, crypto, OIDC, HTTP security, SD-JWT serialization, +and testing. It must not depend on `registry-notary*`. Evidence configuration and scripts are trusted, startup-only deployment artifacts. Rust owns authentication, authorization, fixed source execution, diff --git a/Cargo.lock b/Cargo.lock index 5169e3f9e..be8ab0318 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5369,6 +5369,7 @@ dependencies = [ "registry-platform-httpsec", "registry-platform-httputil", "registry-platform-oidc", + "registry-platform-sdjwt", "reqwest 0.12.28", "rhai", "rustix", diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index 500fd5547..02e96ad71 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -33,6 +33,7 @@ registry-platform-crypto.workspace = true registry-platform-httpsec.workspace = true registry-platform-httputil.workspace = true registry-platform-oidc.workspace = true +registry-platform-sdjwt.workspace = true reqwest.workspace = true rand_core.workspace = true rhai.workspace = true diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 36e779247..8b542ca26 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -50,6 +50,15 @@ pub enum AuditDecision { pub enum ResponseProtection { Signed, Unsigned, + SdJwtVc, +} + +impl ResponseProtection { + /// Report whether release under this mode is cryptographically protected + /// and therefore records the signing key identifier. + pub fn is_signed(self) -> bool { + matches!(self, Self::Signed | Self::SdJwtVc) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -160,9 +169,10 @@ impl EvidenceAuditEvent { { return Err(EvidenceAuditError::InvalidEvent); } - // A signing key identity exists exactly for signed disclosure release. - let signing_key_required = self.phase == AuditPhase::DisclosureRelease - && self.response_protection == ResponseProtection::Signed; + // A signing key identity exists exactly for cryptographically + // protected disclosure release. + let signing_key_required = + self.phase == AuditPhase::DisclosureRelease && self.response_protection.is_signed(); if self.signing_key_id.is_some() != signing_key_required { return Err(EvidenceAuditError::InvalidEvent); } diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index ce799f44c..6f8eca98d 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -1786,6 +1786,8 @@ impl AuthorityGrant { pub enum ResponseFormat { SignedJws, UnsignedJson, + /// Audience-scoped SD-JWT VC serialization of the same assertion. + SdJwtVc, } fn default_response_formats() -> Vec { @@ -1796,7 +1798,7 @@ fn validate_response_formats( formats: &[ResponseFormat], description: &'static str, ) -> Result<(), ConfigError> { - validate_len(formats.len(), 1, 2, description)?; + validate_len(formats.len(), 1, 3, description)?; let mut seen = BTreeSet::new(); for format in formats { if !seen.insert(format_discriminant(*format)) { @@ -1813,6 +1815,7 @@ fn format_discriminant(format: ResponseFormat) -> u8 { match format { ResponseFormat::SignedJws => 0, ResponseFormat::UnsignedJson => 1, + ResponseFormat::SdJwtVc => 2, } } diff --git a/crates/registry-evidence/src/contracts.rs b/crates/registry-evidence/src/contracts.rs index d8eb7e490..d42e6de79 100644 --- a/crates/registry-evidence/src/contracts.rs +++ b/crates/registry-evidence/src/contracts.rs @@ -44,6 +44,8 @@ const REQUEST_NONCE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; /// Shape of the server-minted operation identifier, shared by the response /// header and the problem member so the two cannot describe different values. const OPERATION_PATTERN: &str = "^[0-9A-HJKMNP-TV-Z]{26}$"; +/// Unpadded base64url encoding of exactly one 32-byte Ed25519 public key. +const HOLDER_KEY_COORDINATE_PATTERN: &str = "^[A-Za-z0-9_-]{43}$"; const PROBLEM_VARIANTS: [(&str, u16, &str); 9] = [ ("malformed_request", 400, "Request is not valid"), ("invalid_selector", 400, "Request is not valid"), @@ -281,7 +283,8 @@ fn request_schema() -> Value { "subjects": { "type": "array", "minItems": 1, "maxItems": 8, "items": {"$ref": "#/$defs/subject"} - } + }, + "holderKey": {"$ref": "#/$defs/holder-key"} }, "$defs": { "subject": { @@ -310,9 +313,20 @@ fn request_schema() -> Value { {"type": "integer", "minimum": -9007199254740991_i64, "maximum": 9007199254740991_i64}, {"type": "boolean"} ] + }, + "holder-key": { + "type": "object", "additionalProperties": false, + "required": ["kty", "crv", "x"], + "properties": { + "kty": {"type": "string", "enum": ["OKP"]}, + "crv": {"type": "string", "enum": ["Ed25519"]}, + "x": {"type": "string", "pattern": HOLDER_KEY_COORDINATE_PATTERN}, + "alg": {"type": "string", "enum": ["EdDSA"]}, + "kid": {"type": "string", "minLength": 1, "maxLength": 256} + } } }, - "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it." + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it. holderKey is meaningful only for the SD-JWT VC response format, where it is echoed into the cnf claim; it never reaches authorization, selectors, Rhai, source requests, or audit, and a key carrying any private member is rejected before credential acquisition or source access." }) } @@ -865,6 +879,7 @@ fn openapi_document( ("subject", "EvidenceRequestSubject"), ("selector", "EvidenceRequestSelector"), ("scalar-selector-value", "SelectorValue"), + ("holder-key", "HolderPublicKey"), ], ); insert_schema_family( @@ -939,6 +954,26 @@ fn openapi_document( jwks, &[("ed25519-public-jwk", "Ed25519PublicJwk")], ); + schemas.insert( + "SdJwtVcCredential".to_string(), + json!({ + "type": "string", + "description": "Compact SD-JWT VC: the issuer-signed JWT, then one tilde-separated disclosure per supported value, then a trailing tilde marking an absent key-binding JWT. The issuer never appends a key-binding JWT.", + "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+(~[A-Za-z0-9_-]+)*~$" + }), + ); + schemas.insert( + "JwtVcIssuerMetadata".to_string(), + json!({ + "type": "object", + "additionalProperties": false, + "required": ["issuer", "jwks"], + "properties": { + "issuer": {"type": "string", "maxLength": 512}, + "jwks": {"$ref": "#/components/schemas/JwksDocument"} + } + }), + ); schemas.insert( "HealthStatus".to_string(), json!({ @@ -968,7 +1003,7 @@ fn openapi_document( "post": { "operationId": "createEvidence", "summary": "Produce evidence for one authorized fixed requirement", - "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", + "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only the exact application/dc+sd-jwt media type selects the SD-JWT VC serialization of the same assertion; each is released only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", "security": [{"bearerAuth": []}], "requestBody": { "required": true, @@ -976,10 +1011,11 @@ fn openapi_document( }, "responses": { "200": { - "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized self-identifying unsigned envelope", + "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized SD-JWT VC serialization or self-identifying unsigned envelope", "headers": evidence_response_headers(None), "content": { "application/jose+json": {"schema": {"$ref": "#/components/schemas/FlattenedJws"}}, + "application/dc+sd-jwt": {"schema": {"$ref": "#/components/schemas/SdJwtVcCredential"}}, "application/vnd.registrystack.evidence-unsigned+json": {"schema": {"$ref": "#/components/schemas/UnsignedEvidenceEnvelope"}} } }, @@ -1122,6 +1158,18 @@ fn openapi_document( "content": {"application/jwk-set+json": {"schema": {"$ref": "#/components/schemas/JwksDocument"}}} }} } + }, + "/.well-known/jwt-vc-issuer": { + "get": { + "operationId": "getJwtVcIssuerMetadata", + "summary": "Publish JWT VC Issuer Metadata for the SD-JWT VC response format", + "description": "Discovery is not a trust anchor. The document republishes the same public keys under the provider identity the assertion names, and resolution is meaningful only when that identity is the HTTPS origin of the deployment.", + "responses": {"200": { + "description": "Provider identity and public verification keys", + "headers": response_headers(None), + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JwtVcIssuerMetadata"}}} + }} + } } }, "components": { @@ -1210,6 +1258,7 @@ mod tests { paths.keys().map(String::as_str).collect::>(), [ "/.well-known/evidence/jwks.json", + "/.well-known/jwt-vc-issuer", "/health", "/openapi.json", "/ready", @@ -1236,6 +1285,11 @@ mod tests { ["application/vnd.registrystack.evidence-unsigned+json"] .is_object() ); + assert!( + document["paths"]["/v1/evidence"]["post"]["responses"]["200"]["content"] + ["application/dc+sd-jwt"] + .is_object() + ); assert_eq!( document["paths"]["/v1/evidence"]["post"]["responses"]["406"]["content"] ["application/problem+json"]["schema"]["allOf"][1]["properties"]["code"]["enum"], @@ -1261,6 +1315,11 @@ mod tests { ["content"]["application/jwk-set+json"] .is_object() ); + assert!( + document["paths"]["/.well-known/jwt-vc-issuer"]["get"]["responses"]["200"]["content"] + ["application/json"] + .is_object() + ); assert_eq!( document["components"]["schemas"]["FlattenedJws"]["properties"]["payload"] ["x-decoded-schema"]["$ref"], diff --git a/crates/registry-evidence/src/lib.rs b/crates/registry-evidence/src/lib.rs index 8f2c1b3ae..28c1ba564 100644 --- a/crates/registry-evidence/src/lib.rs +++ b/crates/registry-evidence/src/lib.rs @@ -16,6 +16,7 @@ pub mod problem; pub mod rate_limit; pub mod rhai_runtime; pub mod runtime; +pub mod sdjwt_vc; pub mod secrets; pub mod selector; pub mod server; @@ -33,5 +34,9 @@ pub const EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1: &str = "registry.unsigned-eviden pub const EVIDENCE_JWS_TYP: &str = "evidence+jws"; pub const EVIDENCE_JWS_CTY: &str = "application/evidence+json"; pub const EVIDENCE_JWS_MEDIA_TYPE: &str = "application/jose+json"; +/// Compact SD-JWT VC serialization of the same assertion. The profile adds a +/// response format only; it introduces no credential lifecycle. +pub const EVIDENCE_SD_JWT_VC_MEDIA_TYPE: &str = "application/dc+sd-jwt"; +pub const EVIDENCE_SD_JWT_VC_TYP: &str = "dc+sd-jwt"; pub const EVIDENCE_UNSIGNED_MEDIA_TYPE: &str = "application/vnd.registrystack.evidence-unsigned+json"; diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index ce80462d9..26fe8216e 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -11,7 +11,7 @@ use std::{ use chrono::{DateTime, NaiveDate, SecondsFormat, TimeZone, Utc}; use chrono_tz::Tz; -use clap::{Parser, Subcommand}; +use clap::{ArgGroup, Parser, Subcommand}; use ed25519_dalek::SigningKey; use rand_core::OsRng; use registry_evidence::{ @@ -39,8 +39,9 @@ use registry_evidence::{ project_fixture_response, ResolvedSourceSelector, SourceError, SourceExecutor, SourceStatus, }, verifier::{ - verify_flattened_jws, verify_flattened_jws_report, EvidenceVerificationPolicy, - ExpectedOutput, ExpectedSubject, ExpectedValueForm, VerificationError, + verify_flattened_jws, verify_flattened_jws_report, verify_sd_jwt_vc_report, + EvidenceVerificationPolicy, ExpectedOutput, ExpectedSubject, ExpectedValueForm, + VerificationError, }, }; use registry_platform_crypto::{parse_json_strict, LocalJwkSigner, PrivateJwk}; @@ -83,10 +84,18 @@ enum Command { /// Start the native Evidence HTTP service. Serve, /// Re-verify one stored signed response offline against a pinned key set. + /// + /// Exactly one stored response is named, and its format is named with it. + /// The command never infers a format from the file's contents, so a + /// credential can never be re-verified under the other format's rules. + #[command(group(ArgGroup::new("stored").required(true)))] Verify { /// Stored flattened JWS JSON response file. - #[arg(long)] - jws: PathBuf, + #[arg(long, group = "stored")] + jws: Option, + /// Stored compact SD-JWT VC response file. + #[arg(long = "sd-jwt-vc", group = "stored")] + sd_jwt_vc: Option, /// Pinned trusted JWKS document. This file is the complete trust set. #[arg(long)] jwks: PathBuf, @@ -208,10 +217,24 @@ async fn run(cli: Cli) -> Result { } Command::Verify { jws, + sd_jwt_vc, jwks, policy, at, - } => Ok(verify_stored_response(&jws, &jwks, &policy, at.as_deref())?), + } => { + let stored = jws + .map(StoredResponse::SignedJws) + .or_else(|| sd_jwt_vc.map(StoredResponse::SdJwtVc)) + .ok_or(CommandError::Cli(CliError( + "verify requires one stored response file", + )))?; + Ok(verify_stored_response( + &stored, + &jwks, + &policy, + at.as_deref(), + )?) + } } } @@ -464,6 +487,22 @@ fn expected_value_form(document: ExpectedFormDocument) -> ExpectedValueForm { } } +/// The one stored response an operator named, and the format its bytes are +/// parsed under. The format is an operator statement, never a guess from the +/// file's contents. +enum StoredResponse { + SignedJws(PathBuf), + SdJwtVc(PathBuf), +} + +impl StoredResponse { + fn path(&self) -> &Path { + match self { + Self::SignedJws(path) | Self::SdJwtVc(path) => path, + } + } +} + /// Re-verify one stored signed response offline. /// /// The pinned key set file is the complete trust set: this command opens no @@ -475,7 +514,7 @@ fn expected_value_form(document: ExpectedFormDocument) -> ExpectedValueForm { /// A failure reports only its closed class, so re-verification never becomes /// an oracle for which hidden comparison failed. fn verify_stored_response( - jws_path: &Path, + stored: &StoredResponse, jwks_path: &Path, policy_path: &Path, at: Option<&str>, @@ -486,7 +525,7 @@ fn verify_stored_response( instant.to_rfc3339_opts(SecondsFormat::Secs, true) ); - let stored = read_verification_input(jws_path)?; + let response = read_verification_input(stored.path())?; let trusted: JwksDocument = serde_json::from_value( parse_json_strict(&read_verification_input(jwks_path)?).map_err(|_| VERIFY_MALFORMED)?, ) @@ -496,7 +535,15 @@ fn verify_stored_response( .map_err(|_| VERIFY_MALFORMED)?; let policy = document.into_policy(instant); - match verify_flattened_jws_report(&stored, &trusted, &policy) { + // One policy document, one set of expectations, and one report shape serve + // both response formats; only the serialization the operator named is + // parsed. + let report = match stored { + StoredResponse::SignedJws(_) => verify_flattened_jws_report(&response, &trusted, &policy), + StoredResponse::SdJwtVc(_) => verify_sd_jwt_vc_report(&response, &trusted, &policy), + }; + + match report { Ok(report) => { println!("authentic: yes"); if !report.currently_valid { @@ -557,6 +604,9 @@ fn verification_error_class(error: VerificationError) -> CliError { VerificationError::Payload => CliError("stored response verification failed (payload)"), VerificationError::Policy => CliError("stored response verification failed (policy)"), VerificationError::Time => CliError("stored response verification failed (time)"), + VerificationError::Disclosure => { + CliError("stored response verification failed (disclosure)") + } } } diff --git a/crates/registry-evidence/src/model.rs b/crates/registry-evidence/src/model.rs index 3c8f03795..31f24715a 100644 --- a/crates/registry-evidence/src/model.rs +++ b/crates/registry-evidence/src/model.rs @@ -45,6 +45,54 @@ pub struct EvidenceRequest { /// Unordered role set encoded as an array. Roles are resolved by name and /// canonicalized to requirement declaration order. pub subjects: Vec, + /// Optional holder public key echoed into the SD-JWT VC `cnf` claim. It is + /// meaningful only for the SD-JWT VC response format, never reaches + /// authorization, selectors, Rhai, source requests, or audit, and never + /// appears in the signed-JWS payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub holder_key: Option, +} + +/// Caller-supplied Ed25519 holder public key. `deny_unknown_fields` is the +/// primary defence against private key members: a body carrying `d` or any +/// other unexpected member fails to parse. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HolderPublicKey { + pub kty: String, + pub crv: String, + pub x: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +/// Exact byte length of a raw Ed25519 public key. +const HOLDER_KEY_DECODED_LENGTH: usize = 32; +const MAX_HOLDER_KEY_ID_BYTES: usize = 256; + +impl HolderPublicKey { + /// Accept only a public OKP Ed25519 JWK whose coordinate is the canonical + /// unpadded base64url encoding of exactly 32 bytes. + pub fn is_acceptable(&self) -> bool { + if self.kty != "OKP" || self.crv != "Ed25519" { + return false; + } + if self.alg.as_deref().is_some_and(|alg| alg != "EdDSA") { + return false; + } + if self + .kid + .as_deref() + .is_some_and(|kid| kid.is_empty() || kid.len() > MAX_HOLDER_KEY_ID_BYTES) + { + return false; + } + URL_SAFE_NO_PAD + .decode(&self.x) + .is_ok_and(|decoded| decoded.len() == HOLDER_KEY_DECODED_LENGTH) + } } /// Requester-scoped descriptions of the exact Evidence request shapes that @@ -408,6 +456,7 @@ macro_rules! redacted_debug { redacted_debug!( EvidenceRequest, + HolderPublicKey, EvidenceDefinitions, EvidenceDefinition, EvidenceDefinitionSubject, @@ -564,6 +613,7 @@ mod tests { )])), }, }], + holder_key: None, }; let evidence = Evidence { schema: "protected-schema-canary".to_owned(), diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index bcc6db097..87b5b7408 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -36,6 +36,7 @@ use crate::{ }, problem::ProblemCode, rate_limit::{EvidenceRateLimiter, RateLimitConfig, RateLimitError}, + sdjwt_vc, secrets::{ProtectedSecret, SecretProvider, SecretResolver}, selector::{ match_entitlement, resolve_selectors, validate_entitlement_context, @@ -44,8 +45,8 @@ use crate::{ }, signing::{jwks_document, EvidenceSigner}, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, - EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, - EVIDENCE_UNSIGNED_MEDIA_TYPE, + EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, + EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const MAX_OPERATION_BYTES: usize = 128; @@ -389,6 +390,7 @@ impl EvidenceRuntime { }, }) .collect(), + holder_key: None, }; let Ok(matched) = match_entitlement(self.bundle(), &request, &context) else { continue; @@ -598,6 +600,16 @@ impl EvidenceRuntime { if !request_nonce_is_canonical(&request.request_nonce) { return Err(failure(ProblemCode::MalformedRequest, "request-nonce")); } + // An unacceptable holder key fails before any credential acquisition or + // source access. The key never reaches authorization, selectors, Rhai, + // sources, or audit. + if request + .holder_key + .as_ref() + .is_some_and(|key| !key.is_acceptable()) + { + return Err(failure(ProblemCode::MalformedRequest, "holder-key")); + } let started = Instant::now(); let context = self .authenticator @@ -903,6 +915,52 @@ impl EvidenceRuntime { Some(self.signer.key_id().to_owned()), ) } + ResponseFormat::SdJwtVc => { + // The projection re-encodes the constructed payload and + // re-derives nothing. + let input = match sdjwt_vc::issuance_input(&evidence, request.holder_key.as_ref()) { + Ok(input) => input, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::EvaluationFailure, + "sd-jwt-vc-mapping", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure( + ProblemCode::ServiceUnavailable, + "sd-jwt-vc-mapping", + )); + } + }; + // A signing failure is a safe 503. It never falls back to the + // signed-JWS or unsigned format. + let serialized = match self.signer.sign_sd_jwt_vc(input).await { + Ok(serialized) => serialized, + Err(_) => { + self.append_failure( + &material, + operation, + AuditDecision::SigningFailure, + "signing", + &source_id, + &adapter_id, + started, + ) + .await?; + return Err(failure(ProblemCode::ServiceUnavailable, "signing")); + } + }; + ( + serialized.into_bytes(), + EVIDENCE_SD_JWT_VC_MEDIA_TYPE, + Some(self.signer.key_id().to_owned()), + ) + } ResponseFormat::UnsignedJson => { // No signing operation runs, but the ordinary signing // dependency must still be ready for the deployment. @@ -1136,6 +1194,7 @@ fn map_response_protection(format: ResponseFormat) -> ResponseProtection { match format { ResponseFormat::SignedJws => ResponseProtection::Signed, ResponseFormat::UnsignedJson => ResponseProtection::Unsigned, + ResponseFormat::SdJwtVc => ResponseProtection::SdJwtVc, } } diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index b81a8238d..d0a57eb5b 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -1,4 +1,5 @@ use std::{ + cell::RefCell, collections::{BTreeMap, BTreeSet}, fs, io::Write as _, @@ -48,8 +49,10 @@ use crate::{ runtime::{EvidenceRuntime, RuntimeInitializationError}, server::{build_app, build_app_at_for_test, build_app_with_metrics, serve_listener_for_test}, signing::EvidenceSigner, - verifier::{verify_flattened_jws, EvidenceVerificationPolicy}, - EVIDENCE_UNSIGNED_MEDIA_TYPE, + verifier::{ + verify_flattened_jws, verify_sd_jwt_vc, EvidenceVerificationPolicy, ExpectedValueForm, + }, + EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const AUTH_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"acceptance-auth-key"}"#; @@ -2110,6 +2113,549 @@ async fn all_four_definitions_pass_the_explicitly_authorized_unsigned_path() { } } +/// Rewrite the acceptance bundle so the immutable bundle, and optionally the +/// matched grant, enable the SD-JWT VC response format. +async fn sd_jwt_vc_acceptance(grant_permits: bool) -> PreparedAcceptance { + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + make_writable(&prepared.bundle_root); + let configuration_path = prepared.bundle_root.join("evidence.yaml"); + let mut configuration = + fs::read_to_string(&configuration_path).expect("acceptance configuration is readable"); + replace_exact( + &mut configuration, + "\nresponseFormats: [signed-jws, unsigned-json]", + "\nresponseFormats: [signed-jws, unsigned-json, sd-jwt-vc]", + 1, + ); + if grant_permits { + replace_exact( + &mut configuration, + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json]", + "purpose: fixture-eligibility\n audienceFrom: authenticated-requester\n responseFormats: [signed-jws, unsigned-json, sd-jwt-vc]", + 1, + ); + } + fs::write(&configuration_path, &configuration).expect("test configuration is rewritten"); + make_read_only(&prepared.bundle_root); + prepared +} + +async fn runtime_for(prepared: &PreparedAcceptance) -> Arc { + Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"), + ) +} + +#[tokio::test] +async fn sd_jwt_format_not_permitted_by_bundle() { + // The stock acceptance bundle enables signed and unsigned output only. + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + + assert_eq!(response.status_code(), axum::http::StatusCode::FORBIDDEN); + assert_eq!(response.json::()["code"], json!("not_authorized")); + assert!( + fixture + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "an unenabled response format is denied before source access" + ); + assert!(fs::read_to_string(&fixture.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn sd_jwt_format_not_permitted_by_grant() { + // The bundle enables the format but the one matched grant withholds it. + let prepared = sd_jwt_vc_acceptance(false).await; + let runtime = runtime_for(&prepared).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let denied = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN); + assert_eq!(denied.json::()["code"], json!("not_authorized")); + assert!(prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty()); + + // With both gates open the same assertion is released as an SD-JWT VC. + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + // One evaluation per response format; both must reach the same source. + mount_adult_source_expecting(&prepared.server, None, 2).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let token = access_token(None); + let request = adult_request(); + + // The signed default establishes the independent expectations a relying + // party retains, so the credential is verified against the other format's + // payload rather than against itself. + let signed = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + signed.assert_status_ok(); + let jws = signed.json::(); + let payload = URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("payload decodes"); + let expected: Evidence = serde_json::from_slice(&payload).expect("payload parses"); + let policy = EvidenceVerificationPolicy::from_accepted_transaction( + &expected, + &request.request_nonce, + Duration::from_secs(48 * 60 * 60), + Utc::now(), + Duration::from_secs(30), + ); + + let credential = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {token}")) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(&request).expect("request serializes")) + .await; + credential.assert_status_ok(); + assert_eq!( + credential.header("content-type"), + EVIDENCE_SD_JWT_VC_MEDIA_TYPE + ); + assert_eq!(credential.header("vary"), "Accept"); + assert_eq!(credential.header("cache-control"), "no-store"); + + let serialized = credential.text(); + assert!(serialized.ends_with('~'), "no key-binding JWT is issued"); + let verified = verify_sd_jwt_vc(serialized.as_bytes(), runtime.jwks(), &policy) + .expect("the credential verifies against the signed transaction's expectations"); + assert_eq!(verified.supported_values, expected.supported_values); + assert_eq!(verified.subjects, expected.subjects); + assert_eq!(verified.request_nonce, request.request_nonce); + + // Audit records the closed protection mode and the signing key identity. + let audit = wait_for_audit_counts(&prepared.audit_path, 2, 2).await; + let events = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .collect::>(); + let releases = events + .iter() + .filter(|event| event["record"]["phase"] == json!("disclosure-release")) + .collect::>(); + let credential_release = releases + .iter() + .find(|event| event["record"]["responseProtection"] == json!("sd-jwt-vc")) + .expect("the credential release records the SD-JWT VC mode"); + assert_eq!( + credential_release["record"]["signingKeyId"], + json!("acceptance-evidence-key") + ); + assert!(!audit.contains(&request.request_nonce)); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } +} + +#[tokio::test] +async fn sd_jwt_holder_key_with_private_member_rejected() { + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + // No source is mounted: no request may reach acquisition. + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let mut body = serde_json::to_value(adult_request()).expect("request serializes"); + + for holder_key in [ + json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A" + }), + json!({ + "kty": "oct", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "k": "c2VjcmV0LWtleS1jYW5hcnk" + }), + ] { + body["holderKey"] = holder_key; + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "a holder key carrying private material is not a request" + ); + let problem = response.json::(); + assert_eq!(problem["code"], json!("malformed_request")); + let text = response.text(); + assert!( + !text.contains("nWGxne") && !text.contains("c2VjcmV0"), + "rejected key material is never echoed" + ); + } + + assert!( + prepared + .server + .received_requests() + .await + .expect("request journal is available") + .is_empty(), + "an unacceptable holder key fails before credential acquisition" + ); + assert!(fs::read_to_string(&prepared.audit_path) + .expect("audit is readable") + .is_empty()); +} + +#[tokio::test] +async fn sd_jwt_holder_key_wrong_algorithm_rejected() { + let prepared = sd_jwt_vc_acceptance(true).await; + let runtime = runtime_for(&prepared).await; + mount_adult_source(&prepared.server, None).await; + let http = TestServer::new(build_app(Arc::clone(&runtime))); + let mut body = serde_json::to_value(adult_request()).expect("request serializes"); + + for holder_key in [ + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "ES256"}), + json!({"kty": "EC", "crv": "P-256", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "alg": "EdDSA"}), + json!({"kty": "OKP", "crv": "X25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}), + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg"}), + json!({"kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo="}), + ] { + body["holderKey"] = holder_key.clone(); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + assert_eq!( + response.status_code(), + axum::http::StatusCode::BAD_REQUEST, + "{holder_key} is outside the closed holder-key profile" + ); + assert_eq!(response.json::()["code"], json!("malformed_request")); + } + + // The same request without a holder key still succeeds, so the rejection + // is the key's and not the format's. + body.as_object_mut() + .expect("request is an object") + .remove("holderKey"); + let accepted = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&body) + .await; + accepted.assert_status_ok(); + assert!(!accepted.text().contains("cnf")); +} + +#[tokio::test] +async fn sd_jwt_signing_failure_no_fallback_format() { + let prepared = sd_jwt_vc_acceptance(true).await; + let mut runtime = + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("runtime initializes"); + let private = PrivateJwk::parse(EVIDENCE_PRIVATE_JWK).expect("test signing key parses"); + let delegate = LocalJwkSigner::new(private).expect("local signer builds"); + let provider: Arc = Arc::new(FailAfterSelfTestSigner { + delegate, + calls: AtomicUsize::new(0), + }); + let failing_signer = EvidenceSigner::initialize(provider, "acceptance-evidence-key") + .await + .expect("signer passes its startup self-test"); + runtime.replace_signer_for_test(failing_signer); + mount_adult_source(&prepared.server, None).await; + + let http = TestServer::new(build_app(Arc::new(runtime))); + let response = http + .post("/v1/evidence") + .add_header("authorization", format!("Bearer {}", access_token(None))) + .add_header("accept", EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + .json(&serde_json::to_value(adult_request()).expect("request serializes")) + .await; + + assert_eq!( + response.status_code(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(response.header("content-type"), "application/problem+json"); + let text = response.text(); + assert!( + !text.contains('~') && !text.contains("integrityProtection"), + "a failed credential signature never falls back to another format" + ); + let audit = fs::read_to_string(&prepared.audit_path).expect("audit is readable"); + assert_eq!(audit.matches("\"phase\":\"disclosure-release\"").count(), 0); +} + +/// Serve the operator-driven SD-JWT VC demo documented in `SD-JWT-VC-DEMO.md`. +/// +/// The immutable bundle and the one complete matched grant both enable the +/// credential format, so one deterministic request is released twice: once as +/// the signed default and once as an SD-JWT VC. The harness verifies the +/// credential against expectations taken from the signed transaction rather +/// than from the credential's own bytes, then leaves the pinned key set and a +/// policy document so the operator can re-verify the stored credential offline +/// with `evidence verify --sd-jwt-vc`. +#[tokio::test] +#[ignore = "operator-driven local curl demo"] +async fn sd_jwt_vc_demo_serves_a_credential_for_curl() { + let prepared = sd_jwt_vc_acceptance(true).await; + // One source call per response format. Both formats answer the same + // request, so neither may reach the source more than once. + mount_adult_source_expecting(&prepared.server, None, 2).await; + let runtime = runtime_for(&prepared).await; + + let state_root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../products/evidence/.sd-jwt-vc-demo"); + fs::create_dir_all(&state_root).expect("demo state directory is created"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&state_root, fs::Permissions::from_mode(0o700)) + .expect("demo state directory is owner-only"); + } + let signed_path = state_root.join("response.jws.json"); + let credential_path = state_root.join("credential.txt"); + let metadata_path = state_root.join("issuer-metadata.json"); + let jwks_path = state_root.join("trusted.jwks.json"); + let policy_path = state_root.join("verification-policy.yaml"); + for stale in [ + &signed_path, + &credential_path, + &metadata_path, + &jwks_path, + &policy_path, + ] { + match fs::remove_file(stale) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!("stale demo output could not be removed: {error}"), + } + } + + let request = adult_request(); + write_secret( + &state_root, + "request.json", + &serde_json::to_string_pretty(&request).expect("request serializes"), + ); + write_secret( + &state_root, + "session.env", + &format!("EVIDENCE_ACCESS_TOKEN={}\n", access_token(None)), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:18081") + .await + .expect("demo listener binds on 127.0.0.1:18081"); + let address = listener + .local_addr() + .expect("listener address is available"); + println!( + "Evidence SD-JWT VC demo server is ready at http://{address}. The ignored session.env contains only the short-lived synthetic bearer token. Use the curl commands in products/evidence/SD-JWT-VC-DEMO.md." + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let served = Arc::clone(&runtime); + let server = tokio::spawn(async move { + serve_listener_for_test(served, listener, async move { + let _ = shutdown_rx.await; + }) + .await + }); + + // The signed default is fetched first, because a relying party's + // expectations come from the transaction it accepted, never from the + // credential it is about to check. + let signed = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&signed_path) { + if serde_json::from_slice::(&bytes).is_ok() { + break bytes; + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the signed curl response arrives within three minutes"); + let policy = verification_policy(&runtime, &request, &signed); + let accepted = verify_flattened_jws(&signed, runtime.jwks(), &policy) + .expect("the signed response verifies against the running Evidence JWKS"); + + // A partial write looks like a malformed credential, so the last + // verification failure is retained outside the polling future and reported + // on timeout rather than swallowed. + let last_failure = RefCell::new(None); + let credential_wait = tokio::time::timeout(Duration::from_secs(180), async { + loop { + if let Ok(bytes) = fs::read(&credential_path) { + if bytes.ends_with(b"~") { + match verify_sd_jwt_vc(&bytes, runtime.jwks(), &policy) { + Ok(verified) => { + break ( + String::from_utf8(bytes).expect("credential is ASCII"), + verified, + ) + } + Err(error) => *last_failure.borrow_mut() = Some(format!("{error:?}")), + } + } + if let Ok(problem) = serde_json::from_slice::(&bytes) { + if let Some(code) = problem.get("code").and_then(Value::as_str) { + panic!("Evidence returned the safe problem code {code}"); + } + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + let (credential, verified) = match credential_wait { + Ok(pair) => pair, + Err(_) => panic!( + "the credential curl response arrives and verifies within three minutes; \ + last verification failure: {:?}", + last_failure.borrow() + ), + }; + + assert!( + credential.ends_with('~'), + "no key-binding JWT is issued or expected" + ); + assert_eq!( + credential + .split('~') + .filter(|part| !part.is_empty()) + .count(), + 1 + accepted.supported_values.len(), + "the credential carries the issuer-signed JWT and one disclosure per supported value" + ); + assert_eq!(verified.supported_values, accepted.supported_values); + assert_eq!(verified.subjects, accepted.subjects); + assert_eq!(verified.request_nonce, request.request_nonce); + + shutdown_tx.send(()).expect("demo server is still running"); + server + .await + .expect("demo server task joins") + .expect("demo server stops cleanly"); + + // What an offline relying party keeps besides the credential: the closed + // policy document, written from the accepted transaction. The pinned key + // set is not written here, because the demo fetches it the way a relying + // party does, from the issuer metadata route. + fs::write(&policy_path, demo_verification_policy_document(&policy)) + .expect("the verification policy is written"); + + let audit = wait_for_audit_counts(&prepared.audit_path, 2, 2).await; + let releases = audit + .lines() + .map(|line| serde_json::from_str::(line).expect("audit line is JSON")) + .filter(|event| event["record"]["phase"] == json!("disclosure-release")) + .map(|event| { + event["record"]["responseProtection"] + .as_str() + .expect("every release records a protection mode") + .to_owned() + }) + .collect::>(); + assert_eq!( + releases, + BTreeSet::from(["sd-jwt-vc".to_owned(), "signed".to_owned()]), + "each release records its own closed protection mode" + ); + assert!(!audit.contains(&request.request_nonce)); + for canary in privacy_canaries() { + assert!(!audit.contains(canary)); + } + + println!( + "PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, the credential verified against the signed transaction's expectations, minimization held, and both releases recorded their protection mode." + ); +} + +/// Render the accepted transaction's expectations as the closed policy document +/// the `evidence verify` command parses. +fn demo_verification_policy_document(policy: &EvidenceVerificationPolicy) -> String { + let document = json!({ + "issuedBy": policy.issued_by, + "providedBy": policy.provided_by, + "requirement": policy.requirement, + "evidenceType": policy.evidence_type, + "purpose": policy.purpose, + "audience": policy.audience, + "configurationRevision": policy.configuration_revision, + "requestNonce": policy.request_nonce, + "expectedSubjects": policy + .expected_subjects + .iter() + .map(|subject| json!({"role": subject.role, "binding": subject.binding})) + .collect::>(), + "expectedOutputs": policy + .expected_outputs + .iter() + .map(|output| json!({"concept": output.concept, "form": expected_form_document(&output.form)})) + .collect::>(), + "maximumAssertionLifetimeSeconds": policy.maximum_assertion_lifetime.as_secs(), + "clockSkewSeconds": policy.clock_skew.as_secs(), + }); + serde_norway::to_string(&document).expect("the policy document serializes as YAML") +} + +/// The closed expected value-form vocabulary as a policy document writes it. +fn expected_form_document(form: &ExpectedValueForm) -> Value { + match form { + ExpectedValueForm::Boolean => json!("boolean"), + ExpectedValueForm::Integer => json!("integer"), + ExpectedValueForm::String => json!("string"), + ExpectedValueForm::DateBucket => json!("date-bucket"), + ExpectedValueForm::TimeBucket => json!("time-bucket"), + ExpectedValueForm::EntityReference => json!("entity-reference"), + ExpectedValueForm::Structured => json!("structured"), + ExpectedValueForm::List { + minimum_items, + maximum_items, + } => json!({"list": {"minimumItems": minimum_items, "maximumItems": maximum_items}}), + } +} + #[tokio::test] async fn reordered_grant_subjects_resolve_by_role_and_emit_declaration_order() { let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; @@ -3438,6 +3984,11 @@ async fn mount_licence_source(server: &MockServer) { } async fn mount_adult_source(server: &MockServer, delay: Option) { + mount_adult_source_expecting(server, delay, 1).await; +} + +/// The same fixture source mounted for an exact number of evaluations. +async fn mount_adult_source_expecting(server: &MockServer, delay: Option, expected: u64) { let response = ResponseTemplate::new(200).set_body_json(json!({ "total": 1, "date_of_birth": "2000-01-01" @@ -3457,7 +4008,7 @@ async fn mount_adult_source(server: &MockServer, delay: Option) { "limit": 2 }))) .respond_with(response) - .expect(1) + .expect(expected) .mount(server) .await; } @@ -3621,6 +4172,7 @@ fn request(requirement: &str, purpose: &str, subjects: Vec) -> requirement: requirement.to_owned(), purpose: purpose.to_owned(), subjects, + holder_key: None, } } diff --git a/crates/registry-evidence/src/sdjwt_vc.rs b/crates/registry-evidence/src/sdjwt_vc.rs new file mode 100644 index 000000000..264f50f21 --- /dev/null +++ b/crates/registry-evidence/src/sdjwt_vc.rs @@ -0,0 +1,500 @@ +//! Projection of a constructed Evidence payload onto the frozen SD-JWT VC +//! profile in `products/evidence/contracts/sd-jwt-vc-profile.yaml`, and the +//! inverse projection used by the relying-party verifier. +//! +//! The projection re-derives nothing. It re-encodes the exact payload the +//! signed-JWS format would carry: the always-disclosed claims become public +//! JWT claims, and each supported value becomes exactly one selective +//! disclosure keyed by its concept identifier. The inverse rebuilds that same +//! payload so one policy engine serves both response formats. + +use std::collections::BTreeMap; + +use chrono::{DateTime, SecondsFormat, TimeZone, Utc}; +use registry_platform_crypto::PublicJwk; +use registry_platform_sdjwt::{Disclosure, HolderConfirmation, SdJwtIssuanceInput}; +use serde::Serialize; +use serde_json::{Map, Value}; +use thiserror::Error; + +use crate::{ + model::{Evidence, HolderPublicKey}, + EVIDENCE_SCHEMA_V1, +}; + +/// Claims the issuer writes itself. `status` is absent because Version 1 +/// publishes no credential status. +const ISSUER_OWNED_CLAIMS: [&str; 10] = [ + "iss", "sub", "iat", "exp", "vct", "id", "jti", "_sd", "_sd_alg", "cnf", +]; + +/// The profile's always-disclosed claims that carry Evidence members, in the +/// sorted order the issuance input uses. +const ALWAYS_DISCLOSED_CLAIMS: [&str; 9] = [ + "audience", + "configurationRevision", + "issuedBy", + "observedAt", + "providedBy", + "purpose", + "requestNonce", + "subjects", + "supportsRequirement", +]; + +/// A constructed payload that cannot be projected onto the profile. Every +/// variant is an internal invariant violation rather than caller input, except +/// `HolderKey`, which the runtime rejects earlier and re-checks here so the +/// unacceptable key can never reach a signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum SdJwtVcMappingError { + #[error("an evidence timestamp is not an RFC 3339 instant")] + Timestamp, + #[error("the evidence carries no role-bound subject")] + Subjects, + #[error("an evidence claim is not representable as JSON")] + Claim, + #[error("the holder public key is not an acceptable Ed25519 public JWK")] + HolderKey, +} + +/// Map a constructed payload and an optional holder key onto the issuance +/// input. The public claim set is exactly the profile's always-disclosed list +/// minus the members the issuer writes itself (`iss`, `sub`, `iat`, `exp`, +/// `vct`, `jti`, `_sd_alg`). +pub fn issuance_input( + evidence: &Evidence, + holder_key: Option<&HolderPublicKey>, +) -> Result { + // Deterministic because the kernel canonicalizes subjects to requirement + // declaration order. The complete set travels as a public claim; `sub` is + // a convenience projection of the first role. + let subject = evidence + .subjects + .first() + .ok_or(SdJwtVcMappingError::Subjects)?; + + let mut public_claims = BTreeMap::new(); + public_claims.insert("issuedBy".to_string(), string_claim(&evidence.issued_by)); + public_claims.insert( + "providedBy".to_string(), + string_claim(&evidence.provided_by), + ); + public_claims.insert( + "supportsRequirement".to_string(), + string_claim(&evidence.supports_requirement), + ); + public_claims.insert("purpose".to_string(), string_claim(&evidence.purpose)); + public_claims.insert("audience".to_string(), string_claim(&evidence.audience)); + public_claims.insert( + "observedAt".to_string(), + string_claim(&evidence.observed_at), + ); + public_claims.insert( + "configurationRevision".to_string(), + string_claim(&evidence.configuration_revision), + ); + public_claims.insert( + "requestNonce".to_string(), + string_claim(&evidence.request_nonce), + ); + public_claims.insert("subjects".to_string(), claim_value(&evidence.subjects)?); + + let mut disclosures = Vec::with_capacity(evidence.supported_values.len()); + for supported in &evidence.supported_values { + disclosures.push(Disclosure { + name: supported.provides_value_for.clone(), + value: claim_value(&supported.value)?, + }); + } + + Ok(SdJwtIssuanceInput { + // The technical provider controlling the signing key, matching the + // signed-JWS trust statement. The named legal issuer travels as + // `issuedBy`. + iss: evidence.provided_by.clone(), + sub_ref: subject.binding.clone(), + credential_id: Some(evidence.id.clone()), + iat: unix_seconds(&evidence.issued_at)?, + exp: unix_seconds(&evidence.valid_until)?, + vct: evidence.is_conformant_to.clone(), + // Version 1 publishes no credential status, so no status reference can + // be constructed for one. + status: None, + public_claims, + cnf: holder_key.map(confirmation).transpose()?, + disclosures, + }) +} + +/// Build the `cnf` member. The key identifier stays inside the JWK so the +/// confirmation carries exactly one confirmation method. +fn confirmation(key: &HolderPublicKey) -> Result { + if !key.is_acceptable() { + return Err(SdJwtVcMappingError::HolderKey); + } + Ok(HolderConfirmation { + jwk: PublicJwk { + kty: key.kty.clone(), + kid: key.kid.clone(), + alg: key.alg.clone(), + crv: Some(key.crv.clone()), + x: Some(key.x.clone()), + y: None, + n: None, + e: None, + }, + kid: None, + }) +} + +fn unix_seconds(value: &str) -> Result { + DateTime::parse_from_rfc3339(value) + .map(|instant| instant.timestamp()) + .map_err(|_| SdJwtVcMappingError::Timestamp) +} + +fn string_claim(value: &str) -> Value { + Value::String(value.to_string()) +} + +fn claim_value(value: &T) -> Result { + serde_json::to_value(value).map_err(|_| SdJwtVcMappingError::Claim) +} + +/// A verified token whose claim set is not the profile's projection of an +/// Evidence payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum SdJwtVcClaimError { + #[error("the token carries a claim the profile does not publish")] + UnexpectedClaim, + #[error("the token is missing a claim the profile always discloses")] + MissingClaim, + #[error("a token claim has the wrong type or an inconsistent value")] + ClaimShape, + #[error("a token timestamp is not a representable instant")] + Timestamp, +} + +/// Rebuild the Evidence payload that a compact SD-JWT VC projects from. +/// +/// The claim set is closed: any member outside the issuer-owned claims and the +/// profile's always-disclosed claims fails, which is how a relying party +/// detects a prohibited `status`, `aud`, `nbf`, or smuggled selector claim. The +/// result is a JSON payload, not a parsed `Evidence`, so the caller applies the +/// same contract validation and deserialization the signed-JWS path applies. +pub fn evidence_payload_from_claims( + claims: &Map, + disclosed: &[(String, Value)], +) -> Result { + for name in claims.keys() { + if !ISSUER_OWNED_CLAIMS.contains(&name.as_str()) + && !ALWAYS_DISCLOSED_CLAIMS.contains(&name.as_str()) + { + return Err(SdJwtVcClaimError::UnexpectedClaim); + } + } + + let id = string_of(claims, "id")?; + if string_of(claims, "jti")? != id { + return Err(SdJwtVcClaimError::ClaimShape); + } + + let subjects = claims + .get("subjects") + .ok_or(SdJwtVcClaimError::MissingClaim)? + .clone(); + // `sub` is a projection of the first role-bound subject, never an + // independent identifier. + let first_binding = subjects + .as_array() + .and_then(|roles| roles.first()) + .and_then(|role| role.get("binding")) + .and_then(Value::as_str) + .ok_or(SdJwtVcClaimError::ClaimShape)?; + if string_of(claims, "sub")? != first_binding { + return Err(SdJwtVcClaimError::ClaimShape); + } + + let mut supported_values = Vec::with_capacity(disclosed.len()); + for (concept, value) in disclosed { + supported_values.push(serde_json::json!({ + "providesValueFor": concept, + "value": value, + })); + } + + Ok(serde_json::json!({ + "schema": EVIDENCE_SCHEMA_V1, + "requestNonce": claims.get("requestNonce").ok_or(SdJwtVcClaimError::MissingClaim)?, + "id": id, + "type": "Evidence", + "supportsRequirement": claims.get("supportsRequirement").ok_or(SdJwtVcClaimError::MissingClaim)?, + "isConformantTo": claims.get("vct").ok_or(SdJwtVcClaimError::MissingClaim)?, + "issuedBy": claims.get("issuedBy").ok_or(SdJwtVcClaimError::MissingClaim)?, + "providedBy": claims.get("providedBy").ok_or(SdJwtVcClaimError::MissingClaim)?, + "issuedAt": rfc3339_of(claims, "iat")?, + "observedAt": claims.get("observedAt").ok_or(SdJwtVcClaimError::MissingClaim)?, + "validUntil": rfc3339_of(claims, "exp")?, + "purpose": claims.get("purpose").ok_or(SdJwtVcClaimError::MissingClaim)?, + "audience": claims.get("audience").ok_or(SdJwtVcClaimError::MissingClaim)?, + "configurationRevision": claims.get("configurationRevision").ok_or(SdJwtVcClaimError::MissingClaim)?, + "subjects": subjects, + "supportedValues": supported_values, + })) +} + +fn string_of<'a>(claims: &'a Map, name: &str) -> Result<&'a str, SdJwtVcClaimError> { + claims + .get(name) + .ok_or(SdJwtVcClaimError::MissingClaim)? + .as_str() + .ok_or(SdJwtVcClaimError::ClaimShape) +} + +/// `iat` and `exp` are the profile's only numeric time claims. The kernel +/// emits every Evidence timestamp at whole-second UTC precision, so the +/// rebuilt string is the exact string the signed-JWS payload carries. +fn rfc3339_of(claims: &Map, name: &str) -> Result { + let seconds = claims + .get(name) + .ok_or(SdJwtVcClaimError::MissingClaim)? + .as_i64() + .ok_or(SdJwtVcClaimError::ClaimShape)?; + Utc.timestamp_opt(seconds, 0) + .single() + .map(|instant| instant.to_rfc3339_opts(SecondsFormat::Secs, true)) + .ok_or(SdJwtVcClaimError::Timestamp) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + EvidenceObjectType, PublicValue, SubjectBinding, SupportedValue, + OFFLINE_EVALUATION_REQUEST_NONCE, + }; + use crate::EVIDENCE_SCHEMA_V1; + + fn evidence() -> Evidence { + Evidence { + schema: EVIDENCE_SCHEMA_V1.to_string(), + request_nonce: OFFLINE_EVALUATION_REQUEST_NONCE.to_string(), + id: "urn:evidence:assertion:v1_2f0a".to_string(), + evidence_type_name: EvidenceObjectType::Evidence, + supports_requirement: "urn:example:requirement:adult-status".to_string(), + is_conformant_to: "urn:example:evidence-type:adult-status".to_string(), + issued_by: "urn:example:issuer:civil-registry".to_string(), + provided_by: "urn:example:provider:evidence-service".to_string(), + issued_at: "2026-08-02T09:15:00Z".to_string(), + observed_at: "2026-08-02T09:14:59Z".to_string(), + valid_until: "2026-08-02T09:20:00Z".to_string(), + purpose: "age-gated-service".to_string(), + audience: "urn:example:relying-party:library".to_string(), + configuration_revision: "rev-7".to_string(), + subjects: vec![ + SubjectBinding { + role: "applicant".to_string(), + binding: "urn:evidence:subject:v1_aaaa".to_string(), + }, + SubjectBinding { + role: "guardian".to_string(), + binding: "urn:evidence:subject:v1_bbbb".to_string(), + }, + ], + supported_values: vec![ + SupportedValue { + provides_value_for: "urn:example:concept:is-adult".to_string(), + value: PublicValue::Boolean(true), + }, + SupportedValue { + provides_value_for: "urn:example:concept:jurisdiction".to_string(), + value: PublicValue::String("SE".to_string()), + }, + ], + } + } + + fn holder_key() -> HolderPublicKey { + HolderPublicKey { + kty: "OKP".to_string(), + crv: "Ed25519".to_string(), + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_string(), + alg: None, + kid: None, + } + } + + #[test] + fn projects_the_always_disclosed_claims() { + let evidence = evidence(); + let input = issuance_input(&evidence, None).expect("evidence maps"); + + assert_eq!(input.iss, "urn:example:provider:evidence-service"); + assert_eq!(input.sub_ref, "urn:evidence:subject:v1_aaaa"); + assert_eq!( + input.credential_id.as_deref(), + Some("urn:evidence:assertion:v1_2f0a") + ); + assert_eq!(input.vct, "urn:example:evidence-type:adult-status"); + assert_eq!(input.iat, 1_785_662_100); + assert_eq!(input.exp, 1_785_662_400); + assert!(input.status.is_none()); + assert!(input.cnf.is_none()); + + let names: Vec<&str> = input.public_claims.keys().map(String::as_str).collect(); + assert_eq!( + names, + [ + "audience", + "configurationRevision", + "issuedBy", + "observedAt", + "providedBy", + "purpose", + "requestNonce", + "subjects", + "supportsRequirement", + ] + ); + assert_eq!( + input.public_claims["issuedBy"], + Value::String("urn:example:issuer:civil-registry".to_string()) + ); + assert_eq!( + input.public_claims["requestNonce"], + Value::String(OFFLINE_EVALUATION_REQUEST_NONCE.to_string()) + ); + assert_eq!( + input.public_claims["subjects"], + serde_json::json!([ + {"role": "applicant", "binding": "urn:evidence:subject:v1_aaaa"}, + {"role": "guardian", "binding": "urn:evidence:subject:v1_bbbb"}, + ]) + ); + } + + #[test] + fn selectively_discloses_exactly_one_value_per_concept() { + let evidence = evidence(); + let input = issuance_input(&evidence, None).expect("evidence maps"); + + let disclosed: Vec<(&str, &Value)> = input + .disclosures + .iter() + .map(|disclosure| (disclosure.name.as_str(), &disclosure.value)) + .collect(); + assert_eq!( + disclosed, + [ + ("urn:example:concept:is-adult", &Value::Bool(true)), + ( + "urn:example:concept:jurisdiction", + &Value::String("SE".to_string()) + ), + ] + ); + } + + #[test] + fn omits_the_prohibited_claims() { + let evidence = evidence(); + let input = issuance_input(&evidence, None).expect("evidence maps"); + + for prohibited in ["status", "nbf", "aud", "selector", "grant", "actor"] { + assert!( + !input.public_claims.contains_key(prohibited), + "{prohibited} must not be published" + ); + assert!( + !input + .disclosures + .iter() + .any(|disclosure| disclosure.name == prohibited), + "{prohibited} must not be disclosed" + ); + } + } + + #[test] + fn embeds_an_acceptable_holder_key_as_confirmation() { + let evidence = evidence(); + let mut key = holder_key(); + key.kid = Some("holder-1".to_string()); + key.alg = Some("EdDSA".to_string()); + + let confirmation = issuance_input(&evidence, Some(&key)) + .expect("evidence maps") + .cnf + .expect("confirmation is present"); + assert_eq!(confirmation.jwk.kty, "OKP"); + assert_eq!(confirmation.jwk.crv.as_deref(), Some("Ed25519")); + assert_eq!(confirmation.jwk.x.as_deref(), Some(key.x.as_str())); + assert_eq!(confirmation.jwk.kid.as_deref(), Some("holder-1")); + assert_eq!(confirmation.jwk.alg.as_deref(), Some("EdDSA")); + assert!(confirmation.kid.is_none()); + } + + #[test] + fn rejects_unacceptable_holder_keys() { + let evidence = evidence(); + let mut wrong_curve = holder_key(); + wrong_curve.crv = "P-256".to_string(); + let mut wrong_algorithm = holder_key(); + wrong_algorithm.alg = Some("ES256".to_string()); + let mut wrong_key_type = holder_key(); + wrong_key_type.kty = "EC".to_string(); + let mut short_coordinate = holder_key(); + short_coordinate.x = "11qYAYKxCrfVS_7TyWQHOg".to_string(); + let mut padded_coordinate = holder_key(); + padded_coordinate.x = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo=".to_string(); + + for key in [ + wrong_curve, + wrong_algorithm, + wrong_key_type, + short_coordinate, + padded_coordinate, + ] { + assert_eq!( + issuance_input(&evidence, Some(&key)).unwrap_err(), + SdJwtVcMappingError::HolderKey + ); + } + } + + #[test] + fn rejects_private_key_members_before_mapping() { + let body = serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A", + }); + assert!(serde_json::from_value::(body).is_err()); + } + + #[test] + fn rejects_payloads_the_profile_cannot_represent() { + let mut no_subjects = evidence(); + no_subjects.subjects.clear(); + assert_eq!( + issuance_input(&no_subjects, None).unwrap_err(), + SdJwtVcMappingError::Subjects + ); + + let mut bad_issued_at = evidence(); + bad_issued_at.issued_at = "2026-08-02 09:15:00".to_string(); + assert_eq!( + issuance_input(&bad_issued_at, None).unwrap_err(), + SdJwtVcMappingError::Timestamp + ); + + let mut bad_valid_until = evidence(); + bad_valid_until.valid_until = "never".to_string(); + assert_eq!( + issuance_input(&bad_valid_until, None).unwrap_err(), + SdJwtVcMappingError::Timestamp + ); + } +} diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index 6b3ad522b..7424c8de0 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -519,6 +519,7 @@ pub fn resolve_offline_fixture_authorization( requirement: requirement.id.clone(), purpose: purpose.to_owned(), subjects, + holder_key: None, }; let (authority_name, authority) = bundle .config diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 34654d4b8..fc60d8097 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -39,11 +39,11 @@ use tokio::{net::TcpListener, sync::Semaphore}; use crate::{ config::{ListenerConfig, ResponseFormat}, contracts::{request_contract_accepts, served_openapi_document}, - model::{request_nonce_is_canonical, EvidenceRequest}, + model::{request_nonce_is_canonical, EvidenceRequest, JwksDocument}, observability::{self, operation_id, Metrics}, problem::ProblemCode, runtime::{EvidenceRuntime, RuntimeFailure}, - EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, + EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_MEDIA_TYPE, }; const EVIDENCE_ROUTE: &str = "/v1/evidence"; @@ -52,18 +52,20 @@ const HEALTH_ROUTE: &str = "/health"; const OPENAPI_ROUTE: &str = "/openapi.json"; const READY_ROUTE: &str = "/ready"; const JWKS_ROUTE: &str = "/.well-known/evidence/jwks.json"; +const JWT_VC_ISSUER_ROUTE: &str = "/.well-known/jwt-vc-issuer"; /// Every route template this listener registers. /// /// Operational telemetry labels requests with a member of this set or with a /// single fixed unmatched label, so a caller cannot introduce a label value. -pub(crate) const ROUTE_TEMPLATES: [&str; 6] = [ +pub(crate) const ROUTE_TEMPLATES: [&str; 7] = [ EVIDENCE_ROUTE, DEFINITIONS_ROUTE, HEALTH_ROUTE, OPENAPI_ROUTE, READY_ROUTE, JWKS_ROUTE, + JWT_VC_ISSUER_ROUTE, ]; const JSON_MEDIA_TYPE: &str = "application/json"; @@ -139,6 +141,7 @@ fn build_app_with_tracker_at( .route(OPENAPI_ROUTE, get(openapi)) .route(READY_ROUTE, get(ready)) .route(JWKS_ROUTE, get(jwks)) + .route(JWT_VC_ISSUER_ROUTE, get(jwt_vc_issuer_metadata)) .fallback(unknown_route) .method_not_allowed_fallback(unknown_route) .with_state(state); @@ -445,8 +448,9 @@ async fn create_evidence_negotiated(state: Arc, request: Request Result { let mut values = headers.get_all(ACCEPT).iter(); let Some(value) = values.next() else { @@ -461,6 +465,7 @@ fn resolve_response_format(headers: &HeaderMap) -> Result { Ok(ResponseFormat::UnsignedJson) } + value if value == EVIDENCE_SD_JWT_VC_MEDIA_TYPE.as_bytes() => Ok(ResponseFormat::SdJwtVc), _ => Err(ProblemCode::ResponseFormatNotAcceptable), } } @@ -556,6 +561,31 @@ async fn jwks(State(state): State>, request: Request) -> } } +/// JWT VC Issuer Metadata. Discovery is not a trust anchor: it republishes the +/// same public keys under the provider identity the assertion already names. +/// Resolution is meaningful only when that identity is the HTTPS origin of the +/// deployment; a URN provider identity simply has no resolution path. +async fn jwt_vc_issuer_metadata( + State(state): State>, + request: Request, +) -> Response { + let operation = operation_id(request.extensions()); + let metadata = JwtVcIssuerMetadata { + issuer: &state.runtime.bundle().config.service.provider_id, + jwks: state.runtime.jwks(), + }; + match serialize_response(StatusCode::OK, JSON_MEDIA_TYPE, &metadata) { + Some(response) => response, + None => problem_response(ProblemCode::ServiceUnavailable, &operation), + } +} + +#[derive(Serialize)] +struct JwtVcIssuerMetadata<'a> { + issuer: &'a str, + jwks: &'a JwksDocument, +} + async fn unknown_route(request: Request) -> Response { problem_response( ProblemCode::MalformedRequest, diff --git a/crates/registry-evidence/src/signing.rs b/crates/registry-evidence/src/signing.rs index 173dc1edc..ef29c5a21 100644 --- a/crates/registry-evidence/src/signing.rs +++ b/crates/registry-evidence/src/signing.rs @@ -7,6 +7,7 @@ use registry_platform_crypto::{ verify, KeyReadiness, PublicJwk, SigningAlgorithm, SigningError as ProviderSigningError, SigningProvider, }; +use registry_platform_sdjwt::{SdJwtError, SdJwtIssuanceInput, SdJwtIssuer}; use serde::Serialize; use thiserror::Error; @@ -38,6 +39,8 @@ pub enum EvidenceSigningError { PublishedKey, #[error("the published key set could not be serialized")] KeySerialization(#[source] serde_json::Error), + #[error("the SD-JWT VC serialization could not be produced")] + SdJwtVc(#[source] SdJwtError), } #[derive(Debug, Serialize)] @@ -125,6 +128,20 @@ impl EvidenceSigner { signature: URL_SAFE_NO_PAD.encode(signature), }) } + + /// Serialize the same assertion as a compact SD-JWT VC. The signer is the + /// one active key already used for the flattened JWS; the profile adds no + /// second key, algorithm, or key ceremony. + pub async fn sign_sd_jwt_vc( + &self, + input: SdJwtIssuanceInput, + ) -> Result { + SdJwtIssuer::from_signing_provider(Arc::clone(&self.provider)) + .issue(input) + .await + .map(|signed| signed.jwt) + .map_err(EvidenceSigningError::SdJwtVc) + } } pub fn jwks_document( @@ -185,6 +202,7 @@ fn validate_key_id(key_id: &str) -> Result<(), EvidenceSigningError> { mod tests { use super::*; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; + use sha2::{Digest, Sha256}; const PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"evidence-key-1"}"#; const FIXTURE_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"fixture-key-2026-01"}"#; @@ -285,6 +303,96 @@ mod tests { )); } + /// The SD-JWT VC fixture is the adopter-facing wire contract, so it must be + /// reproduced by the production issuance path over every golden payload: + /// the exact protected header, one disclosure per supported value, sorted + /// unique digests over the encoded disclosure bytes, and a trailing tilde. + #[tokio::test] + async fn sd_jwt_vc_fixture_serialization_and_protected_header_are_exact() { + let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( + "../../../products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml" + )) + .expect("SD-JWT VC fixture parses"); + assert_eq!( + fixture["media_type"].as_str(), + Some(crate::EVIDENCE_SD_JWT_VC_MEDIA_TYPE) + ); + let expected_header = fixture["protected_header"]["exact_json"] + .as_str() + .expect("fixture header is text"); + let signer = fixture_signer().await; + + for case in fixture["cases"] + .as_array() + .expect("fixture cases are an array") + { + let relative = case["payload"].as_str().expect("payload path is text"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/conformance") + .join(relative); + let evidence: crate::model::Evidence = + serde_json::from_slice(&std::fs::read(path).expect("golden payload reads")) + .expect("golden payload is an Evidence document"); + let input = + crate::sdjwt_vc::issuance_input(&evidence, None).expect("golden payload maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("golden payload serializes"); + + let body = serialized + .strip_suffix('~') + .expect("the serialization ends with the key-binding terminator"); + let mut segments = body.split('~'); + let jwt = segments.next().expect("issuer-signed JWT segment"); + let disclosures = segments.collect::>(); + assert_eq!( + disclosures.len(), + evidence.supported_values.len(), + "{} discloses one value per supported value", + case["id"] + ); + + let parts = jwt.split('.').collect::>(); + assert_eq!(parts.len(), 3, "the JWT is compact JWS serialized"); + assert_eq!( + URL_SAFE_NO_PAD.decode(parts[0]).expect("header decodes"), + expected_header.as_bytes() + ); + let claims: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).expect("payload decodes")) + .expect("payload parses"); + assert_eq!(claims["_sd_alg"], serde_json::json!("sha-256")); + + let digests = claims["_sd"] + .as_array() + .expect("_sd is an array") + .iter() + .map(|value| value.as_str().expect("digest is text").to_owned()) + .collect::>(); + let mut sorted = digests.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(digests, sorted, "_sd is sorted and carries no repeat"); + for disclosure in &disclosures { + let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); + assert!( + digests.contains(&digest), + "a disclosure is absent from _sd in {}", + case["id"] + ); + } + + let signature = URL_SAFE_NO_PAD.decode(parts[2]).expect("signature decodes"); + verify( + format!("{}.{}", parts[0], parts[1]).as_bytes(), + &signature, + &signer.public_jwk(), + ) + .expect("fixture signature verifies"); + } + } + #[tokio::test] async fn jws_fixture_payload_bytes_and_protected_header_are_exact() { let fixture: serde_json::Value = serde_norway::from_slice(include_bytes!( diff --git a/crates/registry-evidence/src/verifier.rs b/crates/registry-evidence/src/verifier.rs index 150518784..1b93a7f7d 100644 --- a/crates/registry-evidence/src/verifier.rs +++ b/crates/registry-evidence/src/verifier.rs @@ -1,23 +1,36 @@ -//! Strict verifier for the Evidence Version 1 flattened JWS profile. +//! Strict verifier for the Evidence Version 1 flattened JWS profile and for +//! the SD-JWT VC profile that projects the same payload. -use std::{collections::BTreeMap, time::Duration}; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::Duration, +}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, Utc}; use registry_platform_crypto::{parse_json_strict, verify, PublicJwk, SigningAlgorithm}; use serde::Deserialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; use thiserror::Error; use crate::{ contracts::evidence_contract_accepts, model::{Evidence, FlattenedJws, JwksDocument}, - EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, + sdjwt_vc::evidence_payload_from_claims, + EVIDENCE_JWS_CTY, EVIDENCE_JWS_TYP, EVIDENCE_SCHEMA_V1, EVIDENCE_SD_JWT_VC_TYP, }; const MAX_JWS_BYTES: usize = 256 * 1024; const MAX_PROTECTED_BYTES: usize = 8 * 1024; const MAX_PAYLOAD_BYTES: usize = 128 * 1024; const MAX_TRUSTED_KEYS: usize = 33; +/// One disclosure per Supported Value, bounded well above the largest +/// requirement a Version 1 bundle can declare. +const MAX_DISCLOSURES: usize = 64; +const MAX_DISCLOSURE_BYTES: usize = 8 * 1024; +const MINIMUM_SALT_BYTES: usize = 16; +const MAXIMUM_SALT_BYTES: usize = 64; /// Complete relying-procedure expectations for strict verification. /// @@ -173,6 +186,8 @@ pub enum VerificationError { Policy, #[error("Evidence payload is outside its validity interval")] Time, + #[error("SD-JWT VC disclosures do not match the signed digests")] + Disclosure, } #[derive(Debug, Deserialize)] @@ -184,6 +199,16 @@ struct ProtectedHeader { cty: String, } +/// The SD-JWT VC header carries no `cty`: the credential type travels in the +/// signed `vct` claim. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SdJwtHeader { + alg: String, + kid: String, + typ: String, +} + /// Strict one-call verification: cryptographic authenticity, every policy /// expectation, and current validity must all hold. pub fn verify_flattened_jws( @@ -260,6 +285,223 @@ pub fn verify_flattened_jws_report( }) } +/// Strict one-call verification of an issued SD-JWT VC: cryptographic +/// authenticity, complete disclosure resolution, every policy expectation, and +/// current validity must all hold. +pub fn verify_sd_jwt_vc( + serialized: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + let report = verify_sd_jwt_vc_report(serialized, trusted_jwks, policy)?; + if !report.currently_valid { + return Err(VerificationError::Time); + } + Ok(report.evidence) +} + +/// Verify an issued SD-JWT VC against a pinned trusted key set and the +/// complete independent policy, reporting cryptographic authenticity +/// separately from current validity. +/// +/// Version 1 issues only complete credentials, so verification requires every +/// signed digest to be resolved by exactly one presented disclosure. A holder +/// presenting a subset is out of scope for the issuance profile, and the +/// relying procedure's expected output contract would reject it in any case. +/// A key-binding JWT is never accepted here: the serialization must end with +/// the trailing tilde that marks its absence. +pub fn verify_sd_jwt_vc_report( + serialized: &[u8], + trusted_jwks: &JwksDocument, + policy: &EvidenceVerificationPolicy, +) -> Result { + if serialized.is_empty() || serialized.len() > MAX_JWS_BYTES { + return Err(VerificationError::MalformedJws); + } + let serialized = + std::str::from_utf8(serialized).map_err(|_| VerificationError::MalformedJws)?; + let body = serialized + .strip_suffix('~') + .ok_or(VerificationError::MalformedJws)?; + let mut segments = body.split('~'); + let jwt = segments.next().ok_or(VerificationError::MalformedJws)?; + let encoded_disclosures: Vec<&str> = segments.collect(); + if encoded_disclosures.len() > MAX_DISCLOSURES + || encoded_disclosures.iter().any(|value| value.is_empty()) + { + return Err(VerificationError::MalformedJws); + } + + let mut parts = jwt.split('.'); + let (Some(encoded_header), Some(encoded_payload), Some(encoded_signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(VerificationError::MalformedJws); + }; + + let header_bytes = decode_bounded( + encoded_header, + MAX_PROTECTED_BYTES, + VerificationError::ProtectedHeader, + )?; + let header_strict = + parse_json_strict(&header_bytes).map_err(|_| VerificationError::ProtectedHeader)?; + let header: SdJwtHeader = + serde_json::from_value(header_strict).map_err(|_| VerificationError::ProtectedHeader)?; + if header.alg != "EdDSA" + || header.typ != EVIDENCE_SD_JWT_VC_TYP + || header.kid.is_empty() + || header.kid.len() > 256 + || header.kid.chars().any(char::is_control) + { + return Err(VerificationError::ProtectedHeader); + } + + let keys = trusted_keys(trusted_jwks)?; + let key = keys.get(&header.kid).ok_or(VerificationError::Key)?; + if key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) { + return Err(VerificationError::Key); + } + let signature = decode_bounded( + encoded_signature, + MAX_PROTECTED_BYTES, + VerificationError::Signature, + )?; + let signing_input = [encoded_header.as_bytes(), b".", encoded_payload.as_bytes()].concat(); + verify(&signing_input, &signature, key).map_err(|_| VerificationError::Signature)?; + + // Parse and act on the payload only after signature verification. + let payload_bytes = decode_bounded( + encoded_payload, + MAX_PAYLOAD_BYTES, + VerificationError::Payload, + )?; + let payload_strict = + parse_json_strict(&payload_bytes).map_err(|_| VerificationError::Payload)?; + let Value::Object(mut claims) = payload_strict else { + return Err(VerificationError::Payload); + }; + + let digests = signed_digests(&mut claims)?; + let disclosed = resolve_disclosures(&encoded_disclosures, &digests, &claims)?; + if let Some(confirmation) = claims.remove("cnf") { + validate_confirmation(&confirmation)?; + } + + let payload = evidence_payload_from_claims(&claims, &disclosed) + .map_err(|_| VerificationError::Payload)?; + if !evidence_contract_accepts(&payload).map_err(|_| VerificationError::Payload)? { + return Err(VerificationError::Payload); + } + let evidence: Evidence = + serde_json::from_value(payload).map_err(|_| VerificationError::Payload)?; + let currently_valid = validate_policy(&evidence, policy)?; + Ok(VerificationReport { + evidence, + currently_valid, + }) +} + +/// Take the signed digest set out of the claims. The set must be sorted and +/// free of duplicates, matching the issuance profile exactly. +fn signed_digests(claims: &mut Map) -> Result, VerificationError> { + if claims + .remove("_sd_alg") + .and_then(|alg| alg.as_str().map(str::to_owned)) + != Some("sha-256".to_string()) + { + return Err(VerificationError::Disclosure); + } + let listed = claims + .remove("_sd") + .ok_or(VerificationError::Disclosure)? + .as_array() + .ok_or(VerificationError::Disclosure)? + .iter() + .map(|digest| digest.as_str().map(str::to_owned)) + .collect::>>() + .ok_or(VerificationError::Disclosure)?; + if listed.len() > MAX_DISCLOSURES + || listed.windows(2).any(|pair| pair[0] >= pair[1]) + || listed.iter().any(|digest| digest.len() != 43) + { + return Err(VerificationError::Disclosure); + } + Ok(listed) +} + +/// Resolve every presented disclosure against the signed digests. Each digest +/// must be claimed by exactly one disclosure, each disclosure must carry a +/// distinct name, and no disclosure may shadow a public claim. +fn resolve_disclosures( + encoded: &[&str], + digests: &[String], + claims: &Map, +) -> Result, VerificationError> { + if encoded.len() != digests.len() { + return Err(VerificationError::Disclosure); + } + let signed: BTreeSet<&str> = digests.iter().map(String::as_str).collect(); + let mut resolved = Vec::with_capacity(encoded.len()); + let mut seen_digests = BTreeSet::new(); + let mut seen_names = BTreeSet::new(); + for disclosure in encoded { + if disclosure.len() > MAX_DISCLOSURE_BYTES { + return Err(VerificationError::Disclosure); + } + let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); + if !signed.contains(digest.as_str()) || !seen_digests.insert(digest) { + return Err(VerificationError::Disclosure); + } + let decoded = decode_bounded( + disclosure, + MAX_DISCLOSURE_BYTES, + VerificationError::Disclosure, + )?; + let strict = parse_json_strict(&decoded).map_err(|_| VerificationError::Disclosure)?; + let Value::Array(members) = strict else { + return Err(VerificationError::Disclosure); + }; + let [salt, name, value] = members.as_slice() else { + return Err(VerificationError::Disclosure); + }; + let salt = salt.as_str().ok_or(VerificationError::Disclosure)?; + let salt_bytes = URL_SAFE_NO_PAD + .decode(salt) + .map_err(|_| VerificationError::Disclosure)?; + if salt_bytes.len() < MINIMUM_SALT_BYTES || salt_bytes.len() > MAXIMUM_SALT_BYTES { + return Err(VerificationError::Disclosure); + } + let name = name.as_str().ok_or(VerificationError::Disclosure)?; + if claims.contains_key(name) || !seen_names.insert(name.to_owned()) { + return Err(VerificationError::Disclosure); + } + resolved.push((name.to_owned(), value.clone())); + } + Ok(resolved) +} + +/// The confirmation, when present, carries exactly one Ed25519 public key and +/// no private material. +fn validate_confirmation(confirmation: &Value) -> Result<(), VerificationError> { + let Some(members) = confirmation.as_object() else { + return Err(VerificationError::Payload); + }; + if members.len() != 1 { + return Err(VerificationError::Payload); + } + let jwk = members.get("jwk").ok_or(VerificationError::Payload)?; + let key: PublicJwk = + serde_json::from_value(jwk.clone()).map_err(|_| VerificationError::Payload)?; + if key.kty != "OKP" + || key.crv.as_deref() != Some("Ed25519") + || key.algorithm().ok() != Some(SigningAlgorithm::EdDsa) + { + return Err(VerificationError::Payload); + } + Ok(()) +} + fn trusted_keys(jwks: &JwksDocument) -> Result, VerificationError> { if jwks.keys.is_empty() || jwks.keys.len() > MAX_TRUSTED_KEYS { return Err(VerificationError::Key); @@ -1116,4 +1358,309 @@ mod tests { Err(VerificationError::Key) ); } + + /// Issue the fixture as an SD-JWT VC through the same signer, mapping, and + /// key set the runtime uses. + async fn issued_sd_jwt_vc() -> (String, JwksDocument, EvidenceVerificationPolicy) { + let evidence = fixture_evidence(); + let signer = fixture_signer().await; + let input = crate::sdjwt_vc::issuance_input(&evidence, None).expect("evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("SD-JWT VC serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + (serialized, jwks, policy) + } + + async fn fixture_signer() -> EvidenceSigner { + let private = PrivateJwk::parse(PRIVATE_JWK).expect("key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("signer builds")); + EvidenceSigner::initialize(provider, "evidence-key-1") + .await + .expect("signer initializes") + } + + /// Split an issued serialization into its JWT and its disclosures. + fn split_sd_jwt(serialized: &str) -> (String, Vec) { + let body = serialized.strip_suffix('~').expect("trailing tilde"); + let mut segments = body.split('~'); + let jwt = segments.next().expect("JWT segment").to_owned(); + (jwt, segments.map(str::to_owned).collect()) + } + + fn join_sd_jwt(jwt: &str, disclosures: &[String]) -> String { + let mut serialized = jwt.to_owned(); + for disclosure in disclosures { + serialized.push('~'); + serialized.push_str(disclosure); + } + serialized.push('~'); + serialized + } + + fn encode_disclosure(salt: &str, name: &str, value: Value) -> String { + URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&json!([salt, name, value])).expect("disclosure serializes")) + } + + /// Decode the signed JWT payload of an issued serialization. + fn sd_jwt_claims(jwt: &str) -> Map { + let encoded = jwt.split('.').nth(1).expect("payload segment"); + let decoded = URL_SAFE_NO_PAD.decode(encoded).expect("payload decodes"); + serde_json::from_slice(&decoded).expect("payload parses") + } + + /// Re-encode a JWT with a replaced segment, keeping the original signature. + fn replace_segment(jwt: &str, index: usize, replacement: &str) -> String { + let mut parts: Vec = jwt.split('.').map(str::to_owned).collect(); + parts[index] = replacement.to_owned(); + parts.join(".") + } + + #[tokio::test] + async fn sd_jwt_vc_round_trips_and_verifies_under_the_same_policy() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let evidence = + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy).expect("SD-JWT VC verifies"); + // The rebuilt payload is the payload the signed JWS would carry. + assert_eq!(evidence, fixture_evidence()); + } + + #[tokio::test] + async fn sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material() { + let evidence = fixture_evidence(); + let signer = fixture_signer().await; + let holder = crate::model::HolderPublicKey { + kty: "OKP".to_owned(), + crv: "Ed25519".to_owned(), + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo".to_owned(), + alg: Some("EdDSA".to_owned()), + kid: Some("holder-1".to_owned()), + }; + let input = + crate::sdjwt_vc::issuance_input(&evidence, Some(&holder)).expect("evidence maps"); + let serialized = signer + .sign_sd_jwt_vc(input) + .await + .expect("SD-JWT VC serializes"); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + + let (jwt, _) = split_sd_jwt(&serialized); + let claims = sd_jwt_claims(&jwt); + assert_eq!(claims["cnf"]["jwk"]["x"], json!(holder.x)); + assert!(claims["cnf"]["jwk"].get("d").is_none()); + assert!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy).is_ok(), + "a confirmed credential still verifies" + ); + } + + #[tokio::test] + async fn sd_jwt_disclosure_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let original = URL_SAFE_NO_PAD + .decode(&disclosures[0]) + .expect("disclosure decodes"); + let members: Vec = serde_json::from_slice(&original).expect("disclosure parses"); + let salt = members[0].as_str().expect("salt is a string"); + let name = members[1].as_str().expect("name is a string"); + + let flipped = encode_disclosure(salt, name, json!(true)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[flipped]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + let renamed = encode_disclosure(salt, "urn:example:other-concept", json!(false)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[renamed]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + let unsalted = encode_disclosure("", name, json!(false)); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[unsalted]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + } + + #[tokio::test] + async fn sd_jwt_added_disclosure_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + + let mut extra = disclosures.clone(); + extra.push(encode_disclosure( + "0123456789abcdef0123ab", + "urn:example:extra-concept", + json!(true), + )); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &extra).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + // Presenting the same signed disclosure twice claims one digest twice. + let mut repeated = disclosures.clone(); + repeated.push(disclosures[0].clone()); + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &repeated).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + } + + #[tokio::test] + async fn sd_jwt_removed_digest_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, _) = split_sd_jwt(&serialized); + + // Version 1 issues complete credentials, so an unresolved signed digest + // is a mutation rather than a selective presentation. + assert_eq!( + verify_sd_jwt_vc(join_sd_jwt(&jwt, &[]).as_bytes(), &jwks, &policy), + Err(VerificationError::Disclosure) + ); + + // A stripped trailing tilde is not a valid issued serialization. + assert_eq!( + verify_sd_jwt_vc(serialized.trim_end_matches('~').as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + } + + #[tokio::test] + async fn sd_jwt_payload_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let mut claims = sd_jwt_claims(&jwt); + claims.insert( + "audience".to_owned(), + json!("urn:example:other-relying-party"), + ); + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")); + let mutated = replace_segment(&jwt, 1, &replacement); + + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::Signature) + ); + } + + #[tokio::test] + async fn sd_jwt_protected_header_modification_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + + for header in [ + json!({"alg": "none", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP}), + json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": "JWT"}), + json!({"alg": "EdDSA", "kid": "evidence-key-1", "typ": EVIDENCE_SD_JWT_VC_TYP, "jwk": {"kty": "OKP"}}), + ] { + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); + let mutated = replace_segment(&jwt, 0, &replacement); + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::ProtectedHeader) + ); + } + } + + #[tokio::test] + async fn sd_jwt_unknown_kid_rejected() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + let (jwt, disclosures) = split_sd_jwt(&serialized); + let header = json!({ + "alg": "EdDSA", + "kid": "some-other-key", + "typ": EVIDENCE_SD_JWT_VC_TYP + }); + let replacement = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); + let mutated = replace_segment(&jwt, 0, &replacement); + + assert_eq!( + verify_sd_jwt_vc( + join_sd_jwt(&mutated, &disclosures).as_bytes(), + &jwks, + &policy + ), + Err(VerificationError::Key) + ); + } + + #[tokio::test] + async fn sd_jwt_prohibited_claim_rejected() { + let signer = fixture_signer().await; + let evidence = fixture_evidence(); + let jwks = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + let policy = policy_for( + &evidence, + "2026-08-02T12:00:00Z".parse().expect("time parses"), + ); + + for (name, value) in [ + ( + "status", + json!({"status_list": {"uri": "https://example.test/status"}}), + ), + ("aud", json!("urn:example:relying-party")), + ("nbf", json!(1_785_662_100_i64)), + ("selector", json!({"profile": "national-identifier"})), + ] { + let mut input = + crate::sdjwt_vc::issuance_input(&evidence, None).expect("evidence maps"); + if name == "status" { + input.status = Some(value.clone()); + } else { + input.public_claims.insert(name.to_owned(), value.clone()); + } + let Ok(serialized) = signer.sign_sd_jwt_vc(input).await else { + // The issuer refuses reserved claim names outright, which is a + // stronger outcome than verifier rejection. + continue; + }; + assert_eq!( + verify_sd_jwt_vc(serialized.as_bytes(), &jwks, &policy), + Err(VerificationError::Payload), + "{name} must never be published" + ); + } + } + + #[tokio::test] + async fn sd_jwt_rejected_by_flattened_jws_verifier() { + let (serialized, jwks, policy) = issued_sd_jwt_vc().await; + assert_eq!( + verify_flattened_jws(serialized.as_bytes(), &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + + // The reverse also holds: a flattened JWS is not an SD-JWT VC. + let (jws, _, _) = signed_fixture().await; + assert_eq!( + verify_sd_jwt_vc(&jws, &jwks, &policy), + Err(VerificationError::MalformedJws) + ); + } } diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 1dc7a1b7b..8d6cf40df 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -321,6 +321,10 @@ fn serve_stops_on_sigterm_and_restarts_on_an_archived_audit_chain() { /// The staged verification key identifier, echoed by the protected header. const VERIFY_KEY_ID: &str = "verify-fixture-key"; +/// A staged Ed25519 test key. It signs fixture assertions in this test binary +/// only and is not a deployment key. +const VERIFY_PRIVATE_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"verify-fixture-key"}"#; + /// A staged request nonce, of the exact 43-character request-nonce shape. const FIXTURE_NONCE: &str = "r1N1mq48U3PpZ5keuZEgmA5KMC2KDrF1hT6640koy6I"; @@ -432,6 +436,96 @@ fn verify_rejects_a_verification_instant_that_is_not_strict_utc() { } } +#[test] +fn verify_accepts_an_authentic_and_current_stored_sd_jwt_vc() { + let stored = StoredCredential::stage(&fixture_policy(), |credential| credential); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_eq!( + output.status.code(), + Some(0), + "verify rejected a good credential" + ); + assert!(output.stderr.is_empty(), "verify wrote diagnostics"); + let stdout = std::str::from_utf8(&output.stdout).expect("stdout is UTF-8"); + assert!( + stdout.contains("authentic: yes\n") && stdout.contains("currently-valid: yes\n"), + "verify did not report the credential as authentic and current" + ); + assert!( + stdout.contains("urn:example:concept"), + "verify did not print the rebuilt Evidence for inspection" + ); +} + +#[test] +fn verify_rejects_a_stored_sd_jwt_vc_whose_disclosure_was_replaced() { + // Substitute a well-formed disclosure of the same claim with the opposite + // value. Its digest is absent from the signed `_sd`, so the credential + // fails without the signature itself being touched. + let stored = StoredCredential::stage(&fixture_policy(), |credential| { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let body = credential + .strip_suffix('~') + .expect("the credential ends with the key-binding terminator"); + let (jwt, disclosure) = body.split_once('~').expect("the credential discloses"); + let decoded: serde_json::Value = serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(disclosure) + .expect("disclosure decodes"), + ) + .expect("disclosure parses"); + let replaced = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!([decoded[0], decoded[1], true])) + .expect("disclosure serializes"), + ); + format!("{jwt}~{replaced}~") + }); + let output = stored.verify(Some("2026-08-02T12:00:00Z")); + + assert_verification_failure( + &output, + "2026-08-02T12:00:00Z", + "authentic: no\n", + "evidence: stored response verification failed (disclosure)\n", + ); +} + +#[test] +fn verify_requires_exactly_one_stored_response_format() { + let stored = StoredCredential::stage(&fixture_policy(), |credential| credential); + for arguments in [ + vec![], + vec![ + "--jws".to_owned(), + stored.path("response.sd-jwt").display().to_string(), + "--sd-jwt-vc".to_owned(), + stored.path("response.sd-jwt").display().to_string(), + ], + ] { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .args(&arguments) + .arg("--jwks") + .arg(stored.path("trusted.jwks.json")) + .arg("--policy") + .arg(stored.path("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + let output = command.output().expect("evidence binary starts"); + assert_eq!( + output.status.code(), + Some(2), + "verify accepted an ambiguous stored-response selection" + ); + assert!( + output.stdout.is_empty(), + "verify began before selecting a stored response" + ); + } +} + /// Assert one closed verification failure: exit 1, the chosen instant, the /// expected remaining stdout, only the closed class on stderr, and no leaked /// document value on either stream. @@ -573,6 +667,81 @@ impl StoredResponse { } } +/// The SD-JWT VC counterpart of `StoredResponse`. The same assertion is +/// serialized through the production issuance path, so the command is proven +/// against the bytes an adopter actually receives rather than a hand-built +/// approximation. +struct StoredCredential { + root: tempfile::TempDir, +} + +impl StoredCredential { + /// Issue the fixture assertion, apply `mutate` to the serialization, and + /// stage it beside the pinned key set and the policy. + fn stage(policy: &str, mutate: impl FnOnce(String) -> String) -> Self { + use registry_evidence::{ + model::Evidence, + sdjwt_vc::issuance_input, + signing::{jwks_document, EvidenceSigner}, + }; + use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, SigningProvider}; + use std::sync::Arc; + + let root = tempfile::tempdir().expect("temporary verification inputs"); + let evidence: Evidence = + serde_json::from_value(fixture_evidence()).expect("the fixture is an Evidence payload"); + let private = PrivateJwk::parse(VERIFY_PRIVATE_JWK).expect("fixture key parses"); + let provider: Arc = + Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + + let (credential, trusted) = tokio::runtime::Runtime::new() + .expect("issuance runtime starts") + .block_on(async { + let signer = EvidenceSigner::initialize(provider, VERIFY_KEY_ID) + .await + .expect("signer initializes"); + let input = issuance_input(&evidence, None).expect("the fixture maps"); + let credential = signer + .sign_sd_jwt_vc(input) + .await + .expect("credential serializes"); + let trusted = jwks_document(signer.public_jwk(), []).expect("JWKS builds"); + (credential, trusted) + }); + + fs::write(root.path().join("response.sd-jwt"), mutate(credential)) + .expect("stage the stored credential"); + fs::write( + root.path().join("trusted.jwks.json"), + serde_json::to_vec(&trusted).expect("JWKS serializes"), + ) + .expect("stage the pinned key set"); + fs::write(root.path().join("policy.yaml"), policy).expect("stage the policy"); + Self { root } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + fn verify(&self, at: Option<&str>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidence")); + command + .arg("verify") + .arg("--sd-jwt-vc") + .arg(self.path("response.sd-jwt")) + .arg("--jwks") + .arg(self.path("trusted.jwks.json")) + .arg("--policy") + .arg(self.path("policy.yaml")) + .env_remove("REGISTRY_EVIDENCE_RUNTIME"); + if let Some(at) = at { + command.arg("--at").arg(at); + } + command.output().expect("evidence binary starts") + } +} + fn stop(service: &mut Child) { let pid = rustix::process::Pid::from_raw( i32::try_from(service.id()).expect("child identifier is a pid"), diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index 5f90638c0..dda68fd34 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -48,6 +48,35 @@ struct TestReference { name: String, } +/// The SD-JWT VC profile. Only the members this checker binds to code and to +/// the traceability index are modelled; the rest of the frozen profile is +/// narrative owned by review. +#[derive(Deserialize)] +struct SdJwtVcProfile { + contract: String, + status: String, + response: ProfileResponse, + protected_header: ProfileProtectedHeader, + negative_tests: Vec, +} + +#[derive(Deserialize)] +struct ProfileResponse { + media_type: String, +} + +#[derive(Deserialize)] +struct ProfileProtectedHeader { + typ: ProfileConst, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileConst { + #[serde(rename = "const")] + constant: String, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct AcceptanceTraceability { @@ -164,6 +193,55 @@ fn every_named_security_negative_is_bound_to_an_executable_test() { assert_eq!(mapped, required, "security negative-test mapping drifted"); } +/// The SD-JWT VC profile is a frozen response-format contract, so every +/// negative it names must resolve in the same security traceability index the +/// checker above proves executable. The profile therefore cannot claim a +/// guarantee no test enforces, and the response format cannot drift away from +/// the media type and JWT type the runtime actually emits. +#[test] +fn every_sd_jwt_vc_profile_negative_is_bound_to_a_mapped_security_negative() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let profile: SdJwtVcProfile = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/sd-jwt-vc-profile.yaml")) + .expect("sd-jwt-vc profile reads"), + ) + .expect("sd-jwt-vc profile parses"); + assert_eq!(profile.contract, "registry.evidence.sd-jwt-vc-profile/v1"); + assert_eq!(profile.status, "frozen"); + assert_eq!( + profile.response.media_type, + registry_evidence::EVIDENCE_SD_JWT_VC_MEDIA_TYPE + ); + assert_eq!( + profile.protected_header.typ.constant, + registry_evidence::EVIDENCE_SD_JWT_VC_TYP + ); + + let traceability: Traceability = serde_norway::from_slice( + &fs::read(root.join("products/evidence/contracts/security-test-traceability.yaml")) + .expect("traceability reads"), + ) + .expect("traceability parses"); + let mapped = traceability + .entries + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + + assert!( + !profile.negative_tests.is_empty(), + "the profile names no negative test" + ); + let mut named = BTreeSet::new(); + for id in &profile.negative_tests { + assert!(named.insert(id.clone()), "the profile repeats {id}"); + assert!( + mapped.contains(id), + "profile negative {id} is not mapped to an executable test" + ); + } +} + /// Prove that one mapped reference still names a real Rust test item, so a /// renamed, moved, or deleted test fails the traceability checker. fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index 7e6789814..ca28b7a22 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -1179,6 +1179,7 @@ fn request(requirement: &str, subjects: Vec) -> EvidenceReques requirement: requirement.to_owned(), purpose: PURPOSE.to_owned(), subjects, + holder_key: None, } } diff --git a/products/evidence/AGENTS.md b/products/evidence/AGENTS.md index e5dc06fca..f92a4e77c 100644 --- a/products/evidence/AGENTS.md +++ b/products/evidence/AGENTS.md @@ -23,13 +23,14 @@ process, and one operator-controlled trust domain. It is independent from Registry Notary and must not depend on or copy abstractions from `registry-notary*`. It also does not depend on Registry Manifest, `registry-platform-pdp`, `registry-platform-oid4vci`, -`registry-platform-sdjwt`, `registry-platform-replay`, or -`registry-platform-sts`. +`registry-platform-replay`, or `registry-platform-sts`. Selected `registry-platform-*` primitives may be reused only when their existing contracts fit Evidence directly. The approved candidates are audit, crypto, -OIDC, HTTP security, and testing primitives. Shared-crate changes are separate -platform work and require the platform guidance and affected-consumer gates. +OIDC, HTTP security, testing, and the `registry-platform-sdjwt` serialization +primitive used solely by the SD-JWT VC response format. Shared-crate changes +are separate platform work and require the platform guidance and +affected-consumer gates. Production behavior must stay source-product and assertion-case neutral: diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index f96d12acf..073b7540f 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -121,14 +121,14 @@ Version one is not: - a workflow, orchestration, or case-management engine; - a general ETL, mapping, or query platform; - a runtime policy engine or general PDP; -- a verifiable credential, OID4VCI, SD-JWT VC, holder-proof, or credential-status service; +- a credential issuance, OID4VCI, holder-proof, credential-status, or wallet service; the SD-JWT VC *response format* of section 15.6 is a serialization of the same assertion and adds none of those capabilities; - a multi-tenant SaaS control plane; - a federation or delegated-evaluation protocol; - an AI agent runtime, MCP server, or agent discovery service; - an OOTS Evidence Broker, Data Service Directory, Semantic Repository, Preview Space, or AS4 Access Point; - a replacement for source-system access control. -Document evidence, holder credentials, transaction-bound replay protection, OOTS execution, public or federated catalogs, multi-source fulfillment, source-planning scripts, and the delegated-agent grant profile of section 15.3 are explicitly deferred. Deferring that profile does not defer the optional delegated actor identity of section 8.1: version one carries an actor in the authenticated authority context and authorizes it there, but consumes no agent grant record and exposes no agent-facing operations. The closed requester-scoped definition response is not a catalog or authorization source. +Document evidence, multi-verifier holder credentials, credential status and revocation, transaction-bound replay protection, OOTS execution, public or federated catalogs, multi-source fulfillment, source-planning scripts, and the delegated-agent grant profile of section 15.3 are explicitly deferred. Deferring that profile does not defer the optional delegated actor identity of section 8.1: version one carries an actor in the authenticated authority context and authorizes it there, but consumes no agent grant record and exposes no agent-facing operations. The closed requester-scoped definition response is not a catalog or authorization source. ## 5. Design principles @@ -1185,6 +1185,96 @@ headers, credentials, retries, pagination traversal, response-led requests, multi-call orchestration, and a richer policy language remain separate proposals. None is added merely as an extension seam. +### 15.6 Audience-scoped SD-JWT VC response format + +This profile adds one additional response format for the assertion Version one +already produces. It does not add a credential product, a credential lifecycle, +or an issuance protocol. + +The distinction the profile rests on: SD-JWT VC is a *serialization*, while +OID4VCI is a *delivery protocol*. The serialization is a pure function of an +already-constructed assertion. The delivery protocol requires credential +offers, pre-authorized codes, issuer-held nonces, deferred issuance, and the +persistent state to hold them. Version one's stateless single-process property +is load-bearing for its security argument, so this profile takes the +serialization and refuses the protocol. + +#### What the profile adds + +A third member of the closed response-format vocabulary, selected by the exact +`application/dc+sd-jwt` media type, permitted only when the immutable bundle +and the one complete matched authority grant both allow it. Format selection +creates no permission. Everything before serialization is unchanged: the same +authorization decision, the same fixed source execution, the same bounded +derivation, the same output validation, the same audience-scoped subject +binding, the same durable access and disclosure-release audit ordering. + +The assertion is emitted as an IETF SD-JWT VC: an EdDSA-signed JWT carrying +`_sd` digests, followed by the salted disclosures. The signing key, key +identifier, JWKS publication, and rotation rules are exactly those of the +signed-JWS format. No second key and no second key ceremony are introduced. + +An optional caller-supplied holder public key becomes the `cnf` claim, so the +assertion can be presented later with key binding. Evidence issues; it does not +receive, validate, or reason about presentations. Key-binding JWT validation is +the relying party's responsibility. + +#### The trusted third party + +A third party triggering issuance is not a new trust model. It is the +authenticated authority context of section 8.1 with a grant reference under +section 8.4, which already admits statutory, organizational, consent, and +delegated bases. The triggering party authenticates as itself, its grant names +the requirement, purpose, audience, and subject authority, and the holder key +travels in the request. Evidence still makes exactly one authorization +decision and still does not issue, manage, revoke, or infer authority. + +#### The subject stays audience-scoped + +`sub` is the existing audience-scoped subject binding of section 8.3. The +credential is therefore meaningful to the relying party named in `audience` and +to no other. This is a deliberate limit, not an omission: a holder-scoped +subject identifier would create a correlatable identifier that survives across +verifiers, which is the property section 13 exists to prevent. A multi-verifier +holder credential is a separate profile with its own privacy analysis, not an +increment on this one. + +The consequence must be stated plainly in adopter-facing material. This profile +produces a standards-conformant SD-JWT VC that a wallet can parse, hold, and +present. It does not produce a credential that is meaningful to an arbitrary +verifier. + +#### Profile non-goals + +None of the following is added, stubbed, flagged, or left as a seam: + +- OID4VCI in any part: credential offers, pre-authorized codes, authorization + or token endpoints, `c_nonce`, proof-of-possession challenges, credential + endpoints, or deferred issuance; +- persistent issuance state, an application database, or any store beyond the + existing stateless request-nonce echo; +- status lists, revocation, suspension, or a credential-status endpoint; + freshness remains expiry through `validUntil`; +- presentation-side verification or key-binding JWT validation. The relying + party verifier this product already ships is extended to the second format, + and it checks exactly what it checks for the signed JWS: issuer authenticity + against a pinned key set, and the output contract. It never evaluates a + presentation, a key-binding JWT, or a holder's possession of the confirmed + key; +- wallet onboarding, wallet attestation, or trust-list membership; +- a second signing key, algorithm, or key ceremony; +- holder-scoped or otherwise cross-verifier subject identifiers; +- reissuance, refresh, batch issuance, or credential identifiers that persist + beyond the response. + +#### Claims that remain out of the credential + +Selector profiles, selector values, source identity, source responses, adapter +identity, grant identifiers, and requester identity never appear in the +credential, in a disclosure, or in credential-visible metadata. The disclosure +set is exactly the assertion's supported values. Everything the payload of the +signed-JWS format withholds, this format withholds identically. + ## 16. Initial assertion cases ### Adult status diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 46e9a957f..76725b20c 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -96,10 +96,11 @@ Reuse selected shared primitives without inheriting another product model: | `registry-platform-oidc` | Strict access-token and JWKS verification for the reference authentication profile | | `registry-platform-httpsec` | Security response headers where the existing contract fits | | `registry-platform-httputil` | Bounded source-response body reads | +| `registry-platform-sdjwt` | Compact SD-JWT VC serialization for the SD-JWT VC response format | Evidence must not depend on `registry-notary*`, `registry-platform-pdp`, -`registry-platform-oid4vci`, `registry-platform-sdjwt`, -`registry-platform-replay`, `registry-platform-sts`, or Registry Manifest. +`registry-platform-oid4vci`, `registry-platform-replay`, +`registry-platform-sts`, or Registry Manifest. The governed bundle and closed operator runtime file are trusted and startup-only. Use typed YAML and explicit secret references. Runtime @@ -638,7 +639,7 @@ follow-up issue. | Verification evidence | Focused invariant tests, all package tests, contract drift checks, dependency policy, formatting, package and workspace check, Clippy with warnings denied, and workspace tests pass. Security-sensitive behavior has a named threat, enforcement point, and negative test. | | Local compatibility smoke | After deterministic mocks pass, the read-only DHIS2 and OpenCRVS smoke tests are attempted when local credentials and approved demo selectors are available. Unavailability may be recorded as inconclusive; authenticated schema drift or excess disclosure is investigated and cannot be ignored. No credential or live-data artifact enters the repository or test output. | | Operability | An adopter can author, test, deploy, and maintain a source integration from the configuration, adapter API, fixture contract, and complete DHIS2/OpenCRVS-shaped projects without editing Rust. An operator can independently bind the immutable governed bundle to listener, secret, audit, and private-CA paths for each environment without overriding evidence semantics, configure authentication, authority mappings, source bindings, signing rollover, rate limits, and verifier trust using documented supported paths, and let an authenticated consumer discover the exact revision-bound request shapes it may invoke. Static onboarding still owns token acquisition, human and legal descriptions, endpoint trust, and verifier policy. | -| Stop boundary | No capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed. This includes document evidence, holder credentials, VC, OID4VCI, SD-JWT, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, agents or MCP, federation, workflow, public or federated catalogs, runtime bundle mutation, script-selected transport or multi-call source planning, multi-source fulfillment, a general policy engine, application database, message broker, or worker process. | +| Stop boundary | No capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed. This includes document evidence, credential lifecycle, OID4VCI, status lists, presentation verification, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, agents or MCP, federation, workflow, public or federated catalogs, runtime bundle mutation, script-selected transport or multi-call source planning, multi-source fulfillment, a general policy engine, application database, message broker, or worker process. | ## Required Version 1 acceptance tests @@ -883,7 +884,11 @@ extension APIs for them: requirement; - evidence or raw-source persistence; - document retrieval or multipart responses; -- VC, OID4VCI, SD-JWT, holder binding, status lists, or wallets; +- OID4VCI, credential lifecycle, status lists, revocation, presentation or + key-binding verification, wallet onboarding, or holder-scoped subject + identifiers. The SD-JWT VC response format is a serialization of the same + stateless assertion under `contracts/sd-jwt-vc-profile.yaml`, and the optional + holder key is embedded without ever being validated as possession; - nonce or replay storage beyond stateless request-nonce echo and comparison; - OOTS XML, AS4, Evidence Broker, or DSD runtime code; - federation, agents, MCP, workflow, or public, cross-requester, searchable, diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 273613b20..ab53c6c28 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -191,6 +191,69 @@ starts, Evidence lets it finish under the separately bounded OIDC and source operations so cancellation cannot bypass required audit or signed-response release ordering. +## Response formats + +Evidence releases one stateless assertion. `responseFormats` decides which +serializations may carry it, and the closed values are `signed-jws`, +`unsigned-json`, and `sd-jwt-vc`. Both the immutable bundle and every authority +grant declare the list, both default to `[signed-jws]` alone, and both must +keep `signed-jws` enabled. Startup rejects a duplicate or unknown value and +rejects any list that drops the signed default. + +The two lists are intersected and never unioned. A format is releasable only +where the bundle and the one complete matched grant both name it, so enabling a +format bundle-wide grants nothing by itself, and a grant cannot widen beyond the +bundle. Requesting a format outside the intersection is refused with the +ordinary `not_authorized` problem before credential acquisition and source +access, and the refusal does not reveal which layer withheld it. An `Accept` +that names no known format at all, or that is duplicated, combined, +parameterized, or weighted, returns `response_format_not_acceptable` with HTTP +406, also before source access. + +```yaml +# the immutable bundle: the ceiling +responseFormats: [signed-jws, sd-jwt-vc] + +# the grant: the actual authority, never wider than the bundle +- requirement: urn:example:requirement:adult-status:v1 + purpose: eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws, sd-jwt-vc] +``` + +The requester selects among enabled formats with an exact `Accept`: +`application/jose+json` (or a missing `Accept`, or `*/*`) for the signed +default, `application/vnd.registrystack.evidence-unsigned+json` for the visibly +unsigned envelope, `application/dc+sd-jwt` for the SD-JWT VC. Selection never +changes evaluation, disclosure, or audit obligations. Each release records its +own `responseProtection` in the disclosure-release audit event, with the closed +values `signed`, `unsigned`, and `sd-jwt-vc`; `signingKeyId` is present for the +two cryptographically protected modes and forbidden for unsigned output. + +Enabling `sd-jwt-vc` adds a serialization, not a credential lifecycle. There is +no issuance session, holder binding ceremony, status list, revocation, or +presentation verification, and `/.well-known/jwt-vc-issuer` publishes no +per-requester or per-requirement information. The `vct` claim is the +requirement's declared `isConformantTo` identifier, so the credential type is a +governed bundle decision rather than a client choice. The subject identifier +stays the audience-scoped pseudonym, so the same person requested for a +different audience yields a different identifier and the credential is not a +general-purpose multi-verifier credential. + +A request may carry an optional `holderKey`, which is echoed into the `cnf` +claim and is meaningful only for the SD-JWT VC format. Only a public OKP +Ed25519 JWK is accepted; an unacceptable key is rejected as a malformed request +alongside the nonce check, before authentication, credential acquisition, and +source access. The key never reaches authorization, selectors, Rhai, sources, +audit, or the signed-JWS payload. Evidence issues no key-binding JWT, requires +none, and verifies none, so `cnf` is an unverified caller-supplied +convenience for whatever presentation layer the operator runs elsewhere. + +Signing failure remains fail-closed for every protected format. A deployment +that cannot sign returns a safe transient failure and never downgrades an +SD-JWT VC request to unsigned output or to the signed default. +[The SD-JWT VC demo](SD-JWT-VC-DEMO.md) exercises this whole path locally. + ## Secrets and keys Source credentials and private signing material are supplied only through the @@ -463,6 +526,7 @@ GET /health GET /openapi.json GET /ready GET /.well-known/evidence/jwks.json +GET /.well-known/jwt-vc-issuer ``` `GET /openapi.json` publishes the generated public contract as @@ -476,8 +540,16 @@ Bearer authentication profile and per-principal request budget as evidence creation. A successful `POST /v1/evidence` response uses `application/jose+json` and the -flattened JWS JSON Serialization. No public or cross-requester catalog is -supported. +flattened JWS JSON Serialization unless the requester selected another enabled +format under [response formats](#response-formats). No public or +cross-requester catalog is supported. + +`GET /.well-known/jwt-vc-issuer` is unauthenticated discovery for the SD-JWT VC +format. It publishes the configured provider identity and the same public key +set as `/.well-known/evidence/jwks.json`, and nothing else. It is served +whether or not any grant enables the credential format, it never reveals which +requesters or requirements do, and it is discovery rather than a trust anchor +on exactly the terms in [secrets and keys](#secrets-and-keys). No-match and ambiguous outcomes are publicly indistinguishable by default. Source, signing, and dependency failures use stable safe problem codes and do not reflect protected inputs. Signing failure returns a safe transient failure. diff --git a/products/evidence/README.md b/products/evidence/README.md index 9af47bc4e..535107d8a 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -29,6 +29,9 @@ The following contracts define and verify the implemented Version 1 boundary: - [Operator contract](OPERATOR-CONTRACT.md): supported deployment shape, requester authority and purpose duties, required configuration and secrets, readiness, audit, key, and verification obligations. +- [SD-JWT VC demo](SD-JWT-VC-DEMO.md): one deterministic local run that issues + the same assertion in both later-verifiable formats and re-verifies the + credential offline with `curl` and the `evidence` binary. - [Trusted request-adapter reference](reference/request-adapter/ADAPTER-API.md): complete Rhai API, configuration and fixture contracts, and deployable DHIS2 and OpenCRVS-shaped reference projects. @@ -54,7 +57,10 @@ the same offline and production path on one revision before Version 1 can be called implemented. None may become a Rust domain type, built-in operation, special route, or preferred implementation phase. -Version 1 does not include documents, holder credentials, OID4VCI, SD-JWT, +Version 1 serializes the same assertion as an SD-JWT VC when the bundle and the +matched grant both permit that response format, under the frozen profile in +`contracts/sd-jwt-vc-profile.yaml`. It does not include documents, credential +lifecycle, status lists, OID4VCI, presentation verification, nonce or replay storage beyond stateless request-nonce echo and comparison, server-issued challenges, OOTS execution, federation, delegated agents, MCP, workflow, public or federated catalogs, runtime policy, runtime bundle mutation, @@ -108,19 +114,32 @@ authorization, rate limits, scripts, source requests, logs, metrics, traces, or audit. Callers must not encode identifiers, selectors, secrets, or document digests in it. -Signed flattened JWS is the default and the only later-verifiable format. A -missing `Accept`, `*/*`, or the exact `application/jose+json` all select it. The -exact `application/vnd.registrystack.evidence-unsigned+json` selects a visibly -unsigned envelope, and only when both the immutable bundle and the one complete -matched grant permit that format; otherwise the request is refused with the -ordinary `not_authorized` problem (HTTP 403) before credentials or source -access, without revealing which layer refused. Every authorization refusal -shares this one generic 403, so it is never an oracle for which check failed. A -duplicate, combined, parameterized, weighted, -or unknown `Accept` returns the `response_format_not_acceptable` problem with -HTTP 406 before source access. Unsigned output is transport-authenticated -convenience data for development and for consumers that cannot process JWS. It -is never later-verifiable evidence and never a fallback when signing fails. +Signed flattened JWS is the default format. A missing `Accept`, `*/*`, or the +exact `application/jose+json` all select it. The exact +`application/vnd.registrystack.evidence-unsigned+json` selects a visibly +unsigned envelope, and the exact `application/dc+sd-jwt` selects the same +assertion serialized as an SD-JWT VC. Every format other than the default is +released only when both the immutable bundle and the one complete matched grant +permit it; otherwise the request is refused with the ordinary `not_authorized` +problem (HTTP 403) before credentials or source access, without revealing which +layer refused. Every authorization refusal shares this one generic 403, so it is +never an oracle for which check failed. A duplicate, combined, parameterized, +weighted, or unknown `Accept` returns the `response_format_not_acceptable` +problem with HTTP 406 before source access. Unsigned output is +transport-authenticated convenience data for development and for consumers that +cannot process JWS. It is never later-verifiable evidence and never a fallback +when signing fails. + +The SD-JWT VC format is a second encoding of the one stateless assertion the +signed default carries, under the frozen profile in +[the SD-JWT VC profile](contracts/sd-jwt-vc-profile.yaml). It is not a +credential lifecycle: no issuance session, no holder binding ceremony, no +status list, no revocation, and no presentation or key-binding verification. The +shipped verifier checks an SD-JWT VC for exactly what it checks for a signed +JWS, namely issuer authenticity against a pinned key set and the output +contract, and it never falls back to the credential format when signing fails. +[The SD-JWT VC demo](SD-JWT-VC-DEMO.md) issues one assertion in both formats and +re-verifies the credential offline with `curl` and the `evidence` binary. ## Current verification diff --git a/products/evidence/SD-JWT-VC-DEMO.md b/products/evidence/SD-JWT-VC-DEMO.md new file mode 100644 index 000000000..f0e41d9a0 --- /dev/null +++ b/products/evidence/SD-JWT-VC-DEMO.md @@ -0,0 +1,257 @@ +# Evidence SD-JWT VC demo + +Status: deterministic local operator demo + +This demo issues one Evidence assertion in both governed later-verifiable +formats, then re-verifies the credential offline. It is deterministic and +credential-free: a local mock stands in for the upstream source and an +in-memory test JWKS authenticates the requester. No DHIS2, OpenCRVS, or other +live provider is contacted, and nothing is written outside +`products/evidence/.sd-jwt-vc-demo/`, which is gitignored and owner-only. + +What it proves: the same authorization, minimization, and audit path releases +the same assertion as a signed flattened JWS and as an SD-JWT VC; the credential +carries one disclosure per supported value and nothing else; and a relying party +who kept the original request can verify the stored credential later with no +network and no running server. + +What it does not prove: wallet interoperability, presentation, key-binding, or +any live provider deployment. Those are outside the Version 1 boundary in +[CONCEPT.md](CONCEPT.md) and the frozen profile in +[contracts/sd-jwt-vc-profile.yaml](contracts/sd-jwt-vc-profile.yaml). + +## Run it + +One command, from the repository root: + +```bash +products/evidence/scripts/sd-jwt-vc-demo.sh +``` + +It needs `cargo`, `curl`, and `jq`. The first run compiles the crate, so allow a +few minutes; later runs take seconds. The script is idempotent: rerunning it +replaces the previous artifacts. + +The script starts the demo server, performs every request with plain `curl`, +waits for the server's own checks, and finishes with the shipped offline +verifier. Its six steps are: + +1. fetch the issuer identity and key set from `/.well-known/jwt-vc-issuer`; +2. request the signed default with `Accept: application/jose+json`; +3. request the same assertion with `Accept: application/dc+sd-jwt`; +4. decode the credential's protected header and disclosures; +5. re-verify the stored credential offline with `evidence verify --sd-jwt-vc`; +6. edit one disclosure and re-verify, which must fail. + +Expected output, abbreviated: + +```text +1. Fetch the issuer keys from the published metadata route (no token) + HTTP 200 application/json + issuer: urn:example:fixture:provider:evidence +2. Request the signed default (Accept: application/jose+json) + HTTP 200 application/jose+json +3. Request the same assertion as an SD-JWT VC (Accept: application/dc+sd-jwt) + HTTP 200 application/dc+sd-jwt + +PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, ... + +4. The credential: an issuer-signed JWT, one disclosure per supported value, + and a trailing tilde where a key-binding JWT would go + 1 disclosure(s), no key-binding JWT + protected header: {"alg":"EdDSA","kid":"acceptance-evidence-key","typ":"dc+sd-jwt"} + disclosures (salt, claim name, claim value): + ["7MNkDxEPeSWvyGbI2ziaRw","urn:example:fixture:concept:adult-status",true] + +5. Re-verify the stored credential offline, no network and no server +verified-at: 2026-08-02T13:49:03Z +authentic: yes +currently-valid: yes +{ ... the verified Evidence payload ... } + +6. Tamper with one disclosure and re-verify: selective disclosure is not + an invitation to edit the claim after issuance + rejected: evidence: stored response verification failed (disclosure) +``` + +Anything else is not a pass. The `PASS:` line comes from the server harness +itself, which independently verifies the credential against the signed +transaction, checks that protected source and selector material is absent, and +checks that both releases wrote durable audit events recording their own +response protection mode. + +## What the demo bundle enables + +The demo runs the acceptance bundle with one deliberate change: both the +immutable bundle and the one complete matched grant list `sd-jwt-vc` alongside +`signed-jws` and `unsigned-json`. Both gates are required and are never unioned. +Enabling the format in the bundle alone leaves the request refused with the +ordinary `not_authorized` problem, before any credential or source access, and +without revealing which layer refused. + +```yaml +# the immutable bundle +responseFormats: [signed-jws, unsigned-json, sd-jwt-vc] + +# and the one matched grant +- requirement: urn:example:fixture:requirement:adult-status:v1 + purpose: fixture-eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws, unsigned-json, sd-jwt-vc] +``` + +Production reference bundles declare `responseFormats: [signed-jws]`. The +credential format is an operator decision per deployment and per grant, not a +client choice. + +## Driving it by hand + +To watch the exchange yourself, run the server in terminal 1: + +```bash +CARGO_INCREMENTAL=0 \ +CARGO_PROFILE_DEV_DEBUG=0 \ +CARGO_PROFILE_TEST_DEBUG=0 \ +cargo test --locked -p registry-evidence \ + sd_jwt_vc_demo_serves_a_credential_for_curl \ + -- --ignored --nocapture +``` + +Wait for `Evidence SD-JWT VC demo server is ready`. It listens only on +`127.0.0.1:18081`. Then work in terminal 2, from the repository root. + +The server shuts down as soon as it has verified the credential, so fetch the +public metadata first. It needs no token: + +```bash +curl --fail-with-body --silent \ + --output products/evidence/.sd-jwt-vc-demo/issuer-metadata.json \ + http://127.0.0.1:18081/.well-known/jwt-vc-issuer + +jq '{keys: .jwks.keys}' \ + products/evidence/.sd-jwt-vc-demo/issuer-metadata.json \ + >products/evidence/.sd-jwt-vc-demo/trusted.jwks.json +``` + +Then load the short-lived synthetic bearer token and request the signed default +before the credential: a relying party's expectations come from the transaction +it accepted, never from the credential it is about to check. Both requests pass +the token to `curl` through standard input, so it never reaches a command line, +the process table, or your shell history. + +```bash +set -a +. products/evidence/.sd-jwt-vc-demo/session.env +set +a + +for accept in application/jose+json application/dc+sd-jwt; do + case "$accept" in + application/jose+json) output=response.jws.json ;; + *) output=credential.txt ;; + esac + curl --config - <~~ +``` + +The protected header carries exactly `alg`, `kid`, and `typ: dc+sd-jwt`. The +payload carries the Evidence assertion's own fields plus `_sd` (the sorted, +deduplicated disclosure digests), `_sd_alg: sha-256`, and `vct`. Each disclosure +is the base64url encoding of `[salt, claim name, claim value]`, and its digest +is the base64url encoding of the SHA-256 of those encoded ASCII bytes. + +Selector profiles, selector values, source identity, source responses, adapter +identity, grant identifiers, and requester identity never appear in the +credential, in a disclosure, or in credential-visible metadata. The disclosure +set is exactly the assertion's supported values. + +The subject identifier is an audience-scoped pseudonym +(`urn:evidence:subject:v1_...`). The same person requested for a different +audience yields a different identifier by design, so the credential is not a +general-purpose multi-verifier credential and must not be marketed as one. + +## Verifying it later + +Offline re-verification needs three files and no network: + +```bash +cargo run --locked -p registry-evidence -- verify \ + --sd-jwt-vc products/evidence/.sd-jwt-vc-demo/credential.txt \ + --jwks products/evidence/.sd-jwt-vc-demo/trusted.jwks.json \ + --policy products/evidence/.sd-jwt-vc-demo/verification-policy.yaml +``` + +The format is named by the operator. The command never infers a format from a +file's contents, so a stored response is never re-verified under the other +format's rules. Naming both formats, or neither, is a usage error. + +The policy document is the demo's stand-in for state a relying party retains +independently: the nonce from the request it sent, and the expectations from the +transaction it accepted. Copying values out of the credential under verification +and passing them back as expectations proves nothing. The demo writes it after +verifying the signed default: + +```yaml +audience: https://relying.invalid/procedure +clockSkewSeconds: 30 +configurationRevision: sha256:bcfc829bb1... +evidenceType: urn:example:fixture:evidence-type:adult-status:v1 +expectedOutputs: + - concept: urn:example:fixture:concept:adult-status + form: boolean +expectedSubjects: + - binding: urn:evidence:subject:v1_3QKF0SHXxkQ9... + role: subject +issuedBy: urn:example:fixture:issuer:authority +maximumAssertionLifetimeSeconds: 172800 +providedBy: urn:example:fixture:provider:evidence +purpose: fixture-eligibility +requestNonce: AZ_CwmSORo8gHKmIY1sSLQAAAAAAAAAAAAAAAAAAAAA +requirement: urn:example:fixture:requirement:adult-status:v1 +``` + +One policy document governs both formats, because the same Evidence payload is +verified whichever serialization carried it. The full contract is in +[contracts/verification-policy.schema.yaml](contracts/verification-policy.schema.yaml). + +Exit codes: `0` authentic and currently valid, `3` authentic but no longer +current, `1` everything else. Failures report only a closed class, never a field +value, which is why step 6 prints `(disclosure)` and not the claim it rejected. + +## Related material + +- [FIRST-CURL-TEST.md](FIRST-CURL-TEST.md): the signed and unsigned formats over + the same deterministic path. +- [contracts/sd-jwt-vc-profile.yaml](contracts/sd-jwt-vc-profile.yaml): the + frozen profile, its verifier rules, and its named security negatives. +- [fixtures/conformance/sd-jwt-vc-cases.yaml](fixtures/conformance/sd-jwt-vc-cases.yaml): + the golden wire fixture, reproduced by the production issuance path in tests. +- [SOURCE-TESTING.md](SOURCE-TESTING.md): the live DHIS2 and OpenCRVS demo + checks, which are separate, opt-in, ignored, and read-only. diff --git a/products/evidence/SOURCE-TESTING.md b/products/evidence/SOURCE-TESTING.md index fa7da7fa3..4b29a03ee 100644 --- a/products/evidence/SOURCE-TESTING.md +++ b/products/evidence/SOURCE-TESTING.md @@ -140,7 +140,11 @@ only in test bundles and do not create production domain types. ## Local public-demo smoke tests For the operator-facing first checkpoint, expected outputs, and the explicit -post-checkpoint gap list, see [`FIRST-CURL-TEST.md`](FIRST-CURL-TEST.md). +post-checkpoint gap list, see [`FIRST-CURL-TEST.md`](FIRST-CURL-TEST.md). For +the same deterministic path exercised through the SD-JWT VC response format and +its offline verifier, see [`SD-JWT-VC-DEMO.md`](SD-JWT-VC-DEMO.md). Both are +mock-backed and credential-free, so neither is a live test and neither depends +on the ordering below. Live tests are implemented in a separate ignored integration-test target. The required order is: diff --git a/products/evidence/contracts/README.md b/products/evidence/contracts/README.md index 9a17276a0..f52053b40 100644 --- a/products/evidence/contracts/README.md +++ b/products/evidence/contracts/README.md @@ -17,6 +17,10 @@ The normative source set is: `jws-profile.yaml`: public discovery, request, the required `requestNonce` and its echo in the Evidence payload, response-format negotiation, payload, signing, rotation, and strict verifier rules; +- `sd-jwt-vc-profile.yaml`: the audience-scoped SD-JWT VC response format, its + exact claim and disclosure mapping, the optional `cnf` holder key, the + issuer-metadata path, and its explicit profile non-goals. It adds a + serialization of the same assertion and no credential lifecycle; - `verification-policy.schema.yaml`: the closed all-required relying-procedure policy document consumed by the offline `evidence verify` command, its frozen command surface, exit codes, and no-network rule; @@ -75,10 +79,11 @@ Evidence vocabulary. authorized selectors. It cannot perform I/O. 5. Signed flattened JWS over the exact UTF-8 payload bytes is mandatory and the default result. The exact unsigned media type selects the separately typed - unsigned envelope only when the immutable bundle and the one complete - matched grant both permit it. No signed-path failure falls back to unsigned - output, and the final immutable bytes exist before the disclosure-release - audit that gates them. + unsigned envelope, and the exact `application/dc+sd-jwt` media type selects + the audience-scoped SD-JWT VC serialization, each only when the immutable + bundle and the one complete matched grant both permit it. No failure on any + format falls back to another format, and the final immutable bytes exist + before the disclosure-release audit that gates them. 6. The complete enabled bundle is one disclosure surface. Individually safe definitions may still be rejected when their combination reconstructs a protected value. @@ -87,10 +92,13 @@ Evidence vocabulary. file secrets, and logical private CAs and cannot override governed semantics or source authority. -Version 1 stops before documents, credentials, replay or nonce state beyond the -stateless request-nonce echo and comparison, server-issued challenges, OOTS, -delegated agents, federation, workflow, public or federated catalogs, runtime -bundle mutation, source planning, multi-source fulfillment, and a general -policy engine. Authenticated definition discovery is a closed projection of +Version 1 stops before documents, credential issuance protocols and credential +lifecycle, replay or nonce state beyond the stateless request-nonce echo and +comparison, server-issued challenges, OOTS, delegated agents, federation, +workflow, public or federated catalogs, runtime bundle mutation, source +planning, multi-source fulfillment, and a general policy engine. The SD-JWT VC +response format is a serialization of the same assertion under +`sd-jwt-vc-profile.yaml`; it introduces no offer, code, nonce, status, or +persisted credential state. Authenticated definition discovery is a closed projection of existing authority, not an authorization source or catalog. No contract here reserves a field or hook for future profiles. diff --git a/products/evidence/contracts/audit-event.schema.yaml b/products/evidence/contracts/audit-event.schema.yaml index 7120adf56..442c49758 100644 --- a/products/evidence/contracts/audit-event.schema.yaml +++ b/products/evidence/contracts/audit-event.schema.yaml @@ -48,7 +48,7 @@ properties: role: {type: string, pattern: '^[a-z][a-z0-9._-]{0,63}$'} selectorProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} selectorBundlePseudonym: {$ref: '#/$defs/pseudonym'} - responseProtection: {enum: [signed, unsigned]} + responseProtection: {enum: [signed, unsigned, sd-jwt-vc]} sourceId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} adapterId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} decision: @@ -78,14 +78,14 @@ allOf: - if: properties: phase: {const: disclosure-release} - responseProtection: {const: signed} + responseProtection: {enum: [signed, sd-jwt-vc]} then: {required: [signingKeyId]} - if: {properties: {responseProtection: {const: unsigned}}} then: {not: {required: [signingKeyId]}} audit_rules: access_gate: access-attempt is durably accepted after authorization and before credential acquisition or source access release_gate: disclosure-release is durably accepted after the final immutable response bytes are serialized and before those exact bytes are released - response_protection: every event records the closed non-secret responseProtection mode resolved with authorization; signingKeyId exists exactly for signed disclosure release and is forbidden for unsigned output + response_protection: every event records the closed non-secret responseProtection mode resolved with authorization; signingKeyId exists exactly for cryptographically protected disclosure release, which is the signed flattened JWS and the SD-JWT VC serialization, and is forbidden for unsigned output failure_policy: sink failure blocks the applicable step chain_verification: The complete keyed chain is verified at startup and after restart; steady-state appends and readiness verify the pinned file identity, modification fingerprint, expected length, and verified tail without rescanning the growing file. external_mutation: Any external replacement or modification fails readiness and future appends closed until a restart completes full keyed-chain verification. diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 5e7d9f5cd..baf1770fd 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -68,14 +68,15 @@ $defs: uri: {type: string, format: uri, maxLength: 512} response-formats: # Closed enabled response formats. Signed flattened JWS is mandatory and - # remains the default; the unsigned format must be enabled by the bundle - # and permitted by the complete matched grant. Omission means signed only. + # remains the default; the unsigned and SD-JWT VC formats must each be + # enabled by the bundle and permitted by the complete matched grant. + # Omission means signed only. type: array minItems: 1 - maxItems: 2 + maxItems: 3 uniqueItems: true contains: {const: signed-jws} - items: {enum: [signed-jws, unsigned-json]} + items: {enum: [signed-jws, unsigned-json, sd-jwt-vc]} secret-ref: {type: string, pattern: '^secret:file/[a-z][a-z0-9._-]{0,127}$'} relative-path: {type: string, pattern: '^(adapters|derivations|schemas|codelists|fixtures)/[A-Za-z0-9._/-]+$'} diff --git a/products/evidence/contracts/request.schema.yaml b/products/evidence/contracts/request.schema.yaml index fcd16af1e..85ada04f2 100644 --- a/products/evidence/contracts/request.schema.yaml +++ b/products/evidence/contracts/request.schema.yaml @@ -25,6 +25,12 @@ properties: exactly once. Runtime resolves roles by name and emits the requirement's declaration order internally. items: {$ref: '#/$defs/subject'} + holderKey: + $ref: '#/$defs/holder-key' + description: >- + Optional holder public key echoed into the SD-JWT VC cnf claim. It is + meaningful only for the SD-JWT VC response format and never reaches + authorization, selectors, Rhai, source requests, or audit. $defs: subject: type: object @@ -50,6 +56,18 @@ $defs: propertyNames: pattern: '^[a-z][a-z0-9._-]{0,63}$' additionalProperties: {$ref: '#/$defs/scalar-selector-value'} + holder-key: + type: object + additionalProperties: false + required: [kty, crv, x] + properties: + kty: {type: string, enum: [OKP]} + crv: {type: string, enum: [Ed25519]} + x: + type: string + pattern: '^[A-Za-z0-9_-]{43}$' + alg: {type: string, enum: [EdDSA]} + kid: {type: string, minLength: 1, maxLength: 256} scalar-selector-value: oneOf: - {type: string, minLength: 1, maxLength: 512} @@ -73,3 +91,6 @@ $comment: >- never uniqueness-checked, and never reaches authorization, rate limits, Rhai, source requests, logs, metrics, traces, or native audit. Callers must not encode identifiers, selectors, secrets, or document digests into it. + holderKey is an Ed25519 public JWK. A key carrying any private member, a + non-allowlisted algorithm, or an unparseable body fails before credential + acquisition or source access. diff --git a/products/evidence/contracts/sd-jwt-vc-profile.yaml b/products/evidence/contracts/sd-jwt-vc-profile.yaml new file mode 100644 index 000000000..2f91e6551 --- /dev/null +++ b/products/evidence/contracts/sd-jwt-vc-profile.yaml @@ -0,0 +1,159 @@ +contract: registry.evidence.sd-jwt-vc-profile/v1 +status: frozen +summary: >- + Audience-scoped SD-JWT VC serialization of the Version one assertion. This + profile adds a response format, never a credential lifecycle. Everything + before serialization is the unchanged Version one path: one authorization + decision, fixed source execution, bounded derivation, output validation, + audience-scoped subject binding, and the same durable access and + disclosure-release audit ordering. +response: + media_type: application/dc+sd-jwt + serialization: sd-jwt-vc-compact + structure: issuer-signed JWT, then one tilde-separated disclosure per supported value, then a trailing tilde + key_binding_jwt: never appended by the issuer; the trailing tilde marks an absent key-binding JWT + negotiation: Only the exact application/dc+sd-jwt media type selects this format, and only when the immutable bundle and the complete matched authority grant both permit it. Missing Accept and */* continue to select signed JWS. Duplicate, combined, parameterized, weighted, or unknown negotiation is not acceptable. Every response varies on Accept and remains no-store. + permission_rule: Format selection creates no permission. Denial does not reveal which layer withheld it. +protected_header: + exact_members: [alg, typ, kid] + alg: + allowed: [EdDSA] + rule: Must equal the algorithm bound to the trusted key. Identical to the signed-JWS profile. + typ: {const: dc+sd-jwt} + kid: + required: true + rule: Resolved only within the verifier-pinned provider JWKS. + prohibited: [jku, x5u, jwk, x5c, crit, b64] +claims: + always_disclosed: + iss: + source: service.providerId + rule: >- + The technical provider controlling the signing key, matching the + signed-JWS trust statement. The named legal issuer travels separately + as issuedBy. A valid signature proves the provider signed the exact + payload; governance must establish that the provider is authorized to + produce evidence for that legal issuer. + sub: + source: subjects[0].binding in requirement declaration order + rule: >- + Convenience projection of the first role-bound audience-scoped subject + binding. It is deterministic because the kernel canonicalizes subjects + to requirement declaration order. Verifiers must compare the complete + subjects set, never sub alone. + iat: {source: issuedAt, encoding: unix seconds} + exp: {source: validUntil, encoding: unix seconds, rule: exclusive upper bound, identical to the signed-JWS profile} + vct: {source: isConformantTo, rule: the Evidence Type is the credential type; no separate credential-type vocabulary is introduced} + jti: {source: the Evidence identifier, rule: also emitted as id; not a persisted credential identifier and not usable for status lookup} + _sd_alg: {const: sha-256} + issuedBy: {source: issuer.id} + providedBy: {source: service.providerId} + supportsRequirement: {source: supportsRequirement} + purpose: {source: purpose} + audience: {source: audience} + observedAt: {source: observedAt} + configurationRevision: {source: configurationRevision} + requestNonce: {source: requestNonce, rule: exact echo, never stored, never uniqueness-checked} + subjects: {source: subjects, rule: complete role and binding pairs, unordered set semantics on comparison} + selectively_disclosed: + rule: >- + Exactly one disclosure per supported value. The disclosure claim name is + the concept identifier from providesValueFor and the disclosure value is + the identical public value the signed-JWS payload would carry. The value + forms, schemas, codelists, precision, cardinality, sizes, structured + fields, and uniqueness are closed by the selected concept declaration + before evidence construction, exactly as in the signed-JWS profile. + digest_order: sorted lexicographically before insertion into _sd + salt: fresh per disclosure from a cryptographically secure random source + holder_binding: + cnf: + required: false + source: the optional holderKey member of the request + rule: >- + Present only when the caller supplies a holder public key. Evidence + embeds it and never validates a presentation. Key-binding JWT + verification is the relying party's responsibility. + constraints: + key_type: OKP Ed25519 public JWK + private_members_prohibited: [d, p, q, dp, dq, qi, k] + rule: A key carrying any private member, a non-allowlisted algorithm, or an unparseable body fails before credential acquisition or source access. + prohibited: + - status + - nbf + - aud + - any selector profile, selector field name, or selector value + - any source identity, source response fragment, or adapter identity + - any grant identifier, requester identity, or actor identity + - any credential-status, revocation, or reissuance reference +subject_scope: + binding: the existing audience-scoped subject binding + consequence: >- + The credential is meaningful to the relying party named in audience and to + no other. This is deliberate. A holder-scoped subject identifier would + create a correlatable identifier surviving across verifiers, which the + trust and privacy invariants exist to prevent. Adopter-facing material must + state this limit rather than implying a general multi-verifier credential. +signing: + active_keys: 1 + key_source: identical to the signed-JWS profile; no second key, algorithm, or key ceremony + order: + - core validates the complete derivation result + - core constructs the exact evidence payload including the exact request nonce + - core maps that payload to public claims and disclosures without re-deriving any value + - signing provider signs the issuer-signed JWT + - core serializes the final immutable response bytes + - disclosure-release audit is durably accepted + - the exact pre-audited bytes are released + failure: safe 503 with no fallback to any other response format +key_discovery: + jwks_path: /.well-known/evidence/jwks.json + issuer_metadata_path: /.well-known/jwt-vc-issuer + issuer_metadata_media_type: application/json + issuer_metadata_members: [issuer, jwks] + content: public keys only + trust_rule: >- + Discovery is not a trust anchor. Provider identity and key set remain + pinned through governed verifier configuration. JWT VC Issuer Metadata + resolution applies only when service.providerId is the HTTPS origin of the + deployment; a URN provider identity is valid for the assertion and simply + has no metadata resolution path. +profile_non_goals: + rule: None of the following is implemented, stubbed, feature-flagged, or left as an extension seam. + items: + - OID4VCI in any part, including credential offers, pre-authorized codes, authorization or token endpoints, c_nonce, proof-of-possession challenges, credential endpoints, and deferred issuance + - persistent issuance state, an application database, or any store beyond the stateless request-nonce echo + - status lists, revocation, suspension, or a credential-status endpoint + - presentation-side verification or key-binding JWT validation + - wallet onboarding, wallet attestation, or trust-list membership + - holder-scoped or otherwise cross-verifier subject identifiers + - reissuance, refresh, batch issuance, or persistent credential identifiers +verifier_rules: + - Split the compact serialization on tilde with strict rejection of an empty issuer-signed segment. + - Reject any protected member outside the exact allowlist and require the allowlisted algorithm. + - Resolve kid only in the pinned provider key set. + - Verify the issuer-signed JWT signature before parsing or acting on any claim or disclosure. + - Recompute every disclosure digest and require an exact match against _sd; reject unmatched disclosures and unmatched digests alike. + - Require the exact expected concept identifiers, value forms, and cardinalities after digest verification; missing, extra, duplicated, or wrongly formed output fails even under a valid signature. + - Require the exact expected role-bound subject bindings as an unordered set, compared only after signature verification, from independent trusted state. + - Require the exact request nonce from the independently retained original request. Nonce reuse is not replay prevention and is not rejected by the runtime. + - Treat exp as an exclusive upper bound and apply only the configured clock skew. + - Impose a maximum accepted assertion lifetime in addition to current validity. + - Report cryptographic authenticity separately from current validity. + - Do not infer source truth, legal-signature status, single-use semantics, or revocation status from a valid signature. + - Do not infer holder possession from cnf alone; possession is proven only by a key-binding JWT the relying party itself verifies. +relying_party_tooling: + library: registry_evidence::verifier::verify_sd_jwt_vc, which applies the rules above and then the identical output-contract and policy path the signed-JWS verifier uses + command: evidence verify --sd-jwt-vc --jwks --policy + scope: issuer authenticity and the output contract only. No presentation, key-binding JWT, or holder possession is evaluated, and no network call is made. +# Each identifier is a named security negative resolved through +# security-test-traceability.yaml to executable tests. The last three are +# shared with the signed-JWS profile because this profile changes the +# serialization, never the release, audit, or verifier expectation rules. +negative_tests: + - sec-sd-jwt-projection-integrity + - sec-sd-jwt-format-requires-bundle-and-grant + - sec-sd-jwt-holder-key-closed + - sec-sd-jwt-claims-closed + - sec-release-bytes-pre-audited + - sec-audit-response-protection-mode + - sec-verifier-independent-expectations diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 6ac12fafd..950e0550a 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -232,4 +232,20 @@ cross_cutting: threat: A configured fixed or API-key header name collides with an authorization, framing, routing, cookie, forwarding, proxy, or tracing header, including case variants and known infrastructure aliases. enforcement: One closed ASCII-case-insensitive deny set shared by startup configuration validation and source plan compilation, with prefix families denied before exact names and both checks running before any credential is resolved. negative_test: sec-reserved-header-aliases-closed + sd_jwt_vc_projection_integrity: + threat: The SD-JWT VC serialization carries different content from the signed assertion, or a relying party accepts a modified disclosure, digest, payload, or protected header. + enforcement: The credential is a projection of the identical constructed evidence payload; the verifier rebuilds that payload from the token, requires an exact digest-to-disclosure match in both directions, and then applies the same output-contract and policy checks as the signed JWS. Each serialization is rejected by the other format's verifier. + negative_test: sec-sd-jwt-projection-integrity + sd_jwt_vc_format_authorization: + threat: Requesting the credential media type grants a disclosure the requester is not authorized to receive. + enforcement: Format selection creates no permission. The immutable bundle and the one complete matched authority grant must each enable the format, and denial does not reveal which layer withheld it. + negative_test: sec-sd-jwt-format-requires-bundle-and-grant + sd_jwt_vc_holder_key_closed: + threat: A caller-supplied holder key smuggles private key material, a non-allowlisted algorithm, or an unparseable body into a signed credential. + enforcement: The optional holder key must be a public OKP Ed25519 JWK; any private member, other key type or curve, or malformed body fails before credential acquisition and source access, and the rejection never echoes the submitted key material. + negative_test: sec-sd-jwt-holder-key-closed + sd_jwt_vc_claims_closed: + threat: A claim outside the profile, such as a status reference, an audience restriction, or a smuggled selector value, reaches a relying party under a valid signature. + enforcement: The published claim set is closed to the issuer-owned and always-disclosed names plus the declared disclosures; the verifier rejects any other member instead of ignoring it. + negative_test: sec-sd-jwt-claims-closed fixture_index: ../fixtures/conformance/coverage-matrix.yaml diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 0d08ad824..3c8b60abf 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -161,11 +161,13 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_signed_response_release} - {file: crates/registry-evidence/src/runtime_tests.rs, name: disclosure_audit_failure_prevents_unsigned_response_release} - {file: crates/registry-evidence/src/runtime_tests.rs, name: signing_failure_returns_a_problem_and_never_an_unsigned_body} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_signing_failure_no_fallback_format} - id: sec-audit-response-protection-mode tests: - {file: crates/registry-evidence/src/audit.rs, name: frozen_audit_fixture_matches_native_event_shape_and_phase_rules} - {file: crates/registry-evidence/src/runtime_tests.rs, name: unsigned_envelope_is_exact_audited_and_never_a_signing_fallback} - {file: crates/registry-evidence/src/runtime_tests.rs, name: all_four_definitions_pass_the_explicitly_authorized_unsigned_path} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_format_not_permitted_by_grant} - id: sec-verifier-independent-expectations tests: - {file: crates/registry-evidence/src/verifier.rs, name: expected_nonce_must_match_and_reuse_is_not_replay_prevention} @@ -195,3 +197,28 @@ entries: tests: - {file: crates/registry-evidence/src/config.rs, name: path_templates_headers_and_projection_fail_closed} - {file: crates/registry-evidence/tests/source_contracts.rs, name: forbidden_header_collisions_and_invalid_projection_contracts_fail_at_compilation} + - id: sec-sd-jwt-projection-integrity + tests: + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_vc_round_trips_and_verifies_under_the_same_policy} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_disclosure_modification_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_added_disclosure_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_removed_digest_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_payload_modification_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_protected_header_modification_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_unknown_kid_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_rejected_by_flattened_jws_verifier} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_accepts_an_authentic_and_current_stored_sd_jwt_vc} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_rejects_a_stored_sd_jwt_vc_whose_disclosure_was_replaced} + - {file: crates/registry-evidence/tests/cli.rs, name: verify_requires_exactly_one_stored_response_format} + - id: sec-sd-jwt-format-requires-bundle-and-grant + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_format_not_permitted_by_bundle} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_format_not_permitted_by_grant} + - {file: crates/registry-evidence/src/config.rs, name: response_formats_are_closed_unique_and_keep_signed_mandatory} + - id: sec-sd-jwt-holder-key-closed + tests: + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_with_private_member_rejected} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: sd_jwt_holder_key_wrong_algorithm_rejected} + - {file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_vc_confirmation_is_accepted_and_carries_no_private_material} + - id: sec-sd-jwt-claims-closed + tests: [{file: crates/registry-evidence/src/verifier.rs, name: sd_jwt_prohibited_claim_rejected}] diff --git a/products/evidence/contracts/verification-policy.schema.yaml b/products/evidence/contracts/verification-policy.schema.yaml index 2fd69c266..88b59ca46 100644 --- a/products/evidence/contracts/verification-policy.schema.yaml +++ b/products/evidence/contracts/verification-policy.schema.yaml @@ -86,8 +86,11 @@ ownership: producer: the relying procedure that retained the original request and accepted the original transaction consumer: offline re-verification by the `evidence verify` operator command command: - surface: evidence verify --jws --jwks --policy [--at ] + surface: evidence verify (--jws | --sd-jwt-vc ) --jwks --policy [--at ] jws: one stored flattened JWS JSON response file + sd-jwt-vc: one stored SD-JWT VC response file, in the issued compact serialization + format_selection: exactly one stored response file is named, and naming it states its format; the command never infers a format from a file's contents, so a stored response is never re-verified under the other format's rules + policy_scope: one policy document governs both formats, because the same Evidence payload is verified whichever serialization carried it jwks: the pinned trusted key set, which is the complete trust set for the run policy: one document accepted by this schema at: strict RFC 3339 at zero offset; system time when omitted @@ -100,7 +103,8 @@ output: - 'currently-valid: yes or no, printed only for an authentic response' inspection: on full success the verified Evidence JSON is also printed, for the operator who already holds the stored response failure: only the closed class is reported, on standard error, with no field-level detail - classes: [malformed, protected-header, key, signature, payload, policy, time] + classes: [malformed, protected-header, key, signature, payload, policy, time, disclosure] + disclosure_class: reported only for a stored SD-JWT VC whose disclosures do not reconstruct the payload the signature covers exit_codes: 0: authentic and currently valid 3: authentic but not currently valid diff --git a/products/evidence/fixtures/conformance/coverage-matrix.yaml b/products/evidence/fixtures/conformance/coverage-matrix.yaml index dd3b49f5a..cd27a9a73 100644 --- a/products/evidence/fixtures/conformance/coverage-matrix.yaml +++ b/products/evidence/fixtures/conformance/coverage-matrix.yaml @@ -130,6 +130,7 @@ source_shapes: public_contracts: requests_and_payloads: golden/ jws: jws-cases.yaml + sd_jwt_vc: sd-jwt-vc-cases.yaml audit: audit-events.yaml problems: ../../contracts/problem-contract.yaml security_negative_tests: diff --git a/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml b/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml new file mode 100644 index 000000000..51484ebb3 --- /dev/null +++ b/products/evidence/fixtures/conformance/sd-jwt-vc-cases.yaml @@ -0,0 +1,47 @@ +fixture: registry.evidence.sd-jwt-vc-cases/v1 +profile: ../../contracts/sd-jwt-vc-profile.yaml +media_type: application/dc+sd-jwt +protected_header: + exact_json: '{"alg":"EdDSA","kid":"fixture-key-2026-01","typ":"dc+sd-jwt"}' + base64url_padding: prohibited + cty_member: prohibited +serialization: + structure: issuer-signed JWT, then one tilde-separated disclosure per supported value, then a trailing tilde + key_binding_jwt: never appended by the issuer; the trailing tilde marks its absence + claim_set: closed to the issuer-owned claims and the profile's always-disclosed claims; any other member fails verification +disclosures: + count: exactly one per supported value + name: the concept identifier from providesValueFor + value: the identical public value the signed-JWS payload carries + encoding: base64url without padding of the exact JSON array of salt, claim name, and claim value + digest: base64url without padding of the SHA-256 of the encoded disclosure ASCII bytes + digest_algorithm: sha-256 + digest_order: _sd is sorted lexicographically and carries no repeated digest + salt: fresh per disclosure from a cryptographically secure random source +cases: + - {id: adult-boolean, request: golden/adult-request.json, payload: golden/adult-evidence.json} + - {id: residence-controlled-code, request: golden/residence-request.json, payload: golden/residence-evidence.json} + - {id: licence-boolean-and-category, request: golden/licence-request.json, payload: golden/licence-evidence.json} + - {id: relationship-role-bound-boolean, request: golden/relationship-request.json, payload: golden/relationship-evidence.json} +issuance_procedure: + key_source: the one active signing key already used for the flattened JWS; no second key, algorithm, or key ceremony + payload: the projection of the exact constructed evidence payload; no value is re-derived, re-canonicalized, or reserialized during mapping + verification: resolve the key identifier in the harness-pinned public key set and verify the issuer-signed JWT before parsing any claim or disclosure +negative: + - mutate one disclosure value + - add an undeclared disclosure + - remove one signed digest + - mutate one payload byte + - mutate one protected-header byte + - unknown kid + - a claim outside the published claim set + - the format is not enabled by the immutable bundle + - the format is not permitted by the complete matched grant + - holder key carrying a private member + - holder key with a non-allowlisted key type, curve, or algorithm + - signing-provider failure + - the serialization is offered to the flattened JWS verifier +expected_failure: >- + no credential release; safe service_unavailable where failure is runtime; no + fallback to any other response format; format denial does not reveal whether + the bundle or the grant withheld it diff --git a/products/evidence/generated/evidence-request-v1.schema.json b/products/evidence/generated/evidence-request-v1.schema.json index 1d25a0c49..26e4aab8e 100644 --- a/products/evidence/generated/evidence-request-v1.schema.json +++ b/products/evidence/generated/evidence-request-v1.schema.json @@ -1,6 +1,44 @@ { - "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it.", + "$comment": "Named selector-profile validation follows this transport schema. The profile closes exact field names, scalar types, bounds, aggregate size, value origin, and source placements. Invalid selector material fails before credential acquisition or source access. requestNonce is the canonical unpadded base64url encoding of exactly 32 independently generated random bytes; a noncanonical final symbol is rejected by the runtime. Callers must not encode identifiers, selectors, secrets, or document digests into it. holderKey is meaningful only for the SD-JWT VC response format, where it is echoed into the cnf claim; it never reaches authorization, selectors, Rhai, source requests, or audit, and a key carrying any private member is rejected before credential acquisition or source access.", "$defs": { + "holder-key": { + "additionalProperties": false, + "properties": { + "alg": { + "enum": [ + "EdDSA" + ], + "type": "string" + }, + "crv": { + "enum": [ + "Ed25519" + ], + "type": "string" + }, + "kid": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "kty": { + "enum": [ + "OKP" + ], + "type": "string" + }, + "x": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "kty", + "crv", + "x" + ], + "type": "object" + }, "scalar-selector-value": { "oneOf": [ { @@ -65,6 +103,9 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { + "holderKey": { + "$ref": "#/$defs/holder-key" + }, "purpose": { "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" diff --git a/products/evidence/generated/registry-evidence.openapi.json b/products/evidence/generated/registry-evidence.openapi.json index d0bb9c101..2f62fe015 100644 --- a/products/evidence/generated/registry-evidence.openapi.json +++ b/products/evidence/generated/registry-evidence.openapi.json @@ -430,6 +430,9 @@ "EvidenceRequest": { "additionalProperties": false, "properties": { + "holderKey": { + "$ref": "#/components/schemas/HolderPublicKey" + }, "purpose": { "pattern": "^[a-z][a-z0-9._:-]{0,127}$", "type": "string" @@ -697,6 +700,44 @@ ], "type": "object" }, + "HolderPublicKey": { + "additionalProperties": false, + "properties": { + "alg": { + "enum": [ + "EdDSA" + ], + "type": "string" + }, + "crv": { + "enum": [ + "Ed25519" + ], + "type": "string" + }, + "kid": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "kty": { + "enum": [ + "OKP" + ], + "type": "string" + }, + "x": { + "pattern": "^[A-Za-z0-9_-]{43}$", + "type": "string" + } + }, + "required": [ + "kty", + "crv", + "x" + ], + "type": "object" + }, "JwksDocument": { "additionalProperties": false, "properties": { @@ -716,6 +757,23 @@ "title": "Evidence public JWKS Version 1", "type": "object" }, + "JwtVcIssuerMetadata": { + "additionalProperties": false, + "properties": { + "issuer": { + "maxLength": 512, + "type": "string" + }, + "jwks": { + "$ref": "#/components/schemas/JwksDocument" + } + }, + "required": [ + "issuer", + "jwks" + ], + "type": "object" + }, "Problem": { "additionalProperties": false, "oneOf": [ @@ -1098,6 +1156,11 @@ ], "type": "object" }, + "SdJwtVcCredential": { + "description": "Compact SD-JWT VC: the issuer-signed JWT, then one tilde-separated disclosure per supported value, then a trailing tilde marking an absent key-binding JWT. The issuer never appends a key-binding JWT.", + "pattern": "^[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+(~[A-Za-z0-9_-]+)*~$", + "type": "string" + }, "SelectorValue": { "oneOf": [ { @@ -1232,7 +1295,7 @@ "info": { "description": "Minimum-disclosure signed assertion service, Version 1.", "title": "Registry Evidence API", - "version": "0.16.2" + "version": "0.16.3" }, "openapi": "3.1.0", "paths": { @@ -1272,6 +1335,43 @@ "summary": "Publish the active and retained public verification keys" } }, + "/.well-known/jwt-vc-issuer": { + "get": { + "description": "Discovery is not a trust anchor. The document republishes the same public keys under the provider identity the assertion names, and resolution is meaningful only when that identity is the HTTPS origin of the deployment.", + "operationId": "getJwtVcIssuerMetadata", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JwtVcIssuerMetadata" + } + } + }, + "description": "Provider identity and public verification keys", + "headers": { + "Cache-Control": { + "description": "Evidence responses are never cacheable.", + "schema": { + "enum": [ + "no-store" + ], + "type": "string" + } + }, + "X-Request-Id": { + "description": "Server-minted operation identifier for this request. It is generated by Evidence, never taken from the caller, and is the identifier a caller quotes to an operator. Problem responses repeat it in the operation member.", + "schema": { + "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$", + "type": "string" + } + } + } + } + }, + "summary": "Publish JWT VC Issuer Metadata for the SD-JWT VC response format" + } + }, "/health": { "get": { "operationId": "getHealth", @@ -1506,7 +1606,7 @@ }, "/v1/evidence": { "post": { - "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", + "description": "Missing Accept, */*, and the exact application/jose+json media type select the default signed flattened JWS. Only the exact application/vnd.registrystack.evidence-unsigned+json media type selects the unsigned envelope, and only the exact application/dc+sd-jwt media type selects the SD-JWT VC serialization of the same assertion; each is released only when the immutable bundle and the complete matched authority grant permit it. Duplicate, combined, parameterized, weighted, or unknown negotiation returns 406 before source access.", "operationId": "createEvidence", "requestBody": { "content": { @@ -1521,6 +1621,11 @@ "responses": { "200": { "content": { + "application/dc+sd-jwt": { + "schema": { + "$ref": "#/components/schemas/SdJwtVcCredential" + } + }, "application/jose+json": { "schema": { "$ref": "#/components/schemas/FlattenedJws" @@ -1532,7 +1637,7 @@ } } }, - "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized self-identifying unsigned envelope", + "description": "Signed Evidence as flattened JWS JSON Serialization by default, or the explicitly authorized SD-JWT VC serialization or self-identifying unsigned envelope", "headers": { "Cache-Control": { "description": "Evidence responses are never cacheable.", diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 9579ae71b..d73923ab9 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -117,7 +117,7 @@ The governed file is `bundle/evidence.yaml`. | `subjectBinding` | File-only `secretRef` and positive `keyVersion`. The referenced file contains at least 32 raw secret bytes. Rust derives audience-and-purpose-scoped bindings over the complete canonical role/profile/value bundle, never per-field hashes. | | `rateLimits` | Positive `requestsPerPrincipalPerMinute`, `burstPerPrincipal`, and `failedSelectorAttemptsPerPrincipalAuthorityPerMinute`. Raw selector values never become rate-limit labels. | | `signing` | Exact keys are `format: flattened-jws-json`, `algorithm: EdDSA`, `activeKeyId`, file-only `activeKeyRef`, `retiredPublicJwkFiles`, fixed `jwksPath`, `maximumAssertionValiditySeconds`, and `verifierClockSkewSeconds`. Missing signing material fails readiness; there is no unsigned fallback. | -| `responseFormats` | Closed unique list of 1 or 2 entries drawn from `signed-jws` and `unsigned-json`. `signed-jws` must always be present; a bundle that omits it is rejected at startup. Unsigned output additionally requires the matched grant to permit it, and signing material must still be ready even for an unsigned response. | +| `responseFormats` | Closed unique list of 1 through 3 entries drawn from `signed-jws`, `unsigned-json`, and `sd-jwt-vc`. `signed-jws` must always be present; a bundle that omits it is rejected at startup. Every other format additionally requires the matched grant to permit it, and signing material must still be ready even for an unsigned response. | ### Selector profiles @@ -146,10 +146,11 @@ An authority profile has a `kind` of `statutory`, `organizational`, `consent`, Each grant binds one exact `requirement`, `purpose`, `audienceFrom: authenticated-requester`, an optional `responseFormats` list, and the complete subject-role set. A grant's `responseFormats` follows the same -closed rule as the bundle-level list: 1 or 2 unique entries that must include -`signed-jws`, defaulting to `[signed-jws]` when omitted. Unsigned output -requires both the bundle and the one complete matched grant to permit it, so a -production grant that says nothing permits only signed JWS. +closed rule as the bundle-level list: 1 through 3 unique entries that must +include `signed-jws`, defaulting to `[signed-jws]` when omitted. Unsigned output +and the SD-JWT VC serialization each require both the bundle and the one +complete matched grant to permit them, so a production grant that says nothing +permits only signed JWS. Every grant subject fixes `role`, `selectorProfile`, and one `valueOrigin`: - `request` requires values in the closed public request and prohibits diff --git a/products/evidence/scripts/sd-jwt-vc-demo.sh b/products/evidence/scripts/sd-jwt-vc-demo.sh new file mode 100755 index 000000000..8f4dc3b33 --- /dev/null +++ b/products/evidence/scripts/sd-jwt-vc-demo.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# Run the Evidence SD-JWT VC demo end to end with curl. +# +# The demo is deterministic and credential-free: a local mock stands in for the +# upstream source and an in-memory test JWKS authenticates the requester. No +# DHIS2, OpenCRVS, or other live provider is called and nothing is written +# outside products/evidence/.sd-jwt-vc-demo/. +# +# The steps are the ones a relying party actually performs: request the signed +# default, request the same assertion as an SD-JWT VC, fetch the issuer's keys +# from the published metadata route, and re-verify the stored credential +# offline against a policy built from the accepted transaction. +set -euo pipefail + +repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +state_root="$repository_root/products/evidence/.sd-jwt-vc-demo" +harness_log="$state_root/harness.log" +base_url="http://127.0.0.1:18081" +readiness_timeout_seconds=600 +harness_pid= + +for tool in cargo curl jq; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'The demo needs %s on PATH.\n' "$tool" >&2 + exit 1 + fi +done + +# Decode one unpadded base64url segment read from standard input. jq is already +# required above, so the demo needs no GNU-only coreutils. +decode_base64url() { + jq -Rr ' + (gsub("-"; "+") | gsub("_"; "/")) as $standard + | ((4 - ($standard | length) % 4) % 4) as $padding + | (if $padding == 0 then $standard else $standard + ("=" * $padding) end) + | @base64d + ' +} + +# cargo spawns the test binary as a child, so terminating cargo alone would +# leave the server holding the port. Job control puts the harness in its own +# process group, and the group is what gets signalled. +cleanup() { + if [[ -n "$harness_pid" ]] && kill -0 "$harness_pid" 2>/dev/null; then + kill -TERM -- -"$harness_pid" 2>/dev/null || kill -TERM "$harness_pid" 2>/dev/null || true + wait "$harness_pid" 2>/dev/null || true + fi + unset EVIDENCE_ACCESS_TOKEN +} +trap cleanup EXIT HUP INT PIPE TERM + +cd "$repository_root" +mkdir -p "$state_root" +chmod 700 "$state_root" +rm -f "$harness_log" + +if curl --silent --output /dev/null --max-time 2 "$base_url/health"; then + printf 'Something is already serving %s. Stop it before running the demo.\n' "$base_url" >&2 + exit 1 +fi + +printf 'Starting the deterministic Evidence demo server. The first run compiles the crate.\n' +set -m +CARGO_INCREMENTAL=0 \ + CARGO_PROFILE_DEV_DEBUG=0 \ + CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked -p registry-evidence \ + sd_jwt_vc_demo_serves_a_credential_for_curl \ + -- --ignored --nocapture >"$harness_log" 2>&1 & +harness_pid=$! +set +m + +waited=0 +until grep -q 'demo server is ready' "$harness_log" 2>/dev/null; do + if ! kill -0 "$harness_pid" 2>/dev/null; then + printf 'The demo server exited before it was ready:\n' >&2 + cat "$harness_log" >&2 + exit 1 + fi + if ((waited >= readiness_timeout_seconds)); then + printf 'The demo server was not ready within %ss.\n' "$readiness_timeout_seconds" >&2 + cat "$harness_log" >&2 + exit 1 + fi + sleep 2 + waited=$((waited + 2)) +done +printf 'Server ready at %s\n\n' "$base_url" + +# The short-lived synthetic bearer token stays in shell memory and is passed to +# curl through standard input, never on a command line and never in the log. +set -a +# shellcheck source=/dev/null +. "$state_root/session.env" +set +a + +printf '1. Fetch the issuer keys from the published metadata route (no token)\n' +curl --config - <"$state_root/trusted.jwks.json" +printf ' issuer: %s\n' "$(jq -r .issuer "$state_root/issuer-metadata.json")" + +printf '2. Request the signed default (Accept: application/jose+json)\n' +curl --config - <&2 + cat "$harness_log" >&2 + exit 1 +fi +harness_pid= +grep '^PASS:' "$harness_log" +printf '\n' + +printf '4. The credential: an issuer-signed JWT, one disclosure per supported value,\n' +printf ' and a trailing tilde where a key-binding JWT would go\n' +credential=$(cat "$state_root/credential.txt") +IFS='~' read -r -a segments <<<"$credential" +if [[ "${credential: -1}" != '~' ]]; then + printf 'The credential does not end with a tilde, so it is not the issued form.\n' >&2 + exit 1 +fi +printf ' %s disclosure(s), no key-binding JWT\n' "$((${#segments[@]} - 1))" +printf ' protected header: %s\n' "$(printf '%s' "${segments[0]%%.*}" | decode_base64url)" +printf ' disclosures (salt, claim name, claim value):\n' +for disclosure in "${segments[@]:1}"; do + printf ' %s\n' "$(printf '%s' "$disclosure" | decode_base64url)" +done +printf '\n' + +printf '5. Re-verify the stored credential offline, no network and no server\n' +cargo run --locked --quiet -p registry-evidence -- verify \ + --sd-jwt-vc "$state_root/credential.txt" \ + --jwks "$state_root/trusted.jwks.json" \ + --policy "$state_root/verification-policy.yaml" + +printf '\n6. Tamper with one disclosure and re-verify: selective disclosure is not\n' +printf ' an invitation to edit the claim after issuance\n' +disclosure="${segments[1]}" +if [[ "${disclosure: -1}" == 'A' ]]; then replacement='B'; else replacement='A'; fi +tampered_segments=("${segments[@]}") +tampered_segments[1]="${disclosure%?}$replacement" +printf '%s~' "${tampered_segments[@]}" >"$state_root/tampered-credential.txt" +if cargo run --locked --quiet -p registry-evidence -- verify \ + --sd-jwt-vc "$state_root/tampered-credential.txt" \ + --jwks "$state_root/trusted.jwks.json" \ + --policy "$state_root/verification-policy.yaml" >/dev/null 2>"$state_root/tampered.stderr"; then + printf 'A tampered credential verified. That is a defect, not a demo.\n' >&2 + exit 1 +fi +printf ' rejected: %s\n' "$(cat "$state_root/tampered.stderr")" + +cat <
Date: Sun, 2 Aug 2026 22:34:48 +0700 Subject: [PATCH 013/136] fix(mint): construct EvidenceRequest with the SD-JWT holder key field Landing the Evidence SD-JWT VC response format added the optional holder_key field to EvidenceRequest. Mint's tests drive Evidence's authenticator and build the struct directly, so the delegated subject-binding fixture stopped compiling. Security review: no behavior change. holder_key is meaningful only to the SD-JWT VC response format; it never reaches authorization, selectors, Rhai, source requests, or audit, and never appears in the signed-JWS payload. Setting it to None keeps this fixture exercising authorization alone, which is what the test asserts. Signed-off-by: Jeremi Joslin --- crates/registry-mint/tests/delegated_subject_binding.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs index 355cf912b..c3fe99ac7 100644 --- a/crates/registry-mint/tests/delegated_subject_binding.rs +++ b/crates/registry-mint/tests/delegated_subject_binding.rs @@ -294,6 +294,9 @@ fn subject_bound_request() -> EvidenceRequest { values: None, }, }], + // A holder key belongs to the SD-JWT VC response format and never + // reaches authorization, which is the only thing under test here. + holder_key: None, } } From a3f6bb3ed7f57316fa928734ede3bac91f28939e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 22:48:00 +0700 Subject: [PATCH 014/136] docs(evidence): print each command the SD-JWT VC demo runs The demo performed every request with curl but never showed the invocation, so a reader could not reproduce a step outside the script. Each step now prints the command in copy-pasteable flag form before running it, with paths relative to the repository root. The two verify steps build one array that is both printed and executed, so the shown command cannot drift from the one that runs. Security review: the bearer token is still never rendered. curl keeps receiving its configuration on standard input, so the token reaches no command line and no process listing; the printed form substitutes the literal text $EVIDENCE_ACCESS_TOKEN for any Authorization header value. Verified by running the demo and confirming the 458-character token appears in neither the transcript nor harness.log. Signed-off-by: Jeremi Joslin --- products/evidence/SD-JWT-VC-DEMO.md | 23 ++++++ products/evidence/scripts/sd-jwt-vc-demo.sh | 87 ++++++++++++++++++--- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/products/evidence/SD-JWT-VC-DEMO.md b/products/evidence/SD-JWT-VC-DEMO.md index f0e41d9a0..bc96296af 100644 --- a/products/evidence/SD-JWT-VC-DEMO.md +++ b/products/evidence/SD-JWT-VC-DEMO.md @@ -43,15 +43,36 @@ verifier. Its six steps are: 5. re-verify the stored credential offline with `evidence verify --sd-jwt-vc`; 6. edit one disclosure and re-verify, which must fail. +Every step prints the exact command it runs before running it, so the transcript +doubles as the copy-pasteable version of this walkthrough. The bearer token is +the one thing never printed: the demo passes it to `curl` on standard input, and +the printed form shows `$EVIDENCE_ACCESS_TOKEN` rather than its value. + Expected output, abbreviated: ```text 1. Fetch the issuer keys from the published metadata route (no token) + $ curl \ + --header 'Accept: application/json' \ + --output 'products/evidence/.sd-jwt-vc-demo/issuer-metadata.json' \ + ... \ + http://127.0.0.1:18081/.well-known/jwt-vc-issuer HTTP 200 application/json issuer: urn:example:fixture:provider:evidence 2. Request the signed default (Accept: application/jose+json) + $ curl \ + --request 'POST' \ + --header 'Authorization: Bearer $EVIDENCE_ACCESS_TOKEN' \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/jose+json' \ + --data-binary '@products/evidence/.sd-jwt-vc-demo/request.json' \ + ... \ + http://127.0.0.1:18081/v1/evidence HTTP 200 application/jose+json 3. Request the same assertion as an SD-JWT VC (Accept: application/dc+sd-jwt) + $ curl \ + ... the same request with Accept: application/dc+sd-jwt \ + http://127.0.0.1:18081/v1/evidence HTTP 200 application/dc+sd-jwt PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, ... @@ -64,6 +85,7 @@ PASS: the same assertion was released as a signed JWS and as an SD-JWT VC, ... ["7MNkDxEPeSWvyGbI2ziaRw","urn:example:fixture:concept:adult-status",true] 5. Re-verify the stored credential offline, no network and no server + $ cargo run --locked --quiet -p registry-evidence -- verify --sd-jwt-vc ... verified-at: 2026-08-02T13:49:03Z authentic: yes currently-valid: yes @@ -71,6 +93,7 @@ currently-valid: yes 6. Tamper with one disclosure and re-verify: selective disclosure is not an invitation to edit the claim after issuance + $ cargo run --locked --quiet -p registry-evidence -- verify --sd-jwt-vc ... rejected: evidence: stored response verification failed (disclosure) ``` diff --git a/products/evidence/scripts/sd-jwt-vc-demo.sh b/products/evidence/scripts/sd-jwt-vc-demo.sh index 8f4dc3b33..f80892075 100755 --- a/products/evidence/scripts/sd-jwt-vc-demo.sh +++ b/products/evidence/scripts/sd-jwt-vc-demo.sh @@ -14,6 +14,9 @@ set -euo pipefail repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) state_root="$repository_root/products/evidence/.sd-jwt-vc-demo" +# The demo runs from the repository root, so the printed commands use paths +# relative to it and can be pasted into another terminal unchanged. +state_relative=${state_root#"$repository_root"/} harness_log="$state_root/harness.log" base_url="http://127.0.0.1:18081" readiness_timeout_seconds=600 @@ -37,6 +40,52 @@ decode_base64url() { ' } +# Print a curl invocation in copy-pasteable flag form and then run it. +# +# The configuration is fed to curl on standard input so the bearer token never +# reaches a command line or a process listing. The printed form preserves that +# guarantee: an Authorization header is always rendered as the shell variable +# name, never as its value. +run_curl() { + local config=$1 + local prefix="$repository_root/" + local line key value url= + local -a rendered=() + while IFS= read -r line; do + [[ -n "$line" ]] || continue + if [[ "$line" != *" = "* ]]; then + rendered+=("--$line") + continue + fi + key=${line%% = *} + value=${line#* = } + value=${value#\"} + value=${value%\"} + value=${value//"$prefix"/} + case "$key" in + url) + url=$value + continue + ;; + header) + if [[ "$value" == Authorization:* ]]; then + # shellcheck disable=SC2016 # the variable name is the output, not its value + value='Authorization: Bearer $EVIDENCE_ACCESS_TOKEN' + fi + ;; + esac + rendered+=("--$key '$value'") + done <<<"$config" + + printf ' $ curl' + for line in "${rendered[@]}"; do + printf ' \\\n %s' "$line" + done + printf ' \\\n %s\n' "$url" + + curl --config - <<<"$config" +} + # cargo spawns the test binary as a child, so terminating cargo alone would # leave the server holding the port. Job control puts the harness in its own # process group, and the group is what gets signalled. @@ -95,7 +144,8 @@ set -a set +a printf '1. Fetch the issuer keys from the published metadata route (no token)\n' -curl --config - <"$state_root/trusted.jwks.json" printf ' issuer: %s\n' "$(jq -r .issuer "$state_root/issuer-metadata.json")" printf '2. Request the signed default (Accept: application/jose+json)\n' -curl --config - <"$state_root/tampered-credential.txt" -if cargo run --locked --quiet -p registry-evidence -- verify \ - --sd-jwt-vc "$state_root/tampered-credential.txt" \ - --jwks "$state_root/trusted.jwks.json" \ - --policy "$state_root/verification-policy.yaml" >/dev/null 2>"$state_root/tampered.stderr"; then +tamper_command=( + cargo run --locked --quiet -p registry-evidence -- verify + --sd-jwt-vc "$state_relative/tampered-credential.txt" + --jwks "$state_relative/trusted.jwks.json" + --policy "$state_relative/verification-policy.yaml" +) +printf ' $ %s\n' "${tamper_command[*]}" +if "${tamper_command[@]}" >/dev/null 2>"$state_root/tampered.stderr"; then printf 'A tampered credential verified. That is a defect, not a demo.\n' >&2 exit 1 fi From 6f4bcc8a5619f968559e05e6aef4f8def9e9d386 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 23:27:51 +0700 Subject: [PATCH 015/136] feat(evidence): accept an omitted expires_in and remove URL credentials RFC 6749 section 5.1 makes expires_in recommended rather than required, so a compliant provider may answer with only access_token and token_type. The token parser rejected that response, which made such a provider unreachable. Bundles may now state assumedLifetimeSeconds: a governed positive value bounded by one day, applied only when the provider omits expires_in, and still clamped by maximumCacheSeconds. A present but zero or non-numeric expires_in stays a credential failure that no assumed lifetime rescues. Version 1 also drops query-string credential placement. RFC 6749 section 2.3.1 requires the client identifier and secret to travel in the Authorization header or the request body and never in the request URI. The OpenCRVS reference, its deployment project, and its frozen shape fixture move to form-body placement, which the provider accepts, so no shipped artifact needs the removed option. Security review notes: - No placement can now put a client identifier or secret in a token URL, which removes the documented exposure through authorization-server, proxy, and ingress URL logs. Narrowing the enum is a breaking bundle change; restoring the value later would be additive. - The assumed lifetime is a governed bundle value and is never inferred from the token itself, so no unverified token claim influences cache duration. - The redaction surface is unchanged in strength and narrower in scope: the token URL now carries nothing to redact, and the request body, response, and debug output remain fully redacted. - The opt-in live check now parses token responses exactly as the product does, so it can no longer report a passing profile for a provider the runtime would reject. Acceptance row 19 loses its query-string clause because the placement no longer exists. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/config.rs | 110 +++++++++- crates/registry-evidence/src/source.rs | 62 +++--- .../registry-evidence/tests/live_sources.rs | 41 +++- .../tests/source_contracts.rs | 194 +++++++++++++----- products/evidence/IMPLEMENTATION.md | 4 +- .../acceptance-test-traceability.yaml | 4 +- .../evidence/contracts/bundle.schema.yaml | 5 +- .../contracts/security-test-traceability.yaml | 2 +- .../evidence/contracts/source-contract.yaml | 9 +- .../fixtures/conformance/coverage-matrix.yaml | 2 +- ...n.yaml => oauth-credential-redaction.yaml} | 22 +- .../contract.yaml | 8 +- .../reference/request-adapter/README.md | 15 +- .../deployment-projects/CONFIG.md | 11 +- .../opencrvs-family-evidence/README.md | 16 +- .../bundle/evidence.yaml | 16 +- .../opencrvs-event-search/source.yaml | 11 +- 17 files changed, 399 insertions(+), 133 deletions(-) rename products/evidence/fixtures/conformance/{oauth-query-credential-redaction.yaml => oauth-credential-redaction.yaml} (66%) diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 6f8eca98d..8e84b5083 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -1448,6 +1448,19 @@ pub enum SourceAuthentication { credential_placement: CredentialPlacement, #[serde(rename = "maximumCacheSeconds")] maximum_cache_seconds: u64, + /// Lifetime assumed when the provider omits `expires_in`. + /// + /// RFC 6749 section 5.1 makes `expires_in` recommended rather than + /// required, so a compliant provider may return only `access_token` + /// and `token_type`. The operator states the lifetime here rather than + /// the runtime inferring one from the token, and the cache is still + /// clamped to `maximumCacheSeconds`. + #[serde( + rename = "assumedLifetimeSeconds", + default, + skip_serializing_if = "Option::is_none" + )] + assumed_lifetime_seconds: Option, }, } @@ -1467,6 +1480,7 @@ impl SourceAuthentication { token_endpoint, scope, maximum_cache_seconds, + assumed_lifetime_seconds, .. } => { let token_endpoint = validate_source_url(token_endpoint, false)?; @@ -1476,6 +1490,14 @@ impl SourceAuthentication { if let Some(scope) = scope { validate_string(scope, 1, 512, "OAuth scope")?; } + if let Some(assumed_lifetime_seconds) = assumed_lifetime_seconds { + validate_range( + *assumed_lifetime_seconds, + 1, + 86_400, + "OAuth assumed token lifetime", + )?; + } validate_range( *maximum_cache_seconds, 0, @@ -1503,12 +1525,16 @@ impl SourceAuthentication { } } +/// Where the token request carries the client credentials. +/// +/// RFC 6749 section 2.3.1 defines Basic authentication and the request-body +/// parameters and states that those parameters must not be placed in the +/// request URI, so Version 1 offers no query-string placement. #[derive(Debug, Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum CredentialPlacement { BasicHeader, FormBody, - QueryString, } #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] @@ -3681,6 +3707,7 @@ mod tests { scope: None, credential_placement: CredentialPlacement::FormBody, maximum_cache_seconds: 60, + assumed_lifetime_seconds: None, }; assert_eq!( oauth.validate(), @@ -3692,6 +3719,87 @@ mod tests { } } + /// The assumed lifetime is a governed positive duration, so a zero or + /// oversized value is a configuration error rather than a silent clamp. + #[test] + fn oauth_assumed_token_lifetime_is_a_bounded_positive_duration() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + + for (assumed_lifetime_seconds, expected) in [ + ( + Some(0), + Err(ConfigError::Invalid( + "numeric value is outside Version 1 bounds", + )), + ), + (Some(1), Ok(())), + (Some(86_400), Ok(())), + ( + Some(86_401), + Err(ConfigError::Invalid( + "numeric value is outside Version 1 bounds", + )), + ), + (None, Ok(())), + ] { + let mut oauth = + EvidenceConfig::parse_yaml(valid.as_bytes()).expect("fixture validates"); + oauth.sources.0[0].1.authentication = SourceAuthentication::Oauth2ClientCredentials { + token_endpoint: "https://source.invalid/token".to_owned(), + client_id_ref: SecretRef::parse("secret:file/oauth-client-id").expect("secret ref"), + client_secret_ref: SecretRef::parse("secret:file/oauth-client-secret") + .expect("secret ref"), + scope: None, + credential_placement: CredentialPlacement::FormBody, + maximum_cache_seconds: 60, + assumed_lifetime_seconds, + }; + assert_eq!(oauth.validate(), expected, "{assumed_lifetime_seconds:?}"); + } + } + + /// Query-string placement puts the client id and secret in a URL that + /// authorization-server, proxy, and ingress logs capture, and RFC 6749 + /// section 2.3.1 requires those parameters to travel in the request body. + /// Version 1 accepts only the two placements the specification defines, + /// and the runtime and the published contract must agree on that. + #[test] + fn oauth_credential_placement_rejects_the_query_string_placement() { + let validator = bundle_contract_validator(); + for (placement, accepted) in [ + ("basic-header", true), + ("form-body", true), + ("query-string", false), + ] { + let authentication = serde_json::json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": "https://source.invalid/token", + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "credentialPlacement": placement, + "maximumCacheSeconds": 60, + }); + assert_eq!( + serde_json::from_value::(authentication.clone()).is_ok(), + accepted, + "{placement} runtime deserialization" + ); + + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["sources"]["source-a"]["authentication"] = authentication; + assert_eq!( + validator.is_valid(&instance), + accepted, + "{placement} bundle contract" + ); + } + } + #[test] fn get_sources_must_forbid_the_json_body_channel() { let mut config = EvidenceConfig::parse_yaml(include_bytes!( diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index 464d4347a..65aad5d2a 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -181,6 +181,8 @@ struct OauthPlan { scope: Option, credential_placement: CredentialPlacement, maximum_cache_lifetime: Duration, + /// Lifetime used when the provider omits `expires_in`. + assumed_lifetime: Option, admission_timeout: Duration, cache: Mutex>, } @@ -742,6 +744,7 @@ fn compile_authentication( scope, credential_placement, maximum_cache_seconds, + assumed_lifetime_seconds, } => { let token_endpoint = validate_url(token_endpoint, false)?; if token_endpoint.query().is_some() { @@ -754,6 +757,7 @@ fn compile_authentication( scope: scope.clone(), credential_placement: *credential_placement, maximum_cache_lifetime: Duration::from_secs(*maximum_cache_seconds), + assumed_lifetime: assumed_lifetime_seconds.map(Duration::from_secs), admission_timeout, cache: Mutex::new(None), }))) @@ -911,50 +915,35 @@ impl OauthPlan { let client_secret = resolve(secrets, &self.client_secret_ref)?; let client_id_text = protected_text(&client_id)?; let client_secret_text = protected_text(&client_secret)?; - let mut endpoint = self.token_endpoint.clone(); let mut form = vec![("grant_type", "client_credentials")]; if let Some(scope) = self.scope.as_deref() { form.push(("scope", scope)); } + // Both placements keep the client credentials out of the request URI, + // where proxy and ingress logs would capture them. let mut request = client - .post(endpoint.clone()) + .post(self.token_endpoint.clone()) .header(ACCEPT, JSON_MEDIA_TYPE); - let send_form = match self.credential_placement { + match self.credential_placement { CredentialPlacement::BasicHeader => { request = request.header( AUTHORIZATION, basic_authorization(&client_id, &client_secret)?, ); - true } CredentialPlacement::FormBody => { form.push(("client_id", client_id_text)); form.push(("client_secret", client_secret_text)); - true } - CredentialPlacement::QueryString => { - { - let mut pairs = endpoint.query_pairs_mut(); - for (key, value) in &form { - pairs.append_pair(key, value); - } - pairs.append_pair("client_id", client_id_text); - pairs.append_pair("client_secret", client_secret_text); - } - request = client.post(endpoint).header(ACCEPT, JSON_MEDIA_TYPE); - false - } - }; - if send_form { - request = request.form(&form); } + request = request.form(&form); drop(form); drop(client_id); drop(client_secret); let response = request.send().await.map_err(map_transport_error)?; - let (token, provider_lifetime) = - parse_token_response(response, self.scope.as_deref()).await?; - let cache_lifetime = provider_lifetime.min(self.maximum_cache_lifetime); + let (token, lifetime) = + parse_token_response(response, self.scope.as_deref(), self.assumed_lifetime).await?; + let cache_lifetime = lifetime.min(self.maximum_cache_lifetime); if !cache_lifetime.is_zero() { let expires_at = Instant::now() .checked_add(cache_lifetime) @@ -1159,6 +1148,7 @@ async fn parse_data_response( async fn parse_token_response( response: reqwest::Response, expected_scope: Option<&str>, + assumed_lifetime: Option, ) -> Result<(ProtectedToken, Duration), SourceError> { // `reject_response_status` classifies only redirect/status outcomes, never a // timeout, so every rejection here is a credential-exchange failure. @@ -1188,11 +1178,19 @@ async fn parse_token_response( if !token_type.eq_ignore_ascii_case("bearer") { return Err(SourceError::Credential); } - let expires_in = match object.remove("expires_in") { - Some(JsonValue::Number(value)) => value.as_u64().filter(|value| *value > 0), - _ => None, - } - .ok_or(SourceError::Credential)?; + // RFC 6749 section 5.1 makes `expires_in` recommended rather than required. + // A provider that omits it is accepted only when the bundle states the + // lifetime to assume; a present but unusable value is never rescued by it. + let lifetime = match object.remove("expires_in") { + Some(JsonValue::Number(value)) => Duration::from_secs( + value + .as_u64() + .filter(|seconds| *seconds > 0) + .ok_or(SourceError::Credential)?, + ), + Some(_) => return Err(SourceError::Credential), + None => assumed_lifetime.ok_or(SourceError::Credential)?, + }; if let Some(scope) = object.remove("scope") { let JsonValue::String(scope) = scope else { return Err(SourceError::Credential); @@ -1207,10 +1205,7 @@ async fn parse_token_response( if !object.is_empty() { return Err(SourceError::Credential); } - Ok(( - ProtectedToken::from_string(access_token)?, - Duration::from_secs(expires_in), - )) + Ok((ProtectedToken::from_string(access_token)?, lifetime)) } fn reject_response_status(response: &reqwest::Response) -> Result<(), SourceError> { @@ -1673,8 +1668,9 @@ mod tests { client_secret_ref: SecretRef::parse("secret:file/missing-client-secret") .expect("secret reference parses"), scope: Some("fixture.read".into()), - credential_placement: CredentialPlacement::QueryString, + credential_placement: CredentialPlacement::FormBody, maximum_cache_lifetime: Duration::from_secs(60), + assumed_lifetime: None, admission_timeout: Duration::from_millis(20), cache: Mutex::new(None), }; diff --git a/crates/registry-evidence/tests/live_sources.rs b/crates/registry-evidence/tests/live_sources.rs index bb7f070b7..1e1feb2b5 100644 --- a/crates/registry-evidence/tests/live_sources.rs +++ b/crates/registry-evidence/tests/live_sources.rs @@ -147,9 +147,12 @@ async fn run_opencrvs() -> Result<(), LiveError> { let (token_url, search_url) = opencrvs_urls(required(&config, "OPENCRVS_URL")?)?; let client = live_client()?; + // Form-body placement mirrors the reviewed reference bundle and keeps the + // client credentials out of the token URL, where a proxy or ingress log + // would capture them. let response = client .post(token_url) - .query(&[ + .form(&[ ("client_id", required(&config, "OPENCRVS_CLIENT_ID")?), ("client_secret", required(&config, "OPENCRVS_SECRET")?), ("grant_type", "client_credentials"), @@ -165,6 +168,10 @@ async fn run_opencrvs() -> Result<(), LiveError> { .as_object() .cloned() .ok_or(LiveError::Authentication)?; + // This check must stay as strict as the product's own token parser in + // `source::parse_token_response`. A live check that accepts a response the + // product rejects reports a passing profile for a source Evidence cannot + // actually reach. if !token_object.keys().all(|key| { matches!( key.as_str(), @@ -173,19 +180,35 @@ async fn run_opencrvs() -> Result<(), LiveError> { }) { return Err(LiveError::Authentication); } - if token_object.remove("token_type").is_some_and(|value| { - !value - .as_str() - .is_some_and(|token_type| token_type.eq_ignore_ascii_case("bearer")) - }) { - return Err(LiveError::Authentication); - } let token = token_object .remove("access_token") .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .filter(|value| !value.is_empty() && value.len() <= 16 * 1024) + .filter(|value| !value.is_empty() && value.len() <= MAX_TOKEN_RESPONSE_BYTES) .map(Zeroizing::new) .ok_or(LiveError::Authentication)?; + // `token_type` is required by RFC 6749 section 5.1 and by the product. + if !token_object + .remove("token_type") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .is_some_and(|token_type| token_type.eq_ignore_ascii_case("bearer")) + { + return Err(LiveError::Authentication); + } + // `expires_in` is only recommended, so an absent lifetime is accepted here + // exactly as the product accepts it under a configured assumed lifetime. + // A present lifetime must still be a positive integer. + if token_object + .remove("expires_in") + .is_some_and(|value| value.as_u64().is_none_or(|seconds| seconds == 0)) + { + return Err(LiveError::Authentication); + } + if token_object + .remove("scope") + .is_some_and(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(LiveError::Authentication); + } let tracking_id = required(&config, "OPENCRVS_TEST_TRACKING_ID")?; let search_body = opencrvs_tracking_id_search(tracking_id); diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index bf4a5c9a2..a18169c31 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -116,20 +116,37 @@ fn oauth_source( placement: &str, maximum_cache_seconds: u64, ) -> SourceConfig { - fixed_source( + oauth_source_with_assumed_lifetime( base_url, - json!({ - "kind": "oauth2-client-credentials", - "tokenEndpoint": token_endpoint, - "clientIdRef": "secret:file/oauth-client-id", - "clientSecretRef": "secret:file/oauth-client-secret", - "scope": "fixture.read", - "credentialPlacement": placement, - "maximumCacheSeconds": maximum_cache_seconds - }), + token_endpoint, + placement, + maximum_cache_seconds, + None, ) } +fn oauth_source_with_assumed_lifetime( + base_url: &str, + token_endpoint: &str, + placement: &str, + maximum_cache_seconds: u64, + assumed_lifetime_seconds: Option, +) -> SourceConfig { + let mut authentication = json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": token_endpoint, + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "scope": "fixture.read", + "credentialPlacement": placement, + "maximumCacheSeconds": maximum_cache_seconds + }); + if let Some(seconds) = assumed_lifetime_seconds { + authentication["assumedLifetimeSeconds"] = json!(seconds); + } + fixed_source(base_url, authentication) +} + fn selector(value: &str) -> ResolvedSourceSelector { ResolvedSourceSelector { role: "subject".into(), @@ -693,7 +710,7 @@ async fn get_body_is_rejected_before_static_or_oauth_credential_acquisition() { let mut oauth = oauth_source( &data_server.uri(), &format!("{}/token", token_server.uri()), - "query-string", + "form-body", 60, ); oauth.request.method = HttpMethod::GET; @@ -881,13 +898,14 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a ) .expect("match response fixture is JSON"); if id == "opencrvs-event-search-json" { + // The reference shape returns only access_token and token_type. + // Adding a lifetime here would make the mock more compliant than + // the provider it models and hide the assumed-lifetime path. Mock::given(method("POST")) .and(path("/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "access_token": "shape-oauth-access-token-canary", - "token_type": "Bearer", - "expires_in": 120, - "scope": "fixture.read" + "token_type": "Bearer" }))) .expect(1) .mount(&server) @@ -994,17 +1012,29 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a .filter(|request| request.url.path() == "/oauth/token") .collect::>(); assert_eq!(token_requests.len(), 1, "exact OAuth bootstrap count"); - let query = query_parameters(&token_requests[0].url); + // The reference shape accepts the client credentials in the token + // request body, so no credential may appear in the token URL. + assert!( + query_parameters(&token_requests[0].url).is_empty(), + "OAuth bootstrap URL carries no query" + ); + let form = encoded_parameters(&token_requests[0].body); assert!( - query.len() == 4 - && contains_parameter(&query, "grant_type", "client_credentials") - && contains_parameter(&query, "scope", "fixture.read") - && contains_parameter(&query, "client_id", "shape-oauth-client-canary") - && contains_parameter(&query, "client_secret", "shape-oauth-secret-canary"), - "OAuth bootstrap query is the exact reviewed shape" + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", "shape-oauth-client-canary") + && contains_parameter(&form, "client_secret", "shape-oauth-secret-canary"), + "OAuth bootstrap body is the exact reviewed shape" ); - assert!(token_requests[0].body.is_empty()); assert!(token_requests[0].headers.get("authorization").is_none()); + assert_eq!( + token_requests[0] + .headers + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("application/x-www-form-urlencoded") + ); assert_eq!( token_requests[0] .headers @@ -1491,18 +1521,6 @@ async fn assert_oauth_success_matrix_case(placement: &str, maximum_cache_seconds "form placement token body is not exact" ); } - "query-string" => { - assert!(form.is_empty(), "query placement added a token body"); - assert!(request.headers.get("authorization").is_none()); - assert!( - query.len() == 4 - && contains_parameter(&query, "grant_type", "client_credentials") - && contains_parameter(&query, "scope", "fixture.read") - && contains_parameter(&query, "client_id", &client_id) - && contains_parameter(&query, "client_secret", &client_secret), - "query placement token parameters are not exact" - ); - } _ => panic!("unknown non-secret test placement"), } } @@ -1510,16 +1528,66 @@ async fn assert_oauth_success_matrix_case(placement: &str, maximum_cache_seconds #[tokio::test] async fn oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded() { - for placement in ["basic-header", "form-body", "query-string"] { + for placement in ["basic-header", "form-body"] { assert_oauth_success_matrix_case(placement, 60).await; assert_oauth_success_matrix_case(placement, 0).await; } } +/// A provider that omits `expires_in` still gets a bounded cache, and the +/// configured maximum still wins over the assumed lifetime. +#[tokio::test] +async fn oauth_assumed_lifetime_caches_an_omitted_provider_lifetime_and_stays_clamped() { + for (maximum_cache_seconds, expected_token_requests) in [(60_u64, 1_u64), (0, 2)] { + let server = MockServer::start().await; + let access_token = format!("access-token-{}", ulid::Ulid::new()); + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer" + }))) + .expect(expected_token_requests) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/data")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(2) + .mount(&server) + .await; + + let (_root, secrets) = resolver(&[ + ("oauth-client-id", "assumed-lifetime-client-id"), + ("oauth-client-secret", "assumed-lifetime-client-secret"), + ]); + let source = oauth_source_with_assumed_lifetime( + &server.uri(), + &format!("{}/token", server.uri()), + "form-body", + maximum_cache_seconds, + Some(120), + ); + let executor = SourceExecutor::new(&source, secrets).expect("OAuth executor builds"); + for _ in 0..2 { + executor + .execute( + &[selector("record")], + &RequestParts { + query: vec![], + body: Some(json!({})), + }, + ) + .await + .expect("assumed lifetime authorizes the request"); + } + } +} + #[tokio::test] -async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { +async fn oauth_credential_redaction_fixture_fails_closed_without_data_requests() { let fixture: Value = serde_norway::from_str(include_str!( - "../../../products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml" + "../../../products/evidence/fixtures/conformance/oauth-credential-redaction.yaml" )) .expect("OAuth redaction fixture parses"); let declared = fixture["cases"] @@ -1543,6 +1611,8 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { "token-response-wrong-lifetime".to_owned(), "token-response-wrong-media-type".to_owned(), "token-response-wrong-scope".to_owned(), + "token-response-omitted-lifetime".to_owned(), + "token-response-omitted-lifetime-with-assumed-lifetime".to_owned(), "token-response-wrong-token-type".to_owned(), "token-success".to_owned(), "transport-connection-failure".to_owned(), @@ -1611,6 +1681,15 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { "token_type": "Bearer", "expires_in": 0 }))), + // The minimum RFC 6749 section 5.1 response. Accepted only when the + // bundle states the lifetime to assume. + "token-response-omitted-lifetime" + | "token-response-omitted-lifetime-with-assumed-lifetime" => { + Some(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer" + }))) + } "token-response-wrong-media-type" => Some(ResponseTemplate::new(200).set_body_raw( format!( "{{\"access_token\":\"{access_token}\",\"token_type\":\"Bearer\",\"expires_in\":120}}" @@ -1649,7 +1728,15 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { } else { format!("{}/token", server.uri()) }; - let mut source = oauth_source(&server.uri(), &token_endpoint, "query-string", 0); + let assumed_lifetime_seconds = + (case_id == "token-response-omitted-lifetime-with-assumed-lifetime").then_some(120); + let mut source = oauth_source_with_assumed_lifetime( + &server.uri(), + &token_endpoint, + "form-body", + 0, + assumed_lifetime_seconds, + ); if case_id == "transport-timeout" { source.request.timeout_milliseconds = 20; } @@ -1663,8 +1750,12 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { }, ) .await; - if case_id == "token-success" { - assert!(result.is_ok(), "success fixture case failed"); + let expects_success = matches!( + case_id.as_str(), + "token-success" | "token-response-omitted-lifetime-with-assumed-lifetime" + ); + if expects_success { + assert!(result.is_ok(), "success fixture case {case_id} failed"); } else { let expected_error = match case_id.as_str() { "transport-timeout" => SourceError::Timeout, @@ -1686,7 +1777,7 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { .iter() .filter(|request| request.url.path() == "/data") .count(); - if case_id == "token-success" { + if expects_success { assert!( data_count == 1, "successful token did not authorize one data request" @@ -1705,16 +1796,21 @@ async fn oauth_query_redaction_fixture_fails_closed_without_data_requests() { continue; } let token_request = token_request.expect("token request was journaled"); - let query = query_parameters(&token_request.url); + // No placement may put a credential in the token URL, so the redaction + // surface is the request body and the response, never the URL. + assert!( + query_parameters(&token_request.url).is_empty(), + "token URL carried a query" + ); + let form = encoded_parameters(&token_request.body); assert!( - query.len() == 4 - && contains_parameter(&query, "grant_type", "client_credentials") - && contains_parameter(&query, "scope", "fixture.read") - && contains_parameter(&query, "client_id", &client_id) - && contains_parameter(&query, "client_secret", &client_secret), - "query placement did not deliver the exact closed credential request" + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "fixture.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "form placement did not deliver the exact closed credential request" ); - assert!(token_request.body.is_empty()); } } diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 76725b20c..7882a506f 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -676,8 +676,8 @@ At minimum, pin these acceptance and negative cases: and `ambiguous` across the three source shapes; facts exist only on `match`. 18. Event-index envelope errors and incomplete declaration data fail closed. -19. OAuth token requests and responses are absent from all diagnostics, even - when the provider requires credentials in the query string. +19. OAuth token requests and responses are absent from all diagnostics, and + no placement can put client credentials in the token URL. 20. Live-source tests are ignored by default, read-only, and refuse missing or permissively stored credential files. 21. Adult status passes the complete path with before, on, and after-boundary diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index 7f3f30c48..449269c16 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -100,9 +100,9 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: every_frozen_source_shape_executes_through_production_materialization_and_projection} - {file: crates/registry-evidence/tests/source_contracts.rs, name: source_executor_failure_matrix_is_exact_single_request_and_value_free} - id: acceptance-row-19 - summary: OAuth token requests and responses are absent from all diagnostics, even when the provider requires credentials in the query string. + summary: OAuth token requests and responses are absent from all diagnostics, and no placement can put client credentials in the token URL. tests: - - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_query_redaction_fixture_fails_closed_without_data_requests} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_credential_redaction_fixture_fails_closed_without_data_requests} - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded} - {file: crates/registry-evidence/src/config.rs, name: source_adapter_name_is_audit_safe_and_oauth_endpoint_has_no_query} - id: acceptance-row-20 diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index baf1770fd..7ac1041fa 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -233,8 +233,9 @@ $defs: clientIdRef: {$ref: '#/$defs/secret-ref'} clientSecretRef: {$ref: '#/$defs/secret-ref'} scope: {type: string, minLength: 1, maxLength: 512} - credentialPlacement: {enum: [basic-header, form-body, query-string]} + credentialPlacement: {enum: [basic-header, form-body]} maximumCacheSeconds: {type: integer, minimum: 0, maximum: 86400} + assumedLifetimeSeconds: {type: integer, minimum: 1, maximum: 86400} fixed-request: type: object additionalProperties: false @@ -679,7 +680,7 @@ startup_checks: secret_policy: permitted: File-provider logical names matching the exact secret-reference grammar only. prohibited: private key, password, bearer token, client secret, or expanded environment value in any bundle file - query_string_oauth_rule: Query-string client credentials require the explicit query-string placement and complete token URL, query, body, response, and debug-output redaction. + oauth_placement_rule: Client credentials travel in the Authorization header or the token request body only. There is no query-string placement, so no credential can reach a token URL, and the token URL, body, response, and debug output are still fully redacted. file_provider_rule: Resolve secret:file logical names beneath the configured owner-controlled fileRoot, reject symlinks and path traversal, require regular owner-only files, and parse values as data. deployment_rules: authentication: Evidence independently validates the bearer token against exact configured issuer, audience, token type, algorithm, and JWKS; upstream identity headers are ignored and rejected if mapped diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 3c8b60abf..498a157d6 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -129,7 +129,7 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: materialized_request_reuses_path_template_query_and_body_without_auth_material} - {file: crates/registry-evidence/tests/source_contracts.rs, name: get_body_is_rejected_before_static_or_oauth_credential_acquisition} - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_client_credentials_placements_are_exact_and_cache_reuse_is_bounded} - - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_query_redaction_fixture_fails_closed_without_data_requests} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_credential_redaction_fixture_fails_closed_without_data_requests} - id: sec-runtime-cannot-override-governed-bundle tests: - {file: crates/registry-evidence/src/config.rs, name: runtime_document_is_closed_and_contains_no_governed_override_surface} diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 1d2139a13..db612b747 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -124,15 +124,16 @@ authentication: placement: The exact allowlisted provider header. rule: Header name is validated against the same collision denylist as fixed headers and cannot be Authorization. oauth2-client-credentials: - inputs: [token_endpoint, client_id_secret_ref, client_secret_secret_ref, optional_fixed_scope, credential_placement, maximum_cache_lifetime] + inputs: [token_endpoint, client_id_secret_ref, client_secret_secret_ref, optional_fixed_scope, credential_placement, maximum_cache_lifetime, optional_assumed_token_lifetime] rules: - Token acquisition is credential bootstrap, not an evidence-data request or fact source. - Endpoint, grant, scope, placement, response bounds, redirect denial, and cache maximum are fixed. - Waiting for the per-source token-cache and single-flight boundary is limited by the configured source timeout. - Cache lifetime is clamped to provider expiry and the configured maximum. - Token request and response are unavailable to Rhai and fully redacted. - credential_placements: [basic-header, form-body, query-string] - query_string_rule: Query-string placement is explicit and requires complete token URL, query, body, response, client identifier, and debug-output redaction. + token_lifetime_rule: RFC 6749 section 5.1 makes expires_in recommended rather than required, so a token response carrying only access_token and token_type is accepted when the bundle configures assumedLifetimeSeconds. The assumed lifetime is a governed positive bundle value, never inferred from the token, and the cache is still clamped to the configured maximum. A provider that omits expires_in without a configured assumed lifetime, or returns a zero or non-numeric expires_in, remains a credential failure. + credential_placements: [basic-header, form-body] + credential_placement_rule: RFC 6749 section 2.3.1 defines Basic authentication and the request-body parameters and requires that those parameters never appear in the request URI. Version 1 offers no query-string placement, so no client identifier or secret can reach an authorization-server, proxy, or ingress URL log. The token URL, body, response, and debug output are still fully redacted. token_endpoint_transport: The same HTTPS-or-explicit-numeric-loopback rule as the evidence-data origin. secret_rules: - The governed bundle contains only secret:file logical references. Environment-variable interpolation and literal secret values are not supported. @@ -169,6 +170,6 @@ neutrality: named_compatibility_profiles: Test fixtures and reference documentation only. fixtures: postures: ../fixtures/conformance/acquisition-postures.yaml - oauth_query_redaction: ../fixtures/conformance/oauth-query-credential-redaction.yaml + oauth_credential_redaction: ../fixtures/conformance/oauth-credential-redaction.yaml shapes: ../fixtures/source-shapes/ required_shape_outcomes: [zero, one, multiple, missing-fact, error-envelope] diff --git a/products/evidence/fixtures/conformance/coverage-matrix.yaml b/products/evidence/fixtures/conformance/coverage-matrix.yaml index cd27a9a73..8884c8679 100644 --- a/products/evidence/fixtures/conformance/coverage-matrix.yaml +++ b/products/evidence/fixtures/conformance/coverage-matrix.yaml @@ -126,7 +126,7 @@ source_shapes: - maximum two bounded results solely for cardinality; profiles that cannot field-project declare record-transformed - no candidates, count beyond closed outcome, score, hint, or comparison in derivation or public surfaces - raw selectors, credentials, token flows, source values, and bodies absent from diagnostics and audit - query_oauth_redaction: oauth-query-credential-redaction.yaml + oauth_credential_redaction: oauth-credential-redaction.yaml public_contracts: requests_and_payloads: golden/ jws: jws-cases.yaml diff --git a/products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml b/products/evidence/fixtures/conformance/oauth-credential-redaction.yaml similarity index 66% rename from products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml rename to products/evidence/fixtures/conformance/oauth-credential-redaction.yaml index b1e045f80..1482c5a36 100644 --- a/products/evidence/fixtures/conformance/oauth-query-credential-redaction.yaml +++ b/products/evidence/fixtures/conformance/oauth-credential-redaction.yaml @@ -1,11 +1,17 @@ -fixture: registry.evidence.oauth-query-credential-redaction/v1 +fixture: registry.evidence.oauth-credential-redaction/v1 synthetic_only: true configuration: - credential_placement: query-string + credential_placement: form-body token_endpoint: https://authorization.invalid/oauth/token client_id_ref: secret:file/fixture-oauth-client-id client_secret_ref: secret:file/fixture-oauth-client-secret scope: fixture.read +lifetime_rule: >- + RFC 6749 section 5.1 makes expires_in recommended rather than required. A + token response that omits it is accepted only when the bundle configures + assumedLifetimeSeconds, and the cached lifetime is still clamped to + maximumCacheSeconds. A present but zero, negative, or non-numeric expires_in + remains a credential failure and no assumed lifetime can rescue it. runtime_generated_canary_classes: [client-id, client-secret, access-token] repository_secret_values: prohibited cases: @@ -17,6 +23,8 @@ cases: - {id: token-response-extra-field, provider_status: 200, response: extra-field, expected_public_problem: dependency_unavailable} - {id: token-response-wrong-access-token-field, provider_status: 200, response: wrong-access-token-field, expected_public_problem: dependency_unavailable} - {id: token-response-wrong-token-type, provider_status: 200, response: wrong-token-type, expected_public_problem: dependency_unavailable} + - {id: token-response-omitted-lifetime, provider_status: 200, response: omitted-lifetime, expected_public_problem: dependency_unavailable} + - {id: token-response-omitted-lifetime-with-assumed-lifetime, provider_status: 200, response: omitted-lifetime, assumed_lifetime_seconds: 120, expected: token-used-in-memory-only} - {id: token-response-wrong-media-type, provider_status: 200, response: wrong-media-type, expected_public_problem: dependency_unavailable} - {id: token-response-wrong-scope, provider_status: 200, response: wrong-scope, expected_public_problem: dependency_unavailable} - {id: token-response-wrong-lifetime, provider_status: 200, response: wrong-lifetime, expected_public_problem: dependency_unavailable} @@ -28,11 +36,17 @@ required_absence: - every runtime-generated client-id canary - every runtime-generated client-secret canary - every runtime-generated access-token canary - - complete token URL with query + - complete token URL - token request body - token response body +placement_rule: >- + RFC 6749 section 2.3.1 requires the client identifier and secret to travel in + the Authorization header or the request body and never in the request URI. + Version 1 offers no query-string placement, so the token URL carries no + credential to leak into an authorization-server, proxy, or ingress log. wire_assertion: - provider_receives_configured_query_keys: true + provider_receives_no_token_url_query: true + provider_receives_configured_form_keys: true provider_receives_configured_grant: client_credentials evidence_data_requests_after_token_failure: 0 Rhai_receives_token_or_token_request: false diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml index dda5e221a..977820538 100644 --- a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml @@ -12,8 +12,9 @@ validated_source_definition: clientIdRef: secret:file/fixture-event-search-client-id clientSecretRef: secret:file/fixture-event-search-client-secret scope: fixture.read - credentialPlacement: query-string + credentialPlacement: form-body maximumCacheSeconds: 300 + assumedLifetimeSeconds: 600 request: method: POST path: /events/search @@ -50,11 +51,14 @@ credential_bootstrap: method: POST token_endpoint: 'http://[::1]:18083/oauth/token' grant_type: client_credentials - credential_placement: query-string + credential_placement: form-body + credential_placement_rule: The reference shape accepts client credentials in the token request body, so no credential reaches the request URL. Version 1 offers no query-string placement. client_id: injected from secret reference client_secret: injected from secret reference maximum_response_bytes: 8192 maximum_cache_seconds: 300 + token_response_fields: [access_token, token_type] + omitted_expires_in: The reference shape returns no expires_in, which RFC 6749 section 5.1 permits. The fixture therefore configures assumedLifetimeSeconds and must not be relaxed by adding a lifetime the reference shape does not send. failures: [http-400, http-401, http-429, http-500, timeout, invalid-json, missing-token, wrong-token-type, oversized-response] request: method: POST diff --git a/products/evidence/reference/request-adapter/README.md b/products/evidence/reference/request-adapter/README.md index 545d48fab..79a1afa18 100644 --- a/products/evidence/reference/request-adapter/README.md +++ b/products/evidence/reference/request-adapter/README.md @@ -413,13 +413,14 @@ references. This is honest `record-transformed` compatibility, not field-projected acquisition. A governed decision endpoint or provider-side result projection is preferable where available. -The reference also reflects OpenCRVS's current client bootstrap, which places -the client identifier and secret in the token endpoint query string. This -placement applies only to the token request; Rust sends the resulting access -token to `/events/search` in the `Authorization: Bearer` header. Query-string -bootstrap can expose credentials to upstream URL logs, proxies, or tracing. -Use a safer provider-supported placement when available and require complete -token-URL stripping and redaction locally. External provider logging remains a +The reference bootstraps its client credentials in the token request body, +which the provider accepts and which RFC 6749 section 2.3.1 requires. No +credential reaches the token URL, so upstream URL logs, proxies, and tracing +have nothing to capture. This placement applies only to the token request; +Rust sends the resulting access token to `/events/search` in the +`Authorization: Bearer` header. The provider's own token response returns no +`expires_in`, so the bundle states `assumedLifetimeSeconds` and the cache +stays clamped to `maximumCacheSeconds`. External provider logging remains a deployment risk that this adapter design cannot eliminate. For the simpler adult-status compatibility case, the same preparation ABI can diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index d73923ab9..73056ee6d 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -287,11 +287,12 @@ hop-by-hop header, forwarding/proxy header, or tracing header. Names are validated as HTTP field names. Secret values are bounded and reject controls, CR, and LF. -OAuth `credentialPlacement` is one of `basic-header`, `form-body`, or the -deployment-residual `query-string`. Query-string placement requires complete -token URL/query redaction. Token redirects are denied and token responses are -bounded. The token request is credential bootstrap, not a second evidence-data -lookup. +OAuth `credentialPlacement` is one of `basic-header` or `form-body`. RFC 6749 +section 2.3.1 requires the client identifier and secret to travel in the +Authorization header or the request body and never in the request URI, so +Version 1 offers no query-string placement and no credential can reach a token +URL log. Token redirects are denied and token responses are bounded. The token +request is credential bootstrap, not a second evidence-data lookup. Secret files are byte strings, not base64 fields. Do not base64-encode the audit or subject-binding key unless those encoded ASCII bytes are intentionally diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md index 8011a597b..d7895cee1 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/README.md @@ -91,12 +91,16 @@ wrong type, or a namespace mismatch stops without a signed negative. ## OAuth and secrets -This example retains OpenCRVS query-string client credential placement for -compatibility. It is a deployment residual because authorization-server, -proxy, or ingress URL logs may capture the token request. Prefer Basic-header -or form-body placement when the provider supports it. Locally, the complete -token URL, query, body, response, and debug output must be stripped or -redacted, redirects denied, and token responses bounded. +This example places the client credentials in the token request body, so no +credential reaches a URL that an authorization server, proxy, or ingress may +log. `form-body` and `basic-header` are the only placements Version 1 offers. +Locally, the complete token URL, body, response, and debug output must be +stripped or redacted, redirects denied, and token responses bounded. + +The token endpoint returns only `access_token` and `token_type`. RFC 6749 +section 5.1 makes `expires_in` recommended rather than required, so the bundle +states `assumedLifetimeSeconds` and the cache stays clamped to +`maximumCacheSeconds`. Required secret files beneath `/run/secrets/registry-evidence`, each owned by the service identity with mode `0600`, are: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index ff43259ac..fc5c8fcfa 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -59,8 +59,14 @@ sources: clientIdRef: secret:file/opencrvs-client-id clientSecretRef: secret:file/opencrvs-client-secret scope: recordsearch - credentialPlacement: query-string + # The token endpoint accepts client credentials in the request body, + # so they never reach a URL that a proxy or ingress may log. + credentialPlacement: form-body maximumCacheSeconds: 300 + # OpenCRVS returns only access_token and token_type. RFC 6749 section + # 5.1 permits the omission, so the assumed lifetime is stated here and + # still clamped by maximumCacheSeconds. + assumedLifetimeSeconds: 600 request: method: POST path: /events/search @@ -108,8 +114,14 @@ sources: clientIdRef: secret:file/opencrvs-client-id clientSecretRef: secret:file/opencrvs-client-secret scope: recordsearch - credentialPlacement: query-string + # The token endpoint accepts client credentials in the request body, + # so they never reach a URL that a proxy or ingress may log. + credentialPlacement: form-body maximumCacheSeconds: 300 + # OpenCRVS returns only access_token and token_type. RFC 6749 section + # 5.1 permits the omission, so the assumed lifetime is stated here and + # still clamped by maximumCacheSeconds. + assumedLifetimeSeconds: 600 request: method: POST path: /events/search diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml index 2f4c1d4e0..1584619bb 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml @@ -9,10 +9,15 @@ authentication: clientIdRef: secret:file/events-client-id clientSecretRef: secret:file/events-client-secret scope: recordsearch - # OpenCRVS token-bootstrap compatibility only. The evidence-data request - # still uses Authorization: Bearer. See the documented URL-log risk. - credentialPlacement: query-string + # The token endpoint accepts client credentials in the request body, so + # they never reach a URL that a proxy or ingress may log. The evidence-data + # request still uses Authorization: Bearer. + credentialPlacement: form-body maximumCacheSeconds: 300 + # OpenCRVS returns only access_token and token_type. RFC 6749 section + # 5.1 permits the omission, so the assumed lifetime is stated here and + # still clamped by maximumCacheSeconds. + assumedLifetimeSeconds: 600 request: method: POST path: /events/search From 121e0ba700924d1f70981c2edbbf7cf5cd7254bf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 23:36:30 +0700 Subject: [PATCH 016/136] docs(evidence): document the metrics listener series and labels The operator contract described the telemetry policy but never named the series, labels, or histogram bounds, so an operator writing a scrape config or a dashboard had to read the Rust source. Record the published contract alongside the limits that make it safe to expose. Also states two things the policy paragraph left implicit: the listener performs no authentication of its own, so the private binding is the only access control, and the series cover the HTTP boundary only, with source, signing, credential, and audit health reported by /ready instead. No behavior change. Reference only; the metric names, label sets, bucket bounds, route templates, and problem codes are the ones the runtime already emits and already asserts in tests. Signed-off-by: Jeremi Joslin --- products/evidence/OPERATOR-CONTRACT.md | 98 +++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index ab53c6c28..6251a427c 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -361,7 +361,8 @@ series cardinality is bounded by the deployed contract and cannot grow with caller input. A path that matches no route is counted as `unmatched` and a method outside the served set as `other`, so a caller cannot write a label value. Operators should still reach this listener only from their own network, -since request rates per route are operational information. +since request rates per route are operational information. The series and +labels it publishes are in [Metrics reference](#metrics-reference). The operator owns audit retention, backup, restore, access control, key rotation, and chain verification for the selected durable sink. A deployment @@ -444,6 +445,101 @@ event before the process exits. The archive is a rename of an already closed file, so nothing can be appended to a retired chain. The successor is a new file starting at genesis, so no record belongs to two chains. +## Metrics reference + +This section describes what a configured `metricsListener` serves. It is +operator material: the public evidence contract and the generated OpenAPI +document do not describe it, and a deployment that leaves `metricsListener` +absent serves none of it. + +```yaml +metricsListener: + bindHost: 127.0.0.1 + port: 9090 +``` + +`bindHost` accepts a numeric loopback, RFC 1918 private IPv4, or RFC 4193 +unique-local IPv6 address. Hostnames and unspecified, multicast, and public +addresses are rejected at startup, as is a `bindHost` and `port` pair that +repeats the evidence listener binding. Both listeners bind before either +serves, so a rejected telemetry binding fails startup rather than leaving a +service that reports healthy while publishing nothing. The two share one +lifecycle: the telemetry listener cannot outlive a failed evidence listener. + +The listener serves `GET /metrics` and answers every other path with `404`, +including the evidence routes. The exposition is Prometheus text format, +declared as `Content-Type: text/plain; version=0.0.4`. Two series are +published: + +| Series | Type | Meaning | +|---|---|---| +| `evidence_http_requests_total` | counter | Requests served at the evidence boundary | +| `evidence_http_request_duration_seconds` | histogram | Duration of those requests | + +The histogram publishes `_bucket`, `_sum`, and `_count`. Its upper bounds in +seconds are `0.005`, `0.01`, `0.025`, `0.05`, `0.1`, `0.25`, `0.5`, `1.0`, +`5.0`, and `+Inf`. They are fixed by the build and are not configurable. + +Both series carry the same four labels, and each is drawn from a closed set +fixed by the deployed contract rather than by anything a caller sends: + +| Label | Values | +|---|---| +| `route` | A registered route template, otherwise `unmatched` | +| `method` | `GET`, `POST`, `HEAD`, `OPTIONS`, otherwise `other` | +| `status` | `success`, `client_error`, `server_error` | +| `error` | A reviewed problem code, otherwise `none` | + +The registered route templates are `/v1/evidence`, +`/v1/evidence-definitions`, `/health`, `/ready`, `/openapi.json`, +`/.well-known/evidence/jwks.json`, and `/.well-known/jwt-vc-issuer`. The +reviewed problem codes are the closed public set: `malformed_request`, +`invalid_selector`, `authentication_failed`, `not_authorized`, +`response_format_not_acceptable`, `evidence_not_available`, `rate_limited`, +`dependency_unavailable`, and `service_unavailable`. + +`status` is the outcome class and never the exact status code, because the +exact status of a denial belongs to the closed public problem contract rather +than to operational telemetry. `error` carries the same reviewed problem code +the caller received, which makes a denial rate observable without making the +reason for any one request observable. + +Because both label sets are closed, series cardinality is bounded by the route +table and the problem-code set regardless of traffic, and the registry needs no +eviction. A caller cannot create a series or write a label value: a path +matching no route is counted as `unmatched` and the requested path is never +recorded anywhere in the exposition. + +An abbreviated exposition: + +```text +# HELP evidence_http_requests_total Requests served by the Evidence boundary. +# TYPE evidence_http_requests_total counter +evidence_http_requests_total{route="/health",method="GET",status="success",error="none"} 2 +evidence_http_requests_total{route="/v1/evidence-definitions",method="GET",status="client_error",error="authentication_failed"} 1 +# HELP evidence_http_request_duration_seconds Request duration at the Evidence boundary. +# TYPE evidence_http_request_duration_seconds histogram +evidence_http_request_duration_seconds_bucket{route="/health",method="GET",status="success",error="none",le="0.005"} 2 +evidence_http_request_duration_seconds_sum{route="/health",method="GET",status="success",error="none"} 0.000241 +evidence_http_request_duration_seconds_count{route="/health",method="GET",status="success",error="none"} 2 +``` + +The registry lives in process memory. A restart resets both series to zero, +which a `rate` or `increase` query handles under the ordinary counter-reset +rule. Version 1 neither persists counters nor pushes them anywhere. + +The telemetry listener performs no authentication of its own. The private +binding and the operator's own network are the only access controls, so the +operator must not route it through a public ingress or a shared scrape network. +Request rates per route and per problem code are operational information about +the registry even though no individual request is described. + +The series describe the HTTP boundary only. Version 1 publishes no source-call, +signing, credential-acquisition, or audit-sink series. A slow or failing +upstream source is visible only as evidence-request duration and as the problem +code the boundary returned; audit, signing, and source-credential health are +reported by `/ready` rather than by telemetry. + ## Startup and readiness Before production exposure, the operator runs: From 3f4ad32dbe583f7f6bb13936b063c2f7f288d7d0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 2 Aug 2026 23:44:30 +0700 Subject: [PATCH 017/136] docs(evidence): state that a private metrics bind is a floor, not a boundary The startup check rejects the mistake that actually exposes telemetry, a public or unspecified bindHost, and an operator can reasonably read the accepted range as meaning only they can reach the endpoint. On a flat pod network or a shared VPC every workload holds an RFC 1918 or unique-local address, so binding one there leaves the unauthenticated endpoint scrapable by every neighbour. Name loopback with a same-pod or same-host collector as the shape that keeps the intended boundary, and place the network policy for any wider binding with the operator. Security-sensitive documentation only: no behavior, validation, or default changes. The accepted address range and the absence of authentication on the telemetry listener are unchanged. Signed-off-by: Jeremi Joslin --- products/evidence/OPERATOR-CONTRACT.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 6251a427c..a02bff752 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -534,6 +534,16 @@ operator must not route it through a public ingress or a shared scrape network. Request rates per route and per problem code are operational information about the registry even though no individual request is described. +The accepted address range is therefore a floor, not a boundary. Startup +rejects the mistake that actually exposes telemetry, a public or unspecified +`bindHost`, but an accepted RFC 1918 or unique-local address only means the +endpoint is unreachable from the public internet. On a flat pod network or a +shared VPC every workload already holds such an address, so binding one there +makes the endpoint scrapable by every neighbouring workload. `127.0.0.1` with +a same-pod or same-host collector is the shape that keeps the operator +boundary the operator intended; any wider binding must be closed by a network +policy, and the operator owns that control. + The series describe the HTTP boundary only. Version 1 publishes no source-call, signing, credential-acquisition, or audit-sink series. A slow or failing upstream source is visible only as evidence-request duration and as the problem From f74c2432762ae81575151a797e01ed766fdc15f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 00:53:53 +0700 Subject: [PATCH 018/136] feat(evidence): add evidencectl adopter tooling Add the registry-evidencectl crate and evidencectl binary: adopter tooling beside the frozen Version 1 runtime, like registryctl for the rest of the stack. It generates signing, holder, and HMAC key material, assembles public JWKS documents, scaffolds a neutral deployment project that passes `evidence check` and `evidence evaluate` with zero edits after one keygen pass (optionally paired with a Mint configuration), and drives bundle fixtures through the evidence binary. Every Evidence semantic decision is shelled out to `evidence`; nothing is re-implemented. The only workspace dependency edge is registry-platform-crypto. The source-neutrality gate now also sweeps evidencectl production Rust, templates, and Cargo metadata, and the CI evidence shard includes the new crate so evidencectl-only changes run the Evidence contract jobs. Security review notes (key generation and key handling): - Entropy: getrandom OS randomness into Zeroizing buffers; the private scalar's only non-zeroized copy is the short-lived serde_json string inside the rendered JWK, documented in code. Negative test: jwks rejects a private JWK input without echoing its contents. - File modes: private files 0600 and secret directories 0700, applied at O_CREAT so no window exists with looser permissions; public outputs 0644. Negative tests pin the modes and the 0700 normalization of a pre-created --out-dir. - Overwrite safety: --force removes the existing path first and then creates with O_EXCL, so a symlink at the target is replaced, never followed or written through, including in the remove-to-open race window. Negative tests in keygen and jwks pin this. - Redaction: private key material never reaches stdout, stderr, or process arguments; scaffolds ship no secrets and the scaffolded README instructs operators to place source bearer tokens as 0600 files, never on a command line. - Batch semantics: all target paths are collision-checked before any write, so a refused batch leaves nothing behind. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 2 +- Cargo.lock | 17 + Cargo.toml | 1 + crates/registry-evidencectl/Cargo.toml | 31 + crates/registry-evidencectl/src/fixtures.rs | 337 +++++++++ crates/registry-evidencectl/src/jwks.rs | 114 +++ crates/registry-evidencectl/src/keygen.rs | 340 +++++++++ crates/registry-evidencectl/src/main.rs | 54 ++ crates/registry-evidencectl/src/scaffold.rs | 454 ++++++++++++ .../registry-evidencectl/templates/README.md | 114 +++ .../bundle/adapters/source-a-extract.rhai | 25 + .../bundle/adapters/source-a-prepare.rhai | 18 + .../bundle/derivations/example-flag.rhai | 14 + .../templates/bundle/evidence.yaml | 176 +++++ .../templates/bundle/fixtures/cases.yaml | 136 ++++ .../schemas/adapter-parameters.schema.yaml | 10 + .../bundle/schemas/facts.schema.yaml | 9 + .../registry-evidencectl/templates/gitignore | 4 + .../templates/mint/README-section.md | 94 +++ .../templates/mint/client.yaml.example | 39 + .../templates/mint/mint.yaml | 59 ++ .../templates/runtime.yaml | 36 + crates/registry-evidencectl/tests/fixtures.rs | 445 +++++++++++ .../registry-evidencectl/tests/full_flow.rs | 280 +++++++ crates/registry-evidencectl/tests/jwks.rs | 239 ++++++ crates/registry-evidencectl/tests/keygen.rs | 428 +++++++++++ crates/registry-evidencectl/tests/scaffold.rs | 692 ++++++++++++++++++ .../scripts/check-source-neutrality.sh | 18 +- 28 files changed, 4181 insertions(+), 5 deletions(-) create mode 100644 crates/registry-evidencectl/Cargo.toml create mode 100644 crates/registry-evidencectl/src/fixtures.rs create mode 100644 crates/registry-evidencectl/src/jwks.rs create mode 100644 crates/registry-evidencectl/src/keygen.rs create mode 100644 crates/registry-evidencectl/src/main.rs create mode 100644 crates/registry-evidencectl/src/scaffold.rs create mode 100644 crates/registry-evidencectl/templates/README.md create mode 100644 crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai create mode 100644 crates/registry-evidencectl/templates/bundle/adapters/source-a-prepare.rhai create mode 100644 crates/registry-evidencectl/templates/bundle/derivations/example-flag.rhai create mode 100644 crates/registry-evidencectl/templates/bundle/evidence.yaml create mode 100644 crates/registry-evidencectl/templates/bundle/fixtures/cases.yaml create mode 100644 crates/registry-evidencectl/templates/bundle/schemas/adapter-parameters.schema.yaml create mode 100644 crates/registry-evidencectl/templates/bundle/schemas/facts.schema.yaml create mode 100644 crates/registry-evidencectl/templates/gitignore create mode 100644 crates/registry-evidencectl/templates/mint/README-section.md create mode 100644 crates/registry-evidencectl/templates/mint/client.yaml.example create mode 100644 crates/registry-evidencectl/templates/mint/mint.yaml create mode 100644 crates/registry-evidencectl/templates/runtime.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures.rs create mode 100644 crates/registry-evidencectl/tests/full_flow.rs create mode 100644 crates/registry-evidencectl/tests/jwks.rs create mode 100644 crates/registry-evidencectl/tests/keygen.rs create mode 100644 crates/registry-evidencectl/tests/scaffold.rs diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index a316af223..1ff0b199a 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -42,7 +42,7 @@ "xtask", ), "relay": ("registry-relay",), - "evidence": ("registry-evidence",), + "evidence": ("registry-evidence", "registry-evidencectl"), "mint": ("registry-mint",), "developer-tools": ( "registry-config-report", diff --git a/Cargo.lock b/Cargo.lock index be8ab0318..95563c856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5394,6 +5394,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "registry-evidencectl" +version = "0.16.3" +dependencies = [ + "anyhow", + "base64", + "clap", + "ed25519-dalek", + "getrandom 0.4.3", + "registry-platform-crypto", + "serde", + "serde_json", + "serde_norway", + "tempfile", + "zeroize", +] + [[package]] name = "registry-language-server" version = "0.16.3" diff --git a/Cargo.toml b/Cargo.toml index feecaf710..c4761029b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/registry-config-report", "crates/registry-evidence", + "crates/registry-evidencectl", "crates/registry-platform-audit", "crates/registry-platform-authcommon", "crates/registry-platform-cache", diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml new file mode 100644 index 000000000..860a67499 --- /dev/null +++ b/crates/registry-evidencectl/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "registry-evidencectl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Evidence adopter tooling: key generation, project scaffolding, fixture runs." +repository.workspace = true +publish = false + +[[bin]] +name = "evidencectl" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +clap.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +registry-platform-crypto.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +zeroize.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/registry-evidencectl/src/fixtures.rs b/crates/registry-evidencectl/src/fixtures.rs new file mode 100644 index 000000000..f15c6f276 --- /dev/null +++ b/crates/registry-evidencectl/src/fixtures.rs @@ -0,0 +1,337 @@ +//! Fixture-run driver. Shells out to the `evidence` binary for every +//! semantic decision and only aggregates results. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, ExitCode}, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Subcommand}; +use serde::Serialize; +use serde_norway::Value as YamlValue; + +#[derive(Debug, Subcommand)] +pub enum FixturesCommand { + /// Run `evidence check` and every bundle fixture through `evidence evaluate`. + Run(RunArgs), +} + +#[derive(Debug, Args)] +pub struct RunArgs { + /// Deployment project directory containing runtime.yaml and bundle/. + #[arg(long)] + pub project: PathBuf, + + /// Path to the evidence binary; defaults to `evidence` on PATH. + #[arg(long)] + pub evidence_bin: Option, + + /// Emit one machine-readable JSON report on standard output. + #[arg(long)] + pub json: bool, +} + +/// The result of one `evidence` invocation: whether it exited zero, and, when +/// it did not, its captured stderr for the operator to read. +struct StepOutcome { + passed: bool, + stderr: Option, +} + +#[derive(Debug, Serialize)] +struct CheckReport { + passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + stderr: Option, +} + +#[derive(Debug, Serialize)] +struct FixtureReport { + path: String, + passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + stderr: Option, +} + +#[derive(Debug, Serialize)] +struct RunReport { + check: CheckReport, + fixtures: Vec, + passed: bool, +} + +pub fn run(command: FixturesCommand) -> Result { + match command { + FixturesCommand::Run(args) => run_fixtures(args), + } +} + +fn run_fixtures(args: RunArgs) -> Result { + let runtime_path = args.project.join("runtime.yaml"); + if !runtime_path.is_file() { + bail!( + "runtime configuration not found at {} (expected a deployment project directory containing runtime.yaml)", + runtime_path.display() + ); + } + let bundle_directory = resolve_bundle_directory(&runtime_path, &args.project)?; + let bundle_config_path = bundle_directory.join("evidence.yaml"); + let fixture_paths = discover_fixtures(&bundle_config_path)?; + let evidence_bin = resolve_evidence_binary(args.evidence_bin.as_deref())?; + + let check_outcome = run_evidence_step(&evidence_bin, &runtime_path, &["check"]); + let check_passed = check_outcome.passed; + + // A broken bundle makes per-fixture results meaningless, so a failing + // check short-circuits before any fixture is evaluated. + let mut fixtures = Vec::new(); + if check_passed { + for fixture_path in &fixture_paths { + let outcome = run_evidence_step( + &evidence_bin, + &runtime_path, + &["evaluate", "--fixture", fixture_path], + ); + fixtures.push(FixtureReport { + path: fixture_path.clone(), + passed: outcome.passed, + stderr: outcome.stderr, + }); + } + } + + let overall_passed = check_passed && fixtures.iter().all(|fixture| fixture.passed); + let report = RunReport { + check: CheckReport { + passed: check_passed, + stderr: check_outcome.stderr, + }, + fixtures, + passed: overall_passed, + }; + + if args.json { + print_diagnostics(&report, true); + let encoded = serde_json::to_string(&report).context("failed to encode the JSON report")?; + println!("{encoded}"); + } else { + print_diagnostics(&report, false); + } + + Ok(if overall_passed { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +/// Resolve the bundle directory a project's `runtime.yaml` names. A relative +/// `bundleDirectory` is resolved against the runtime file's own directory, an +/// absolute one is used as-is, and `/bundle` is the default only when +/// the key is absent. This is discovery, not validation: `evidence check` is +/// left to reject a runtime configuration that is otherwise malformed. +fn resolve_bundle_directory(runtime_path: &Path, project: &Path) -> Result { + let bytes = fs::read(runtime_path).with_context(|| { + format!( + "failed to read runtime configuration at {}", + runtime_path.display() + ) + })?; + let document: YamlValue = serde_norway::from_slice(&bytes).with_context(|| { + format!( + "failed to parse runtime configuration at {}", + runtime_path.display() + ) + })?; + match document.get("bundleDirectory") { + Some(value) => { + let value = value.as_str().ok_or_else(|| { + anyhow!( + "bundleDirectory in {} is not a string", + runtime_path.display() + ) + })?; + let path = Path::new(value); + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + let base = runtime_path.parent().unwrap_or(project); + Ok(base.join(path)) + } + } + None => Ok(project.join("bundle")), + } +} + +/// Enumerate the bundle-relative fixture paths a project's requirements +/// reference. This is discovery, not validation: unknown fields anywhere in +/// the document are tolerated, and `evidence check` is left to reject a +/// bundle that is otherwise malformed. +fn discover_fixtures(bundle_config_path: &Path) -> Result> { + let bytes = fs::read(bundle_config_path).with_context(|| { + format!( + "failed to read bundle configuration at {}", + bundle_config_path.display() + ) + })?; + let document: YamlValue = serde_norway::from_slice(&bytes).with_context(|| { + format!( + "failed to parse bundle configuration at {}", + bundle_config_path.display() + ) + })?; + let requirements = document + .get("requirements") + .and_then(YamlValue::as_sequence) + .ok_or_else(|| { + anyhow!( + "bundle configuration at {} has no requirements list", + bundle_config_path.display() + ) + })?; + + let mut fixture_paths: Vec = Vec::new(); + for requirement in requirements { + let fixture_path = requirement + .get("fixtures") + .and_then(YamlValue::as_str) + .ok_or_else(|| { + anyhow!( + "a requirement in {} has no fixtures path", + bundle_config_path.display() + ) + })?; + if !fixture_paths + .iter() + .any(|existing| existing == fixture_path) + { + fixture_paths.push(fixture_path.to_owned()); + } + } + Ok(fixture_paths) +} + +/// Resolve the `evidence` binary: an explicit `--evidence-bin`, else +/// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. +fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + if !path.is_file() { + bail!("evidence binary not found at {}", path.display()); + } + return Ok(path.to_path_buf()); + } + if let Ok(env_path) = env::var("EVIDENCE_BIN") { + let path = PathBuf::from(&env_path); + if !path.is_file() { + bail!( + "evidence binary not found at {} (from EVIDENCE_BIN)", + path.display() + ); + } + return Ok(path); + } + find_on_path("evidence").ok_or_else(|| { + anyhow!( + "evidence binary not found: pass --evidence-bin, set EVIDENCE_BIN, or add `evidence` to PATH" + ) + }) +} + +fn find_on_path(name: &str) -> Option { + let path_var = env::var_os("PATH")?; + env::split_paths(&path_var).find_map(|dir| { + let candidate = dir.join(name); + is_candidate_executable(&candidate).then_some(candidate) + }) +} + +/// A regular file, and, on unix, one with at least one executable bit set. A +/// non-executable file on `PATH` is skipped so resolution falls through to +/// the clearer "not found" error instead of a spawn failure later. +fn is_candidate_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Run one `evidence --runtime ` invocation. +/// +/// Standard output and standard error are captured rather than inherited so +/// steps never interleave, and any failure to even spawn the process is +/// treated the same as a nonzero exit: the step failed. +fn run_evidence_step(evidence_bin: &Path, runtime_path: &Path, args: &[&str]) -> StepOutcome { + let mut command = Command::new(evidence_bin); + command.arg("--runtime").arg(runtime_path).args(args); + match command.output() { + Ok(output) if output.status.success() => StepOutcome { + passed: true, + stderr: None, + }, + Ok(output) => StepOutcome { + passed: false, + stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()), + }, + Err(error) => StepOutcome { + passed: false, + stderr: Some(format!("failed to run {}: {error}", evidence_bin.display())), + }, + } +} + +/// Print one line per step and a summary line. +/// +/// In JSON mode this goes to stderr, keeping stdout reserved for the single +/// JSON document; in human mode it is the entire report and goes to stdout. +fn print_diagnostics(report: &RunReport, to_stderr: bool) { + let mut lines = Vec::new(); + lines.push(step_line("check", report.check.passed)); + if !report.check.passed { + lines.extend(indented(report.check.stderr.as_deref())); + } + for fixture in &report.fixtures { + lines.push(step_line(&fixture.path, fixture.passed)); + if !fixture.passed { + lines.extend(indented(fixture.stderr.as_deref())); + } + } + let passed_count = + usize::from(report.check.passed) + report.fixtures.iter().filter(|f| f.passed).count(); + let failed_count = + usize::from(!report.check.passed) + report.fixtures.iter().filter(|f| !f.passed).count(); + lines.push(format!("{passed_count} passed, {failed_count} failed")); + + for line in lines { + if to_stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } +} + +fn step_line(name: &str, passed: bool) -> String { + let status = if passed { "PASS" } else { "FAIL" }; + format!("{status}: {name}") +} + +fn indented(stderr: Option<&str>) -> Vec { + let text = stderr.unwrap_or_default(); + if text.trim().is_empty() { + return vec![" (no output captured)".to_owned()]; + } + text.lines().map(|line| format!(" {line}")).collect() +} diff --git a/crates/registry-evidencectl/src/jwks.rs b/crates/registry-evidencectl/src/jwks.rs new file mode 100644 index 000000000..4efa9cd42 --- /dev/null +++ b/crates/registry-evidencectl/src/jwks.rs @@ -0,0 +1,114 @@ +//! Public JWKS assembly from public JWK files. Inputs containing private +//! material are rejected outright. + +use std::{ + collections::HashMap, + fs::{self, OpenOptions}, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::PathBuf, + process::ExitCode, +}; + +use anyhow::{bail, Context, Result}; +use clap::Args; +use registry_platform_crypto::{canonicalize_json, PublicJwk}; + +const OUTPUT_FILE_MODE: u32 = 0o644; + +#[derive(Debug, Args)] +pub struct JwksArgs { + /// Output JWKS document path. + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, + + /// Public JWK files to include, in order. + #[arg(required = true)] + pub public_jwk_files: Vec, +} + +pub fn run(args: JwksArgs) -> Result { + if args.out.exists() && !args.force { + bail!( + "refusing to overwrite existing output without --force: {}", + args.out.display() + ); + } + + let mut entries = Vec::new(); + // canonical bytes per kid already accepted, so a repeated kid can be + // recognized as either an identical duplicate or a genuine conflict. + let mut seen_by_kid: HashMap> = HashMap::new(); + + for path in &args.public_jwk_files { + let contents = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + // Validates the JWK and hard-rejects any private member (including a + // "d" value); the error never carries the file's contents. + let public = PublicJwk::parse(&contents) + .with_context(|| format!("{} is not a valid public JWK", path.display()))?; + let kid = public + .kid + .clone() + .ok_or_else(|| anyhow::anyhow!("{} is missing a \"kid\" member", path.display()))?; + + let value: serde_json::Value = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse {}", path.display()))?; + let canonical = canonicalize_json(&value) + .with_context(|| format!("failed to canonicalize {}", path.display()))?; + + match seen_by_kid.get(&kid) { + Some(existing) if existing == &canonical => continue, + Some(_) => bail!( + "conflicting public JWKs share kid \"{kid}\" (last seen at {})", + path.display() + ), + None => { + seen_by_kid.insert(kid, canonical); + entries.push(value); + } + } + } + + let mut document = serde_json::to_string_pretty(&serde_json::json!({ "keys": entries })) + .context("failed to render the JWKS document")?; + document.push('\n'); + + write_owner_file(&args.out, document.as_bytes(), args.force)?; + + println!("wrote {}", args.out.display()); + + Ok(ExitCode::SUCCESS) +} + +/// Writes `contents` to `path` at `OUTPUT_FILE_MODE`, set atomically at file +/// creation. A force overwrite first removes anything already at `path`, +/// including a symlink, so the create that follows always creates a fresh +/// file and `OUTPUT_FILE_MODE` is always the one `O_CREAT` applies, never a +/// later chmod that could instead land on whatever a symlink now points at. +fn write_owner_file(path: &std::path::Path, contents: &[u8], force: bool) -> Result<()> { + if force { + match fs::symlink_metadata(path) { + Ok(_) => fs::remove_file(path) + .with_context(|| format!("failed to remove existing {}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())) + } + } + } + let mut options = OpenOptions::new(); + options.write(true).mode(OUTPUT_FILE_MODE).create_new(true); + let mut file = options + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to persist {}", path.display()))?; + Ok(()) +} diff --git a/crates/registry-evidencectl/src/keygen.rs b/crates/registry-evidencectl/src/keygen.rs new file mode 100644 index 000000000..a16a264ae --- /dev/null +++ b/crates/registry-evidencectl/src/keygen.rs @@ -0,0 +1,340 @@ +//! Key material generation. Private material is written as owner-only files +//! and never reaches standard output. + +use std::{ + fs::{self, OpenOptions}, + io::Write as _, + os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use clap::{Args, Subcommand}; +use ed25519_dalek::SigningKey; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use zeroize::Zeroizing; + +#[derive(Debug, Subcommand)] +pub enum KeygenCommand { + /// Ed25519 signing keypair as private and public JWK files. + Signing(SigningArgs), + /// One random raw secret file, 32 bytes (audit or subject-binding HMAC). + Secret(SecretArgs), + /// Ed25519 holder keypair for SD-JWT VC confirmation binding. + Holder(HolderArgs), +} + +#[derive(Debug, Args)] +pub struct SigningArgs { + /// Secret directory receiving the private JWK file (created 0700). + #[arg(long)] + pub out_dir: PathBuf, + + /// Key identifier; defaults to the RFC 7638 JWK thumbprint. + #[arg(long)] + pub kid: Option, + + /// Public JWK output path; defaults to a file inside the secret directory. + #[arg(long)] + pub public_out: Option, + + /// Overwrite existing output files. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Args)] +pub struct SecretArgs { + /// Output file for the raw secret (written 0600). + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, +} + +#[derive(Debug, Args)] +pub struct HolderArgs { + /// Directory receiving the holder private JWK file (created 0700). + #[arg(long)] + pub out_dir: PathBuf, + + /// Key identifier; defaults to the RFC 7638 JWK thumbprint. + #[arg(long)] + pub kid: Option, + + /// Public JWK output path; defaults to a file inside the secret directory. + #[arg(long)] + pub public_out: Option, + + /// Overwrite existing output files. + #[arg(long)] + pub force: bool, +} + +/// Filename for the private signing JWK, fixed to match the reference +/// deployment project's secret-mount layout. +const SIGNING_PRIVATE_FILENAME: &str = "signing-ed25519-private-jwk"; +const SIGNING_PUBLIC_FILENAME: &str = "signing-ed25519-public.jwk.json"; +const HOLDER_PRIVATE_FILENAME: &str = "holder-ed25519-private-jwk"; +const HOLDER_PUBLIC_FILENAME: &str = "holder-ed25519-public.jwk.json"; + +const PRIVATE_FILE_MODE: u32 = 0o600; +const PUBLIC_FILE_MODE: u32 = 0o644; +const PRIVATE_DIR_MODE: u32 = 0o700; + +/// Exactly 32 raw bytes: one HMAC secret (audit or subject-binding). +const SECRET_FILE_BYTES: usize = 32; + +pub fn run(command: KeygenCommand) -> Result { + match command { + KeygenCommand::Signing(args) => run_keypair( + &args.out_dir, + args.kid.as_deref(), + args.public_out.as_deref(), + args.force, + SIGNING_PRIVATE_FILENAME, + SIGNING_PUBLIC_FILENAME, + ), + KeygenCommand::Secret(args) => run_secret(&args), + KeygenCommand::Holder(args) => run_keypair( + &args.out_dir, + args.kid.as_deref(), + args.public_out.as_deref(), + args.force, + HOLDER_PRIVATE_FILENAME, + HOLDER_PUBLIC_FILENAME, + ), + } +} + +fn run_keypair( + out_dir: &Path, + kid: Option<&str>, + public_out: Option<&Path>, + force: bool, + private_filename: &str, + public_filename: &str, +) -> Result { + if let Some(kid) = kid { + if kid.trim().is_empty() { + bail!("--kid must not be empty or whitespace-only"); + } + } + + let private_path = out_dir.join(private_filename); + let public_path = public_out + .map(Path::to_path_buf) + .unwrap_or_else(|| out_dir.join(public_filename)); + + // Every target path is known up front, so the whole batch can be checked + // for collisions before anything is written. + reject_existing(&[&private_path, &public_path], force)?; + + let mut secret = Zeroizing::new([0_u8; 32]); + getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + let signing_key = SigningKey::from_bytes(&secret); + let x = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()); + let d = Zeroizing::new(URL_SAFE_NO_PAD.encode(secret.as_slice())); + + let kid = match kid { + Some(kid) => kid.to_string(), + None => default_kid(&x)?, + }; + + let private_json = Zeroizing::new( + serde_json::to_string_pretty(&serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + // `json!` copies `d` into a `serde_json::Value::String` whose heap + // buffer this crate does not zeroize, unlike every other copy of + // the secret above. Accepted: serde_json owns the escaping here, + // and the copy is short-lived, but it is not wiped. + "d": d.as_str(), + "x": x, + "alg": "EdDSA", + "kid": kid, + })) + .context("failed to render the private JWK")?, + ); + let public_json = serde_json::to_string_pretty(&serde_json::json!({ + "kty": "OKP", + "crv": "Ed25519", + "x": x, + "alg": "EdDSA", + "kid": kid, + "use": "sig", + })) + .context("failed to render the public JWK")?; + + // Self-check: a key this tool cannot parse back is not fit to ship. + PrivateJwk::parse(&private_json).context("generated private JWK failed validation")?; + PublicJwk::parse(&public_json).context("generated public JWK failed validation")?; + + ensure_private_dir(out_dir)?; + ensure_parent_dir(&public_path)?; + write_owner_file( + &private_path, + private_json.as_bytes(), + PRIVATE_FILE_MODE, + force, + )?; + write_owner_file( + &public_path, + public_json.as_bytes(), + PUBLIC_FILE_MODE, + force, + )?; + + println!("wrote {}", private_path.display()); + println!("wrote {}", public_path.display()); + println!("kid: {kid}"); + + Ok(ExitCode::SUCCESS) +} + +fn run_secret(args: &SecretArgs) -> Result { + reject_existing(&[&args.out], args.force)?; + + let mut secret = Zeroizing::new([0_u8; SECRET_FILE_BYTES]); + getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + + if let Some(parent) = args + .out + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + ensure_secret_parent_dir(parent)?; + } + write_owner_file(&args.out, secret.as_slice(), PRIVATE_FILE_MODE, args.force)?; + + println!("wrote {}", args.out.display()); + + Ok(ExitCode::SUCCESS) +} + +/// Default kid: the RFC 7638 thumbprint of the public key. +fn default_kid(x: &str) -> Result { + let public = PublicJwk { + kty: "OKP".to_string(), + kid: None, + alg: None, + crv: Some("Ed25519".to_string()), + x: Some(x.to_string()), + y: None, + n: None, + e: None, + }; + public.jkt().context("failed to compute the JWK thumbprint") +} + +/// Refuses to proceed if any target path already exists, unless `force` is +/// set. Checked for every path before any file is written so a batch either +/// completes in full or leaves nothing behind. +fn reject_existing(paths: &[&Path], force: bool) -> Result<()> { + if force { + return Ok(()); + } + let existing: Vec = paths + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if existing.is_empty() { + return Ok(()); + } + bail!( + "refusing to overwrite existing output without --force: {}", + existing.join(", ") + ); +} + +/// Creates the parent directory of `path` if it does not already exist. Used +/// for public output paths, which carry no confidentiality requirement of +/// their own, so no particular mode is imposed. +fn ensure_parent_dir(path: &Path) -> Result<()> { + let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + else { + return Ok(()); + }; + if parent.exists() { + return Ok(()); + } + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display())) +} + +/// Creates `dir` as mode 0700 if missing, or normalizes its mode to 0700 if +/// it already exists. Used for `--out-dir`: the caller named `dir` itself as +/// the secret directory, so an existing directory found there is brought +/// under this tool's ownership either way. +fn ensure_private_dir(dir: &Path) -> Result<()> { + ensure_private_dir_impl(dir, true) +} + +/// Creates `dir` as mode 0700 if missing; leaves its mode untouched if it +/// already exists. Used for `--out`'s parent directory, which is derived from +/// the output path rather than named by the caller as a secret directory, so +/// this tool does not re-chmod a directory it did not create. +fn ensure_secret_parent_dir(dir: &Path) -> Result<()> { + ensure_private_dir_impl(dir, false) +} + +/// Shared implementation: a symlink or non-directory at `dir` is always +/// rejected. A missing `dir` is always created at mode 0700. Whether an +/// already-existing `dir` has its mode normalized to 0700 is left to the +/// caller via `normalize_existing`. +fn ensure_private_dir_impl(dir: &Path, normalize_existing: bool) -> Result<()> { + if dir.exists() { + let metadata = fs::symlink_metadata(dir) + .with_context(|| format!("failed to inspect {}", dir.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("{} exists and is not a plain directory", dir.display()); + } + if !normalize_existing { + return Ok(()); + } + } else { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + builder.mode(PRIVATE_DIR_MODE); + builder + .create(dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + } + fs::set_permissions(dir, fs::Permissions::from_mode(PRIVATE_DIR_MODE)) + .with_context(|| format!("failed to set permissions on {}", dir.display())) +} + +/// Writes `contents` to `path` with `mode`, set atomically at file creation +/// so there is no window where the file is readable with the wrong +/// permissions. A force overwrite first removes anything already at `path`, +/// including a symlink, so the create that follows always creates a fresh +/// file and `mode` is always the one `O_CREAT` applies, never a later chmod +/// that could instead land on whatever a symlink now points at. +fn write_owner_file(path: &Path, contents: &[u8], mode: u32, force: bool) -> Result<()> { + if force { + match fs::symlink_metadata(path) { + Ok(_) => fs::remove_file(path) + .with_context(|| format!("failed to remove existing {}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())) + } + } + } + let mut options = OpenOptions::new(); + options.write(true).mode(mode).create_new(true); + let mut file = options + .open(path) + .with_context(|| format!("failed to create {}", path.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to persist {}", path.display()))?; + Ok(()) +} diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs new file mode 100644 index 000000000..a1b1ada69 --- /dev/null +++ b/crates/registry-evidencectl/src/main.rs @@ -0,0 +1,54 @@ +//! Evidence adopter tooling: key generation, project scaffolding, fixture +//! runs. Companion to the frozen `evidence` runtime CLI; it never implements +//! Evidence semantics itself and shells out to the runtime binary for them. + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +mod fixtures; +mod jwks; +mod keygen; +mod scaffold; + +#[derive(Debug, Parser)] +#[command( + name = "evidencectl", + version, + about = "Evidence adopter tooling: keys, project scaffolds, fixture runs" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Generate Evidence deployment key material as owner-only files. + #[command(subcommand)] + Keygen(keygen::KeygenCommand), + /// Assemble a public JWKS document from public JWK files. + Jwks(jwks::JwksArgs), + /// Scaffold a neutral Evidence deployment project. + New(scaffold::NewArgs), + /// Drive the evidence binary across a project's bundle fixtures. + #[command(subcommand)] + Fixtures(fixtures::FixturesCommand), +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + let result = match cli.command { + Command::Keygen(command) => keygen::run(command), + Command::Jwks(args) => jwks::run(args), + Command::New(args) => scaffold::run(args), + Command::Fixtures(command) => fixtures::run(command), + }; + match result { + Ok(code) => code, + Err(error) => { + eprintln!("evidencectl: {error:#}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs new file mode 100644 index 000000000..b094845a2 --- /dev/null +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -0,0 +1,454 @@ +//! Deployment-project scaffolding. Emits a neutral, tutorial-shaped project +//! that passes `evidence check` and `evidence evaluate` after one keygen pass. + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{bail, Context as _}; +use clap::Args; + +/// Governed identifiers the scaffold stamps into the bundle. They are all in +/// the reserved example namespace so a generated project can never be mistaken +/// for a governed deployment, and every one of them is meant to be replaced. +const TRUST_DOMAIN: &str = "urn:example:scaffold:trust-domain"; +const REQUIREMENT_ID: &str = "urn:example:scaffold:requirement:example-flag:v1"; +const FRAMEWORK_ID: &str = "urn:example:scaffold:framework:example-flag:v1"; +const EVIDENCE_TYPE_ID: &str = "urn:example:scaffold:evidence-type:example-flag:v1"; +const CONCEPT_ID: &str = "urn:example:scaffold:concept:example-flag"; +const DISCLOSURE_FAMILY_ID: &str = "urn:example:scaffold:disclosure-family:example-flag"; + +/// The requester tag the bundle's authority profile admits, and the one a +/// paired Mint registration mints for its caller. +const REQUESTER_TAG: &str = "scaffold-agency"; + +/// The signing key identifier the bundle declares. `keygen signing --kid` +/// takes the same value, so the scaffolded project needs no edit to sign. +const SIGNING_KEY_ID: &str = "scaffold-signing-key-1"; + +/// The access-token contract, written into the bundle's `authentication` block +/// and, when a Mint configuration is rendered beside it, into that document +/// too. One source for both sides is what stops the pairing drifting. +const TOKEN_AUDIENCE: &str = "evidence-scaffold"; +const TOKEN_ALGORITHM: &str = "EdDSA"; +const TOKEN_JWKS_PATH: &str = "/.well-known/jwks.json"; +const PRINCIPAL_CLAIM: &str = "sub"; +const REQUESTER_TAGS_CLAIM: &str = "evidence_tags"; +const EVIDENCE_AUDIENCE_CLAIM: &str = "evidence_audience"; +const GRANT_ID_CLAIM: &str = "evidence_grant_id"; +const GRANT_AUTHORITY_CLAIM: &str = "evidence_authority"; + +/// The token issuer the bundle trusts. A standalone project names a placeholder +/// identity provider; a paired one names the Mint deployment beside it. +const IDENTITY_ISSUER: &str = "https://identity.invalid"; +const MINT_ISSUER: &str = "https://mint.invalid"; + +/// Identifiers a paired Mint deployment is scaffolded with. Like every other +/// identifier here they are meant to be replaced. +const MINT_SIGNING_KEY_ID: &str = "scaffold-mint-key-1"; +const MINT_CLIENT_ID: &str = "scaffold-client"; +const MINT_CLIENT_KEY_ID: &str = "scaffold-client-key-1"; +const MINT_CLIENT_PRINCIPAL: &str = "urn:example:scaffold:client"; +const MINT_CLIENT_AUDIENCE: &str = "https://requester.invalid"; + +/// The comment above the bundle's `authentication` block. It is the one part of +/// that block whose wording depends on where the tokens come from. +const AUTHENTICATION_NOTE: &str = "\ +# Requesters present an OIDC access token. Replace the issuer, the JWKS URI and +# the audience with the identity provider that serves this deployment."; +const MINT_AUTHENTICATION_NOTE: &str = "\ +# Requesters present an OIDC access token minted by the Registry Mint +# deployment in mint/. Every value below is mirrored in mint/mint.yaml, and a +# single-sided edit produces tokens Mint issues and this deployment refuses."; + +/// Project-relative locations the scaffold owns. +const BUNDLE_DIRECTORY: &str = "bundle"; +const SECRET_DIRECTORY: &str = "secrets"; +const AUDIT_DIRECTORY: &str = "audit"; +const AUDIT_FILE: &str = "audit/evidence.jsonl"; +const RUNTIME_FILE: &str = "runtime.yaml"; +const MINT_DIRECTORY: &str = "mint"; +const MINT_CONFIG_FILE: &str = "mint/mint.yaml"; +const MINT_CLIENT_DIRECTORY: &str = "mint/clients"; +const MINT_SECRET_DIRECTORY: &str = "mint/secrets"; +/// The example caller's key is not Mint's own, so it sits beside rather than in +/// the secret root Mint reads. Both are under a `secrets` path component, which +/// is what the generated `.gitignore` excludes. +const MINT_CALLER_SECRET_DIRECTORY: &str = "mint/secrets/caller"; + +/// One rendered file: where it lands in the project, and its template bytes. +struct ProjectFile { + relative: &'static str, + template: &'static str, +} + +const PROJECT_FILES: [ProjectFile; 10] = [ + ProjectFile { + relative: "README.md", + template: include_str!("../templates/README.md"), + }, + ProjectFile { + relative: ".gitignore", + template: include_str!("../templates/gitignore"), + }, + ProjectFile { + relative: RUNTIME_FILE, + template: include_str!("../templates/runtime.yaml"), + }, + ProjectFile { + relative: "bundle/evidence.yaml", + template: include_str!("../templates/bundle/evidence.yaml"), + }, + ProjectFile { + relative: "bundle/adapters/source-a-prepare.rhai", + template: include_str!("../templates/bundle/adapters/source-a-prepare.rhai"), + }, + ProjectFile { + relative: "bundle/adapters/source-a-extract.rhai", + template: include_str!("../templates/bundle/adapters/source-a-extract.rhai"), + }, + ProjectFile { + relative: "bundle/derivations/example-flag.rhai", + template: include_str!("../templates/bundle/derivations/example-flag.rhai"), + }, + ProjectFile { + relative: "bundle/schemas/adapter-parameters.schema.yaml", + template: include_str!("../templates/bundle/schemas/adapter-parameters.schema.yaml"), + }, + ProjectFile { + relative: "bundle/schemas/facts.schema.yaml", + template: include_str!("../templates/bundle/schemas/facts.schema.yaml"), + }, + ProjectFile { + relative: "bundle/fixtures/cases.yaml", + template: include_str!("../templates/bundle/fixtures/cases.yaml"), + }, +]; + +/// The paired Registry Mint deployment, rendered only for `--with-mint`. +/// +/// The registration is written under a name Mint's registry loader ignores. It +/// needs the caller's public key before it can serve, and the scaffold has no +/// key to put there: it never generates key material. +const MINT_FILES: [ProjectFile; 2] = [ + ProjectFile { + relative: MINT_CONFIG_FILE, + template: include_str!("../templates/mint/mint.yaml"), + }, + ProjectFile { + relative: "mint/clients/scaffold-client.yaml.example", + template: include_str!("../templates/mint/client.yaml.example"), + }, +]; + +/// Appended to the rendered README when a Mint configuration is rendered. +const MINT_README_SECTION: &str = include_str!("../templates/mint/README-section.md"); + +#[derive(Debug, Args)] +pub struct NewArgs { + /// Directory to create the deployment project in. + pub directory: PathBuf, + + /// Evidence provider identifier stamped into the bundle. + #[arg(long, default_value = "urn:example:scaffold:provider")] + pub provider_id: String, + + /// Issuing authority identifier stamped into the bundle. + #[arg(long, default_value = "urn:example:scaffold:issuer")] + pub issuer_id: String, + + /// Also render a paired Registry Mint configuration for the project. + #[arg(long)] + pub with_mint: bool, + + /// Scaffold into a non-empty directory. + #[arg(long)] + pub force: bool, +} + +pub fn run(args: NewArgs) -> anyhow::Result { + if directory_has_entries(&args.directory)? && !args.force { + bail!( + "refusing to scaffold into the non-empty directory {}; pass --force to proceed", + args.directory.display() + ); + } + + fs::create_dir_all(&args.directory).with_context(|| { + format!( + "creating the project directory {}", + args.directory.display() + ) + })?; + // Absolute paths belong in runtime.yaml, so the generated project runs from + // any working directory. Canonicalizing after creation resolves symlinked + // parents such as a temporary directory on macOS. + let root = fs::canonicalize(&args.directory).with_context(|| { + format!( + "resolving the project directory {}", + args.directory.display() + ) + })?; + // A rewrite has to clear the immutability the documented freeze applies, + // otherwise the read-only bundle from an earlier run rejects every write. + restore_writable(&root)?; + + // A rewrite that drops --with-mint must not leave the earlier run's mint/ + // tree behind: it would keep documenting and registering a token issuer + // the freshly rendered bundle no longer pairs with. + if !args.with_mint && root.join(MINT_CONFIG_FILE).is_file() { + bail!( + "{} already has a mint/ tree rendered by an earlier `--with-mint` scaffold; \ + pass --with-mint to re-render it in sync, or delete mint/ first", + root.display() + ); + } + + let placeholders = placeholders(&root, &args)?; + for file in &PROJECT_FILES { + let path = root.join(file.relative); + let mut rendered = render(file.template, &placeholders) + .with_context(|| format!("rendering {}", file.relative))?; + // The Mint steps are the tail of the README rather than a second file, + // so an adopter reads one document either way. + if args.with_mint && file.relative == "README.md" { + rendered.push_str( + &render(MINT_README_SECTION, &placeholders) + .context("rendering the Mint README section")?, + ); + } + write_project_file(&path, &rendered)?; + } + + let secret_root = root.join(SECRET_DIRECTORY); + create_secret_directory(&secret_root)?; + let audit_root = root.join(AUDIT_DIRECTORY); + fs::create_dir_all(&audit_root) + .with_context(|| format!("creating {}", audit_root.display()))?; + + if args.with_mint { + for file in &MINT_FILES { + let path = root.join(file.relative); + let rendered = render(file.template, &placeholders) + .with_context(|| format!("rendering {}", file.relative))?; + write_project_file(&path, &rendered)?; + } + create_secret_directory(&root.join(MINT_SECRET_DIRECTORY))?; + } + + report(&root, &secret_root, args.with_mint); + Ok(ExitCode::SUCCESS) +} + +/// The substitutions every template shares. Values are computed once so the +/// bundle, the runtime file, the README and any paired Mint configuration +/// cannot drift from each other. +fn placeholders(root: &Path, args: &NewArgs) -> anyhow::Result> { + let token_issuer = if args.with_mint { + MINT_ISSUER + } else { + IDENTITY_ISSUER + }; + let authentication_note = if args.with_mint { + MINT_AUTHENTICATION_NOTE + } else { + AUTHENTICATION_NOTE + }; + Ok(vec![ + ("project_root", path_string(root)?), + ( + "bundle_directory", + path_string(&root.join(BUNDLE_DIRECTORY))?, + ), + ("secret_root", path_string(&root.join(SECRET_DIRECTORY))?), + ("audit_path", path_string(&root.join(AUDIT_FILE))?), + ("provider_id", args.provider_id.clone()), + ("issuer_id", args.issuer_id.clone()), + ("trust_domain", TRUST_DOMAIN.to_owned()), + ("requirement_id", REQUIREMENT_ID.to_owned()), + ("framework_id", FRAMEWORK_ID.to_owned()), + ("evidence_type_id", EVIDENCE_TYPE_ID.to_owned()), + ("concept_id", CONCEPT_ID.to_owned()), + ("disclosure_family_id", DISCLOSURE_FAMILY_ID.to_owned()), + ("signing_key_id", SIGNING_KEY_ID.to_owned()), + ("requester_tag", REQUESTER_TAG.to_owned()), + ("authentication_note", authentication_note.to_owned()), + ("token_issuer", token_issuer.to_owned()), + ("token_audience", TOKEN_AUDIENCE.to_owned()), + ("token_algorithm", TOKEN_ALGORITHM.to_owned()), + ("token_jwks_path", TOKEN_JWKS_PATH.to_owned()), + ("token_jwks_uri", format!("{token_issuer}{TOKEN_JWKS_PATH}")), + ("principal_claim", PRINCIPAL_CLAIM.to_owned()), + ("requester_tags_claim", REQUESTER_TAGS_CLAIM.to_owned()), + ( + "evidence_audience_claim", + EVIDENCE_AUDIENCE_CLAIM.to_owned(), + ), + ("grant_id_claim", GRANT_ID_CLAIM.to_owned()), + ("grant_authority_claim", GRANT_AUTHORITY_CLAIM.to_owned()), + ( + "mint_config_path", + path_string(&root.join(MINT_CONFIG_FILE))?, + ), + ( + "mint_clients_directory", + path_string(&root.join(MINT_CLIENT_DIRECTORY))?, + ), + ( + "mint_secret_root", + path_string(&root.join(MINT_SECRET_DIRECTORY))?, + ), + ( + "mint_caller_secret_root", + path_string(&root.join(MINT_CALLER_SECRET_DIRECTORY))?, + ), + ("mint_signing_key_id", MINT_SIGNING_KEY_ID.to_owned()), + ("mint_client_id", MINT_CLIENT_ID.to_owned()), + ("mint_client_key_id", MINT_CLIENT_KEY_ID.to_owned()), + ("mint_client_principal", MINT_CLIENT_PRINCIPAL.to_owned()), + ("mint_client_audience", MINT_CLIENT_AUDIENCE.to_owned()), + ("mint_token_endpoint", format!("{token_issuer}/token")), + ]) +} + +/// Substitute every `{{name}}` marker, then refuse output that still holds one. +/// A silently unrendered marker would produce a project that fails much later, +/// in `evidence check`, with a far less obvious cause. +fn render(template: &str, placeholders: &[(&'static str, String)]) -> anyhow::Result { + let mut rendered = template.to_owned(); + for (name, value) in placeholders { + rendered = rendered.replace(&format!("{{{{{name}}}}}"), value); + } + if rendered.contains("{{") { + bail!("the template contains a placeholder the scaffold does not define"); + } + Ok(rendered) +} + +fn path_string(path: &Path) -> anyhow::Result { + path.to_str() + .map(ToOwned::to_owned) + .with_context(|| format!("the path {} is not valid UTF-8", path.display())) +} + +fn directory_has_entries(path: &Path) -> anyhow::Result { + match fs::read_dir(path) { + Ok(mut entries) => Ok(entries.next().transpose()?.is_some()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => { + Err(error).with_context(|| format!("reading the directory {}", path.display())) + } + } +} + +fn write_project_file(path: &Path, contents: &str) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating the directory {}", parent.display()))?; + } + fs::write(path, contents).with_context(|| format!("writing {}", path.display())) +} + +/// Create the secret root the runtime file names, owner-only and empty. Keys +/// are generated by `evidencectl keygen`, never here. +fn create_secret_directory(path: &Path) -> anyhow::Result<()> { + fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("restricting {} to its owner", path.display()))?; + Ok(()) +} + +/// Give the owner write permission back across an existing project tree. +fn restore_writable(root: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(root) + .with_context(|| format!("reading the permissions of {}", root.display()))?; + if metadata.file_type().is_symlink() { + return Ok(()); + } + let mode = metadata.permissions().mode(); + if mode & 0o200 == 0 { + fs::set_permissions(root, fs::Permissions::from_mode(mode | 0o200)) + .with_context(|| format!("restoring write permission on {}", root.display()))?; + } + if !metadata.is_dir() { + return Ok(()); + } + for entry in + fs::read_dir(root).with_context(|| format!("reading the directory {}", root.display()))? + { + let entry = entry.with_context(|| format!("reading the directory {}", root.display()))?; + restore_writable(&entry.path())?; + } + Ok(()) +} + +/// Report paths only. No key material exists yet, and none is ever printed. +fn report(root: &Path, secret_root: &Path, with_mint: bool) { + println!( + "Scaffolded an Evidence deployment project in {}", + root.display() + ); + println!(" bundle: {}", root.join(BUNDLE_DIRECTORY).display()); + println!(" runtime: {}", root.join(RUNTIME_FILE).display()); + println!(" secrets: {} (empty, owner-only)", secret_root.display()); + println!(" audit: {}", root.join(AUDIT_DIRECTORY).display()); + if with_mint { + println!( + " mint: {} (paired token issuer)", + root.join(MINT_DIRECTORY).display() + ); + } + println!(); + println!("Next steps:"); + println!( + " evidencectl keygen signing --out-dir {} --kid {SIGNING_KEY_ID}", + secret_root.display() + ); + println!( + " evidencectl keygen secret --out {}", + secret_root.join("audit-hmac-key").display() + ); + println!( + " evidencectl keygen secret --out {}", + secret_root.join("subject-binding-hmac-key").display() + ); + println!( + " chmod -R a-w {} && chmod 444 {}", + root.join(BUNDLE_DIRECTORY).display(), + root.join(RUNTIME_FILE).display() + ); + println!( + " evidence check --runtime {}", + root.join(RUNTIME_FILE).display() + ); + println!( + " evidence evaluate --runtime {} --fixture fixtures/cases.yaml", + root.join(RUNTIME_FILE).display() + ); + if with_mint { + println!(); + println!("Next steps for the paired Registry Mint deployment:"); + println!( + " evidencectl keygen signing --out-dir {} --kid {MINT_SIGNING_KEY_ID}", + root.join(MINT_SECRET_DIRECTORY).display() + ); + println!( + " evidencectl keygen signing --out-dir {} --kid {MINT_CLIENT_KEY_ID}", + root.join(MINT_CALLER_SECRET_DIRECTORY).display() + ); + println!( + " # copy the caller public key into {}/{MINT_CLIENT_ID}.yaml.example,", + root.join(MINT_CLIENT_DIRECTORY).display() + ); + println!(" # then rename that file to {MINT_CLIENT_ID}.yaml to register the caller"); + println!( + " mint check --config {}", + root.join(MINT_CONFIG_FILE).display() + ); + } + println!(); + println!("{} explains the rest.", root.join("README.md").display()); +} diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md new file mode 100644 index 000000000..58e6586a6 --- /dev/null +++ b/crates/registry-evidencectl/templates/README.md @@ -0,0 +1,114 @@ +# Evidence deployment project + +Generated by `evidencectl new`. This is a complete, synthetic Evidence Version 1 +deployment project: one requirement, one generic JSON over HTTP source, and the +acceptance fixture that proves the requirement offline. Nothing here talks to a +real system yet, and every identifier is in the `urn:example:scaffold` namespace. + +## What was generated + +```text +{{project_root}} + bundle/ governed, reviewed, mounted read-only + evidence.yaml the deployment contract + adapters/ request preparation and fact extraction (Rhai) + derivations/ requirement derivation (Rhai) + schemas/ closed adapter-parameter and fact schemas + fixtures/ synthetic acceptance cases + runtime.yaml process-local paths and listener, not governed + secrets/ key material, created empty with mode 0700 + audit/ audit records written by the service + .gitignore keeps secrets, audit and output out of version control +``` + +Identifiers stamped into the bundle: + +- provider: `{{provider_id}}` +- issuer: `{{issuer_id}}` +- requirement: `{{requirement_id}}` +- concept: `{{concept_id}}` +- active signing key id: `{{signing_key_id}}` + +## Next steps + +Generate the key material. Private material is written owner-only and is never +printed. + +```bash +evidencectl keygen signing --out-dir {{secret_root}} --kid {{signing_key_id}} +evidencectl keygen secret --out {{secret_root}}/audit-hmac-key +evidencectl keygen secret --out {{secret_root}}/subject-binding-hmac-key +``` + +The signing key id passed to `keygen signing` must equal `signing.activeKeyId` +in `bundle/evidence.yaml`. If you let `keygen` derive the key id from the JWK +thumbprint instead, copy the printed key id into that field before continuing. + +The source in this project uses a static bearer token. That token is issued by +the source system itself, not generated by this tool: obtain it from the source +system and write it into `{{secret_root}}/source-bearer-token` as a file with +mode 0600. Never pass it on a command line, and never commit it. + +Freeze both deployment inputs, then check the deployment and replay the fixture: + +```bash +chmod -R a-w {{bundle_directory}} +chmod 444 {{project_root}}/runtime.yaml + +evidence check --runtime {{project_root}}/runtime.yaml +evidence evaluate --runtime {{project_root}}/runtime.yaml \ + --fixture fixtures/cases.yaml +``` + +`check` loads and validates both inputs. `evaluate` replays every synthetic case +in the fixture against the reviewed adapter, derivation and output gate, with no +source and no network involved. Both must pass before this project is worth +deploying. + +## Freezing and unfreezing + +Evidence refuses to start from a deployment input it could write to, and reports +`deployment input is not immutable`. The bundle directory and every file beneath +it must have no write bits, and `runtime.yaml` must be mode `444`. The secret +root must be owner-only, mode `700`. + +To edit the project again, unfreeze it first and freeze it afterwards: + +```bash +chmod -R u+w {{bundle_directory}} +chmod 644 {{project_root}}/runtime.yaml +# edit, then freeze again +chmod -R a-w {{bundle_directory}} +chmod 444 {{project_root}}/runtime.yaml +``` + +## Signing the revision + +Evidence computes a revision hash over the exact bundle bytes it loaded and +records it in audit entries and in the signed assertion provenance. Signing the +configuration itself is deliberately not a runtime feature: the service verifies +immutability, not authorship. + +Bind authorship in your release pipeline instead. Sign the reviewed revision +with a signed git tag over the commit that contains `bundle/`, or with +`cosign sign-blob` over the bundle revision hash, and keep that signature with +the release record. Promote the same reviewed `bundle/` bytes from staging to +production, and rebind only `runtime.yaml`, secrets and trust anchors per +environment. + +## Making it yours + +Work through `bundle/evidence.yaml` in this order: + +1. Replace the `urn:example:scaffold` identifiers with identifiers your + jurisdiction governs. +2. Point `authentication` at the identity provider that issues requester access + tokens. +3. Point `sources.source-a` at the system that already holds the data, and + narrow `projection` to the smallest field set the derivation needs. +4. Adjust the derivation and its fixture together. A requirement whose fixture + does not cover every required case category will not load. + +The full configuration contract, including the authoring and promotion workflow, +is documented in `products/evidence/reference/request-adapter/deployment-projects/CONFIG.md` +in the Registry Stack repository. diff --git a/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai b/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai new file mode 100644 index 000000000..9609019e9 --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai @@ -0,0 +1,25 @@ +// Fact extraction. Decides whether the projected source response resolved to +// exactly one record, and emits only the narrow facts the derivation needs. +// A response the adapter does not understand is a protocol error rather than a +// silent no_match, so a source change cannot be mistaken for an answer. +fn extract(source_response, parameters) { + if !source_response.contains("total") || + type_of(source_response["total"]) != "i64" || + source_response["total"] < 0 { + throw("source_protocol_error"); + } + if source_response["total"] == 0 { + if len(source_response) != 1 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if source_response["total"] > 1 { + return #{outcome: "ambiguous"}; + } + if !source_response.contains("event_date") { + return #{outcome: "match", facts: #{}}; + } + if type_of(source_response["event_date"]) != "string" { + throw("source_protocol_error"); + } + #{outcome: "match", facts: #{event_date: source_response["event_date"]}} +} diff --git a/crates/registry-evidencectl/templates/bundle/adapters/source-a-prepare.rhai b/crates/registry-evidencectl/templates/bundle/adapters/source-a-prepare.rhai new file mode 100644 index 000000000..c91a274ac --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/adapters/source-a-prepare.rhai @@ -0,0 +1,18 @@ +// Request preparation. Turns the already validated selector values and the +// reviewed adapter parameters into the request body the source expects. +// Preparation may not invent fields: everything it emits comes from a selector +// value or from a closed adapter parameter. +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [], + body: #{ + lookup: #{ + record_reference: subject["values"]["record_reference"], + registry_code: subject["values"]["registry_code"] + }, + fields: parameters["requestedFields"], + limit: parameters["resultLimit"] + } + } +} diff --git a/crates/registry-evidencectl/templates/bundle/derivations/example-flag.rhai b/crates/registry-evidencectl/templates/bundle/derivations/example-flag.rhai new file mode 100644 index 000000000..c00a6eed9 --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/derivations/example-flag.rhai @@ -0,0 +1,14 @@ +// Requirement derivation. Reduces the extracted facts to the single reviewed +// concept the requester is authorized to learn. The event date enters here and +// never leaves: only the boolean below reaches the signed assertion. +fn derive(facts, selectors, evaluation_context) { + let event_date = parse_date(required(facts.event_date, "required_fact_missing")); + let threshold_date = add_calendar_years( + event_date, + evaluation_context.parameters.threshold_years + ); + [#{ + concept_id: "{{concept_id}}", + value: compare_dates(evaluation_context.legal_local_date, threshold_date) >= 0 + }] +} diff --git a/crates/registry-evidencectl/templates/bundle/evidence.yaml b/crates/registry-evidencectl/templates/bundle/evidence.yaml new file mode 100644 index 000000000..7fb337416 --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/evidence.yaml @@ -0,0 +1,176 @@ +# Governed Evidence deployment bundle. +# +# This file and every artifact it references are reviewed together and mounted +# read-only. Nothing here is environment specific: process-local paths, the +# listener, the secret root and audit storage live in runtime.yaml instead. +# +# The single requirement below is a deliberately abstract placeholder. It +# answers one boolean question, whether a recorded event date is at least a +# reviewed number of years in the past, and it exists to show the mechanics. +# Replace it with the question your jurisdiction actually governs. +version: 1 + +service: + providerId: {{provider_id}} + trustDomain: {{trust_domain}} + +issuer: + id: {{issuer_id}} + +{{authentication_note}} +authentication: + kind: oidc-access-token + issuer: {{token_issuer}} + audiences: [{{token_audience}}] + tokenTypes: [at+jwt] + algorithms: [{{token_algorithm}}] + jwksUri: {{token_jwks_uri}} + principalClaim: {{principal_claim}} + requesterTagsClaim: {{requester_tags_claim}} + evidenceAudienceClaim: {{evidence_audience_claim}} + grantIdClaim: {{grant_id_claim}} + grantAuthorityClaim: {{grant_authority_claim}} + +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + failClosed: true + +subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 + +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 + +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: {{signing_key_id}} + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 + +responseFormats: [signed-jws] + +# A selector profile is the closed set of fields a requester may send to +# identify a subject. Nothing outside a profile can reach a source. +selectorProfiles: + subject-lookup-v1: + maximumAggregateBytes: 420 + # One entry per field a requester may send. Replace these two with the + # identifiers your subjects are actually looked up by, and keep the byte + # bounds as tight as those identifiers allow. + fields: + record_reference: + type: string + minimumBytes: 1 + maximumBytes: 200 + registry_code: + type: string + minimumBytes: 1 + maximumBytes: 64 + +# One generic JSON over HTTP source. Point baseUrl and path at the system that +# already holds the data, and keep the projection as narrow as the derivation +# needs: anything outside the projection is discarded before extraction runs. +sources: + source-a: + transport: http-json + # Replace with the origin of the system that holds the data. The path below + # is appended to it. + baseUrl: https://source.invalid + posture: field-projected + # The bearer token is read from the runtime secret root, never from this + # file. Name the secret file here and put its bytes there. + authentication: + kind: static-bearer + tokenRef: secret:file/source-bearer-token + request: + method: POST + path: /v1/facts + fixedHeaders: + - name: Accept + value: application/json + # Which selector profile fields reach this source, and in which role. + # Add an alternative for each accepted combination of fields. + selectorInputs: + - role: subject + alternatives: + - profile: subject-lookup-v1 + fields: [record_reference, registry_code] + prepareScript: adapters/source-a-prepare.rhai + # Reviewed constants the prepare script may read. They are validated + # against the schema below, so change the two together. + adapterParameters: + requestedFields: [event_date] + resultLimit: 2 + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 8 + maximumCollectionItems: 16 + maximumStringBytes: 256 + maximumNormalizedBytes: 4096 + projection: [/total, /event_date] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/facts.schema.yaml + +# An authority profile says which authenticated requesters may ask for which +# requirement, for which purpose, about which subject roles. +authorityProfiles: + statutory-caseworker-v1: + kind: statutory + requesterTags: [{{requester_tag}}] + grants: + - requirement: {{requirement_id}} + purpose: scaffold-eligibility + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: subject-lookup-v1 + valueOrigin: request + +requirements: + - id: {{requirement_id}} + kind: criterion + source: source-a + purposes: [scaffold-eligibility] + subjectRoles: + - role: subject + cardinality: one + selectorProfiles: [subject-lookup-v1] + referenceFrameworks: [{{framework_id}}] + evidenceType: {{evidence_type_id}} + observationTimezone: UTC + validitySeconds: 86400 + derivation: + script: derivations/example-flag.rhai + # Reviewed inputs to the script above. The script may read these and + # nothing else from this file. + parameters: + threshold_years: 18 + # What the signed assertion carries. One boolean here is the whole + # disclosure: add a concept only when the answer genuinely needs it. + concepts: + - id: {{concept_id}} + form: boolean + required: true + constraints: {} + fixtures: fixtures/cases.yaml + # Requirements sharing a family could be combined to reconstruct the value + # behind them, so a bundle that allows the combination is refused at load. + disclosureGuard: + families: [{{disclosure_family_id}}] + existenceDisclosure: collapse-unresolved diff --git a/crates/registry-evidencectl/templates/bundle/fixtures/cases.yaml b/crates/registry-evidencectl/templates/bundle/fixtures/cases.yaml new file mode 100644 index 000000000..dcfc20e38 --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/fixtures/cases.yaml @@ -0,0 +1,136 @@ +# Synthetic acceptance cases for the requirement. `evidence evaluate` replays +# every case offline against the reviewed adapter, derivation and output gate, +# so no source and no network are involved. +# +# The bundle is rejected unless the case identifiers cover every required +# category: `positive`, `no-match`, `source-failure`, `anti-reconstruction`, +# and at least one identifier each starting with `negative`, `boundary`, +# `missing` and `ambiguous`. +fixture: registry.evidence.scaffold.example-flag/v1 +coequal_acceptance_definition: true +synthetic_only: true + +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selectors: + subject: + profile: subject-lookup-v1 + # Synthetic identifiers only. These are asserted to be absent from + # diagnostics at the end of this file, so keep them distinctive. + values: + record_reference: RCD-000123 + registry_code: RGC-07 + +cases: + # A resolved record past the threshold produces a signed true. + - id: positive + source: + total: 1 + event_date: '2000-01-01' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + # A resolved record short of the threshold produces a signed false. A + # negative answer is a successful answer, not an error. + - id: negative-false-is-success + source: + total: 1 + event_date: '2010-01-01' + expected_value: false + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-before + legal_local_date: '2026-08-01' + source: + total: 1 + event_date: '2008-08-02' + expected_value: false + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-on + legal_local_date: '2026-08-02' + source: + total: 1 + event_date: '2008-08-02' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-after + legal_local_date: '2026-08-03' + source: + total: 1 + event_date: '2008-08-02' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-leap-day + legal_local_date: '2026-02-28' + source: + total: 1 + event_date: '2008-02-29' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + # One record resolved, but the fact the derivation requires is absent. The + # requester learns only that evidence is not available. + - id: missing-fact + source: + total: 1 + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + - id: no-match + source: + total: 0 + expected_lookup: no_match + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + - id: ambiguous + source: + total: 2 + expected_lookup: ambiguous + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # A source that never answers is a dependency problem, never a negative. + - id: source-failure + source_failure: timeout + expected_public_problem: dependency_unavailable + signed_success: false + + # The output gate rejects a derivation result whose type contradicts the + # declared concept form, so a script defect cannot become signed evidence. + - id: negative-wrong-derived-type + injected_derivation: + - concept_id: '{{concept_id}}' + value: 'true' + expected: output-gate-rejection + + # Two requirements that share a disclosure family could be combined to + # reconstruct the underlying value, so such a bundle must not load. + - id: anti-reconstruction + companion_bundle: threshold-ladder + expected: bundle-rejection + +# What the signed assertion must and must not carry. +privacy_expectation: + evidence_contains: ['{{concept_id}}'] + evidence_excludes: [event_date, record_reference, registry_code, selector-profile] + diagnostics_exclude: [RCD-000123, RGC-07, '2000-01-01'] diff --git a/crates/registry-evidencectl/templates/bundle/schemas/adapter-parameters.schema.yaml b/crates/registry-evidencectl/templates/bundle/schemas/adapter-parameters.schema.yaml new file mode 100644 index 000000000..15781f15a --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/schemas/adapter-parameters.schema.yaml @@ -0,0 +1,10 @@ +# Closed schema for the reviewed adapter parameters of source-a. Constants keep +# the request shape under review rather than under operator control. +type: object +additionalProperties: false +required: [requestedFields, resultLimit] +properties: + requestedFields: + const: [event_date] + resultLimit: + const: 2 diff --git a/crates/registry-evidencectl/templates/bundle/schemas/facts.schema.yaml b/crates/registry-evidencectl/templates/bundle/schemas/facts.schema.yaml new file mode 100644 index 000000000..44b79811b --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/schemas/facts.schema.yaml @@ -0,0 +1,9 @@ +# Closed schema for the facts extraction may hand to the derivation. Extraction +# output that does not match exactly is rejected before any derivation runs. +type: object +additionalProperties: false +required: [event_date] +properties: + event_date: + type: string + format: date diff --git a/crates/registry-evidencectl/templates/gitignore b/crates/registry-evidencectl/templates/gitignore new file mode 100644 index 000000000..61485bea1 --- /dev/null +++ b/crates/registry-evidencectl/templates/gitignore @@ -0,0 +1,4 @@ +# Key material, audit records and local output never belong in version control. +secrets/ +audit/ +out/ diff --git a/crates/registry-evidencectl/templates/mint/README-section.md b/crates/registry-evidencectl/templates/mint/README-section.md new file mode 100644 index 000000000..a33145834 --- /dev/null +++ b/crates/registry-evidencectl/templates/mint/README-section.md @@ -0,0 +1,94 @@ + +## Paired Registry Mint deployment + +This project was scaffolded with `--with-mint`, so it carries the Registry Mint +configuration that issues the access tokens it verifies. Mint exists for a +deployment that has many callers and no identity provider; it is a supporting +service, not a second product to adopt. + +```text +mint/ + mint.yaml issuer, signing key, token policy + clients/ one registration per caller, public data + {{mint_client_id}}.yaml.example rename it once its key is real + secrets/ Mint's signing key, created empty, mode 0700 + caller/ the example caller's own key +``` + +The dependency runs one way. Evidence knows nothing about Mint beyond an issuer, +a key set and a set of claim names, and these values were written from one +source when the project was scaffolded: + +| `bundle/evidence.yaml` | `mint/mint.yaml` | +|---|---| +| `authentication.issuer` | `issuer` | +| `authentication.audiences` | `accessTokens.audiences` | +| `authentication.jwksUri` | `issuer` followed by `signing.jwksPath` | +| `authentication.algorithms` | `signing.algorithm` | +| `authentication.principalClaim` | `accessTokens.claims.principal` | +| `authentication.requesterTagsClaim` | `accessTokens.claims.requesterTags` | +| `authentication.evidenceAudienceClaim` | `accessTokens.claims.evidenceAudience` | +| `authentication.grantIdClaim` | `accessTokens.claims.grantId` | +| `authentication.grantAuthorityClaim` | `accessTokens.claims.grantAuthority` | + +Change any of them in both documents. A single-sided edit produces tokens Mint +issues happily and Evidence refuses. The caller registration is the same story +in one more place: its `requesterTags` must match an authority profile in +`bundle/evidence.yaml`, or the caller authenticates and is then refused +everything. + +### Next steps for Mint + +Generate Mint's own signing key. Its id must equal `signing.activeKeyId`: + +```bash +evidencectl keygen signing --out-dir {{mint_secret_root}} \ + --kid {{mint_signing_key_id}} +``` + +Generate a key for the example caller. This one belongs to the caller rather +than to Mint: keep the public half here and move the private half to wherever +that client runs. + +```bash +evidencectl keygen signing --out-dir {{mint_caller_secret_root}} \ + --kid {{mint_client_key_id}} +``` + +Copy the `x` member of +`{{mint_caller_secret_root}}/signing-ed25519-public.jwk.json` into the `keys` +entry of `{{mint_clients_directory}}/{{mint_client_id}}.yaml.example`, rename +that file to `{{mint_client_id}}.yaml`, then load the deployment: + +```bash +mint check --config {{mint_config_path}} +mint serve --config {{mint_config_path}} +``` + +`check` loads the configuration, the signing key and the client registry, then +exits without opening a socket. Mint reads `clients/*.yaml` only, so a +registration still carrying the placeholder key is ignored rather than +half-applied, and `SIGHUP` reloads that directory in place: onboarding a caller +never restarts Evidence. + +### Obtaining a token + +```bash +mint token --url {{mint_token_endpoint}} \ + --client-id {{mint_client_id}} \ + --key {{mint_caller_secret_root}}/signing-ed25519-private-jwk +``` + +`mint token` is a caller tool. It signs a client assertion with the caller's own +key and presents it to a running endpoint, which decides on its own terms; +nothing it can obtain is anything the same client could not have obtained over +the wire. It prints the access token on stdout and nothing else. + +Mint serves plain HTTP and expects TLS termination it does not manage. The +issuer is `https`, and Evidence insists on `https` for both the issuer and the +key set with no exception for loopback, so put a terminator in front of Mint +before this is anything but a local experiment. + +The scaffold generates no key material, and none of the commands above print +any. `.gitignore` already excludes every `secrets/` directory in this project, +including Mint's. diff --git a/crates/registry-evidencectl/templates/mint/client.yaml.example b/crates/registry-evidencectl/templates/mint/client.yaml.example new file mode 100644 index 000000000..c80e8dbcf --- /dev/null +++ b/crates/registry-evidencectl/templates/mint/client.yaml.example @@ -0,0 +1,39 @@ +# One registered caller, and the whole authorization decision Mint makes for it. +# +# Mint loads `*.yaml` from this directory, so this file is ignored until it is +# renamed to `{{mint_client_id}}.yaml`. Rename it once the key below is real: a +# registration is public data, and the scaffold has no caller key to put here. +# +# Authority is read from this file, never from what the caller signs. The caller +# proves which entry it is; this entry decides what may be said about it. +clientId: {{mint_client_id}} + +# The principal Evidence records for every request this caller makes. +principal: {{mint_client_principal}} + +# The audience minted into the evidence this caller receives. Evidence parses it +# as a URL and mixes it into subject binding, so it identifies the caller rather +# than the deployment. +evidenceAudience: {{mint_client_audience}} + +# These must match an authority profile in `bundle/evidence.yaml`. A caller +# whose tags match no profile authenticates and is then refused everything. +requesterTags: [{{requester_tag}}] + +# Optional, and minted only for a caller acting under a recorded authority. +# Evidence reads the pair from the grant claims the bundle names, and requires +# both members or neither. +# grant: +# id: urn:example:scaffold:grant:1 +# authority: urn:example:scaffold:authority:1 + +# Public JWKs only, at most eight, each with a distinct kid. Replace `x` with +# the public half of the caller's key, from +# {{mint_caller_secret_root}}/signing-ed25519-public.jwk.json. +# A document carrying a private member is refused. +keys: + - kty: OKP + crv: Ed25519 + kid: {{mint_client_key_id}} + alg: EdDSA + x: replace-with-the-caller-public-key diff --git a/crates/registry-evidencectl/templates/mint/mint.yaml b/crates/registry-evidencectl/templates/mint/mint.yaml new file mode 100644 index 000000000..bf30ca1dc --- /dev/null +++ b/crates/registry-evidencectl/templates/mint/mint.yaml @@ -0,0 +1,59 @@ +# Registry Mint configuration, paired with the Evidence deployment beside it. +# +# Mint issues the short-lived access tokens that deployment verifies, so a +# deployment with no identity provider still authenticates its callers. The +# dependency runs one way only: Evidence knows nothing about Mint beyond an +# issuer, a key set and a set of claim names. +# +# Every value shared with `bundle/evidence.yaml` was written from one source +# when this project was scaffolded. Change them in both documents or Mint will +# issue tokens Evidence refuses. +# +# Paths resolve relative to this file. Everything here is startup-only except +# the client registry, which `SIGHUP` reloads in place. +version: 1 + +issuer: {{token_issuer}} + +# Mint serves plain HTTP and expects TLS termination it does not manage. The +# issuer above is https, so put a terminator in front of this listener. +listener: + address: 127.0.0.1 + port: 8081 + maximumRequestBytes: 16384 + requestTimeoutMilliseconds: 5000 + +# Generated by `evidencectl keygen signing`, never by the scaffold. The file +# must be a private JWK, owner-read-only, whose kid equals the id below. +signing: + algorithm: {{token_algorithm}} + activeKeyId: {{mint_signing_key_id}} + activeKeyFile: secrets/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: {{token_jwks_path}} + +# `audiences` is what Evidence accepts as `aud`, and each claim name below is +# the one Evidence reads from a token. A lifetime longer than a few minutes +# would rebuild the long-lived bearer token Mint exists to avoid. +accessTokens: + audiences: [{{token_audience}}] + lifetimeSeconds: 300 + claims: + principal: {{principal_claim}} + requesterTags: {{requester_tags_claim}} + evidenceAudience: {{evidence_audience_claim}} + grantId: {{grant_id_claim}} + grantAuthority: {{grant_authority_claim}} + +# Callers authenticate with a short JWT signed by their own key (RFC 7523). The +# audience binds an assertion to this endpoint, so one presented to another +# service cannot be replayed here. +clientAssertion: + audience: {{mint_token_endpoint}} + maximumLifetimeSeconds: 300 + algorithms: [{{token_algorithm}}] + +# One `*.yaml` file per registered caller. This directory is public data: a +# registration carrying private key material is refused. +clients: + directory: clients diff --git a/crates/registry-evidencectl/templates/runtime.yaml b/crates/registry-evidencectl/templates/runtime.yaml new file mode 100644 index 000000000..e2116aaa0 --- /dev/null +++ b/crates/registry-evidencectl/templates/runtime.yaml @@ -0,0 +1,36 @@ +# Process-local runtime configuration. This file is not governed content: it +# binds the reviewed bundle to one environment. Staging and production may use +# different runtime files over the same bundle bytes without changing evidence +# semantics. +# +# The paths below were written as absolute paths when the project was +# scaffolded. Change them for a real deployment and freeze the file again. +version: 1 + +bundleDirectory: {{bundle_directory}} + +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + +# Secret files are read from this directory only. The directory itself must be +# owner-only (mode 0700) and each secret file owner-read-only (mode 0600). +secretProviders: + file: + root: {{secret_root}} + +auditStorage: + path: {{audit_path}} + maximumFileBytes: 1073741824 + +# Add a trust profile here only when a bundle source declares one; the two +# sets must match exactly. +outboundTls: + systemRoots: true + trustProfiles: {} diff --git a/crates/registry-evidencectl/tests/fixtures.rs b/crates/registry-evidencectl/tests/fixtures.rs new file mode 100644 index 000000000..777bb3f96 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures.rs @@ -0,0 +1,445 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Build a minimal deployment project: `runtime.yaml` at the root and a +/// `bundle/evidence.yaml` whose requirements reference the given +/// bundle-relative fixture paths. The driver only ever parses this file to +/// enumerate fixtures, so its other fields are placeholders. +fn write_project(root: &Path, fixture_paths: &[&str]) -> PathBuf { + let project = root.join("project"); + fs::create_dir_all(project.join("bundle")).expect("create bundle dir"); + fs::write(project.join("runtime.yaml"), b"placeholder: true\n").expect("write runtime.yaml"); + + let mut requirements = String::new(); + for (index, fixture_path) in fixture_paths.iter().enumerate() { + requirements.push_str(&format!( + " - id: urn:example:fixture:requirement:{index}\n fixtures: {fixture_path}\n" + )); + } + let evidence_yaml = format!("version: 1\nrequirements:\n{requirements}"); + fs::write(project.join("bundle").join("evidence.yaml"), evidence_yaml) + .expect("write bundle evidence.yaml"); + project +} + +/// Write a stub `evidence` binary that: +/// - appends its argv (one argument per line, `===` between invocations) to +/// the file named by `$ARGV_LOG`; +/// - exits 1 with a fixed diagnostic on stderr when its step name equals +/// `$FAIL_STEP` (`check`, or `evaluate:`), and exits 0 +/// otherwise. +fn write_stub_evidence(dir: &Path) -> PathBuf { + let path = dir.join("evidence"); + let script = r#"#!/bin/sh +set -eu + +for arg in "$@"; do + printf '%s\n' "$arg" >> "$ARGV_LOG" +done +printf '===\n' >> "$ARGV_LOG" + +fixture="" +prev="" +for arg in "$@"; do + if [ "$prev" = "--fixture" ]; then + fixture="$arg" + fi + prev="$arg" +done + +step="check" +for arg in "$@"; do + case "$arg" in + evaluate) step="evaluate" ;; + esac +done +if [ -n "$fixture" ]; then + step="evaluate:$fixture" +fi + +if [ "$step" = "${FAIL_STEP:-}" ]; then + printf 'stub failure for %s\n' "$step" >&2 + exit 1 +fi + +printf 'stub ok for %s\n' "$step" +exit 0 +"#; + fs::write(&path, script).expect("write stub evidence script"); + let mut permissions = fs::metadata(&path).expect("stat stub").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("chmod stub"); + path +} + +/// Parse the argv log into one `Vec` per invocation, in call order. +fn read_argv_log(path: &Path) -> Vec> { + let contents = fs::read_to_string(path).unwrap_or_default(); + contents + .split("===\n") + .filter(|block| !block.is_empty()) + .map(|block| block.lines().map(str::to_owned).collect()) + .collect() +} + +#[test] +fn happy_path_runs_check_then_each_fixture_and_reports_pass() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: check"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/b.yaml"), "{stdout}"); + assert!(stdout.contains("3 passed, 0 failed"), "{stdout}"); + + let runtime_path = project.join("runtime.yaml"); + let runtime_path = runtime_path.to_str().expect("runtime path is utf8"); + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations, + vec![ + vec!["--runtime", runtime_path, "check"], + vec![ + "--runtime", + runtime_path, + "evaluate", + "--fixture", + "fixtures/a.yaml" + ], + vec![ + "--runtime", + runtime_path, + "evaluate", + "--fixture", + "fixtures/b.yaml" + ], + ], + "unexpected evidence invocations" + ); +} + +#[test] +fn check_failure_short_circuits_before_any_fixture_evaluation() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "check") + .output() + .expect("run evidencectl"); + + assert!( + !output.status.success(), + "expected nonzero exit on check failure" + ); + let stdout = stdout_of(&output); + assert!(stdout.contains("FAIL: check"), "{stdout}"); + assert!(stdout.contains("stub failure for check"), "{stdout}"); + assert!(stdout.contains("0 passed, 1 failed"), "{stdout}"); + assert!( + !stdout.contains("fixtures/a.yaml") && !stdout.contains("fixtures/b.yaml"), + "no fixture should have been reported: {stdout}" + ); + + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations.len(), + 1, + "evaluate must never be invoked once check fails: {invocations:?}" + ); +} + +#[test] +fn one_failing_fixture_is_reported_with_its_stderr_and_the_rest_still_run() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!( + !output.status.success(), + "expected nonzero exit on fixture failure" + ); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: check"), "{stdout}"); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("FAIL: fixtures/b.yaml"), "{stdout}"); + assert!( + stdout.contains("stub failure for evaluate:fixtures/b.yaml"), + "failing step's stderr must be included: {stdout}" + ); + assert!(stdout.contains("2 passed, 1 failed"), "{stdout}"); + + let invocations = read_argv_log(&argv_log); + assert_eq!( + invocations.len(), + 3, + "every fixture must still run despite one failure: {invocations:?}" + ); +} + +#[test] +fn json_output_is_one_parseable_document_on_stdout_with_expected_pass_fail_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .arg("--json") + .env("ARGV_LOG", &argv_log) + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stdout = stdout_of(&output); + let stdout_lines: Vec<&str> = stdout.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!( + stdout_lines.len(), + 1, + "stdout must carry exactly one JSON document: {stdout}" + ); + + let report: serde_json::Value = + serde_json::from_str(stdout_lines[0]).expect("parse JSON report"); + assert_eq!(report["passed"], serde_json::Value::Bool(false)); + assert_eq!(report["check"]["passed"], serde_json::Value::Bool(true)); + let fixtures = report["fixtures"].as_array().expect("fixtures array"); + assert_eq!(fixtures.len(), 2); + assert_eq!(fixtures[0]["path"], "fixtures/a.yaml"); + assert_eq!(fixtures[0]["passed"], serde_json::Value::Bool(true)); + assert_eq!(fixtures[1]["path"], "fixtures/b.yaml"); + assert_eq!(fixtures[1]["passed"], serde_json::Value::Bool(false)); + assert!(fixtures[1]["stderr"] + .as_str() + .expect("failing fixture carries stderr") + .contains("stub failure for evaluate:fixtures/b.yaml")); + + // Human diagnostics belong on stderr in JSON mode, not on stdout. + let stderr = stderr_of(&output); + assert!(stderr.contains("FAIL: fixtures/b.yaml"), "{stderr}"); +} + +#[test] +fn missing_runtime_yaml_errors_clearly_without_invoking_evidence() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(&project).expect("create project dir"); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!(stderr.contains("runtime.yaml"), "{stderr}"); + assert!(!argv_log.exists(), "evidence must never be invoked"); +} + +#[test] +fn unresolvable_evidence_binary_errors_clearly() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let missing_bin = dir.path().join("nowhere").join("evidence"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&missing_bin) + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!(stderr.contains("evidence binary not found"), "{stderr}"); + assert!( + stderr.contains(missing_bin.to_str().expect("utf8 path")), + "{stderr}" + ); +} + +#[test] +fn fixtures_are_discovered_at_a_relative_bundle_directory_named_in_runtime_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(project.join("custom-bundle")).expect("create custom bundle dir"); + fs::write( + project.join("runtime.yaml"), + b"placeholder: true\nbundleDirectory: custom-bundle\n", + ) + .expect("write runtime.yaml"); + fs::write( + project.join("custom-bundle").join("evidence.yaml"), + "version: 1\nrequirements:\n - id: urn:example:fixture:requirement:0\n fixtures: fixtures/a.yaml\n", + ) + .expect("write bundle evidence.yaml"); + + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("2 passed, 0 failed"), "{stdout}"); +} + +#[test] +fn fixtures_are_discovered_at_an_absolute_bundle_directory_named_in_runtime_yaml() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path().join("project"); + fs::create_dir_all(&project).expect("create project dir"); + let bundle_directory = dir.path().join("elsewhere").join("bundle"); + fs::create_dir_all(&bundle_directory).expect("create bundle dir"); + + let runtime_yaml = format!( + "placeholder: true\nbundleDirectory: {}\n", + bundle_directory.to_str().expect("bundle directory is utf8") + ); + fs::write(project.join("runtime.yaml"), runtime_yaml).expect("write runtime.yaml"); + fs::write( + bundle_directory.join("evidence.yaml"), + "version: 1\nrequirements:\n - id: urn:example:fixture:requirement:0\n fixtures: fixtures/a.yaml\n", + ) + .expect("write bundle evidence.yaml"); + + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!(stdout.contains("PASS: fixtures/a.yaml"), "{stdout}"); + assert!(stdout.contains("2 passed, 0 failed"), "{stdout}"); +} + +#[test] +fn a_non_executable_evidence_on_path_is_skipped_with_a_clear_resolution_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + + let path_dir = dir.path().join("path-entry"); + fs::create_dir(&path_dir).expect("create path entry dir"); + let candidate = path_dir.join("evidence"); + fs::write(&candidate, b"#!/bin/sh\nexit 0\n").expect("write candidate"); + let mut permissions = fs::metadata(&candidate) + .expect("stat candidate") + .permissions(); + permissions.set_mode(0o644); + fs::set_permissions(&candidate, permissions).expect("chmod candidate non-executable"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .env("PATH", &path_dir) + .env_remove("EVIDENCE_BIN") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("evidence binary not found"), + "a non-executable candidate on PATH must be skipped: {stderr}" + ); +} + +#[test] +fn evidence_bin_env_var_is_used_when_the_flag_is_omitted() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .env("EVIDENCE_BIN", &stub) + .env("ARGV_LOG", &argv_log) + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let invocations = read_argv_log(&argv_log); + assert_eq!(invocations.len(), 2, "check plus one fixture"); +} diff --git a/crates/registry-evidencectl/tests/full_flow.rs b/crates/registry-evidencectl/tests/full_flow.rs new file mode 100644 index 000000000..68f3097cd --- /dev/null +++ b/crates/registry-evidencectl/tests/full_flow.rs @@ -0,0 +1,280 @@ +#![cfg(unix)] + +//! Full adopter path, end to end. +//! +//! Drives the README-documented flow with the real `evidencectl` and +//! `evidence` binaries and zero manual edits: scaffold a project, provision +//! key material, freeze the deployment inputs the way `evidence` requires on +//! unix, then run every bundle fixture through `evidencectl fixtures run` in +//! both its human and `--json` reporting modes. Nothing in this file prints +//! key material. + +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, + sync::OnceLock, +}; + +const SIGNING_KID: &str = "scaffold-signing-key-1"; +const SECRET_FILES: [&str; 2] = ["audit-hmac-key", "subject-binding-hmac-key"]; + +#[test] +fn the_documented_adopter_flow_passes_check_and_the_scaffolded_fixture() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + + let new_output = evidencectl(&["new", project.to_str().expect("project path")]); + assert!( + new_output.status.success(), + "evidencectl new failed: {}", + stderr_of(&new_output) + ); + + let bin = evidence_binary(); + + // Before key material exists and the project is frozen, the same driver + // must fail. This proves the later pass is not vacuous. + let premature = evidencectl(&[ + "fixtures", + "run", + "--project", + project.to_str().expect("project path"), + "--evidence-bin", + bin.to_str().expect("evidence binary path"), + ]); + assert!( + !premature.status.success(), + "fixtures run unexpectedly succeeded before key material and freezing were in place" + ); + + let secrets = project.join("secrets"); + let signing_output = evidencectl(&[ + "keygen", + "signing", + "--out-dir", + secrets.to_str().expect("secrets dir"), + "--kid", + SIGNING_KID, + ]); + assert!( + signing_output.status.success(), + "evidencectl keygen signing failed: {}", + stderr_of(&signing_output) + ); + + for name in SECRET_FILES { + let out = secrets.join(name); + let secret_output = evidencectl(&[ + "keygen", + "secret", + "--out", + out.to_str().expect("secret path"), + ]); + assert!( + secret_output.status.success(), + "evidencectl keygen secret failed for {name}: {}", + stderr_of(&secret_output) + ); + } + + freeze(&project); + let run_output = evidencectl(&[ + "fixtures", + "run", + "--project", + project.to_str().expect("project path"), + "--evidence-bin", + bin.to_str().expect("evidence binary path"), + ]); + let json_output = evidencectl(&[ + "fixtures", + "run", + "--project", + project.to_str().expect("project path"), + "--evidence-bin", + bin.to_str().expect("evidence binary path"), + "--json", + ]); + unfreeze(&project); + + assert!( + run_output.status.success(), + "evidencectl fixtures run failed: {}", + stderr_of(&run_output) + ); + let stdout = stdout_of(&run_output); + assert!( + stdout.contains("2 passed, 0 failed"), + "unexpected fixtures run summary: {stdout}" + ); + + assert!( + json_output.status.success(), + "evidencectl fixtures run --json failed: {}", + stderr_of(&json_output) + ); + let json_stdout = stdout_of(&json_output); + let json_lines: Vec<&str> = json_stdout + .lines() + .filter(|line| !line.is_empty()) + .collect(); + assert_eq!( + json_lines.len(), + 1, + "stdout must carry exactly one JSON document: {json_stdout}" + ); + let report: serde_json::Value = serde_json::from_str(json_lines[0]).expect("parse JSON report"); + assert_eq!(report["passed"], serde_json::Value::Bool(true)); + assert_eq!(report["check"]["passed"], serde_json::Value::Bool(true)); + let fixtures = report["fixtures"].as_array().expect("fixtures array"); + assert_eq!(fixtures.len(), 1); + assert_eq!(fixtures[0]["path"], "fixtures/cases.yaml"); + assert_eq!(fixtures[0]["passed"], serde_json::Value::Bool(true)); +} + +fn evidencectl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Locate the `evidence` binary this flow drives. +/// +/// `EVIDENCE_BIN` wins when the caller already built one. Otherwise the +/// binary is built once from this workspace and reused for the test. +fn evidence_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| { + if let Some(path) = std::env::var_os("EVIDENCE_BIN") { + return PathBuf::from(path); + } + let build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(workspace_root()) + .args([ + "build", + "--locked", + "-p", + "registry-evidence", + "--bin", + "evidence", + "--profile", + ¤t_test_profile(), + "--message-format", + "json-render-diagnostics", + ]) + .output() + .expect("building the evidence binary"); + assert!( + build.status.success(), + "building the evidence binary failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + String::from_utf8_lossy(&build.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| { + message.get("reason").and_then(serde_json::Value::as_str) + == Some("compiler-artifact") + }) + .filter_map(|message| { + message + .get("executable") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + }) + .find(|executable| { + executable + .file_name() + .is_some_and(|name| name == "evidence") + }) + .expect("the evidence binary path") + }) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .to_path_buf() +} + +/// The profile this test binary was itself built with, read from its own +/// path (`target//deps/`) rather than assumed. A nested +/// `cargo build` passes this back with `--profile` so it reuses the artifacts +/// the outer build already produced (e.g. CI's `--profile ci`) instead of +/// triggering a second full build under the default `dev` profile. +fn current_test_profile() -> String { + let exe = std::env::current_exe().expect("current test executable path"); + let deps_dir = exe + .parent() + .expect("test executable has a parent directory"); + let profile_dir = deps_dir + .parent() + .expect("the deps directory has a parent directory"); + let profile = profile_dir + .file_name() + .and_then(std::ffi::OsStr::to_str) + .expect("the profile directory name is valid UTF-8"); + if profile == "debug" { + "dev".to_owned() + } else { + profile.to_owned() + } +} + +/// The documented freeze: no write bits anywhere in the bundle, and a +/// read-only runtime file. Evidence refuses a deployment input it could write. +fn freeze(project: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + set_tree_mode(&project.join("bundle"), 0o555, 0o444); + fs::set_permissions( + project.join("runtime.yaml"), + fs::Permissions::from_mode(0o444), + ) + .expect("freezing the runtime file"); +} + +/// Restore write permissions so the temporary directory can be removed. +fn unfreeze(project: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + set_tree_mode(&project.join("bundle"), 0o755, 0o644); + fs::set_permissions( + project.join("runtime.yaml"), + fs::Permissions::from_mode(0o644), + ) + .expect("unfreezing the runtime file"); +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + + let metadata = fs::symlink_metadata(path).expect("tree entry"); + if metadata.is_dir() { + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("opening a directory"); + for entry in fs::read_dir(path).expect("reading a directory") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("setting a directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("setting a file mode"); + } +} diff --git a/crates/registry-evidencectl/tests/jwks.rs b/crates/registry-evidencectl/tests/jwks.rs new file mode 100644 index 000000000..0e00da919 --- /dev/null +++ b/crates/registry-evidencectl/tests/jwks.rs @@ -0,0 +1,239 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn mode_of(path: &Path) -> u32 { + fs::metadata(path) + .unwrap_or_else(|error| panic!("stat {}: {error}", path.display())) + .permissions() + .mode() + & 0o777 +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Generates a signing keypair via the `keygen signing` subcommand and +/// returns the path to its public JWK file. Reuses the tool under test +/// instead of hand-rolling key material. +fn generate_public_jwk(dir: &Path, name: &str, kid: &str) -> std::path::PathBuf { + let out_dir = dir.join(name); + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", kid]) + .output() + .expect("run evidencectl keygen"); + assert!(output.status.success(), "{}", stderr_of(&output)); + out_dir.join("signing-ed25519-public.jwk.json") +} + +#[test] +fn assembles_a_jwks_document_from_public_jwk_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = generate_public_jwk(dir.path(), "first", "kid-a"); + let second = generate_public_jwk(dir.path(), "second", "kid-b"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&first) + .arg(&second) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(&out), 0o644); + let contents = fs::read_to_string(&out).expect("read jwks"); + assert!(contents.ends_with('\n'), "output must end with a newline"); + + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + let keys = document["keys"].as_array().expect("keys array"); + assert_eq!(keys.len(), 2); + let kids: Vec<&str> = keys + .iter() + .map(|key| key["kid"].as_str().unwrap()) + .collect(); + assert!(kids.contains(&"kid-a")); + assert!(kids.contains(&"kid-b")); +} + +#[test] +fn rejects_a_private_jwk_input_without_printing_its_contents() { + let dir = tempfile::tempdir().expect("tempdir"); + let private_path = dir.path().join("oops-private.json"); + let canary = "s3cr3t-d-value-canary"; + fs::write( + &private_path, + format!( + r#"{{"kty":"OKP","crv":"Ed25519","d":"{canary}","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"leaked"}}"# + ), + ) + .expect("write fake private jwk"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&private_path) + .output() + .expect("run evidencectl jwks"); + assert!( + !output.status.success(), + "private JWK input must be rejected" + ); + assert!(!out.exists(), "no output should be written on rejection"); + + let stderr = stderr_of(&output); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(!stdout.contains(canary), "stdout leaked private material"); + assert!(!stderr.contains(canary), "stderr leaked private material"); +} + +#[test] +fn deduplicates_identical_duplicate_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-dup"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let contents = fs::read_to_string(&out).expect("read jwks"); + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + let keys = document["keys"].as_array().expect("keys array"); + assert_eq!( + keys.len(), + 1, + "identical duplicate entries must be deduplicated" + ); +} + +#[test] +fn conflicting_keys_sharing_a_kid_is_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = generate_public_jwk(dir.path(), "first", "shared-kid"); + let second = generate_public_jwk(dir.path(), "second", "shared-kid"); + let out = dir.path().join("jwks.json"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&first) + .arg(&second) + .output() + .expect("run evidencectl jwks"); + assert!( + !output.status.success(), + "two different keys sharing a kid must be rejected" + ); + assert!(!out.exists()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("shared-kid"), + "error should name the conflicting kid: {stderr}" + ); +} + +#[test] +fn force_replaces_a_symlinked_output_path_without_writing_through_it() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-sym"); + let target = dir.path().join("elsewhere.json"); + fs::write(&target, b"untouched").expect("seed symlink target"); + let out = dir.path().join("jwks.json"); + symlink(&target, &out).expect("create symlink at the output path"); + + let output = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg("--force") + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let metadata = fs::symlink_metadata(&out).expect("stat output path"); + assert!( + metadata.is_file(), + "the symlink must be replaced by a regular file" + ); + assert!(!metadata.file_type().is_symlink()); + assert_eq!(mode_of(&out), 0o644); + assert_eq!( + fs::read(&target).expect("read symlink target"), + b"untouched", + "the symlink's former target must never be written through" + ); +} + +#[test] +fn refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let public = generate_public_jwk(dir.path(), "solo", "kid-x"); + let out = dir.path().join("jwks.json"); + + let first = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!(first.status.success(), "{}", stderr_of(&first)); + let original = fs::read(&out).expect("read jwks"); + + let second = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg(&public) + .output() + .expect("run evidencectl jwks"); + assert!( + !second.status.success(), + "overwrite without --force must be refused" + ); + assert_eq!(fs::read(&out).expect("read jwks"), original); + + let another = generate_public_jwk(dir.path(), "another", "kid-y"); + let third = evidencectl() + .arg("jwks") + .arg("--out") + .arg(&out) + .arg("--force") + .arg(&public) + .arg(&another) + .output() + .expect("run evidencectl jwks"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!(mode_of(&out), 0o644); + + let contents = fs::read_to_string(&out).expect("read jwks"); + let document: serde_json::Value = serde_json::from_str(&contents).expect("valid json"); + assert_eq!(document["keys"].as_array().expect("keys array").len(), 2); +} diff --git a/crates/registry-evidencectl/tests/keygen.rs b/crates/registry-evidencectl/tests/keygen.rs new file mode 100644 index 000000000..0b4e51dc3 --- /dev/null +++ b/crates/registry-evidencectl/tests/keygen.rs @@ -0,0 +1,428 @@ +#![cfg(unix)] + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +use registry_platform_crypto::{PrivateJwk, PublicJwk}; + +fn evidencectl() -> Command { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) +} + +fn mode_of(path: &Path) -> u32 { + fs::metadata(path) + .unwrap_or_else(|error| panic!("stat {}: {error}", path.display())) + .permissions() + .mode() + & 0o777 +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +/// Asserts neither captured stream contains `needle`, e.g. private key +/// material that must never reach the terminal or logs. +fn assert_output_excludes(output: &Output, needle: &str) { + let stdout = stdout_of(output); + let stderr = stderr_of(output); + assert!( + !stdout.contains(needle), + "stdout leaked secret material: {stdout}" + ); + assert!( + !stderr.contains(needle), + "stderr leaked secret material: {stderr}" + ); +} + +#[test] +fn signing_writes_private_and_public_jwk_with_expected_modes() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "keygen signing failed: {}", + stderr_of(&output) + ); + + assert_eq!(mode_of(&out_dir), 0o700, "out-dir mode"); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + let public_path = out_dir.join("signing-ed25519-public.jwk.json"); + assert_eq!(mode_of(&private_path), 0o600, "private file mode"); + assert_eq!(mode_of(&public_path), 0o644, "public file mode"); + + let private_contents = fs::read_to_string(&private_path).expect("read private jwk"); + let public_contents = fs::read_to_string(&public_path).expect("read public jwk"); + + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + + let expected_kid = public.jkt().expect("thumbprint"); + assert_eq!(private.kid.as_deref(), Some(expected_kid.as_str())); + assert_eq!(public.kid.as_deref(), Some(expected_kid.as_str())); + + // The "d" value must never appear on stdout or stderr. + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn signing_kid_override_replaces_the_default_thumbprint() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", "custom-kid-1"]) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let private_contents = + fs::read_to_string(out_dir.join("signing-ed25519-private-jwk")).expect("read private jwk"); + let public_contents = fs::read_to_string(out_dir.join("signing-ed25519-public.jwk.json")) + .expect("read public jwk"); + + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + assert_eq!(private.kid.as_deref(), Some("custom-kid-1")); + assert_eq!(public.kid.as_deref(), Some("custom-kid-1")); + + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn signing_rejects_an_empty_or_whitespace_only_kid() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .args(["--kid", " "]) + .output() + .expect("run evidencectl"); + assert!( + !output.status.success(), + "a whitespace-only --kid must be refused" + ); + assert!( + !out_dir.join("signing-ed25519-private-jwk").exists(), + "no key material should be generated for a refused --kid" + ); +} + +#[test] +fn signing_public_out_overrides_the_default_public_path() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + let public_out = dir.path().join("elsewhere").join("signing-public.json"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--public-out") + .arg(&public_out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert!(public_out.is_file()); + assert!(!out_dir.join("signing-ed25519-public.jwk.json").exists()); + assert_eq!(mode_of(&public_out), 0o644); +} + +#[test] +fn holder_writes_private_and_public_jwk_with_holder_filenames() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("holder-keys"); + + let output = evidencectl() + .args(["keygen", "holder", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let private_path = out_dir.join("holder-ed25519-private-jwk"); + let public_path = out_dir.join("holder-ed25519-public.jwk.json"); + assert_eq!(mode_of(&private_path), 0o600); + assert_eq!(mode_of(&public_path), 0o644); + + let private_contents = fs::read_to_string(&private_path).expect("read private jwk"); + let public_contents = fs::read_to_string(&public_path).expect("read public jwk"); + let private = PrivateJwk::parse(&private_contents).expect("private JWK parses"); + let public = PublicJwk::parse(&public_contents).expect("public JWK parses"); + assert_eq!(private.kid, public.kid); + + let d_value = private.d.clone().expect("private JWK has d"); + assert_output_excludes(&output, &d_value); +} + +#[test] +fn secret_writes_exactly_32_raw_bytes_with_owner_only_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secrets").join("audit-hmac-key"); + + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(out.parent().unwrap()), 0o700, "parent dir mode"); + assert_eq!(mode_of(&out), 0o600, "secret file mode"); + + let bytes = fs::read(&out).expect("read secret"); + assert_eq!(bytes.len(), 32); +} + +#[test] +fn secret_invocations_generate_independent_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = dir.path().join("first.key"); + let second = dir.path().join("second.key"); + + for out in [&first, &second] { + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + } + + let first_bytes = fs::read(&first).expect("read first secret"); + let second_bytes = fs::read(&second).expect("read second secret"); + assert_eq!(first_bytes.len(), 32); + assert_eq!(second_bytes.len(), 32); + assert_ne!(first_bytes, second_bytes); +} + +#[test] +fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + + let first = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(first.status.success(), "{}", stderr_of(&first)); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + let original_private = fs::read_to_string(&private_path).expect("read private jwk"); + + let second = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!( + !second.status.success(), + "second run without --force unexpectedly succeeded" + ); + let unchanged = fs::read_to_string(&private_path).expect("read private jwk"); + assert_eq!( + original_private, unchanged, + "file must be untouched on refusal" + ); + + let third = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!( + mode_of(&private_path), + 0o600, + "mode preserved across --force" + ); + + let regenerated = fs::read_to_string(&private_path).expect("read private jwk"); + assert_ne!( + original_private, regenerated, + "--force must regenerate key material" + ); +} + +#[test] +fn secret_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secret.key"); + + let first = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(first.status.success(), "{}", stderr_of(&first)); + let original = fs::read(&out).expect("read secret"); + + let second = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(!second.status.success()); + assert_eq!(fs::read(&out).expect("read secret"), original); + + let third = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(third.status.success(), "{}", stderr_of(&third)); + assert_eq!(mode_of(&out), 0o600); + assert_ne!(fs::read(&out).expect("read secret"), original); +} + +#[test] +fn signing_batch_abort_leaves_the_private_file_unwritten() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + + // Pre-create only the public target; the private target must never be + // written once the batch is refused. + fs::write(out_dir.join("signing-ed25519-public.jwk.json"), b"stale").expect("seed public file"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(!output.status.success(), "batch should have been refused"); + assert!( + !out_dir.join("signing-ed25519-private-jwk").exists(), + "private key must not be written when the batch aborts" + ); + let public_contents = + fs::read(out_dir.join("signing-ed25519-public.jwk.json")).expect("read public file"); + assert_eq!( + public_contents, b"stale", + "pre-existing public file must be untouched" + ); +} + +#[test] +fn secret_leaves_a_pre_existing_parent_directorys_mode_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let parent = dir.path().join("secrets"); + fs::create_dir(&parent).expect("create parent dir"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).expect("loosen parent mode"); + + let out = parent.join("audit-hmac-key"); + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!( + mode_of(&parent), + 0o755, + "a parent directory this invocation did not create must keep its own mode" + ); +} + +#[test] +fn signing_out_dir_mode_is_normalized_to_0700_when_pre_created_looser() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + fs::set_permissions(&out_dir, fs::Permissions::from_mode(0o755)).expect("loosen out-dir mode"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!( + mode_of(&out_dir), + 0o700, + "a pre-existing out-dir must be normalized to owner-only" + ); +} + +#[test] +fn signing_force_replaces_a_symlinked_private_path_without_writing_through_it() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + + let target = dir.path().join("attacker-target"); + fs::write(&target, b"untouched").expect("seed symlink target"); + + let private_path = out_dir.join("signing-ed25519-private-jwk"); + symlink(&target, &private_path).expect("create symlink at the private path"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .arg("--force") + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let metadata = fs::symlink_metadata(&private_path).expect("stat private path"); + assert!( + metadata.file_type().is_file(), + "the symlink must be replaced by a regular file" + ); + assert!(!metadata.file_type().is_symlink()); + + let target_contents = fs::read(&target).expect("read symlink target"); + assert_eq!( + target_contents, b"untouched", + "the symlink's former target must never be written through" + ); +} + +#[test] +fn signing_error_names_the_offending_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + let out_dir = dir.path().join("keys"); + fs::create_dir(&out_dir).expect("create out-dir"); + fs::write(out_dir.join("signing-ed25519-private-jwk"), b"stale").expect("seed private file"); + + let output = evidencectl() + .args(["keygen", "signing", "--out-dir"]) + .arg(&out_dir) + .output() + .expect("run evidencectl"); + assert!(!output.status.success()); + let stderr = stderr_of(&output); + assert!( + stderr.contains("signing-ed25519-private-jwk"), + "error should name the offending path: {stderr}" + ); +} diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs new file mode 100644 index 000000000..2c59ae885 --- /dev/null +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -0,0 +1,692 @@ +//! Acceptance gate for `evidencectl new`. +//! +//! A scaffolded project must satisfy the real `evidence` binary with no edits: +//! add key material, apply the documented freeze, and both `check` and every +//! scaffolded fixture must pass. Nothing in this file prints key material. + +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ed25519_dalek::SigningKey; +use tempfile::TempDir; + +const SECRET_FILES: [&str; 2] = ["audit-hmac-key", "subject-binding-hmac-key"]; + +/// Project-relative paths the paired Registry Mint configuration occupies. +const MINT_CONFIG: &str = "mint/mint.yaml"; +const MINT_CLIENT_REGISTRATION: &str = "mint/clients/scaffold-client.yaml.example"; +const MINT_SECRETS: &str = "mint/secrets"; + +#[test] +fn a_scaffolded_project_passes_check_and_every_fixture() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path")]); + + passes_check_and_every_fixture(&project); +} + +/// The paired variant changes the authentication block, which `check` loads and +/// validates, so it earns the same gate rather than only a file listing. +#[test] +fn a_mint_paired_project_passes_check_and_every_fixture() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path"), "--with-mint"]); + + passes_check_and_every_fixture(&project); +} + +fn passes_check_and_every_fixture(project: &Path) { + provision_secrets(project); + let fixtures = scaffolded_fixtures(project); + assert!( + !fixtures.is_empty(), + "the scaffold must generate at least one fixture" + ); + + let runtime = project.join("runtime.yaml"); + freeze(project); + let check = evidence(&[ + "check", + "--runtime", + runtime.to_str().expect("runtime path"), + ]); + let evaluations = fixtures + .iter() + .map(|fixture| { + ( + fixture.clone(), + evidence(&[ + "evaluate", + "--runtime", + runtime.to_str().expect("runtime path"), + "--fixture", + fixture, + ]), + ) + }) + .collect::>(); + unfreeze(project); + + assert!( + check.status.success(), + "evidence check failed: {}", + String::from_utf8_lossy(&check.stderr) + ); + assert!( + String::from_utf8_lossy(&check.stdout).contains("passed check"), + "unexpected evidence check output" + ); + for (fixture, outcome) in evaluations { + assert!( + outcome.status.success(), + "evidence evaluate failed for {fixture}: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + assert!( + String::from_utf8_lossy(&outcome.stdout).contains("Evidence fixture passed ("), + "unexpected evidence evaluate output for {fixture}" + ); + } +} + +#[test] +fn a_non_empty_directory_is_refused_without_force() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + fs::create_dir_all(&project).expect("project directory"); + fs::write(project.join("occupied.txt"), "existing\n").expect("existing file"); + + let refusal = evidencectl(&["new", project.to_str().expect("project path")]); + assert!( + !refusal.status.success(), + "scaffolding a non-empty directory must fail without --force" + ); + assert!( + String::from_utf8_lossy(&refusal.stderr).contains("--force"), + "the refusal must name the flag that overrides it" + ); + assert!( + !project.join("runtime.yaml").exists(), + "a refused scaffold must not write into the directory" + ); + + scaffold(&[project.to_str().expect("project path"), "--force"]); + assert!(project.join("runtime.yaml").is_file()); + assert!(project.join("bundle/evidence.yaml").is_file()); + assert!( + project.join("occupied.txt").is_file(), + "--force must not delete unrelated files" + ); +} + +#[test] +fn a_frozen_project_can_be_scaffolded_again_with_force() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path")]); + freeze(&project); + + let rewrite = evidencectl(&[ + "new", + project.to_str().expect("project path"), + "--force", + "--provider-id", + "urn:example:scaffold:provider:second", + ]); + let outcome = rewrite.status.success(); + unfreeze(&project); + assert!( + outcome, + "rewriting a frozen project failed: {}", + String::from_utf8_lossy(&rewrite.stderr) + ); + let bundle = fs::read_to_string(project.join("bundle/evidence.yaml")).expect("bundle"); + assert!(bundle.contains("urn:example:scaffold:provider:second")); +} + +#[test] +fn re_scaffolding_without_with_mint_over_a_stale_mint_tree_fails() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path"), "--with-mint"]); + assert!(project.join(MINT_CONFIG).is_file()); + + let rewrite = evidencectl(&["new", project.to_str().expect("project path"), "--force"]); + assert!( + !rewrite.status.success(), + "re-scaffolding without --with-mint over a stale mint/ tree must fail" + ); + let stderr = String::from_utf8_lossy(&rewrite.stderr); + assert!( + stderr.contains("mint/"), + "the refusal must name the mint/ directory: {stderr}" + ); + assert!( + project.join(MINT_CONFIG).is_file(), + "a refused rewrite must not disturb the existing mint/ tree" + ); + + scaffold(&[ + project.to_str().expect("project path"), + "--force", + "--with-mint", + ]); + assert!(project.join(MINT_CONFIG).is_file()); +} + +#[test] +fn generated_state_is_excluded_from_version_control() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path")]); + + let ignored = fs::read_to_string(project.join(".gitignore")).expect("gitignore"); + for entry in ["secrets/", "audit/", "out/"] { + assert!( + ignored.lines().any(|line| line.trim() == entry), + "the generated .gitignore must exclude {entry}" + ); + } +} + +#[test] +fn the_secret_directory_is_owner_only_and_empty() { + use std::os::unix::fs::PermissionsExt as _; + + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path")]); + + let secrets = project.join("secrets"); + let metadata = fs::metadata(&secrets).expect("secret directory"); + assert!(metadata.is_dir()); + assert_eq!( + metadata.permissions().mode() & 0o777, + 0o700, + "the secret directory must be owner-only" + ); + assert_eq!( + fs::read_dir(&secrets).expect("secret directory").count(), + 0, + "the scaffold must not generate key material" + ); + assert!(project.join("audit").is_dir()); +} + +#[test] +fn the_mint_configuration_is_rendered_only_when_it_is_asked_for() { + use std::os::unix::fs::PermissionsExt as _; + + let workspace = TempDir::new().expect("temporary directory"); + let standalone = workspace.path().join("standalone"); + scaffold(&[standalone.to_str().expect("project path")]); + assert!( + !standalone.join("mint").exists(), + "the default scaffold must not render a Mint configuration" + ); + assert!( + !fs::read_to_string(standalone.join("README.md")) + .expect("readme") + .contains("Registry Mint"), + "the default README must not document a Mint pairing" + ); + + let paired = workspace.path().join("paired"); + scaffold(&[paired.to_str().expect("project path"), "--with-mint"]); + assert!(paired.join(MINT_CONFIG).is_file()); + assert!(paired.join(MINT_CLIENT_REGISTRATION).is_file()); + + // The registration is inert until an operator supplies a key and renames + // it: Mint loads `*.yaml` only. + assert_eq!( + fs::read_dir(paired.join("mint/clients")) + .expect("client registry directory") + .filter_map(|entry| entry.expect("client entry").file_name().into_string().ok()) + .filter(|name| name.ends_with(".yaml")) + .count(), + 0, + "the scaffold must not register a client it has no key for" + ); + + let secrets = paired.join(MINT_SECRETS); + let metadata = fs::metadata(&secrets).expect("mint secret directory"); + assert!(metadata.is_dir()); + assert_eq!( + metadata.permissions().mode() & 0o777, + 0o700, + "the Mint secret directory must be owner-only" + ); + assert_eq!( + fs::read_dir(&secrets) + .expect("mint secret directory") + .count(), + 0, + "the scaffold must not generate Mint key material" + ); + + let readme = fs::read_to_string(paired.join("README.md")).expect("readme"); + for expected in ["Registry Mint", "mint check", "evidencectl keygen signing"] { + assert!( + readme.contains(expected), + "the paired README must document {expected}" + ); + } + assert!( + !readme.contains("\"d\":") && !readme.contains("PRIVATE"), + "the README must never carry key material" + ); +} + +/// The two documents are rendered from one set of values, and this is what says +/// so: every value the pairing depends on has to agree on both sides. +#[test] +fn the_mint_pairing_values_mirror_the_evidence_bundle() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path"), "--with-mint"]); + + let bundle = yaml(&project.join("bundle/evidence.yaml")); + let mint = yaml(&project.join(MINT_CONFIG)); + let authentication = &bundle["authentication"]; + let access_tokens = &mint["accessTokens"]; + + assert_eq!( + authentication["issuer"], mint["issuer"], + "Evidence must trust the issuer Mint stamps into its tokens" + ); + assert_eq!( + authentication["audiences"], access_tokens["audiences"], + "Evidence must accept the audience Mint mints for" + ); + assert_eq!( + authentication["jwksUri"] + .as_str() + .expect("the bundle names a JWKS URI"), + format!( + "{}{}", + mint["issuer"].as_str().expect("Mint names an issuer"), + mint["signing"]["jwksPath"] + .as_str() + .expect("Mint names a JWKS path") + ), + "Evidence must fetch the key set Mint publishes" + ); + assert!( + authentication["algorithms"] + .as_sequence() + .expect("the bundle names token algorithms") + .contains(&mint["signing"]["algorithm"]), + "Evidence must accept the algorithm Mint signs with" + ); + + for (evidence_field, mint_field) in [ + ("principalClaim", "principal"), + ("requesterTagsClaim", "requesterTags"), + ("evidenceAudienceClaim", "evidenceAudience"), + ("grantIdClaim", "grantId"), + ("grantAuthorityClaim", "grantAuthority"), + ] { + assert_eq!( + authentication[evidence_field], access_tokens["claims"][mint_field], + "{evidence_field} and claims.{mint_field} name the same claim" + ); + } + + // The registration side of the pairing: a caller whose tags match no + // authority profile authenticates and is then refused everything. + let profiles = bundle["authorityProfiles"] + .as_mapping() + .expect("the bundle declares authority profiles"); + assert_eq!(profiles.len(), 1, "the scaffold declares one profile"); + let profile = profiles.values().next().expect("the authority profile"); + let client = yaml(&project.join(MINT_CLIENT_REGISTRATION)); + assert_eq!( + client["requesterTags"], profile["requesterTags"], + "the registered caller must carry the profile's requester tags" + ); + + let endpoint = mint["clientAssertion"]["audience"] + .as_str() + .expect("Mint names an assertion audience"); + assert!( + endpoint.starts_with(mint["issuer"].as_str().expect("Mint names an issuer")), + "the token endpoint must live under the issuer" + ); +} + +/// The real `mint` binary loads what the scaffold wrote, over a registry with +/// one registered caller. Anything the two documents disagree about that +/// `mint check` can see fails here. +#[test] +fn the_rendered_mint_configuration_passes_mint_check() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path"), "--with-mint"]); + provision_mint_secrets(&project); + + let outcome = mint(&[ + "check", + "--config", + project.join(MINT_CONFIG).to_str().expect("config path"), + ]); + let logged = format!( + "{}{}", + String::from_utf8_lossy(&outcome.stdout), + String::from_utf8_lossy(&outcome.stderr) + ); + assert!(outcome.status.success(), "mint check failed: {logged}"); + assert!( + logged.contains("configuration is valid"), + "unexpected mint check output: {logged}" + ); + assert!( + logged.contains("\"clients\":1"), + "mint check must have loaded the registered caller: {logged}" + ); +} + +/// Run `evidencectl new` and require success. +fn scaffold(arguments: &[&str]) { + let mut invocation = vec!["new"]; + invocation.extend_from_slice(arguments); + let outcome = evidencectl(&invocation); + assert!( + outcome.status.success(), + "evidencectl new failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); +} + +fn evidencectl(arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +fn evidence(arguments: &[&str]) -> std::process::Output { + Command::new(evidence_binary()) + .args(arguments) + .output() + .expect("running evidence") +} + +fn mint(arguments: &[&str]) -> std::process::Output { + Command::new(mint_binary()) + .args(arguments) + .output() + .expect("running mint") +} + +/// Locate the `evidence` binary this acceptance gate drives. +fn evidence_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| workspace_binary("EVIDENCE_BIN", "registry-evidence", "evidence")) +} + +/// Locate the `mint` binary the paired configuration is checked against. +fn mint_binary() -> &'static Path { + static BINARY: OnceLock = OnceLock::new(); + BINARY.get_or_init(|| workspace_binary("MINT_BIN", "registry-mint", "mint")) +} + +/// Resolve a workspace binary. The environment variable wins when the caller +/// already built one. Otherwise the binary is built once from this workspace +/// and reused by every test in this file. +fn workspace_binary(variable: &str, package: &str, name: &str) -> PathBuf { + if let Some(path) = std::env::var_os(variable) { + return PathBuf::from(path); + } + let build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(workspace_root()) + .args([ + "build", + "--locked", + "-p", + package, + "--bin", + name, + "--profile", + ¤t_test_profile(), + "--message-format", + "json-render-diagnostics", + ]) + .output() + .unwrap_or_else(|error| panic!("building the {name} binary: {error}")); + assert!( + build.status.success(), + "building the {name} binary failed: {}", + String::from_utf8_lossy(&build.stderr) + ); + String::from_utf8_lossy(&build.stdout) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|message| { + message.get("reason").and_then(serde_json::Value::as_str) == Some("compiler-artifact") + }) + .filter_map(|message| { + message + .get("executable") + .and_then(serde_json::Value::as_str) + .map(PathBuf::from) + }) + .find(|executable| executable.file_name().is_some_and(|found| found == name)) + .unwrap_or_else(|| panic!("the {name} binary path")) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("workspace root") + .to_path_buf() +} + +/// The profile this test binary was itself built with, read from its own +/// path (`target//deps/`) rather than assumed. A nested +/// `cargo build` passes this back with `--profile` so it reuses the artifacts +/// the outer build already produced (e.g. CI's `--profile ci`) instead of +/// triggering a second full build under the default `dev` profile. +fn current_test_profile() -> String { + let exe = std::env::current_exe().expect("current test executable path"); + let deps_dir = exe + .parent() + .expect("test executable has a parent directory"); + let profile_dir = deps_dir + .parent() + .expect("the deps directory has a parent directory"); + let profile = profile_dir + .file_name() + .and_then(std::ffi::OsStr::to_str) + .expect("the profile directory name is valid UTF-8"); + if profile == "debug" { + "dev".to_owned() + } else { + profile.to_owned() + } +} + +/// Write the key material the scaffolded runtime expects, owner-read-only. +/// +/// The signing key identifier is taken from the generated bundle, so a scaffold +/// that stops declaring one fails here rather than silently signing with a key +/// the deployment does not know. +fn provision_secrets(project: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + let secrets = project.join("secrets"); + let bundle = yaml(&project.join("bundle/evidence.yaml")); + let key_id = bundle["signing"]["activeKeyId"] + .as_str() + .expect("active signing key id"); + + let (private_jwk, _) = ed25519_key(key_id); + write_secret( + &secrets.join("signing-ed25519-private-jwk"), + private_jwk.as_bytes(), + ); + for name in SECRET_FILES { + let mut material = [0_u8; 32]; + getrandom::fill(&mut material).expect("random secret"); + write_secret(&secrets.join(name), &material); + } + assert_eq!( + fs::metadata(&secrets) + .expect("secret directory") + .permissions() + .mode() + & 0o777, + 0o700 + ); +} + +fn write_secret(path: &Path, material: &[u8]) { + use std::os::unix::fs::PermissionsExt as _; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("key material directory"); + } + fs::write(path, material).expect("writing key material"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("restricting key material"); +} + +/// A fresh Ed25519 keypair as its private JWK document and its public `x` +/// member. Nothing here is printed; the private half only ever reaches an +/// owner-only file under a temporary directory. +fn ed25519_key(key_id: &str) -> (String, String) { + let mut seed = [0_u8; 32]; + getrandom::fill(&mut seed).expect("random signing seed"); + let signing_key = SigningKey::from_bytes(&seed); + let public = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let private = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"{key_id}","d":"{}","x":"{public}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()) + ); + (private, public) +} + +/// Give the scaffolded Mint deployment the key material and the one registered +/// caller its README tells an operator to produce. +/// +/// The scaffold renders the registration with a placeholder key and the +/// `.yaml.example` name Mint's registry loader ignores, so this is the step +/// that turns it into a registration: a real public key, under the real name. +fn provision_mint_secrets(project: &Path) { + let root = project.join("mint"); + let config = yaml(&project.join(MINT_CONFIG)); + let (signing_jwk, _) = ed25519_key( + config["signing"]["activeKeyId"] + .as_str() + .expect("active key id"), + ); + write_secret( + &root.join( + config["signing"]["activeKeyFile"] + .as_str() + .expect("active key file"), + ), + signing_jwk.as_bytes(), + ); + + let example = project.join(MINT_CLIENT_REGISTRATION); + let mut registration = yaml(&example); + let caller_key_id = registration["keys"][0]["kid"] + .as_str() + .expect("the registration names a caller key id") + .to_owned(); + let (caller_jwk, caller_public) = ed25519_key(&caller_key_id); + assert!( + !registration["keys"][0]["x"] + .as_str() + .expect("the registration carries a placeholder key") + .is_empty(), + "the placeholder key must be a value an operator can recognise" + ); + registration["keys"][0]["x"] = serde_norway::Value::String(caller_public); + + // The caller's own key belongs to the caller, so it lands where the README + // says it does rather than beside Mint's. + write_secret( + &root.join("secrets/caller/signing-ed25519-private-jwk"), + caller_jwk.as_bytes(), + ); + fs::write( + example.with_file_name("scaffold-client.yaml"), + serde_norway::to_string(®istration).expect("registration YAML"), + ) + .expect("writing the client registration"); +} + +fn yaml(path: &Path) -> serde_norway::Value { + serde_norway::from_str( + &fs::read_to_string(path) + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("parsing {}: {error}", path.display())) +} + +/// The bundle-relative fixture paths the scaffold generated. +fn scaffolded_fixtures(project: &Path) -> Vec { + let mut fixtures = fs::read_dir(project.join("bundle/fixtures")) + .expect("fixtures directory") + .map(|entry| entry.expect("fixture entry").file_name()) + .filter_map(|name| name.to_str().map(ToOwned::to_owned)) + .filter(|name| name.ends_with(".yaml")) + .map(|name| format!("fixtures/{name}")) + .collect::>(); + fixtures.sort(); + fixtures +} + +/// The documented freeze: no write bits anywhere in the bundle, and a +/// read-only runtime file. Evidence refuses a deployment input it could write. +fn freeze(project: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + set_tree_mode(&project.join("bundle"), 0o555, 0o444); + fs::set_permissions( + project.join("runtime.yaml"), + fs::Permissions::from_mode(0o444), + ) + .expect("freezing the runtime file"); +} + +/// Restore write permissions so the temporary directory can be removed. +fn unfreeze(project: &Path) { + use std::os::unix::fs::PermissionsExt as _; + + set_tree_mode(&project.join("bundle"), 0o755, 0o644); + fs::set_permissions( + project.join("runtime.yaml"), + fs::Permissions::from_mode(0o644), + ) + .expect("unfreezing the runtime file"); +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + use std::os::unix::fs::PermissionsExt as _; + + let metadata = fs::symlink_metadata(path).expect("tree entry"); + if metadata.is_dir() { + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("opening a directory"); + for entry in fs::read_dir(path).expect("reading a directory") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + fs::set_permissions(path, fs::Permissions::from_mode(directory_mode)) + .expect("setting a directory mode"); + } else { + fs::set_permissions(path, fs::Permissions::from_mode(file_mode)) + .expect("setting a file mode"); + } +} diff --git a/products/evidence/scripts/check-source-neutrality.sh b/products/evidence/scripts/check-source-neutrality.sh index 8f5efdf23..8c5e89430 100755 --- a/products/evidence/scripts/check-source-neutrality.sh +++ b/products/evidence/scripts/check-source-neutrality.sh @@ -7,7 +7,12 @@ trap 'rm -rf "$temporary_root"' EXIT HUP INT TERM production_text="$temporary_root/production-rust.txt" : >"$production_text" -for source_file in $(rg --files "$repository_root/crates/registry-evidence/src" -g '*.rs' | sort); do +for source_file in $( + rg --files \ + "$repository_root/crates/registry-evidence/src" \ + "$repository_root/crates/registry-evidencectl/src" \ + -g '*.rs' | sort +); do case "$source_file" in *_tests.rs) continue ;; esac @@ -125,17 +130,22 @@ sys.stdout.write(source[cursor:]) PY done +evidencectl_templates="$repository_root/crates/registry-evidencectl/templates" + if rg -n -i 'dhis2|opencrvs' \ "$production_text" \ + "$evidencectl_templates" \ "$repository_root/crates/registry-evidence/Cargo.toml" \ + "$repository_root/crates/registry-evidencectl/Cargo.toml" \ "$repository_root/Cargo.toml"; then - echo 'Evidence production code or Cargo metadata contains a prohibited source-product name.' >&2 + echo 'Evidence production code, tooling templates, or Cargo metadata contains a prohibited source-product name.' >&2 exit 1 fi if rg -n -i 'adult|age[_ -]?at|residence|licen[cs]e|parentage|legal[_ -]?parent|given_name|family_name|birth_date|national[_ -]?identifier' \ - "$production_text"; then - echo 'Evidence production Rust contains acceptance-case or jurisdiction-specific vocabulary.' >&2 + "$production_text" \ + "$evidencectl_templates"; then + echo 'Evidence production Rust or tooling templates contain acceptance-case or jurisdiction-specific vocabulary.' >&2 exit 1 fi From 249587753acd7cd9e06161ff2d6919d121a9eebd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 00:54:01 +0700 Subject: [PATCH 019/136] docs(evidence): document evidencectl in repo and product guidance Add the registry-evidencectl row to the repository map, state its boundary beside the runtime (outside the frozen Version 1 contract, shells out to the evidence binary for every semantic decision, no registry-notary* dependency, covered by the same neutrality checks), and extend the Evidence reproducible gate commands to build and test both crates. Signed-off-by: Jeremi Joslin --- AGENTS.md | 14 ++++++++++++-- products/evidence/README.md | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ed8bfd71..9bfc1f8f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ Evidence's authenticator; Evidence does not depend on Mint. | `crates/registry-relay` | Protected read APIs (Relay) | | `crates/registry-notary*` | Evidence gateway: server, core, client, source adapters, worker harness (Notary) | | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | +| `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, project scaffolds, fixture runs | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | | `crates/registry-platform-*` | Shared primitives: audit, authcommon, cache, config, crypto, httpsec, httputil, oid4vci, oidc, ops, pdp, replay, sdjwt, sts, testing | @@ -47,11 +48,20 @@ subsystems. Evidence serializes the same stateless assertion as an SD-JWT VC response format under its own frozen profile; that is a second encoding of one response, never a credential lifecycle. -The implementation is one `registry-evidence` crate and one `evidence` binary. -It may reuse narrowly applicable `registry-platform-*` +The runtime implementation is one `registry-evidence` crate and one `evidence` +binary. It may reuse narrowly applicable `registry-platform-*` primitives such as audit, crypto, OIDC, HTTP security, SD-JWT serialization, and testing. It must not depend on `registry-notary*`. +`registry-evidencectl` (`evidencectl`) is adopter tooling beside the runtime, +like `registryctl` is for the rest of the stack. It sits outside the frozen +Version 1 runtime contract: it generates key material and deployment-project +scaffolds and drives fixture runs, but it shells out to the `evidence` binary +for every Evidence semantic decision and never re-implements evaluation, +signing, or verification. It must not depend on `registry-notary*`, and its +source and scaffold templates are covered by the same source-product and +domain neutrality checks as the runtime. + Evidence configuration and scripts are trusted, startup-only deployment artifacts. Rust owns authentication, authorization, fixed source execution, bounded script execution, output validation, evidence construction, signing, diff --git a/products/evidence/README.md b/products/evidence/README.md index 535107d8a..fc1e4b43f 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -73,6 +73,19 @@ smoke documentation. Production Rust, Cargo metadata, public configuration, routes, CLI options, and generated public contracts must remain source-product neutral. +## Adopter tooling + +`evidencectl`, built from the `registry-evidencectl` crate, is adopter tooling +beside the runtime, like `registryctl` for the rest of the stack. It sits +outside the frozen Version 1 runtime contract: it generates signing, holder, +and HMAC key material, assembles public JWKS documents, scaffolds a neutral +deployment project that passes `evidence check` and `evidence evaluate` +without edits after one keygen pass, and drives fixture runs. It shells out to +the `evidence` binary for every Evidence semantic decision and never +re-implements evaluation, signing, or verification. It must not depend on +`registry-notary*`, and its source and scaffold templates are covered by the +same source-product and domain neutrality checks as the runtime. + ## Discovering available evidence An authenticated caller lists the complete Evidence request shapes it can @@ -147,9 +160,9 @@ From the monorepo root, the Evidence-specific reproducible gate is: ```sh cargo fmt --check -cargo check --locked -p registry-evidence --all-targets -cargo test --locked -p registry-evidence -cargo clippy --locked -p registry-evidence --all-targets -- -D warnings +cargo check --locked -p registry-evidence -p registry-evidencectl --all-targets +cargo test --locked -p registry-evidence -p registry-evidencectl +cargo clippy --locked -p registry-evidence -p registry-evidencectl --all-targets -- -D warnings products/evidence/scripts/check-contracts.sh products/evidence/scripts/check-source-neutrality.sh ``` From e002a9a89de19d15836e98794b7d8aa8499bc747 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 00:54:11 +0700 Subject: [PATCH 020/136] style(evidence): reformat reference deployment YAML for editability Convert flow-style mappings in the reference deployment projects to block style so an adopter copying these files can edit one field per line: selector profile fields, fixture case documents, and schema property maps. Short scalar lists and single-key const properties stay inline. Every file is proven byte-for-byte semantically identical to its previous parse (yq JSON round-trip diff), and the registry-evidence suite that loads these bundles passes unchanged. The opencrvs-family-evidence bundle/evidence.yaml carries the same reformat but is left uncommitted here because the working tree also holds unrelated in-progress edits to it. Signed-off-by: Jeremi Joslin --- .../bundle/evidence.yaml | 68 +++- .../bundle/fixtures/adult-status-cases.yaml | 244 ++++++++++++--- .../fixtures/professional-licence-cases.yaml | 290 ++++++++++++++---- ...dult-status-adapter-parameters.schema.yaml | 15 +- .../schemas/adult-status-facts.schema.yaml | 9 +- ...nal-licence-adapter-parameters.schema.yaml | 30 +- .../professional-licence-facts.schema.yaml | 18 +- .../bundle/fixtures/adult-status-cases.yaml | 124 ++++++-- .../registered-parent-references-cases.yaml | 97 +++++- .../registered-parent-relationship-cases.yaml | 125 ++++++-- .../schemas/birth-adult-facts.schema.yaml | 9 +- .../schemas/birth-parents-facts.schema.yaml | 10 +- .../birth-parents-parameters.schema.yaml | 5 +- 13 files changed, 838 insertions(+), 206 deletions(-) diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml index ea76bbb1a..387d9057f 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml @@ -43,7 +43,10 @@ selectorProfiles: tracked-entity-reference-v1: maximumAggregateBytes: 64 fields: - record_reference: {type: string, minimumBytes: 11, maximumBytes: 64} + record_reference: + type: string + minimumBytes: 11 + maximumBytes: 64 sources: population-tracker: transport: http-json @@ -58,11 +61,13 @@ sources: method: GET path: /api/tracker/trackedEntities fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json selectorInputs: - role: subject alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} + - profile: tracked-entity-reference-v1 + fields: [record_reference] prepareScript: adapters/prepare.rhai adapterParameters: program: Prg00000001 @@ -107,11 +112,13 @@ sources: method: GET path: /api/tracker/trackedEntities fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json selectorInputs: - role: subject alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} + - profile: tracked-entity-reference-v1 + fields: [record_reference] prepareScript: adapters/prepare.rhai adapterParameters: program: Prg00000002 @@ -182,7 +189,9 @@ requirements: source: population-tracker purposes: [benefit-eligibility] subjectRoles: - - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} + - role: subject + cardinality: one + selectorProfiles: [tracked-entity-reference-v1] referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] evidenceType: urn:gov:example:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok @@ -192,7 +201,8 @@ requirements: selectorInputs: - role: subject alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} + - profile: tracked-entity-reference-v1 + fields: [record_reference] parameters: {minimum_age_years: 18} concepts: - id: urn:gov:example:concept:adult-status @@ -208,7 +218,9 @@ requirements: source: professional-licence-register purposes: [professional-registration-verification] subjectRoles: - - {role: subject, cardinality: one, selectorProfiles: [tracked-entity-reference-v1]} + - role: subject + cardinality: one + selectorProfiles: [tracked-entity-reference-v1] referenceFrameworks: [urn:gov:example:framework:professional-practice-licence:v1] evidenceType: urn:gov:example:evidence-type:professional-licence-status:v1 observationTimezone: Asia/Bangkok @@ -218,16 +230,44 @@ requirements: selectorInputs: - role: subject alternatives: - - {profile: tracked-entity-reference-v1, fields: [record_reference]} + - profile: tracked-entity-reference-v1 + fields: [record_reference] parameters: active_state: ACTIVE expiry_buckets: - - {minimumInclusive: {type: decimal, value: '-365000'}, maximumExclusive: {type: decimal, value: '0'}, code: expired} - - {minimumInclusive: {type: decimal, value: '0'}, maximumExclusive: {type: decimal, value: '31'}, code: within-30-days} - - {minimumInclusive: {type: decimal, value: '31'}, maximumExclusive: {type: decimal, value: '91'}, code: within-90-days} - - {minimumInclusive: {type: decimal, value: '91'}, maximumExclusive: {type: decimal, value: '365001'}, code: later} + - minimumInclusive: + type: decimal + value: '-365000' + maximumExclusive: + type: decimal + value: '0' + code: expired + - minimumInclusive: + type: decimal + value: '0' + maximumExclusive: + type: decimal + value: '31' + code: within-30-days + - minimumInclusive: + type: decimal + value: '31' + maximumExclusive: + type: decimal + value: '91' + code: within-90-days + - minimumInclusive: + type: decimal + value: '91' + maximumExclusive: + type: decimal + value: '365001' + code: later concepts: - - {id: urn:gov:example:concept:professional-licence-active, form: boolean, required: true, constraints: {}} + - id: urn:gov:example:concept:professional-licence-active + form: boolean + required: true + constraints: {} - id: urn:gov:example:concept:professional-licence-expiry-category form: controlled-category required: true diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml index 06d74bcc1..78849303d 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml @@ -12,122 +12,269 @@ common: values: {record_reference: Tei00000001} expectedRequestParts: query: - - {name: program, value: Prg00000001} - - {name: orgUnits, value: Org00000001} - - {name: trackedEntities, value: Tei00000001} - - {name: fields, value: "trackedEntity,attributes[attribute,value]"} - - {name: pageSize, value: "2"} - - {name: page, value: "1"} - - {name: totalPages, value: "true"} + - name: program + value: Prg00000001 + - name: orgUnits + value: Org00000001 + - name: trackedEntities + value: Tei00000001 + - name: fields + value: "trackedEntity,attributes[attribute,value]" + - name: pageSize + value: "2" + - name: page + value: "1" + - name: totalPages + value: "true" body: null expectedTransport: path: /api/tracker/trackedEntities fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json cases: - id: positive response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Dob00000001, value: "2000-01-01"} - - {attribute: Oth00000001, value: SOURCE-UNRELATED-CANARY} + - attribute: Dob00000001 + value: "2000-01-01" + - attribute: Oth00000001 + value: SOURCE-UNRELATED-CANARY expected: lookup: match derivationRuns: true signed: true - facts: {record_reference: Tei00000001, date_of_birth: "2000-01-01"} + facts: + record_reference: Tei00000001 + date_of_birth: "2000-01-01" value: true + - id: negative-false-is-success response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 - attributes: [{attribute: Dob00000001, value: "2010-01-01"}] + attributes: + - attribute: Dob00000001 + value: "2010-01-01" expected: lookup: match derivationRuns: true - facts: {record_reference: Tei00000001, date_of_birth: "2010-01-01"} + facts: + record_reference: Tei00000001 + date_of_birth: "2010-01-01" value: false signed: true + - id: boundary-on response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 - attributes: [{attribute: Dob00000001, value: "2008-08-02"}] + attributes: + - attribute: Dob00000001 + value: "2008-08-02" expected: lookup: match derivationRuns: true signed: true - facts: {record_reference: Tei00000001, date_of_birth: "2008-08-02"} + facts: + record_reference: Tei00000001 + date_of_birth: "2008-08-02" value: true + - id: missing-fact response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 - attributes: [{attribute: Oth00000001, value: SOURCE-UNRELATED-CANARY}] - expected: {publicProblem: evidence_not_available, derivationRuns: false, signed: false} + attributes: + - attribute: Oth00000001 + value: SOURCE-UNRELATED-CANARY + expected: + publicProblem: evidence_not_available + derivationRuns: false + signed: false + - id: no-match - response: {pager: {page: 1, pageSize: 2, total: 0, pageCount: 0}, trackedEntities: []} - expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 0 + pageCount: 0 + trackedEntities: [] + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false + - id: ambiguous response: - pager: {page: 1, pageSize: 2, total: 2, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 2 + pageCount: 1 trackedEntities: - - {trackedEntity: Tei00000001, attributes: []} - - {trackedEntity: Tei00000002, attributes: []} - expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - trackedEntity: Tei00000001 + attributes: [] + - trackedEntity: Tei00000002 + attributes: [] + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false + # A tracker page whose envelope is truncated, inconsistent, or wrongly typed is # never read as an authoritative no-match, unique match, or duplicate. - id: truncated-pager - response: {pager: {page: 1, pageSize: 2, total: 1}, trackedEntities: [{trackedEntity: Tei00000001, attributes: []}]} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 1 + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: pager-count-mismatch - response: {pager: {page: 1, pageSize: 2, total: 1, pageCount: 2}, trackedEntities: [{trackedEntity: Tei00000001, attributes: []}]} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 2 + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: total-collection-mismatch - response: {pager: {page: 1, pageSize: 2, total: 1, pageCount: 1}, trackedEntities: []} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 + trackedEntities: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: fractional-total - response: {pager: {page: 1, pageSize: 2, total: 1.5, pageCount: 1}, trackedEntities: []} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 1.5 + pageCount: 1 + trackedEntities: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + # One tracked entity carrying the configured attribute twice, or carrying it # with a non-string value, is a provider or configuration fault rather than a # record that legitimately lacks a date of birth. - id: duplicate-configured-attribute response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Dob00000001, value: "2000-01-01"} - - {attribute: Dob00000001, value: "2010-01-01"} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - attribute: Dob00000001 + value: "2000-01-01" + - attribute: Dob00000001 + value: "2010-01-01" + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: non-string-attribute-value response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 - attributes: [{attribute: Dob00000001, value: 20000101}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + attributes: + - attribute: Dob00000001 + value: 20000101 + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: returned-subject-mismatch response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000002 - attributes: [{attribute: Dob00000001, value: "2000-01-01"}] + attributes: + - attribute: Dob00000001 + value: "2000-01-01" expected: lookup: match error: derivation_input_error publicProblem: evidence_not_available derivationRuns: true signed: false - - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} + + - id: source-failure + sourceFailure: timeout + expected: + publicProblem: dependency_unavailable + signed: false + # An expired provider session answers a JSON request with an HTML sign-in page. - - {id: source-html-sign-in-page, sourceFailure: invalid-media-type, expected: {publicProblem: dependency_unavailable, signed: false}} + - id: source-html-sign-in-page + sourceFailure: invalid-media-type + expected: + publicProblem: dependency_unavailable + signed: false + - id: hostile-reference selectorOverrides: subject: @@ -138,7 +285,10 @@ cases: path: /api/tracker/trackedEntities query: "program=Prg00000001&orgUnits=Org00000001&trackedEntities=X%26fields%3D%2A%250D%250AInjected%3Ayes&fields=trackedEntity%2Cattributes%5Battribute%2Cvalue%5D&pageSize=2&page=1&totalPages=true" body: null - - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} + + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: {bundle: rejected} privacyExpectation: evidenceContains: [urn:gov:example:concept:adult-status] evidenceExcludes: [date_of_birth, record_reference, tracked-entity-reference-v1, Tei00000001, Tei00000002] diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml index 2d16f67ce..ff61fcb07 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml @@ -12,33 +12,49 @@ common: values: {record_reference: Tei00000001} expectedRequestParts: query: - - {name: program, value: Prg00000002} - - {name: orgUnits, value: Org00000001} - - {name: trackedEntities, value: Tei00000001} - - {name: fields, value: "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]"} - - {name: pageSize, value: "2"} - - {name: page, value: "1"} - - {name: totalPages, value: "true"} + - name: program + value: Prg00000002 + - name: orgUnits + value: Org00000001 + - name: trackedEntities + value: Tei00000001 + - name: fields + value: "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]" + - name: pageSize + value: "2" + - name: page + value: "1" + - name: totalPages + value: "true" body: null expectedTransport: path: /api/tracker/trackedEntities fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json cases: - id: positive response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-20"} - - {attribute: Oth00000001, value: LICENCE-UNRELATED-CANARY} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-20" + - attribute: Oth00000001 + value: LICENCE-UNRELATED-CANARY enrollments: - program: Prg00000002 status: ACTIVE events: - - {programStage: Rev00000001, status: COMPLETED} + - programStage: Rev00000001 + status: COMPLETED expected: lookup: match derivationRuns: true @@ -52,21 +68,29 @@ cases: values: urn:gov:example:concept:professional-licence-active: true urn:gov:example:concept:professional-licence-expiry-category: within-30-days + # A completed restriction event withholds the active licence without changing # the enrollment state, so the two signals are read together. - id: restricted-licence-is-not-active response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-12-31"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-12-31" enrollments: - program: Prg00000002 status: ACTIVE events: - - {programStage: Rst00000001, status: COMPLETED} + - programStage: Rst00000001 + status: COMPLETED expected: lookup: match derivationRuns: true @@ -80,21 +104,29 @@ cases: values: urn:gov:example:concept:professional-licence-active: false urn:gov:example:concept:professional-licence-expiry-category: later + # A restriction event that exists but is not completed is a restriction that # has not been recorded, not a restriction in force. - id: scheduled-restriction-is-not-recorded response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-09-15"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-09-15" enrollments: - program: Prg00000002 status: ACTIVE events: - - {programStage: Rst00000001, status: SCHEDULE} + - programStage: Rst00000001 + status: SCHEDULE expected: lookup: match derivationRuns: true @@ -108,16 +140,25 @@ cases: values: urn:gov:example:concept:professional-licence-active: true urn:gov:example:concept:professional-licence-expiry-category: within-90-days + - id: negative-cancelled-enrollment-is-success response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-10-01"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-10-01" enrollments: - - {program: Prg00000002, status: CANCELLED, events: []} + - program: Prg00000002 + status: CANCELLED + events: [] expected: lookup: match derivationRuns: true @@ -133,14 +174,22 @@ cases: urn:gov:example:concept:professional-licence-expiry-category: within-90-days - id: boundary-last-valid-day response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-02"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-02" enrollments: - - {program: Prg00000002, status: ACTIVE, events: []} + - program: Prg00000002 + status: ACTIVE + events: [] expected: lookup: match derivationRuns: true @@ -157,14 +206,22 @@ cases: - id: boundary-first-expired-day observed_at: "2026-08-03T00:00:00Z" response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-02"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-02" enrollments: - - {program: Prg00000002, status: ACTIVE, events: []} + - program: Prg00000002 + status: ACTIVE + events: [] expected: lookup: match derivationRuns: true @@ -182,92 +239,186 @@ cases: # carries no licence state, which is unresolved rather than a denied licence. - id: missing-fact response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-20"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-20" enrollments: - - {program: Prg00000001, status: ACTIVE, events: []} - expected: {publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - program: Prg00000001 + status: ACTIVE + events: [] + expected: + publicProblem: evidence_not_available + derivationRuns: false + signed: false + - id: no-match - response: {pager: {page: 1, pageSize: 2, total: 0, pageCount: 0}, trackedEntities: []} - expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + response: + pager: + page: 1 + pageSize: 2 + total: 0 + pageCount: 0 + trackedEntities: [] + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false + - id: ambiguous response: - pager: {page: 1, pageSize: 2, total: 2, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 2 + pageCount: 1 trackedEntities: - - {trackedEntity: Tei00000001, attributes: [], enrollments: []} - - {trackedEntity: Tei00000002, attributes: [], enrollments: []} - expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - trackedEntity: Tei00000001 + attributes: [] + enrollments: [] + - trackedEntity: Tei00000002 + attributes: [] + enrollments: [] + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false + # Two enrollments in one programme leave no reviewed rule for which one # governs the licence, and an event without a status cannot be read at all. - id: duplicate-programme-enrollment response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-20"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-20" enrollments: - - {program: Prg00000002, status: ACTIVE, events: []} - - {program: Prg00000002, status: CANCELLED, events: []} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + - program: Prg00000002 + status: ACTIVE + events: [] + - program: Prg00000002 + status: CANCELLED + events: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: event-without-status response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-20"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-20" enrollments: - program: Prg00000002 status: ACTIVE - events: [{programStage: Rst00000001}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + events: + - programStage: Rst00000001 + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: truncated-pager response: - pager: {page: 1, pageSize: 2, total: 1} - trackedEntities: [{trackedEntity: Tei00000001, attributes: [], enrollments: []}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + pager: + page: 1 + pageSize: 2 + total: 1 + trackedEntities: + - trackedEntity: Tei00000001 + attributes: [] + enrollments: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + # A validity window that ends before it starts is an inconsistent record, and # it collapses publicly with the unresolved classes rather than signing false. - id: inverted-validity-window response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000001 attributes: - - {attribute: Vfr00000001, value: "2026-12-01"} - - {attribute: Vun00000001, value: "2026-01-01"} + - attribute: Vfr00000001 + value: "2026-12-01" + - attribute: Vun00000001 + value: "2026-01-01" enrollments: - - {program: Prg00000002, status: ACTIVE, events: []} + - program: Prg00000002 + status: ACTIVE + events: [] expected: lookup: match error: derivation_input_error publicProblem: evidence_not_available derivationRuns: true signed: false + - id: returned-subject-mismatch response: - pager: {page: 1, pageSize: 2, total: 1, pageCount: 1} + pager: + page: 1 + pageSize: 2 + total: 1 + pageCount: 1 trackedEntities: - trackedEntity: Tei00000002 attributes: - - {attribute: Vfr00000001, value: "2025-01-01"} - - {attribute: Vun00000001, value: "2026-08-20"} + - attribute: Vfr00000001 + value: "2025-01-01" + - attribute: Vun00000001 + value: "2026-08-20" enrollments: - - {program: Prg00000002, status: ACTIVE, events: []} + - program: Prg00000002 + status: ACTIVE + events: [] expected: lookup: match error: derivation_input_error publicProblem: evidence_not_available derivationRuns: true signed: false - - {id: source-failure, sourceFailure: connection-refused, expected: {publicProblem: dependency_unavailable, signed: false}} + + - id: source-failure + sourceFailure: connection-refused + expected: + publicProblem: dependency_unavailable + signed: false + - id: hostile-reference selectorOverrides: subject: @@ -278,7 +429,10 @@ cases: path: /api/tracker/trackedEntities query: "program=Prg00000002&orgUnits=Org00000001&trackedEntities=X%26fields%3D%2A%250D%250AInjected%3Ayes&fields=trackedEntity%2Cattributes%5Battribute%2Cvalue%5D%2Cenrollments%5Bprogram%2Cstatus%2Cevents%5BprogramStage%2Cstatus%5D%5D&pageSize=2&page=1&totalPages=true" body: null - - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} + + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: {bundle: rejected} privacyExpectation: evidenceContains: - urn:gov:example:concept:professional-licence-active diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml index 38d39ed97..6e54dac2a 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-adapter-parameters.schema.yaml @@ -9,10 +9,19 @@ required: - totalPages - dateOfBirthAttribute properties: - program: {type: string, minLength: 1, maxLength: 128} - organisationUnit: {type: string, minLength: 1, maxLength: 128} + program: + type: string + minLength: 1 + maxLength: 128 + organisationUnit: + type: string + minLength: 1 + maxLength: 128 providerFields: {const: "trackedEntity,attributes[attribute,value]"} pageSize: {const: "2"} page: {const: "1"} totalPages: {const: "true"} - dateOfBirthAttribute: {type: string, minLength: 1, maxLength: 128} + dateOfBirthAttribute: + type: string + minLength: 1 + maxLength: 128 diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml index ffbe79466..86e970c59 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-facts.schema.yaml @@ -2,5 +2,10 @@ type: object additionalProperties: false required: [record_reference, date_of_birth] properties: - record_reference: {type: string, minLength: 11, maxLength: 64} - date_of_birth: {type: string, format: date} + record_reference: + type: string + minLength: 11 + maxLength: 64 + date_of_birth: + type: string + format: date diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml index 62a6b4dac..c585c09bc 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-adapter-parameters.schema.yaml @@ -13,15 +13,33 @@ required: - completedEventStatus - absentRestrictionEventMeansUnrestricted properties: - program: {type: string, minLength: 1, maxLength: 128} - organisationUnit: {type: string, minLength: 1, maxLength: 128} + program: + type: string + minLength: 1 + maxLength: 128 + organisationUnit: + type: string + minLength: 1 + maxLength: 128 providerFields: const: "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]" pageSize: {const: "2"} page: {const: "1"} totalPages: {const: "true"} - validFromAttribute: {type: string, minLength: 1, maxLength: 128} - validUntilAttribute: {type: string, minLength: 1, maxLength: 128} - restrictionStage: {type: string, minLength: 1, maxLength: 128} - completedEventStatus: {type: string, minLength: 1, maxLength: 32} + validFromAttribute: + type: string + minLength: 1 + maxLength: 128 + validUntilAttribute: + type: string + minLength: 1 + maxLength: 128 + restrictionStage: + type: string + minLength: 1 + maxLength: 128 + completedEventStatus: + type: string + minLength: 1 + maxLength: 32 absentRestrictionEventMeansUnrestricted: {type: boolean} diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml index 4c75f07df..3408b6c56 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-facts.schema.yaml @@ -2,8 +2,18 @@ type: object additionalProperties: false required: [record_reference, licence_state, valid_from, valid_until, restriction_recorded] properties: - record_reference: {type: string, minLength: 11, maxLength: 64} - licence_state: {type: string, minLength: 1, maxLength: 32} - valid_from: {type: string, format: date} - valid_until: {type: string, format: date} + record_reference: + type: string + minLength: 11 + maxLength: 64 + licence_state: + type: string + minLength: 1 + maxLength: 32 + valid_from: + type: string + format: date + valid_until: + type: string + format: date restriction_recorded: {type: boolean} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml index 724b0b7fd..dea35de27 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/adult-status-cases.yaml @@ -17,49 +17,133 @@ common: type: and clauses: - eventType: birth - status: {type: exact, term: REGISTERED} - trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + status: + type: exact + term: REGISTERED + trackingId: + type: exact + term: TRACKING-SYNTHETIC-001 limit: 2 offset: 0 expectedTransport: path: /events/search fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json cases: - id: positive response: total: 1 results: - - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2000-01-01", declaration: {unrelated: SOURCE-UNRELATED-CANARY}} - expected: {lookup: match, value: true, derivationRuns: true, signed: true} + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + dateOfEvent: "2000-01-01" + declaration: {unrelated: SOURCE-UNRELATED-CANARY} + expected: + lookup: match + value: true + derivationRuns: true + signed: true - id: negative-false-is-success response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2010-01-01"}] - expected: {lookup: match, value: false, derivationRuns: true, signed: true} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + dateOfEvent: "2010-01-01" + expected: + lookup: match + value: false + derivationRuns: true + signed: true - id: boundary-on response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, dateOfEvent: "2008-08-02"}] - expected: {lookup: match, value: true, derivationRuns: true, signed: true} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + dateOfEvent: "2008-08-02" + expected: + lookup: match + value: true + derivationRuns: true + signed: true - id: missing-fact response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} - - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: no-match + response: + total: 0 + results: [] + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false - id: ambiguous response: total: 2 results: - - {type: birth, status: REGISTERED} - - {type: birth, status: REGISTERED} - expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} - - {id: fractional-total, response: {total: 0.5, results: []}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} - - {id: wrong-status, response: {total: 1, results: [{type: birth, status: DECLARED, dateOfEvent: "2000-01-01"}]}, expected: {error: source_protocol_error, derivationRuns: false, signed: false}} - - {id: returned-subject-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, dateOfEvent: "2000-01-01"}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} - - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} - - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} + - type: birth + status: REGISTERED + - type: birth + status: REGISTERED + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false + - id: fractional-total + response: + total: 0.5 + results: [] + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: wrong-status + response: + total: 1 + results: + - type: birth + status: DECLARED + dateOfEvent: "2000-01-01" + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: returned-subject-mismatch + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-OTHER + dateOfEvent: "2000-01-01" + expected: + lookup: match + error: derivation_input_error + derivationRuns: true + signed: false + - id: source-failure + sourceFailure: timeout + expected: + publicProblem: dependency_unavailable + signed: false + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: {bundle: rejected} privacyExpectation: evidenceContains: [urn:gov:example:concept:adult-status] evidenceExcludes: [date_of_birth, tracking_id, opencrvs-tracking-id-v1] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml index f15f8f83f..61fcd5362 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml @@ -17,14 +17,19 @@ common: type: and clauses: - eventType: birth - status: {type: exact, term: REGISTERED} - trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + status: + type: exact + term: REGISTERED + trackingId: + type: exact + term: TRACKING-SYNTHETIC-001 limit: 2 offset: 0 expectedTransport: path: /events/search fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json cases: - id: positive response: @@ -34,7 +39,12 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - expected: {lookup: match, entityReferenceCount: 1, rawReferencesDisclosed: false, derivationRuns: true, signed: true} + expected: + lookup: match + entityReferenceCount: 1 + rawReferencesDisclosed: false + derivationRuns: true + signed: true - id: boundary-two-parents response: total: 1 @@ -46,20 +56,50 @@ cases: mother.personReference: PERSON-SYNTHETIC-A father.personReference: PERSON-SYNTHETIC-B unrelated.field: SOURCE-UNRELATED-CANARY - expected: {lookup: match, entityReferenceCount: 2, rawReferencesDisclosed: false, derivationRuns: true, signed: true} + expected: + lookup: match + entityReferenceCount: 2 + rawReferencesDisclosed: false + derivationRuns: true + signed: true - id: missing-parent-set response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {}}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} - - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {} + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: no-match + response: + total: 0 + results: [] + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false - id: ambiguous-child response: total: 2 results: - - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: PERSON-SYNTHETIC-A}} - - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-002, declaration: {father.personReference: PERSON-SYNTHETIC-B}} - expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-002 + declaration: {father.personReference: PERSON-SYNTHETIC-B} + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false - id: negative-duplicate-parent-reference response: total: 1 @@ -67,11 +107,36 @@ cases: - type: birth status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 - declaration: {mother.personReference: PERSON-SYNTHETIC-A, father.personReference: PERSON-SYNTHETIC-A} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} - - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} - - {id: returned-child-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, declaration: {mother.personReference: PERSON-SYNTHETIC-A}}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} - - {id: anti-reconstruction, derivationMutation: return-raw-reference, expected: {outputGate: rejected, signed: false}} + declaration: + mother.personReference: PERSON-SYNTHETIC-A + father.personReference: PERSON-SYNTHETIC-A + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: source-failure + sourceFailure: timeout + expected: + publicProblem: dependency_unavailable + signed: false + - id: returned-child-mismatch + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-OTHER + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + lookup: match + error: derivation_input_error + derivationRuns: true + signed: false + - id: anti-reconstruction + derivationMutation: return-raw-reference + expected: + outputGate: rejected + signed: false privacyExpectation: evidenceContains: [urn:gov:example:concept:registered-parent-references] evidenceExcludes: [parent_references, tracking_id, person_reference, relationship_set_contract] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml index 4e83740d7..8dfcaa054 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -28,14 +28,19 @@ common: type: and clauses: - eventType: birth - status: {type: exact, term: REGISTERED} - trackingId: {type: exact, term: TRACKING-SYNTHETIC-001} + status: + type: exact + term: REGISTERED + trackingId: + type: exact + term: TRACKING-SYNTHETIC-001 limit: 2 offset: 0 expectedTransport: path: /events/search fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json cases: - id: positive response: @@ -48,7 +53,11 @@ cases: mother.personReference: PERSON-SYNTHETIC-A father.personReference: PERSON-SYNTHETIC-B unrelated.field: SOURCE-UNRELATED-CANARY - expected: {lookup: match, value: true, derivationRuns: true, signed: true} + expected: + lookup: match + value: true + derivationRuns: true + signed: true - id: negative-false-is-success response: total: 1 @@ -76,20 +85,49 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - expected: {lookup: match, value: true, derivationRuns: true, signed: true} - - {id: no-match, response: {total: 0, results: []}, expected: {lookup: no_match, publicProblem: evidence_not_available, derivationRuns: false, signed: false}} + expected: + lookup: match + value: true + derivationRuns: true + signed: true + - id: no-match + response: + total: 0 + results: [] + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false - id: ambiguous-child response: total: 2 results: - - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: PERSON-SYNTHETIC-A}} - - {type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-002, declaration: {father.personReference: PERSON-SYNTHETIC-B}} - expected: {lookup: ambiguous, publicProblem: evidence_not_available, derivationRuns: false, signed: false} + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-002 + declaration: {father.personReference: PERSON-SYNTHETIC-B} + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false - id: missing-parent-set response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {unrelated.field: SOURCE-UNRELATED-CANARY}}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {unrelated.field: SOURCE-UNRELATED-CANARY} + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: duplicate-parent-reference response: total: 1 @@ -97,19 +135,64 @@ cases: - type: birth status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 - declaration: {mother.personReference: PERSON-SYNTHETIC-A, father.personReference: PERSON-SYNTHETIC-A} - expected: {error: source_protocol_error, derivationRuns: false, signed: false} + declaration: + mother.personReference: PERSON-SYNTHETIC-A + father.personReference: PERSON-SYNTHETIC-A + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: wrong-parent-reference-type response: total: 1 - results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-001, declaration: {mother.personReference: 123}}] - expected: {error: source_protocol_error, derivationRuns: false, signed: false} - - {id: namespace-mismatch, derivationParameterMutation: {candidate_reference_namespace: urn:gov:example:other:person}, expected: {signed: false, error: derivation_input_error, derivationRuns: true}} - - {id: returned-child-mismatch, response: {total: 1, results: [{type: birth, status: REGISTERED, trackingId: TRACKING-SYNTHETIC-OTHER, declaration: {mother.personReference: PERSON-SYNTHETIC-A}}]}, expected: {lookup: match, error: derivation_input_error, derivationRuns: true, signed: false}} - - {id: swapped-roles, requestMutation: swap-subject-roles, expected: {rejectedBefore: source, sourceRequestCount: 0, signed: false}} - - {id: caller-candidate-substitution, requestMutation: supply-grant-derived-candidate, expected: {rejectedBefore: source, sourceRequestCount: 0, signed: false}} - - {id: source-failure, sourceFailure: timeout, expected: {publicProblem: dependency_unavailable, signed: false}} - - {id: anti-reconstruction, bundleMutation: duplicate-disclosure-family, expected: {bundle: rejected}} + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-001 + declaration: {mother.personReference: 123} + expected: + error: source_protocol_error + derivationRuns: false + signed: false + - id: namespace-mismatch + derivationParameterMutation: {candidate_reference_namespace: urn:gov:example:other:person} + expected: + signed: false + error: derivation_input_error + derivationRuns: true + - id: returned-child-mismatch + response: + total: 1 + results: + - type: birth + status: REGISTERED + trackingId: TRACKING-SYNTHETIC-OTHER + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + lookup: match + error: derivation_input_error + derivationRuns: true + signed: false + - id: swapped-roles + requestMutation: swap-subject-roles + expected: + rejectedBefore: source + sourceRequestCount: 0 + signed: false + - id: caller-candidate-substitution + requestMutation: supply-grant-derived-candidate + expected: + rejectedBefore: source + sourceRequestCount: 0 + signed: false + - id: source-failure + sourceFailure: timeout + expected: + publicProblem: dependency_unavailable + signed: false + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: {bundle: rejected} privacyExpectation: evidenceContains: [urn:gov:example:concept:registered-parent-relationship-confirmed] evidenceExcludes: [parent_references, tracking_id, person_reference, relationship_set_contract] diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml index 950c04460..0cd3d0153 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-facts.schema.yaml @@ -2,5 +2,10 @@ type: object additionalProperties: false required: [record_tracking_id, date_of_birth] properties: - record_tracking_id: {type: string, minLength: 1, maxLength: 64} - date_of_birth: {type: string, format: date} + record_tracking_id: + type: string + minLength: 1 + maxLength: 64 + date_of_birth: + type: string + format: date diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml index a6771e182..60e2b7551 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-facts.schema.yaml @@ -7,13 +7,19 @@ required: - relationship_set_contract - relationship_set_complete properties: - record_tracking_id: {type: string, minLength: 1, maxLength: 64} + record_tracking_id: + type: string + minLength: 1 + maxLength: 64 parent_references: type: array minItems: 1 maxItems: 2 uniqueItems: true - items: {type: string, minLength: 1, maxLength: 128} + items: + type: string + minLength: 1 + maxLength: 128 reference_namespace: {const: urn:gov:example:opencrvs:person} relationship_set_contract: {const: urn:gov:example:opencrvs:registered-parent-set:v1} relationship_set_complete: {const: true} diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml index 71ec8d098..dee78e4c7 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-parameters.schema.yaml @@ -24,7 +24,10 @@ properties: minItems: 1 maxItems: 2 uniqueItems: true - items: {type: string, minLength: 1, maxLength: 128} + items: + type: string + minLength: 1 + maxLength: 128 minimumParentReferences: {const: 1} maximumParentReferences: {const: 2} parentReferenceNamespace: {const: urn:gov:example:opencrvs:person} From 5f64824f8dac60e9123bebef3cdef2cef5ca8838 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:46:12 +0700 Subject: [PATCH 021/136] feat(evidencectl): reject NUL bytes in generated secret material The runtime refuses a file-provided secret containing a NUL byte, which a uniform 32-byte draw carries about 11.8% of the time. A scaffolded project therefore had roughly a one in eight chance of failing at `evidence serve`, long after `evidence check` and the fixtures had passed, for a reason that pointed at the secret file rather than at the tool that wrote it. Draw by rejection sampling instead. The value stays uniform over the accepted set, 255^32 or about 255.8 bits, which is not a meaningful reduction in strength for an HMAC key. Also state in the scaffold report that the source bearer token has to be obtained from the source system and written by hand: check, fixtures, and startup all pass without it, so a missing token is otherwise discovered by the first live request. Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/src/keygen.rs | 21 ++++++++++++-- crates/registry-evidencectl/src/scaffold.rs | 6 ++++ crates/registry-evidencectl/tests/keygen.rs | 28 +++++++++++++++++++ crates/registry-evidencectl/tests/scaffold.rs | 23 +++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidencectl/src/keygen.rs b/crates/registry-evidencectl/src/keygen.rs index a16a264ae..9b544dc34 100644 --- a/crates/registry-evidencectl/src/keygen.rs +++ b/crates/registry-evidencectl/src/keygen.rs @@ -199,8 +199,7 @@ fn run_keypair( fn run_secret(args: &SecretArgs) -> Result { reject_existing(&[&args.out], args.force)?; - let mut secret = Zeroizing::new([0_u8; SECRET_FILE_BYTES]); - getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + let secret = generate_secret()?; if let Some(parent) = args .out @@ -216,6 +215,24 @@ fn run_secret(args: &SecretArgs) -> Result { Ok(ExitCode::SUCCESS) } +/// Draws `SECRET_FILE_BYTES` uniformly at random, rejecting any draw that +/// contains a NUL byte. +/// +/// The Evidence runtime refuses a file-provided secret containing NUL, which a +/// uniform 32-byte draw carries about 11.8% of the time. Rejection sampling +/// keeps the value uniform over the accepted set (255^32, or 255.8 bits) and +/// keeps a scaffolded project working the first time, instead of failing at +/// `evidence serve` long after `evidence check` passed. +fn generate_secret() -> Result> { + let mut secret = Zeroizing::new([0_u8; SECRET_FILE_BYTES]); + loop { + getrandom::fill(secret.as_mut_slice()).context("failed to generate random key material")?; + if !secret.contains(&0) { + return Ok(secret); + } + } +} + /// Default kid: the RFC 7638 thumbprint of the public key. fn default_kid(x: &str) -> Result { let public = PublicJwk { diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index b094845a2..af7bc44e5 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -415,6 +415,12 @@ fn report(root: &Path, secret_root: &Path, with_mint: bool) { " evidencectl keygen secret --out {}", secret_root.join("subject-binding-hmac-key").display() ); + println!( + " # obtain the source system's own bearer token and write it to {},", + secret_root.join("source-bearer-token").display() + ); + println!(" # mode 0600. check, the fixtures and startup all pass without it; the"); + println!(" # first live request is where a missing token is discovered."); println!( " chmod -R a-w {} && chmod 444 {}", root.join(BUNDLE_DIRECTORY).display(), diff --git a/crates/registry-evidencectl/tests/keygen.rs b/crates/registry-evidencectl/tests/keygen.rs index 0b4e51dc3..4983d9a17 100644 --- a/crates/registry-evidencectl/tests/keygen.rs +++ b/crates/registry-evidencectl/tests/keygen.rs @@ -218,6 +218,34 @@ fn secret_invocations_generate_independent_values() { assert_ne!(first_bytes, second_bytes); } +/// The Evidence runtime rejects any file-provided secret containing a NUL +/// byte, and a uniform 32-byte draw carries one about 11.8% of the time. A +/// generated secret must therefore never contain one, or roughly one project +/// in five fails at `evidence serve` long after `evidence check` passed. +#[test] +fn secret_never_contains_a_nul_byte() { + let dir = tempfile::tempdir().expect("tempdir"); + + // 64 draws leave under one chance in 3000 of a run where every unfixed + // draw happened to be NUL-free. + for index in 0..64 { + let out = dir.path().join(format!("secret-{index}.key")); + let output = evidencectl() + .args(["keygen", "secret", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let bytes = fs::read(&out).expect("read secret"); + assert_eq!(bytes.len(), 32); + assert!( + !bytes.contains(&0), + "draw {index} contains a NUL byte the runtime rejects" + ); + } +} + #[test] fn signing_refuses_overwrite_without_force_then_succeeds_with_force() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index 2c59ae885..277925e4e 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -42,6 +42,29 @@ fn a_mint_paired_project_passes_check_and_every_fixture() { passes_check_and_every_fixture(&project); } +/// The scaffolded source authenticates with a bearer token the source system +/// issues and nothing here generates. `check` and every fixture pass without +/// it, and the service starts without it, so a reader who follows only the +/// printed steps first discovers it missing at the first live request. The +/// printed steps must name it, as the generated README already does. +#[test] +fn the_printed_next_steps_name_the_source_bearer_token() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + let outcome = evidencectl(&["new", project.to_str().expect("project path")]); + assert!( + outcome.status.success(), + "evidencectl new failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + + let printed = String::from_utf8_lossy(&outcome.stdout); + assert!( + printed.contains("source-bearer-token"), + "the printed next steps never mention the source bearer token:\n{printed}" + ); +} + fn passes_check_and_every_fixture(project: &Path) { provision_secrets(project); let fixtures = scaffolded_fixtures(project); From 5c4245fcc8d7bf4d40a77cd84910d4d642ea2776 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:46:19 +0700 Subject: [PATCH 022/136] style(evidence): expand remaining inline YAML in the reference bundle Finishes the reformatting the reference deployment bundle already started: the last flow-style mappings become block mappings, so selector fields, fixed headers, and selector alternatives are edited and diffed a line at a time. Verified value-identical: the parsed document is unchanged. Signed-off-by: Jeremi Joslin --- .../bundle/evidence.yaml | 65 ++++++++++++++----- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index fc5c8fcfa..942805333 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -43,11 +43,17 @@ selectorProfiles: opencrvs-tracking-id-v1: maximumAggregateBytes: 64 fields: - tracking_id: {type: string, minimumBytes: 1, maximumBytes: 64} + tracking_id: + type: string + minimumBytes: 1 + maximumBytes: 64 civil-person-reference-v1: maximumAggregateBytes: 128 fields: - person_reference: {type: string, minimumBytes: 1, maximumBytes: 128} + person_reference: + type: string + minimumBytes: 1 + maximumBytes: 128 sources: registered-birth-date: transport: http-json @@ -71,11 +77,13 @@ sources: method: POST path: /events/search fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json selectorInputs: - role: subject alternatives: - - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - profile: opencrvs-tracking-id-v1 + fields: [tracking_id] prepareScript: adapters/birth-event-prepare.rhai adapterParameters: selectorRole: subject @@ -126,11 +134,13 @@ sources: method: POST path: /events/search fixedHeaders: - - {name: Accept, value: application/json} + - name: Accept + value: application/json selectorInputs: - role: child alternatives: - - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - profile: opencrvs-tracking-id-v1 + fields: [tracking_id] prepareScript: adapters/birth-event-prepare.rhai adapterParameters: selectorRole: child @@ -206,7 +216,9 @@ requirements: source: registered-birth-date purposes: [eligibility-assessment] subjectRoles: - - {role: subject, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} + - role: subject + cardinality: one + selectorProfiles: [opencrvs-tracking-id-v1] referenceFrameworks: [urn:gov:example:framework:age-of-majority:v1] evidenceType: urn:gov:example:evidence-type:adult-status:v1 observationTimezone: Asia/Bangkok @@ -216,10 +228,14 @@ requirements: selectorInputs: - role: subject alternatives: - - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - profile: opencrvs-tracking-id-v1 + fields: [tracking_id] parameters: {minimum_age_years: 18} concepts: - - {id: urn:gov:example:concept:adult-status, form: boolean, required: true, constraints: {}} + - id: urn:gov:example:concept:adult-status + form: boolean + required: true + constraints: {} fixtures: fixtures/adult-status-cases.yaml disclosureGuard: families: [urn:gov:example:disclosure-family:adult-status] @@ -229,8 +245,12 @@ requirements: source: registered-birth-parents purposes: [family-relationship-verification] subjectRoles: - - {role: child, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} - - {role: candidate-parent, cardinality: one, selectorProfiles: [civil-person-reference-v1]} + - role: child + cardinality: one + selectorProfiles: [opencrvs-tracking-id-v1] + - role: candidate-parent + cardinality: one + selectorProfiles: [civil-person-reference-v1] referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] evidenceType: urn:gov:example:evidence-type:registered-parent-relationship:v1 validitySeconds: 86400 @@ -239,16 +259,21 @@ requirements: selectorInputs: - role: child alternatives: - - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - profile: opencrvs-tracking-id-v1 + fields: [tracking_id] - role: candidate-parent alternatives: - - {profile: civil-person-reference-v1, fields: [person_reference]} + - profile: civil-person-reference-v1 + fields: [person_reference] parameters: matching_policy: exact-opaque-reference-v1 candidate_reference_namespace: urn:gov:example:opencrvs:person relationship_set_contract: urn:gov:example:opencrvs:registered-parent-set:v1 concepts: - - {id: urn:gov:example:concept:registered-parent-relationship-confirmed, form: boolean, required: true, constraints: {}} + - id: urn:gov:example:concept:registered-parent-relationship-confirmed + form: boolean + required: true + constraints: {} fixtures: fixtures/registered-parent-relationship-cases.yaml disclosureGuard: families: [urn:gov:example:disclosure-family:registered-parent-relationship] @@ -258,7 +283,9 @@ requirements: source: registered-birth-parents purposes: [family-case-record] subjectRoles: - - {role: child, cardinality: one, selectorProfiles: [opencrvs-tracking-id-v1]} + - role: child + cardinality: one + selectorProfiles: [opencrvs-tracking-id-v1] referenceFrameworks: [urn:gov:example:framework:civil-registration-parent-record:v1] evidenceType: urn:gov:example:evidence-type:registered-parent-references:v1 validitySeconds: 86400 @@ -267,7 +294,8 @@ requirements: selectorInputs: - role: child alternatives: - - {profile: opencrvs-tracking-id-v1, fields: [tracking_id]} + - profile: opencrvs-tracking-id-v1 + fields: [tracking_id] parameters: reference_namespace: urn:gov:example:opencrvs:person relationship_set_contract: urn:gov:example:opencrvs:registered-parent-set:v1 @@ -275,7 +303,10 @@ requirements: - id: urn:gov:example:concept:registered-parent-references form: entity-reference-list required: true - constraints: {minimumItems: 1, maximumItems: 2, unique: true} + constraints: + minimumItems: 1 + maximumItems: 2 + unique: true fixtures: fixtures/registered-parent-references-cases.yaml disclosureGuard: families: [urn:gov:example:disclosure-family:registered-parent-references] From 2ed642522a8e1217de1c6d9d8dc499af82c04a76 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:47:07 +0700 Subject: [PATCH 023/136] perf(evidence): commit audit appends in groups to sustain 1000 req/s Measured audit throughput was about 270 appends/second and flat from 1 to 128 concurrent appenders: every append took one fsync under a held mutex, at 3.70 ms each. At two audit records per request that capped the service near 135 requests/second regardless of hardware or concurrency. Security review note, required for a change to audit integrity. What changed Chain-head ownership moves from registry_platform_audit::ChainState into Evidence's DurableJsonlSink. ChainState::append held its mutex across sink.write(), which serialized every append whatever the sink did internally. The on-disk chain is byte-identical: same keyed hasher, same envelope. A two-lock split replaces the single mutex. A short state lock claims a chain position and advances the head, performing no I/O; a separate flush lock is held across the blocking fsync. Appends arriving during a write form the next batch. Group commit is leader-follower with no timer and no configured window: the first caller to arrive while no write is in flight writes everything queued so far, and the others wait on the flush lock to find their records already durable. Batch size is 1 at idle and grows by itself under load. Segments rotate at auditStorage.maximumFileBytes with chain continuity across the seam, and startup verification is now bounded to the active segment, so restart time no longer grows with retained history. Properties preserved Chain positions are handed out in enqueue order under one lock, so concurrent appends extend one chain rather than forking it. Nothing is reported durable before its own bytes are synced. A failed durable write leaves the in-memory head ahead of the disk, so the sink poisons itself and refuses every later append for the life of the process rather than chaining onto a record the disk never received; concurrent waiters on a poisoned batch each receive the failure instead of hanging. A batch crossing a segment bound flushes the buffered run into the outgoing segment before sealing. Pinned-writer identity is still validated before the append and again after the sync, for both the segment and the lock file. Defect found and fixed during review The first version of the split read the recorded fingerprint under the state lock but compared it after releasing the lock, and acquired that lock with a non-blocking try_lock. Both were wrong under load: the service's own appends changed the file between snapshot and comparison, so readiness read its own traffic as external mutation, and try_lock lost to writer contention, which is busyness rather than ill health. Measured 195 of 200 probes unready while the service wrote its own audit records, which would have taken a healthy service out of a load balancer rotation under sustained load. The fingerprint is now compared under the same lock the writer advances it under, and only while the sink is quiescent. Tamper detection is unchanged. Accepted risk Startup verification is bounded to the active segment, so tampering inside an already sealed segment is not caught at boot. The new `evidence verify-audit` command is the out-of-band counterpart that does catch it. It reads the audit path and hash secret from the deployment's own runtime document and takes no path or secret flags, so it can neither be aimed at a foreign chain nor take a secret on a command line. It reports a missing sealed segment as archived history rather than as corruption, so deliberate archival stays distinguishable from tampering. Cross-crate change AuditEnvelope::new_with_hasher in registry-platform-audit becomes public. Visibility only, no behavior change. The alternative was a second implementation of the chain hash inside Evidence, and two implementations of a security-critical hash chain diverge eventually. Notary shares the crate and is unaffected; its tests pass unchanged. Result Audit appends, same host, before and after: 270/s to 322/s at 1 concurrent appender, ~270/s to 1,253/s at 8, to 4,245/s at 32, and to 12,864/s at 128. End to end over real sockets through the whole request path, including token verification, rate limiting, Rhai preparation, one source call, Rhai extraction, evidence construction, Ed25519 signing, and both audit appends: 6,976 to 7,057 requests/second across two runs at 128 in flight, p50 17.8 ms, zero non-2xx. The in-process source's own ceiling is measured in the same run and the check reports the run inconclusive below a 5x margin; it measured 20.8x. The request path also gains structured request logging, a correlation header, and an opt-in metrics listener, and the audit and rate-limiter capacity gauges that make the ceilings above observable. They land here rather than separately because the gauges read storage state this change introduces. The traceability checker now accepts #[tokio::test(flavor = "multi_thread")] as a test item. A concurrency invariant cannot be proven by a single-threaded test, so the stricter form meant leaving these invariants untraced. Signed-off-by: Jeremi Joslin --- Cargo.lock | 1 + crates/registry-evidence/Cargo.toml | 5 + .../registry-evidence/benches/audit_bench.rs | 172 ++ crates/registry-evidence/src/audit.rs | 1488 +++++++++++++++-- crates/registry-evidence/src/main.rs | 417 ++++- crates/registry-evidence/src/observability.rs | 344 +++- crates/registry-evidence/src/rate_limit.rs | 40 + crates/registry-evidence/src/runtime.rs | 26 +- crates/registry-evidence/src/runtime_tests.rs | 614 ++++++- crates/registry-evidence/src/server.rs | 7 +- .../tests/security_contract_traceability.rs | 7 +- crates/registry-platform-audit/src/lib.rs | 12 +- products/evidence/OPERATOR-CONTRACT.md | 390 ++++- .../contracts/security-invariant-matrix.yaml | 2 +- .../contracts/security-test-traceability.yaml | 14 + 15 files changed, 3322 insertions(+), 217 deletions(-) create mode 100644 crates/registry-evidence/benches/audit_bench.rs diff --git a/Cargo.lock b/Cargo.lock index 95563c856..f1be4f6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5357,6 +5357,7 @@ dependencies = [ "chrono", "chrono-tz 0.10.4", "clap", + "criterion", "ed25519-dalek", "fs2", "http", diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index 02e96ad71..cf748e311 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -58,7 +58,12 @@ zeroize.workspace = true [dev-dependencies] assert-json-diff.workspace = true axum-test.workspace = true +criterion.workspace = true rcgen.workspace = true tempfile.workspace = true tokio-rustls.workspace = true wiremock.workspace = true + +[[bench]] +name = "audit_bench" +harness = false diff --git a/crates/registry-evidence/benches/audit_bench.rs b/crates/registry-evidence/benches/audit_bench.rs new file mode 100644 index 000000000..7f9d912ab --- /dev/null +++ b/crates/registry-evidence/benches/audit_bench.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Microbenchmarks for the durable Evidence audit chain. +//! +//! These measure the real filesystem path through `DurableJsonlSink`, because +//! the cost that decides service throughput is the per-record `fsync` issued +//! while the chain-state mutex is held, not the chain hashing. +//! +//! Covers: +//! - one sequential append, the latency floor a request pays per audit record; +//! - concurrent appends, which show whether added concurrency raises append +//! throughput or merely queues behind the same serialized `fsync`; +//! - event construction and serialization alone, for scale against the I/O. +//! +//! The `record_bytes` line printed on startup reports the on-disk size of one +//! representative record, which is what sizes the audit file against its +//! configured ceiling. + +use std::{hint::black_box, sync::Arc}; + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use registry_evidence::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind, EvidenceAuditEvent, + EvidenceAuditLog, ResponseProtection, +}; +use tokio::{runtime::Runtime, task::JoinSet}; + +/// Far above anything an individual benchmark run appends, so the file-size +/// ceiling never interferes with the measurement. +const BENCH_MAXIMUM_FILE_BYTES: u64 = 64 * 1024 * 1024 * 1024; +const BENCH_SECRET: [u8; 64] = [0x5a; 64]; +const CONCURRENCY_LEVELS: [usize; 4] = [1, 8, 32, 128]; + +/// Build a pseudonym of the shape the runtime actually writes, so records are +/// representative in size rather than artificially short. +fn pseudonym(seed: u8) -> String { + let digest: String = (0u8..32) + .map(|byte| format!("{:02x}", byte ^ seed)) + .collect(); + format!("hmac-sha256:v1:{digest}") +} + +fn sample_event() -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + "evidence.request.evaluate".to_string(), + AuditPhase::AccessAttempt, + "adult-status".to_string(), + "2026-08-01T00:00:00Z/1".to_string(), + "age-verification".to_string(), + pseudonym(0x11), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: Some(pseudonym(0x22)), + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "national-identifier".to_string(), + selector_bundle_pseudonym: Some(pseudonym(0x33)), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 12, + ) +} + +fn runtime() -> Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime") +} + +/// Initialize a durable log over a fresh temporary file. The `TempDir` is +/// returned because dropping it would delete the file out from under the sink. +fn durable_log(runtime: &Runtime) -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("audit.jsonl"); + let log = runtime.block_on(async { + EvidenceAuditLog::initialize(path, BENCH_MAXIMUM_FILE_BYTES, BENCH_SECRET.to_vec(), 1) + .await + .expect("initialize audit log") + }); + (directory, Arc::new(log)) +} + +/// Report the on-disk size of a single record. This is the number that decides +/// how quickly a deployment reaches its configured audit file ceiling. +fn report_record_bytes(runtime: &Runtime) { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("audit.jsonl"); + runtime.block_on(async { + let log = + EvidenceAuditLog::initialize(&path, BENCH_MAXIMUM_FILE_BYTES, BENCH_SECRET.to_vec(), 1) + .await + .expect("initialize audit log"); + log.append(sample_event()).await.expect("append"); + }); + let bytes = std::fs::metadata(&path).expect("metadata").len(); + eprintln!("audit/record_bytes: {bytes}"); +} + +/// One append at a time: the per-record cost a request pays, dominated by the +/// `fsync` in the sink write path. +fn benchmark_sequential_append(c: &mut Criterion) { + let runtime = runtime(); + report_record_bytes(&runtime); + let (_directory, log) = durable_log(&runtime); + + let mut group = c.benchmark_group("audit/durable_append"); + group.throughput(Throughput::Elements(1)); + group.bench_function("sequential", |b| { + b.to_async(&runtime) + .iter(|| async { log.append(black_box(sample_event())).await.expect("append") }); + }); + group.finish(); +} + +/// Many appends in flight at once. Because the sink holds its state mutex +/// across the blocking write and `fsync`, throughput here is expected to stay +/// flat as concurrency rises: the extra callers queue rather than batch. +fn benchmark_concurrent_append(c: &mut Criterion) { + let runtime = runtime(); + let (_directory, log) = durable_log(&runtime); + + let mut group = c.benchmark_group("audit/durable_append_concurrent"); + for concurrency in CONCURRENCY_LEVELS { + group.throughput(Throughput::Elements(concurrency as u64)); + group.bench_with_input( + BenchmarkId::from_parameter(concurrency), + &concurrency, + |b, &concurrency| { + b.to_async(&runtime).iter(|| { + let log = Arc::clone(&log); + async move { + let mut appends = JoinSet::new(); + for _ in 0..concurrency { + let log = Arc::clone(&log); + appends.spawn(async move { + log.append(sample_event()).await.expect("append") + }); + } + while let Some(result) = appends.join_next().await { + black_box(result.expect("join")); + } + } + }); + }, + ); + } + group.finish(); +} + +/// Event construction and JSON serialization with no I/O, for scale against +/// the durable append measurements. +fn benchmark_event_serialization(c: &mut Criterion) { + let mut group = c.benchmark_group("audit/event"); + group.bench_function("construct", |b| b.iter(|| black_box(sample_event()))); + let event = sample_event(); + group.bench_function("serialize", |b| { + b.iter(|| serde_json::to_value(black_box(&event)).expect("serialize")); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(50); + targets = + benchmark_sequential_append, + benchmark_concurrent_append, + benchmark_event_serialization +} +criterion_main!(benches); diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 8b542ca26..3ae2d9b24 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -2,25 +2,29 @@ use std::{ fs::{File, TryLockError}, - io::{BufRead as _, BufReader, Error as IoError, ErrorKind, Seek, SeekFrom, Write}, + io::{BufRead as _, BufReader, Error as IoError, ErrorKind, Read as _, Seek, SeekFrom, Write}, path::{Path, PathBuf}, sync::Arc, }; #[cfg(test)] -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicU64, Ordering}; -use async_trait::async_trait; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_platform_audit::{ verify_jsonl_lines_with_hasher, AuditChainHasher, AuditEnvelope, AuditError, AuditHashSecret, - AuditKeyHasher, AuditSink, ChainState, OptionalHashHex, + AuditKeyHasher, OptionalHashHex, }; use serde::Serialize; use thiserror::Error; const AUDIT_SCHEMA: &str = "registry.evidence.audit/v1"; const MAX_AUDIT_LINE_BYTES: usize = 1024 * 1024; +/// Sealed segments are named `.` with a zero-padded, +/// fixed-width sequence, so lexical order matches chain order and the sink's +/// `.lock` companion can never be mistaken for a segment. +const SEGMENT_SEQUENCE_DIGITS: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -195,11 +199,27 @@ pub enum EvidenceAuditError { InvalidEvent, #[error("audit initialization or write failed")] Audit(#[from] AuditError), + /// A span of sealed history is absent. Reported separately from a hash + /// break so an operator can tell deliberate archival from tampering. + #[error("audit chain is missing sealed segment {sequence}")] + SegmentMissing { sequence: u64 }, +} + +/// The chain's on-disk footprint, sealed segments and the active segment +/// together. +/// +/// Rotation never deletes a sealed segment, so this only falls when an +/// operator archives one. That is why it is measured by walking the segment +/// directory rather than accumulated in a counter: a counter would keep +/// reporting bytes an operator had already reclaimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditStorageUsage { + pub segments: usize, + pub bytes: u64, } pub struct EvidenceAuditLog { sink: Arc, - chain: ChainState, key_hasher: AuditKeyHasher, key_version: u32, } @@ -227,11 +247,17 @@ impl EvidenceAuditLog { let secret = AuditHashSecret::new(master_secret)?; let chain_hasher = AuditChainHasher::keyed(secret.clone()); let key_hasher = AuditKeyHasher::Keyed(secret); - let sink = Arc::new(DurableJsonlSink::open(path.into(), maximum_file_bytes)?); - let chain = ChainState::bootstrap_or_start_empty(sink.as_ref(), chain_hasher).await?; + let sink = Arc::new(DurableJsonlSink::open( + path.into(), + maximum_file_bytes, + chain_hasher, + )?); + // The sink owns the chain head rather than a separate chain object, + // because the head has to advance in the same lock that claims a place + // in the pending batch. + sink.verify_startup().await?; Ok(Self { sink, - chain, key_hasher, key_version, }) @@ -254,22 +280,60 @@ impl EvidenceAuditLog { Ok(format!("hmac-sha256:v{}:{digest}", self.key_version)) } + /// Measure the chain's footprint for the capacity gauge. + /// + /// This walks the audit directory, so it runs on the blocking pool: the + /// number of sealed segments grows without bound and the caller is a + /// scrape handler on the async runtime. A segment that disappears midway + /// through the walk is skipped rather than failing the read, because an + /// operator archiving history concurrently is expected, not an error. + pub async fn storage_usage(&self) -> Result { + let path = self.sink.path.clone(); + tokio::task::spawn_blocking(move || { + let segments = audit_segment_paths(&path)?; + let mut bytes = 0u64; + let mut counted = 0usize; + for segment in &segments { + match std::fs::symlink_metadata(segment) { + Ok(metadata) => { + counted += 1; + bytes = bytes.saturating_add(metadata.len()); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(AuditError::Io(error)), + } + } + Ok(AuditStorageUsage { + segments: counted, + bytes, + }) + }) + .await + .map_err(|error| AuditError::Io(IoError::other(error)))? + .map_err(EvidenceAuditError::from) + } + pub async fn append( &self, event: EvidenceAuditEvent, ) -> Result { event.validate_phase_fields()?; - self.chain - .append(self.sink.as_ref(), event) + let record = serde_json::to_value(event).map_err(AuditError::Json)?; + self.sink + .append_record(record) .await .map_err(EvidenceAuditError::Audit) } pub async fn ready(&self) -> bool { - let Some(expected_tail) = self.chain.try_last_hash() else { - return false; - }; - self.sink.ready(expected_tail).await + self.sink.ready().await + } + + /// Durable writes performed so far, for proving that concurrent appends + /// share them rather than each paying an `fsync`. + #[cfg(test)] + pub(crate) fn durable_writes(&self) -> usize { + self.sink.durable_writes.load(Ordering::Relaxed) } } @@ -277,18 +341,64 @@ struct DurableJsonlSink { path: PathBuf, lock_path: PathBuf, maximum_file_bytes: u64, + hasher: AuditChainHasher, state: tokio::sync::Mutex, - audit_file: File, + /// Held by whichever caller is performing the current durable write, so + /// exactly one runs at a time and the rest queue behind it. Separate from + /// `state` because the write must not hold the state lock: appends arriving + /// during it are what form the next batch. + flush: tokio::sync::Mutex<()>, + /// Highest enqueue position known to be on disk. Compared against a + /// caller's own position to decide whether it still has to write. + durable: AtomicU64, _writer_lock: File, #[cfg(test)] full_verifications: AtomicUsize, + #[cfg(test)] + durable_writes: AtomicUsize, } -#[derive(Clone, Copy)] +/// Writer state guarded by the sink mutex. The active segment handle lives here +/// rather than on the sink because rotation replaces it, and the replacement +/// must become visible to the next writer atomically with the sequence and +/// fingerprint it belongs to. struct SinkState { verified: bool, fingerprint: FileFingerprint, tail_hash: Option<[u8; 32]>, + audit_file: File, + next_sequence: u64, + /// Serialized records that have taken a chain position but are not on disk + /// yet. Always exactly the records between `durable` and `enqueued`. + pending: Vec, + /// Chain positions handed out so far, counting from one. + enqueued: u64, + /// Why the sink stopped accepting writes. Set when a durable write fails, + /// which leaves the in-memory head ahead of the disk. + poison: Option, +} + +impl SinkState { + /// Refuse work the sink can no longer perform safely. + /// + /// A poisoned sink stays poisoned for the process's life. That is + /// deliberate: after a failed durable write the head has advanced past + /// records the disk never received, so any later append would chain onto + /// something that does not exist. Failing every request is visible; + /// continuing would fork the chain silently. + fn check_writable(&self) -> Result<(), AuditError> { + if let Some(reason) = &self.poison { + return Err(AuditError::Io(IoError::other(format!( + "audit sink stopped after a failed durable write: {reason}" + )))); + } + if !self.verified { + return Err(AuditError::Io(IoError::other( + "audit chain was not verified at startup", + ))); + } + Ok(()) + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -317,7 +427,11 @@ impl std::fmt::Debug for DurableJsonlSink { } impl DurableJsonlSink { - fn open(path: PathBuf, maximum_file_bytes: u64) -> Result { + fn open( + path: PathBuf, + maximum_file_bytes: u64, + hasher: AuditChainHasher, + ) -> Result { if !path.is_absolute() { return Err(AuditError::Io(IoError::new( ErrorKind::InvalidInput, @@ -337,12 +451,13 @@ impl DurableJsonlSink { ))); } + // A pre-existing active segment larger than the configured bound is not + // an error: `maximum_file_bytes` is a rotation threshold, so an + // oversized segment is simply sealed by the next append. Refusing to + // start would turn a lowered bound into an outage. let created = !path.exists(); let file = open_append_nofollow(&path)?; validate_owner_only_regular_file(&file)?; - if file.metadata().map_err(AuditError::Io)?.len() > maximum_file_bytes { - return Err(file_size_error()); - } file.sync_all().map_err(AuditError::Io)?; if created { sync_parent(parent)?; @@ -367,52 +482,83 @@ impl DurableJsonlSink { } let fingerprint = file_fingerprint(&file)?; + let next_sequence = newest_sealed_sequence(&path)? + .map_or(1, |sequence| sequence.checked_add(1).unwrap_or(sequence)); Ok(Self { path, lock_path, maximum_file_bytes, + hasher, state: tokio::sync::Mutex::new(SinkState { verified: false, fingerprint, tail_hash: None, + audit_file: file, + next_sequence, + pending: Vec::new(), + enqueued: 0, + poison: None, }), - audit_file: file, + flush: tokio::sync::Mutex::new(()), + durable: AtomicU64::new(0), _writer_lock: writer_lock, #[cfg(test)] full_verifications: AtomicUsize::new(0), + #[cfg(test)] + durable_writes: AtomicUsize::new(0), }) } - async fn ready(&self, expected_tail: Option<[u8; 32]>) -> bool { - // Readiness probes must never queue behind audit writes or one another. - // The startup scan establishes the authenticated chain head; steady - // state checks are constant-time fingerprint and pinned-file checks. - let Ok(state) = self.state.try_lock() else { - return false; - }; - if !state.verified || state.tail_hash != expected_tail { + async fn ready(&self) -> bool { + // Waiting for this lock is safe and a non-blocking acquire would not be: + // the writer holds it only long enough to claim a chain position, never + // across a durable write, so contention here means the service is busy + // rather than unhealthy and refusing to wait would report a working + // service as unready under its own load. The one long hold, the startup + // scan that establishes the authenticated chain head, completes before + // the service serves. + let state = self.state.lock().await; + if state.check_writable().is_err() { return false; } let path = self.path.clone(); let lock_path = self.lock_path.clone(); - let maximum = self.maximum_file_bytes; - let expected_fingerprint = state.fingerprint; - let Ok(file) = self.audit_file.try_clone() else { + let Ok(file) = state.audit_file.try_clone() else { return false; }; let Ok(writer_lock) = self._writer_lock.try_clone() else { return false; }; + // The recorded fingerprint only describes the file between durable + // writes, so it is only compared when nothing is queued and everything + // enqueued is on disk. While a write is in flight the file legitimately + // differs from it, and that write validates its own pinned identity and + // resulting length before reporting success, so an append is never the + // thing that has to be caught here. Both reads happen under the lock the + // writer advances them under: outside it, the service's own traffic + // would look like external mutation. + let quiescent = + state.pending.is_empty() && self.durable.load(Ordering::Acquire) == state.enqueued; + if quiescent { + let Ok(metadata) = file.metadata() else { + return false; + }; + let Ok(observed) = file_fingerprint(&file) else { + return false; + }; + if !metadata.is_file() || observed != state.fingerprint { + return false; + } + } + // The probe's own sync must not hold the state lock: appends take it to + // claim a chain position, and readiness is not allowed to stall them. + drop(state); + // Segment length is deliberately not a health property: with rotation a + // full segment is a routine state the next append resolves, and a + // lowered bound would otherwise wedge the service permanently unready. tokio::task::spawn_blocking(move || -> Result { validate_pinned_path(&path, &file)?; validate_pinned_path(&lock_path, &writer_lock)?; - let metadata = file.metadata().map_err(AuditError::Io)?; - if !metadata.is_file() - || metadata.len() > maximum - || file_fingerprint(&file)? != expected_fingerprint - { - return Ok(false); - } file.sync_all().map_err(AuditError::Io)?; validate_pinned_path(&path, &file)?; validate_pinned_path(&lock_path, &writer_lock)?; @@ -424,86 +570,133 @@ impl DurableJsonlSink { .unwrap_or(false) } + /// Establish the authenticated chain head at startup. + /// + /// Only the active segment is replayed. The head it continues from is the + /// last record of the newest sealed segment, read on its own, so restart + /// cost is bounded by one segment rather than by all retained history. + /// Proving sealed segments is the job of [`verify_audit_chain`], run out of + /// band; corruption inside an already sealed segment is therefore not + /// caught at startup. fn verify_and_tail( + path: &Path, file: File, - maximum_file_bytes: u64, hasher: &AuditChainHasher, ) -> Result, AuditError> { - let length = file.metadata().map_err(AuditError::Io)?.len(); - if length > maximum_file_bytes { - return Err(file_size_error()); - } - verify_reader(file, hasher) + let sealed_head = sealed_tail_hash(path, hasher)?; + Ok(verify_reader(file, hasher, sealed_head)?.head) } } -#[async_trait] -impl AuditSink for DurableJsonlSink { - async fn write(&self, envelope: &AuditEnvelope) -> Result<(), AuditError> { - let line = envelope.to_jsonl()?; - let expected_prev = envelope.prev_hash; - let mut state = self.state.lock().await; - if !state.verified || state.tail_hash != expected_prev { - return Err(AuditError::ChainForkDetected { - expected: OptionalHashHex(state.tail_hash), - found: OptionalHashHex(expected_prev), - }); - } - let path = self.path.clone(); - let lock_path = self.lock_path.clone(); - let maximum = self.maximum_file_bytes; - let expected_fingerprint = state.fingerprint; - let mut file = self.audit_file.try_clone().map_err(AuditError::Io)?; - let writer_lock = self._writer_lock.try_clone().map_err(AuditError::Io)?; - let next_fingerprint = tokio::task::spawn_blocking(move || { - validate_pinned_path(&path, &file)?; - validate_pinned_path(&lock_path, &writer_lock)?; - if file_fingerprint(&file)? != expected_fingerprint { - return Err(AuditError::Io(IoError::other( - "audit file changed outside the initialized writer", - ))); - } - let current = file.metadata().map_err(AuditError::Io)?.len(); +impl DurableJsonlSink { + /// Enqueue one record and return once it is durable. + /// + /// The chain head advances under `state`, so records take chain positions + /// in enqueue order. Nothing under that lock touches the filesystem, which + /// is what lets appends arriving during a durable write join the next one + /// instead of queueing behind an `fsync`. + async fn append_record(&self, record: serde_json::Value) -> Result { + let (envelope, position) = { + let mut state = self.state.lock().await; + state.check_writable()?; + let envelope = AuditEnvelope::new_with_hasher(record, state.tail_hash, &self.hasher)?; + let line = envelope.to_jsonl()?; + // Checked before the head advances, so a record too large for an + // empty segment fails on its own rather than poisoning the batch it + // would otherwise have joined. let incoming = u64::try_from(line.len()).map_err(|_| file_size_error())?; - if current.saturating_add(incoming) > maximum { + if incoming > self.maximum_file_bytes { return Err(file_size_error()); } - file.write_all(line.as_bytes()).map_err(AuditError::Io)?; - file.flush().map_err(AuditError::Io)?; - file.sync_all().map_err(AuditError::Io)?; - validate_pinned_path(&path, &file)?; - validate_pinned_path(&lock_path, &writer_lock)?; - let fingerprint = file_fingerprint(&file)?; - if fingerprint.length != current.saturating_add(incoming) { - return Err(AuditError::Io(IoError::other( - "audit file length changed during append", - ))); + state.pending.push(line); + state.enqueued = state.enqueued.saturating_add(1); + state.tail_hash = Some(envelope.record_hash); + (envelope, state.enqueued) + }; + self.flush_through(position).await?; + Ok(envelope) + } + + /// Return once every record up to `position` is on disk. + /// + /// The first caller to arrive while no write is in flight writes everything + /// queued so far; the rest wait and find their records already durable. + /// There is no timer and no configured window: a batch is exactly what + /// accumulated during the previous write, so it is one record on an idle + /// service and grows by itself under load. + async fn flush_through(&self, position: u64) -> Result<(), AuditError> { + loop { + if self.durable.load(Ordering::Acquire) >= position { + return Ok(()); } - Ok(fingerprint) - }) - .await - .map_err(|error| AuditError::Io(IoError::other(error)))??; - state.fingerprint = next_fingerprint; - state.tail_hash = Some(envelope.record_hash); - Ok(()) + let _writer = self.flush.lock().await; + if self.durable.load(Ordering::Acquire) >= position { + return Ok(()); + } + self.flush_once().await?; + } } - #[allow(deprecated)] - async fn tail_hash(&self) -> Result, AuditError> { - self.tail_hash_with_hasher(&AuditChainHasher::unkeyed_dev_only()) + /// Write and sync everything currently queued. + /// + /// The caller holds `flush`, so exactly one of these runs at a time and the + /// batch it takes is never split with another writer. + async fn flush_once(&self) -> Result<(), AuditError> { + let (request, through) = { + let mut state = self.state.lock().await; + state.check_writable()?; + if state.pending.is_empty() { + return Ok(()); + } + let request = BlockingAppend { + lines: std::mem::take(&mut state.pending), + path: self.path.clone(), + lock_path: self.lock_path.clone(), + maximum: self.maximum_file_bytes, + expected_fingerprint: state.fingerprint, + sequence: state.next_sequence, + file: state.audit_file.try_clone().map_err(AuditError::Io)?, + writer_lock: self._writer_lock.try_clone().map_err(AuditError::Io)?, + }; + (request, state.enqueued) + }; + #[cfg(test)] + self.durable_writes.fetch_add(1, Ordering::Relaxed); + let (rotated, appended) = tokio::task::spawn_blocking(move || request.run()) .await + .map_err(|error| AuditError::Io(IoError::other(error)))?; + + let mut state = self.state.lock().await; + // Adopt a replaced segment even when the write that triggered the + // rotation then failed. The rename already happened on disk, so leaving + // the pinned handle on the sealed segment would fail + // `validate_pinned_path` on every later append and wedge the sink. + if let Some(sealed) = rotated { + state.audit_file = sealed.active; + state.next_sequence = sealed.next_sequence; + state.fingerprint = file_fingerprint(&state.audit_file)?; + } + match appended { + Ok(fingerprint) => { + state.fingerprint = fingerprint; + drop(state); + self.durable.store(through, Ordering::Release); + Ok(()) + } + Err(error) => { + state.poison = Some(error.to_string()); + Err(error) + } + } } - async fn tail_hash_with_hasher( - &self, - hasher: &AuditChainHasher, - ) -> Result, AuditError> { + /// Establish the authenticated chain head at startup. + async fn verify_startup(&self) -> Result, AuditError> { let mut state = self.state.lock().await; let path = self.path.clone(); let lock_path = self.lock_path.clone(); - let maximum = self.maximum_file_bytes; - let hasher = hasher.clone(); - let file = self.audit_file.try_clone().map_err(AuditError::Io)?; + let hasher = self.hasher.clone(); + let file = state.audit_file.try_clone().map_err(AuditError::Io)?; let writer_lock = self._writer_lock.try_clone().map_err(AuditError::Io)?; #[cfg(test)] self.full_verifications.fetch_add(1, Ordering::Relaxed); @@ -511,7 +704,7 @@ impl AuditSink for DurableJsonlSink { validate_pinned_path(&path, &file)?; validate_pinned_path(&lock_path, &writer_lock)?; let tail_hash = - Self::verify_and_tail(file.try_clone().map_err(AuditError::Io)?, maximum, &hasher)?; + Self::verify_and_tail(&path, file.try_clone().map_err(AuditError::Io)?, &hasher)?; Ok((tail_hash, file_fingerprint(&file)?)) }) .await @@ -546,25 +739,28 @@ fn file_fingerprint(file: &File) -> Result { }) } +/// The record hash a segment ended on, and how many records it held. +struct SegmentVerification { + head: Option<[u8; 32]>, + records: usize, +} + +/// Replay one segment, requiring its first record to continue `expected_head`. +/// `None` means the segment must start the chain at genesis, which is what the +/// only segment of an unrotated chain does. fn verify_reader( mut file: File, hasher: &AuditChainHasher, -) -> Result, AuditError> { + expected_head: Option<[u8; 32]>, +) -> Result { file.seek(SeekFrom::Start(0)).map_err(AuditError::Io)?; let mut reader = BufReader::new(file); - let mut expected_previous = None; + let mut expected_previous = expected_head; let mut records = 0usize; while let Some(line) = read_bounded_jsonl_line(&mut reader)? { let verification = verify_jsonl_lines_with_hasher([line.trim_end_matches('\n')], hasher) .map_err(AuditError::ChainVerification)?; - if records == 0 { - if verification.start_prev_hash.is_some() { - return Err(AuditError::ChainForkDetected { - expected: OptionalHashHex(None), - found: OptionalHashHex(verification.start_prev_hash), - }); - } - } else if verification.start_prev_hash != expected_previous { + if verification.start_prev_hash != expected_previous { return Err(AuditError::ChainForkDetected { expected: OptionalHashHex(expected_previous), found: OptionalHashHex(verification.start_prev_hash), @@ -573,7 +769,425 @@ fn verify_reader( expected_previous = verification.last_hash; records += verification.records; } - Ok(expected_previous) + Ok(SegmentVerification { + head: expected_previous, + records, + }) +} + +/// A sealed segment and the sequence the next rotation will claim. +struct SealedSegment { + active: File, + next_sequence: u64, +} + +/// The blocking half of one durable append. +/// +/// This is a struct rather than a closure so that a rotation can be reported +/// back to the caller on the failure path as well as the success path: once the +/// rename has happened the writer state must follow it regardless of what the +/// subsequent write did. +struct BlockingAppend { + lines: Vec, + path: PathBuf, + lock_path: PathBuf, + maximum: u64, + expected_fingerprint: FileFingerprint, + sequence: u64, + file: File, + writer_lock: File, +} + +impl BlockingAppend { + fn run(mut self) -> (Option, Result) { + let mut rotated = None; + let appended = self.append(&mut rotated); + (rotated, appended) + } + + /// Write the whole batch and sync once. + /// + /// The batch is one `fsync` regardless of how many records it holds, which + /// is the whole point: the cost that bounds append throughput is the sync, + /// not the bytes. A batch that crosses the segment bound is split, and each + /// outgoing segment is synced by its own seal before the rename. + fn append( + &mut self, + rotated: &mut Option, + ) -> Result { + // This check stays first, ahead of any rotation decision. It is what + // separates a legitimate rotation, which renames a path whose inode + // still matches the writer's own handle, from an external rename, which + // leaves the pinned handle naming a file the path no longer resolves to. + validate_pinned_path(&self.path, &self.file)?; + validate_pinned_path(&self.lock_path, &self.writer_lock)?; + if file_fingerprint(&self.file)? != self.expected_fingerprint { + return Err(AuditError::Io(IoError::other( + "audit file changed outside the initialized writer", + ))); + } + let mut current = self.file.metadata().map_err(AuditError::Io)?.len(); + let lines = std::mem::take(&mut self.lines); + let mut run = String::new(); + for line in &lines { + let incoming = u64::try_from(line.len()).map_err(|_| file_size_error())?; + // A record that cannot fit an empty segment must fail closed rather + // than rotate forever looking for room it will never find. + if incoming > self.maximum { + return Err(file_size_error()); + } + if current.saturating_add(incoming) > self.maximum && current > 0 { + // Everything buffered for the outgoing segment has to reach it + // before the seal, because the seal is what syncs and renames + // it. `seal_active_segment` relies on a sealed segment never + // holding a torn record. + self.file + .write_all(run.as_bytes()) + .map_err(AuditError::Io)?; + self.file.flush().map_err(AuditError::Io)?; + run.clear(); + let sealed = seal_active_segment(&self.path, &self.file, self.sequence)?; + self.file = sealed.active.try_clone().map_err(AuditError::Io)?; + self.sequence = sealed.next_sequence; + current = 0; + // Only the newest replacement matters to the caller: it is the + // handle and sequence the writer state must adopt. + *rotated = Some(sealed); + } + run.push_str(line); + current = current.saturating_add(incoming); + } + self.file + .write_all(run.as_bytes()) + .map_err(AuditError::Io)?; + self.file.flush().map_err(AuditError::Io)?; + self.file.sync_all().map_err(AuditError::Io)?; + validate_pinned_path(&self.path, &self.file)?; + validate_pinned_path(&self.lock_path, &self.writer_lock)?; + let fingerprint = file_fingerprint(&self.file)?; + if fingerprint.length != current { + return Err(AuditError::Io(IoError::other( + "audit file length changed during append", + ))); + } + Ok(fingerprint) + } +} + +fn segment_path(path: &Path, sequence: u64) -> PathBuf { + let mut value = path.as_os_str().to_owned(); + value.push(format!( + ".{sequence:0width$}", + width = SEGMENT_SEQUENCE_DIGITS + )); + PathBuf::from(value) +} + +/// Recognize `candidate` as a sealed segment of the chain rooted at `path` and +/// return its sequence. +fn segment_sequence(path: &Path, candidate: &Path) -> Option { + let active = path.file_name()?.to_str()?; + let suffix = candidate + .file_name()? + .to_str()? + .strip_prefix(active)? + .strip_prefix('.')?; + if suffix.len() != SEGMENT_SEQUENCE_DIGITS || !suffix.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + suffix.parse().ok() +} + +/// Enumerate the sealed segments of the chain rooted at `path`, oldest first. +/// +/// Enumeration reads the directory and parses suffixes rather than probing +/// sequences upward from one, so a missing middle segment shows up as a gap +/// instead of silently truncating the set to the segments before it. +fn sealed_segments(path: &Path) -> Result, AuditError> { + let parent = path.parent().ok_or_else(|| { + AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path has no parent", + )) + })?; + let mut sealed = Vec::new(); + for entry in std::fs::read_dir(parent).map_err(AuditError::Io)? { + let candidate = entry.map_err(AuditError::Io)?.path(); + if let Some(sequence) = segment_sequence(path, &candidate) { + sealed.push((sequence, candidate)); + } + } + sealed.sort_unstable_by_key(|(sequence, _)| *sequence); + Ok(sealed) +} + +/// Enumerate the chain's segments oldest first: every sealed segment in +/// sequence order, then the active segment when it exists. +pub fn audit_segment_paths(path: &Path) -> Result, AuditError> { + let mut segments: Vec = sealed_segments(path)? + .into_iter() + .map(|(_, path)| path) + .collect(); + if std::fs::symlink_metadata(path).is_ok() { + segments.push(path.to_path_buf()); + } + Ok(segments) +} + +fn newest_sealed_segment(path: &Path) -> Result, AuditError> { + Ok(sealed_segments(path)?.pop()) +} + +fn newest_sealed_sequence(path: &Path) -> Result, AuditError> { + Ok(newest_sealed_segment(path)?.map(|(sequence, _)| sequence)) +} + +/// Seal the active segment under the next free sequence and open an empty +/// replacement at the configured path. +/// +/// Chain continuity needs nothing extra here: the head lives in memory and +/// survives rotation, so the first record written after this call carries the +/// sealed segment's last record hash as its predecessor. +/// +/// Crashing between the rename and the replacement leaves no active segment. +/// Startup recreates it and recovers the head from the sealed tail, so the seam +/// still closes. This assumes the filesystem does not reorder the rename after +/// the create; a filesystem that does could lose a segment silently. +/// +/// One invariant here is load-bearing for reading a sealed segment's last +/// record on its own: a sealed segment can never hold a torn final record, +/// because rotation only ever renames a file every one of whose records +/// returned from a successful `sync_all`. +fn seal_active_segment( + path: &Path, + active: &File, + sequence: u64, +) -> Result { + let parent = path.parent().ok_or_else(|| { + AuditError::Io(IoError::new( + ErrorKind::InvalidInput, + "audit path has no parent", + )) + })?; + active.sync_all().map_err(AuditError::Io)?; + + // Never rename over an existing sealed segment: that would erase history. + // The exclusive writer lock makes this process the only Evidence writer for + // this chain, so probing for a free sequence cannot race another sink. + let mut sequence = sequence; + let mut sealed = segment_path(path, sequence); + while std::fs::symlink_metadata(&sealed).is_ok() { + sequence = next_sequence(sequence)?; + sealed = segment_path(path, sequence); + } + std::fs::rename(path, &sealed).map_err(AuditError::Io)?; + + let replacement = open_append_nofollow(path)?; + validate_owner_only_regular_file(&replacement)?; + if replacement.metadata().map_err(AuditError::Io)?.len() != 0 { + return Err(AuditError::Io(IoError::other( + "replacement audit segment is not empty", + ))); + } + replacement.sync_all().map_err(AuditError::Io)?; + sync_parent(parent)?; + Ok(SealedSegment { + active: replacement, + next_sequence: next_sequence(sequence)?, + }) +} + +fn next_sequence(sequence: u64) -> Result { + sequence + .checked_add(1) + .ok_or_else(|| AuditError::Io(IoError::other("audit segment sequence is exhausted"))) +} + +/// Recover the chain head an active segment continues from by reading only the +/// last record of the newest sealed segment. +fn sealed_tail_hash( + path: &Path, + hasher: &AuditChainHasher, +) -> Result, AuditError> { + let Some((_, newest)) = newest_sealed_segment(path)? else { + return Ok(None); + }; + let file = open_sealed_segment(&newest)?; + // An empty newest sealed segment is a hard error, never a fall back to + // genesis: otherwise truncating the sealed tail and the active segment to + // zero would start a clean chain in a directory full of history. + let Some(line) = last_jsonl_line(file)? else { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "sealed audit segment holds no records", + ))); + }; + let verification = verify_jsonl_lines_with_hasher([line.trim_end_matches('\n')], hasher) + .map_err(AuditError::ChainVerification)?; + Ok(verification.last_hash) +} + +/// Read a segment's final complete record without reading the segment, bounded +/// by the same per-record limit the forward reader enforces. +fn last_jsonl_line(mut file: File) -> Result, AuditError> { + let length = file.metadata().map_err(AuditError::Io)?.len(); + if length == 0 { + return Ok(None); + } + let bound = u64::try_from(MAX_AUDIT_LINE_BYTES.saturating_add(1)).unwrap_or(u64::MAX); + let window = bound.min(length); + file.seek(SeekFrom::Start(length - window)) + .map_err(AuditError::Io)?; + let mut tail = vec![ + 0u8; + usize::try_from(window).map_err(|_| AuditError::Io(IoError::other( + "audit segment tail is unreadable" + )))? + ]; + file.read_exact(&mut tail).map_err(AuditError::Io)?; + if tail.pop() != Some(b'\n') { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "sealed audit segment has an incomplete final record", + ))); + } + let start = tail + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |index| index + 1); + if start == 0 && window < length { + return Err(AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit JSONL record exceeds its bound", + ))); + } + let line = String::from_utf8(tail[start..].to_vec()).map_err(|_| { + AuditError::Io(IoError::new( + ErrorKind::InvalidData, + "audit JSONL is not UTF-8", + )) + })?; + Ok(Some(line)) +} + +/// Open a sealed segment for reading. +/// +/// Sealed segments are read-only history, so link count is deliberately not +/// checked here: the single-link rule exists to pin the *active* writer's file, +/// while an operator archiving sealed history with a hard link is legitimate. +/// Ownership and mode still are checked, so a segment another user could have +/// written is never read, and `O_NOFOLLOW` still rejects a symlink planted at a +/// segment name. +fn open_sealed_segment(path: &Path) -> Result { + let file = open_read_nofollow(path)?; + validate_owner_only_readable_file(&file)?; + Ok(file) +} + +/// Result of an out-of-band verification pass over a whole audit chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditChainSummary { + /// Segments actually replayed. + pub segments: usize, + pub records: usize, + pub head: Option<[u8; 32]>, + /// Sequence of the oldest and newest sealed segments, absent when the chain + /// has never rotated. + pub first_sequence: Option, + pub last_sequence: Option, + /// Whether the active segment was replayed. False when a running writer + /// holds the chain, in which case only sealed history was proven. + pub active_verified: bool, +} + +/// Verify every segment of an audit chain, sealed history included. +/// +/// Startup verification is deliberately bounded to the active segment so that +/// restart time does not grow with retained history. This is its counterpart: +/// a full replay across every seam, meant to run out of band, and the only +/// check that detects tampering inside an already sealed segment. +/// +/// A gap in the sealed sequence is reported as [`EvidenceAuditError::SegmentMissing`] +/// naming the absent sequence, not as a hash break, so that history an operator +/// archived is distinguishable from history someone rewrote. +/// +/// The active segment is only replayed when the writer lock is free. Against a +/// running service it would race an in-flight append and report a partially +/// written final record as corruption, so it is skipped and `active_verified` +/// says so. +pub fn verify_audit_chain( + path: &Path, + master_secret: &AuditHashSecret, +) -> Result { + let hasher = AuditChainHasher::keyed(master_secret.clone()); + let sealed = sealed_segments(path).map_err(EvidenceAuditError::Audit)?; + let first_sequence = sealed.first().map(|(sequence, _)| *sequence); + let last_sequence = sealed.last().map(|(sequence, _)| *sequence); + if let Some(first) = first_sequence { + for (offset, (sequence, _)) in sealed.iter().enumerate() { + let expected = first.saturating_add(offset as u64); + if *sequence != expected { + return Err(EvidenceAuditError::SegmentMissing { sequence: expected }); + } + } + } + + let mut head = None; + let mut records = 0usize; + let mut segments = 0usize; + for (_, segment) in &sealed { + let file = open_sealed_segment(segment).map_err(EvidenceAuditError::Audit)?; + let verification = verify_reader(file, &hasher, head).map_err(EvidenceAuditError::Audit)?; + head = verification.head; + records = records.saturating_add(verification.records); + segments = segments.saturating_add(1); + } + + let active_verified = match active_segment_if_quiescent(path)? { + Some(file) => { + let verification = + verify_reader(file, &hasher, head).map_err(EvidenceAuditError::Audit)?; + head = verification.head; + records = records.saturating_add(verification.records); + segments = segments.saturating_add(1); + true + } + None => false, + }; + + Ok(AuditChainSummary { + first_sequence, + last_sequence, + active_verified, + segments, + records, + head, + }) +} + +/// Open the active segment for verification, but only if no writer holds the +/// chain. Returns `None` when a live Evidence process owns the lock, or when +/// the active segment is absent because a crash landed between the rename and +/// the replacement. +fn active_segment_if_quiescent(path: &Path) -> Result, EvidenceAuditError> { + let lock_path = lock_path(path); + if lock_path.exists() { + let guard = open_lock_nofollow(&lock_path).map_err(EvidenceAuditError::Audit)?; + match guard.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => return Ok(None), + Err(TryLockError::Error(error)) => { + return Err(EvidenceAuditError::Audit(AuditError::Io(error))) + } + } + } + if !std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_file()) { + return Ok(None); + } + let file = open_read_nofollow(path).map_err(EvidenceAuditError::Audit)?; + validate_owner_only_regular_file(&file).map_err(EvidenceAuditError::Audit)?; + Ok(Some(file)) } fn read_bounded_jsonl_line(reader: &mut BufReader) -> Result, AuditError> { @@ -636,6 +1250,31 @@ fn validate_pinned_path(path: &Path, pinned: &File) -> Result<(), AuditError> { Ok(()) } +/// The owner-only checks that apply to any audit file, with the single-link +/// requirement left out. See [`open_sealed_segment`] for why sealed history is +/// allowed more than one name. +#[cfg(unix)] +fn validate_owner_only_readable_file(file: &File) -> Result<(), AuditError> { + use std::os::unix::fs::MetadataExt as _; + + let metadata = file.metadata().map_err(AuditError::Io)?; + if !metadata.is_file() + || metadata.uid() != rustix::process::geteuid().as_raw() + || metadata.mode() & 0o077 != 0 + { + return Err(AuditError::Io(IoError::new( + ErrorKind::PermissionDenied, + "audit files must be owner-only regular files", + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_owner_only_readable_file(file: &File) -> Result<(), AuditError> { + validate_owner_only_regular_file(file) +} + #[cfg(unix)] fn validate_owner_only_regular_file(file: &File) -> Result<(), AuditError> { use std::os::unix::fs::MetadataExt as _; @@ -1294,4 +1933,629 @@ mod tests { "" ); } + + fn audit_secret() -> AuditHashSecret { + AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) + .expect("audit secret builds") + } + + /// Change one byte of a record without changing its length, so the record + /// no longer matches the hash the chain recorded for it. + fn corrupt_line(line: &str) -> String { + let mut bytes = line.as_bytes().to_vec(); + for byte in bytes.iter_mut() { + if byte.is_ascii_lowercase() { + *byte = if *byte == b'z' { b'y' } else { *byte + 1 }; + break; + } + } + String::from_utf8(bytes).expect("a corrupted record stays UTF-8") + } + + fn rewrite_segment_line(path: &Path, index: usize, rewrite: impl Fn(&str) -> String) { + let contents = std::fs::read_to_string(path).expect("segment reads"); + let mut lines: Vec = contents.lines().map(str::to_owned).collect(); + lines[index] = rewrite(&lines[index]); + let mut rewritten = lines.join("\n"); + rewritten.push('\n'); + std::fs::write(path, rewritten).expect("segment rewrites"); + } + + /// Readiness reports on the chain, not on how busy the writer is. The + /// fingerprint it compares is the one the writer advances on every append, + /// so a probe that read it outside the writer's lock would see the + /// service's own traffic as external mutation and flap under load. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn readiness_holds_while_appends_are_in_flight() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut writers = tokio::task::JoinSet::new(); + for _ in 0..8 { + let log = Arc::clone(&log); + let stop = Arc::clone(&stop); + writers.spawn(async move { + while !stop.load(Ordering::Relaxed) { + log.append(event(&log)).await.expect("event appends"); + } + }); + } + + // Probe often enough to land inside a durable write rather than only in + // the gaps between them, which is the window the race lives in. + let mut probes = 0usize; + let mut unready = 0usize; + for _ in 0..200 { + if log.ready().await { + probes += 1; + } else { + unready += 1; + } + tokio::task::yield_now().await; + } + stop.store(true, Ordering::Relaxed); + while let Some(result) = writers.join_next().await { + result.expect("writer task joins"); + } + + assert_eq!( + unready, 0, + "readiness stayed true through {probes} probes but reported unready {unready} times while the service was writing its own audit records" + ); + } + + /// The point of group commit: appends that arrive while a durable write is + /// in flight join the next one instead of each paying their own `fsync`. + #[tokio::test] + async fn concurrent_appends_share_durable_writes() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + + const APPENDS: usize = 64; + let mut appends = tokio::task::JoinSet::new(); + for _ in 0..APPENDS { + let log = Arc::clone(&log); + appends.spawn(async move { log.append(event(&log)).await.expect("event appends") }); + } + let mut hashes = Vec::new(); + while let Some(result) = appends.join_next().await { + hashes.push(result.expect("append task joins").record_hash); + } + hashes.sort_unstable(); + hashes.dedup(); + assert_eq!( + hashes.len(), + APPENDS, + "every concurrent append gets its own chain position" + ); + + let writes = log.durable_writes(); + assert!( + writes < APPENDS, + "concurrent appends must share durable writes, saw {writes} for {APPENDS} records" + ); + drop(log); + + let summary = verify_audit_chain(&path, &audit_secret()).expect("chain verifies"); + assert_eq!( + summary.records, APPENDS, + "batching must not drop or duplicate a record" + ); + assert!(summary.active_verified); + } + + /// A durable write that fails leaves the in-memory head ahead of the disk, + /// so the sink must refuse everything afterwards rather than chain onto a + /// record that was never written. + #[tokio::test] + async fn a_failed_durable_write_poisons_the_sink_instead_of_forking_the_chain() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + log.append(event(&log)).await.expect("event appends"); + assert!(log.ready().await); + + // Truncating through a second handle leaves the writer's pinned handle + // valid but the file no longer the one it fingerprinted. + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .expect("external truncation opens"); + + let failed = log.append(event(&log)).await; + assert!( + failed.is_err(), + "an externally modified file fails the write" + ); + + let after = log.append(event(&log)).await; + assert!( + after.is_err(), + "the sink stays failed rather than continuing on a head the disk never received" + ); + assert!( + !log.ready().await, + "a poisoned sink never reports itself ready again" + ); + } + + /// Callers wait for a batch they did not write, so a batch that fails has + /// to hand every one of them the failure. Waiting on a durable write that + /// will never arrive would hang the request that asked for the audit + /// record, which is a worse outcome than refusing it. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn a_poisoned_sink_fails_concurrent_waiters_instead_of_hanging_them() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = Arc::new( + EvidenceAuditLog::initialize( + &path, + 1024 * 1024, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"), + ); + log.append(event(&log)).await.expect("event appends"); + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&path) + .expect("external truncation opens"); + assert!( + log.append(event(&log)).await.is_err(), + "an externally modified file fails the write" + ); + + let mut waiters = tokio::task::JoinSet::new(); + for _ in 0..32 { + let log = Arc::clone(&log); + waiters.spawn(async move { log.append(event(&log)).await }); + } + let outcomes = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut outcomes = Vec::new(); + while let Some(result) = waiters.join_next().await { + outcomes.push(result.expect("append task joins")); + } + outcomes + }) + .await + .expect("a poisoned sink answers every waiter rather than hanging one"); + + assert_eq!(outcomes.len(), 32); + assert!( + outcomes.iter().all(Result::is_err), + "every waiter is told the chain stopped, none is handed a position that was never written" + ); + } + + #[tokio::test] + async fn storage_usage_counts_every_segment_and_grows_across_rotation() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + let empty = log.storage_usage().await.expect("usage reads"); + assert_eq!( + empty.segments, 1, + "the active segment counts before any append" + ); + assert_eq!(empty.bytes, 0); + + log.append(event(&log)).await.expect("event appends"); + let single = log.storage_usage().await.expect("usage reads"); + assert_eq!(single.segments, 1); + assert!(single.bytes > 0, "an appended record occupies bytes"); + + const RECORDS: usize = 24; + for _ in 0..RECORDS { + log.append(event(&log)).await.expect("event appends"); + } + + let rolled = log.storage_usage().await.expect("usage reads"); + assert!( + rolled.segments > 1, + "a bound smaller than the appended volume must roll at least once" + ); + assert_eq!( + rolled.segments, + audit_segment_paths(&path) + .expect("segments enumerate") + .len(), + "usage counts sealed segments as well as the active one" + ); + assert!( + rolled.bytes > single.bytes, + "sealed history keeps counting toward the footprint after rotation" + ); + + // Retention is the operator's, so archiving a sealed segment must show + // up as a smaller footprint rather than being masked by a counter that + // only ever accumulates. + let sealed = sealed_segments(&path).expect("sealed segments enumerate"); + let (_, oldest) = sealed.first().expect("rotation sealed a segment"); + let archived = std::fs::metadata(oldest).expect("sealed metadata").len(); + std::fs::remove_file(oldest).expect("sealed segment archives away"); + + let pruned = log.storage_usage().await.expect("usage reads"); + assert_eq!(pruned.segments, rolled.segments - 1); + assert_eq!(pruned.bytes, rolled.bytes - archived); + } + + /// Append past the per-segment bound and prove the sealed segment and the + /// active segment are one chain, not two independent ones. + #[tokio::test] + async fn appends_rotate_into_sealed_segments_and_the_chain_spans_the_seam() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + + const RECORDS: usize = 24; + for _ in 0..RECORDS { + log.append(event(&log)).await.expect("event appends"); + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() > 1, + "a bound smaller than the appended volume must roll at least once" + ); + assert_eq!( + segments.last().expect("an active segment exists"), + &path, + "the configured path stays the active segment" + ); + assert!( + log.ready().await, + "the chain stays ready across its own rotation" + ); + + // Against a live writer the verifier proves sealed history only, rather + // than racing an in-flight append and calling a partial line corruption. + let live = verify_audit_chain(&path, &audit_secret()) + .expect("sealed history verifies while the writer runs"); + assert!(!live.active_verified); + assert_eq!(live.segments, segments.len() - 1); + drop(log); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the chain verifies across every seam"); + assert!(summary.active_verified); + assert_eq!(summary.records, RECORDS, "no record is lost to rotation"); + assert_eq!(summary.segments, segments.len()); + assert_eq!(summary.first_sequence, Some(1)); + assert_eq!(summary.last_sequence, Some(segments.len() as u64 - 1)); + } + + /// Rotation must never be reachable ahead of the pinned-path check, or an + /// external rename would be laundered into a legitimate-looking seal. + #[tokio::test] + async fn pathname_replacement_is_rejected_even_when_the_append_would_rotate() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + // Fill the active segment so the next append is one that would rotate. + while std::fs::metadata(&path) + .expect("active segment reads") + .len() + == 0 + || audit_segment_paths(&path) + .expect("segments enumerate") + .len() + < 2 + { + log.append(event(&log)).await.expect("event appends"); + } + let sealed_before = audit_segment_paths(&path) + .expect("segments enumerate") + .len(); + + let displaced = directory.path().join("displaced.jsonl"); + std::fs::rename(&path, &displaced).expect("the active segment is renamed away"); + std::fs::write(&path, "").expect("a replacement is planted"); + + assert!( + log.append(event(&log)).await.is_err(), + "an append must not continue onto a replaced pathname, rotation or not" + ); + assert!(!log.ready().await); + assert_eq!( + audit_segment_paths(&path) + .expect("segments enumerate") + .len(), + sealed_before, + "a rejected append must not seal anything" + ); + } + + /// A gap in sealed history is reported as a missing segment, not as a hash + /// break, so an operator can tell archival from tampering. + #[tokio::test] + async fn an_archived_middle_segment_is_reported_as_missing_not_as_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(event(&log)).await.expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs a sealed segment that is neither first nor last" + ); + std::fs::remove_file(&segments[1]).expect("a middle segment is archived away"); + + assert!( + matches!( + verify_audit_chain(&path, &audit_secret()), + Err(EvidenceAuditError::SegmentMissing { sequence: 2 }) + ), + "a gap must name the absent sequence rather than look like tampering" + ); + } + + /// A restart after rotation must resume the sealed chain rather than + /// starting a second one. + #[tokio::test] + async fn a_restart_after_rotation_continues_from_the_sealed_tail() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + const BEFORE: usize = 24; + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..BEFORE { + log.append(event(&log)).await.expect("event appends"); + } + assert!( + audit_segment_paths(&path) + .expect("segments enumerate") + .len() + > 1 + ); + } + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a rotated chain verifies on restart"); + restarted + .append(event(&restarted)) + .await + .expect("a restarted rotated chain accepts an append"); + drop(restarted); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the chain verifies after a restart across a seam"); + assert_eq!(summary.records, BEFORE + 1); + } + + /// Crashing between the rename and the creation of the replacement leaves + /// no active segment. Restart must recover the chain head from the sealed + /// tail instead of silently beginning a new chain at genesis. + #[tokio::test] + async fn a_missing_active_segment_recovers_from_the_sealed_tail() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + const BEFORE: usize = 24; + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..BEFORE { + log.append(event(&log)).await.expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!(segments.len() > 1, "the fixture must have rolled"); + let sealed_records: usize = segments[..segments.len() - 1] + .iter() + .map(|segment| { + std::fs::read_to_string(segment) + .expect("sealed segment reads") + .lines() + .count() + }) + .sum(); + std::fs::remove_file(&path).expect("the active segment is lost to a crash"); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("a missing active segment is recreated"); + restarted + .append(event(&restarted)) + .await + .expect("appends resume after the active segment is lost"); + drop(restarted); + + let summary = verify_audit_chain(&path, &audit_secret()) + .expect("the recovered chain still spans its seams"); + assert_eq!( + summary.records, + sealed_records + 1, + "the record written after recovery continues sealed history, and the \ + records lost with the active segment are not silently replaced" + ); + assert!( + summary.records < BEFORE + 1, + "the fixture must actually have lost the active segment's records" + ); + assert!( + summary.head.is_some(), + "the recovered chain continues rather than restarting at genesis" + ); + } + + /// The chain head is recovered from the last record of the newest sealed + /// segment, so corrupting that record is caught at startup. + #[tokio::test] + async fn a_corrupt_sealed_tail_is_rejected_at_startup() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(event(&log)).await.expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let newest_sealed = segments[segments.len() - 2].clone(); + let sealed_lines = std::fs::read_to_string(&newest_sealed) + .expect("sealed segment reads") + .lines() + .count(); + rewrite_segment_line(&newest_sealed, sealed_lines - 1, corrupt_line); + + assert!( + EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .is_err(), + "a corrupt sealed tail must not be accepted as the chain head" + ); + } + + /// Boot-time verification deliberately covers only the active segment and + /// the sealed tail it chains to, so history is bounded rather than replayed + /// from genesis. This pins the accepted cost: corruption inside an already + /// sealed segment starts the service and is caught by the out-of-band + /// verifier instead. + #[tokio::test] + async fn sealed_segment_corruption_passes_startup_and_fails_the_verifier() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(event(&log)).await.expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let oldest_sealed = segments[0].clone(); + assert!( + std::fs::read_to_string(&oldest_sealed) + .expect("sealed segment reads") + .lines() + .count() + > 1, + "the corrupted record must not be the sealed tail" + ); + rewrite_segment_line(&oldest_sealed, 0, corrupt_line); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("startup does not replay sealed history"); + assert!(restarted.ready().await); + drop(restarted); + + assert!( + verify_audit_chain(&path, &audit_secret()).is_err(), + "the out-of-band verifier is what catches sealed-segment corruption" + ); + } } diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 26fe8216e..e236df6e8 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -15,6 +15,7 @@ use clap::{ArgGroup, Parser, Subcommand}; use ed25519_dalek::SigningKey; use rand_core::OsRng; use registry_evidence::{ + audit::{verify_audit_chain, AuditChainSummary, EvidenceAuditError}, bundle::{ArtifactFault, Bundle, BundleError, DeploymentInputs, RuntimeDocument}, config::{ConfigError, EvidenceConfig, OutboundTlsConfig, SelectorInput}, kernel::{ @@ -44,6 +45,7 @@ use registry_evidence::{ VerificationError, }, }; +use registry_platform_audit::{AuditHashSecret, OptionalHashHex}; use registry_platform_crypto::{parse_json_strict, LocalJwkSigner, PrivateJwk}; use serde::Deserialize; use serde_json::{Map as JsonMap, Value}; @@ -106,6 +108,13 @@ enum Command { #[arg(long)] at: Option, }, + /// Run a full out-of-band verification pass over the audit chain. + /// + /// Startup verification is deliberately bounded to the active segment, so + /// restart time does not grow with retained history; tampering inside an + /// already sealed segment is not caught there. This is the counterpart + /// check that catches it, meant to run out of band. + VerifyAudit, } #[derive(Debug, PartialEq, Eq)] @@ -235,6 +244,7 @@ async fn run(cli: Cli) -> Result { at.as_deref(), )?) } + Command::VerifyAudit => run_verify_audit(&cli.runtime), } } @@ -415,9 +425,21 @@ struct ExpectedOutputDocument { } /// The closed expected value-form vocabulary, as written in a policy document. +/// +/// The two alternatives are untagged because the policy schema writes a scalar +/// form as a plain string and the list form as a mapping under `list`. An +/// externally tagged enum would instead demand the YAML tag `!list`, which no +/// relying party writing to the published schema would ever produce. #[derive(Debug, Deserialize)] -#[serde(rename_all = "kebab-case")] +#[serde(untagged)] enum ExpectedFormDocument { + Scalar(ExpectedScalarFormDocument), + List(ExpectedListFormDocument), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum ExpectedScalarFormDocument { Boolean, Integer, String, @@ -425,7 +447,12 @@ enum ExpectedFormDocument { TimeBucket, EntityReference, Structured, - List(ExpectedListDocument), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedListFormDocument { + list: ExpectedListDocument, } #[derive(Debug, Deserialize)] @@ -473,16 +500,30 @@ impl VerificationPolicyDocument { fn expected_value_form(document: ExpectedFormDocument) -> ExpectedValueForm { match document { - ExpectedFormDocument::Boolean => ExpectedValueForm::Boolean, - ExpectedFormDocument::Integer => ExpectedValueForm::Integer, - ExpectedFormDocument::String => ExpectedValueForm::String, - ExpectedFormDocument::DateBucket => ExpectedValueForm::DateBucket, - ExpectedFormDocument::TimeBucket => ExpectedValueForm::TimeBucket, - ExpectedFormDocument::EntityReference => ExpectedValueForm::EntityReference, - ExpectedFormDocument::Structured => ExpectedValueForm::Structured, - ExpectedFormDocument::List(bounds) => ExpectedValueForm::List { - minimum_items: bounds.minimum_items, - maximum_items: bounds.maximum_items, + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Boolean) => { + ExpectedValueForm::Boolean + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Integer) => { + ExpectedValueForm::Integer + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::String) => { + ExpectedValueForm::String + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::DateBucket) => { + ExpectedValueForm::DateBucket + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::TimeBucket) => { + ExpectedValueForm::TimeBucket + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::EntityReference) => { + ExpectedValueForm::EntityReference + } + ExpectedFormDocument::Scalar(ExpectedScalarFormDocument::Structured) => { + ExpectedValueForm::Structured + } + ExpectedFormDocument::List(wrapper) => ExpectedValueForm::List { + minimum_items: wrapper.list.minimum_items, + maximum_items: wrapper.list.maximum_items, }, } } @@ -610,6 +651,98 @@ fn verification_error_class(error: VerificationError) -> CliError { } } +/// Run a full out-of-band audit verification pass for the deployment named by +/// one closed operator runtime file. +/// +/// The audit storage path and hash secret are read from the same runtime +/// document and secret provider the serving process uses; this command takes +/// no path or secret flags of its own, so it can never be pointed at an audit +/// chain the deployment does not own. +fn run_verify_audit(runtime_path: &Path) -> Result { + let deployment = DeploymentInputs::load(runtime_path).map_err(deployment_load_error)?; + let secrets = SecretResolver::new( + [SecretProvider::File], + &deployment.runtime.config.secret_providers.file.root, + ) + .map_err(|_| CliError("audit verification secret resolver failed"))?; + let audit_secret = secrets + .resolve(deployment.bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| CliError("audit verification secret resolution failed"))?; + let master_secret = AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + .map_err(|_| CliError("audit verification secret is invalid"))?; + verify_audit_with_secret( + Path::new(&deployment.runtime.config.audit_storage.path), + &master_secret, + ) +} + +/// Verify one audit chain and print the operator report. +/// +/// Split from [`run_verify_audit`] so the report and the failure +/// classification can be exercised directly against a constructed chain, +/// without a full deployment bundle and runtime document on disk. +fn verify_audit_with_secret( + audit_path: &Path, + master_secret: &AuditHashSecret, +) -> Result { + match verify_audit_chain(audit_path, master_secret) { + Ok(summary) => { + println!("{}", audit_chain_report(&summary)); + Ok(ExitCode::SUCCESS) + } + Err(error) => { + let (detail, class) = audit_verification_failure(error); + println!("{detail}"); + Err(CommandError::Cli(class)) + } + } +} + +/// Render an out-of-band audit verification result for an operator. +/// +/// The head hash and the segment and record counts carry no request content, +/// so they are safe to print; nothing secret-derived beyond the chain head +/// appears here. When the active segment could not be verified, the report +/// says so plainly rather than reading as a pass of the whole chain. +fn audit_chain_report(summary: &AuditChainSummary) -> String { + let sealed_sequence = match (summary.first_sequence, summary.last_sequence) { + (Some(first), Some(last)) => format!("{first}-{last}"), + _ => "none".to_owned(), + }; + let active_segment = if summary.active_verified { + "verified".to_owned() + } else { + "not verified: a running writer holds it, so only sealed history was proven".to_owned() + }; + format!( + "segments: {}\nrecords: {}\nsealed-sequence: {sealed_sequence}\nhead: {}\nactive-segment: {active_segment}", + summary.segments, + summary.records, + OptionalHashHex(summary.head), + ) +} + +/// Classify an audit verification failure for the operator report and exit. +/// +/// A gap in the sealed sequence is archived-or-missing history, not a hash +/// break, so it is reported in those terms and kept distinguishable from +/// every other verification failure, which is corruption. +fn audit_verification_failure(error: EvidenceAuditError) -> (String, CliError) { + match error { + EvidenceAuditError::SegmentMissing { sequence } => ( + format!( + "sealed segment {sequence} is archived or missing from the chain; \ + this is not corruption" + ), + CliError("audit chain is missing sealed history"), + ), + _ => ( + "audit chain verification failed".to_owned(), + CliError("audit chain verification failed"), + ), + } +} + async fn evaluate_fixture( bundle: &Arc, kernel: &OfflineKernel, @@ -2392,8 +2525,83 @@ fn safe_fixture_name(path: &Path) -> Result<&str, CliError> { #[cfg(test)] mod tests { use super::*; + use registry_evidence::audit::{ + audit_segment_paths, AuditAuthority, AuditDecision, AuditPhase, AuditSubject, + AuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, ResponseProtection, + }; use std::fs; + /// Every expected form the published policy schema accepts must parse the + /// way that schema writes it. The list form is a mapping under `list`, not + /// a YAML tag, and it is the only form a list-valued concept can state. + #[test] + fn policy_documents_parse_every_expected_form_as_the_contract_writes_it() { + for (written, expected) in [ + ("boolean", ExpectedValueForm::Boolean), + ("integer", ExpectedValueForm::Integer), + ("string", ExpectedValueForm::String), + ("date-bucket", ExpectedValueForm::DateBucket), + ("time-bucket", ExpectedValueForm::TimeBucket), + ("entity-reference", ExpectedValueForm::EntityReference), + ("structured", ExpectedValueForm::Structured), + ( + "{list: {minimumItems: 1, maximumItems: 2}}", + ExpectedValueForm::List { + minimum_items: 1, + maximum_items: 2, + }, + ), + ] { + let document = verification_policy_document(&format!("form: {written}")); + let policy: VerificationPolicyDocument = serde_norway::from_str(&document) + .unwrap_or_else(|error| panic!("`{written}` is a policy form: {error}")); + assert_eq!( + policy.into_policy(Utc::now()).expected_outputs[0].form, + expected, + "`{written}` parsed as a different form" + ); + } + } + + #[test] + fn policy_documents_reject_forms_outside_the_closed_vocabulary() { + for written in [ + "list", + "{list: {minimumItems: 1}}", + "{list: {minimumItems: 1, maximumItems: 2, extra: 3}}", + "{set: {minimumItems: 1, maximumItems: 2}}", + "date_bucket", + ] { + let document = verification_policy_document(&format!("form: {written}")); + assert!( + serde_norway::from_str::(&document).is_err(), + "`{written}` is not a policy form but parsed as one" + ); + } + } + + /// One complete policy document whose single expected output states `form`. + fn verification_policy_document(form: &str) -> String { + format!( + "issuedBy: urn:example:issuer\n\ + providedBy: urn:example:provider\n\ + requirement: urn:example:requirement:v1\n\ + evidenceType: urn:example:evidence-type:v1\n\ + purpose: example-purpose\n\ + audience: https://relying-party.example\n\ + configurationRevision: sha256:0\n\ + requestNonce: example-nonce\n\ + expectedSubjects:\n\ + \x20 - {{role: subject, binding: urn:evidence:subject:v1_{binding}}}\n\ + expectedOutputs:\n\ + \x20 - concept: urn:example:concept\n\ + \x20 {form}\n\ + maximumAssertionLifetimeSeconds: 86400\n\ + clockSkewSeconds: 30\n", + binding = "A".repeat(43), + ) + } + #[test] fn fixture_paths_never_escape_the_captured_bundle() { assert_eq!( @@ -2652,6 +2860,191 @@ mod tests { ); } + fn test_audit_secret() -> AuditHashSecret { + AuditHashSecret::new(b"0123456789abcdef0123456789abcdef".to_vec()) + .expect("audit secret builds") + } + + fn test_audit_event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + "01K1EXAMPLE0000000000000000".to_owned(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_owned(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_owned(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_owned(), + selector_profile: "person-v1".to_owned(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 5, + ) + } + + /// Change one byte of a record without changing its length, so the + /// record no longer matches the hash the chain recorded for it. + fn corrupt_audit_line(line: &str) -> String { + let mut bytes = line.as_bytes().to_vec(); + for byte in bytes.iter_mut() { + if byte.is_ascii_lowercase() { + *byte = if *byte == b'z' { b'y' } else { *byte + 1 }; + break; + } + } + String::from_utf8(bytes).expect("a corrupted record stays UTF-8") + } + + #[tokio::test] + async fn verify_audit_reports_a_clean_multi_segment_chain() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs several sealed segments plus the active one" + ); + + let secret = test_audit_secret(); + let summary = + verify_audit_chain(&path, &secret).expect("a clean multi-segment chain verifies"); + assert_eq!(summary.records, 48); + assert_eq!(summary.segments, segments.len()); + assert!(summary.active_verified); + assert_eq!(summary.first_sequence, Some(1)); + + assert!(verify_audit_with_secret(&path, &secret).is_ok()); + } + + /// Startup verification only replays the active segment; this pins the + /// counterpart it exists for: corruption planted in an already sealed + /// segment passes startup and is only caught by the out-of-band verifier. + #[tokio::test] + async fn verify_audit_fails_on_sealed_segment_corruption() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..24 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + + let segments = audit_segment_paths(&path).expect("segments enumerate"); + let oldest_sealed = segments[0].clone(); + let contents = fs::read_to_string(&oldest_sealed).expect("sealed segment reads"); + let mut lines: Vec = contents.lines().map(str::to_owned).collect(); + assert!( + lines.len() > 1, + "the corrupted record must not be the sealed tail" + ); + lines[0] = corrupt_audit_line(&lines[0]); + let mut rewritten = lines.join("\n"); + rewritten.push('\n'); + fs::write(&oldest_sealed, rewritten).expect("segment rewrites"); + + let restarted = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("startup does not replay sealed history"); + assert!(restarted.ready().await); + drop(restarted); + + assert!( + verify_audit_with_secret(&path, &test_audit_secret()).is_err(), + "the out-of-band verifier must catch sealed-segment corruption" + ); + } + + /// A gap in sealed history is an operator archiving a segment, not + /// tampering, so it must be reported by sequence rather than folded into + /// the generic corruption message. + #[tokio::test] + async fn verify_audit_reports_an_archived_segment_as_missing_not_corrupt() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + { + let log = EvidenceAuditLog::initialize( + &path, + 2048, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + for _ in 0..48 { + log.append(test_audit_event(&log)) + .await + .expect("event appends"); + } + } + let segments = audit_segment_paths(&path).expect("segments enumerate"); + assert!( + segments.len() >= 4, + "the fixture needs a sealed segment that is neither first nor last" + ); + fs::remove_file(&segments[1]).expect("a middle segment is archived away"); + + let error = verify_audit_chain(&path, &test_audit_secret()) + .expect_err("a gap in sealed history must fail verification"); + let sequence = match &error { + EvidenceAuditError::SegmentMissing { sequence } => *sequence, + other => panic!("expected a missing-segment error, got {other:?}"), + }; + assert_eq!(sequence, 2); + + let (detail, _) = audit_verification_failure(error); + assert!( + detail.contains(&format!("segment {sequence}")) + && detail.contains("archived or missing"), + "the report must name the sequence and describe archival: {detail}" + ); + assert!( + detail.contains("not corruption"), + "the report must state plainly that this is not corruption: {detail}" + ); + + assert!(verify_audit_with_secret(&path, &test_audit_secret()).is_err()); + } + #[cfg(unix)] #[tokio::test] async fn offline_cli_evaluates_every_coequal_acceptance_fixture() { diff --git a/crates/registry-evidence/src/observability.rs b/crates/registry-evidence/src/observability.rs index 978c305a5..442ceea3d 100644 --- a/crates/registry-evidence/src/observability.rs +++ b/crates/registry-evidence/src/observability.rs @@ -10,7 +10,10 @@ use std::{ collections::BTreeMap, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, time::{Duration, Instant}, }; @@ -25,7 +28,11 @@ use axum::{ }; use ulid::Ulid; -use crate::problem::ProblemCode; +use crate::{ + audit::{AuditStorageUsage, EvidenceAuditLog}, + problem::ProblemCode, + rate_limit::EvidenceRateLimiter, +}; /// Correlation identifier returned to the caller on every response. /// @@ -189,6 +196,29 @@ fn duration_milliseconds(elapsed: Duration) -> u64 { #[derive(Default)] pub(crate) struct Metrics { series: Mutex>, + /// Current count of pseudonym keys tracked by the rate limiter, for the + /// `evidence_rate_limiter_tracked_keys` gauge. Unlike `series`, this is + /// not derived from request content: it is republished on every scrape + /// from [`crate::rate_limit::EvidenceRateLimiter::tracked_key_count`], + /// so it stays a single unlabeled series regardless of traffic. See + /// security invariant V1-I33. + rate_limiter_tracked_keys: AtomicUsize, + /// The limiter the metrics scrape handler samples immediately before + /// each render. `None` for registries that are never served on the + /// metrics listener (for example, an unrelated middleware test). + rate_limiter: Option>, + /// Current segment count and total on-disk bytes of the audit chain, for + /// the `evidence_audit_segments` and `evidence_audit_bytes` gauges. + /// Rotation never deletes a sealed segment, so nothing in the runtime + /// bounds this growth; these gauges are how an operator sees the footprint + /// they own. Republished on every scrape from + /// [`crate::audit::EvidenceAuditLog::storage_usage`], so they stay two + /// unlabeled series regardless of traffic. See security invariant V1-I33. + audit_segments: AtomicUsize, + audit_bytes: AtomicU64, + /// The chain the metrics scrape handler samples immediately before each + /// render. `None` for the same reason as `rate_limiter`. + audit: Option>, } #[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -207,6 +237,20 @@ struct Series { } impl Metrics { + /// A registry that serves the metrics listener: it samples `rate_limiter` + /// on every scrape to publish the `evidence_rate_limiter_tracked_keys` + /// gauge. + pub(crate) fn new( + rate_limiter: Arc, + audit: Arc, + ) -> Self { + Self { + rate_limiter: Some(rate_limiter), + audit: Some(audit), + ..Self::default() + } + } + fn record( &self, route: &'static str, @@ -236,6 +280,28 @@ impl Metrics { } } + /// Publish the rate limiter's current tracked-key count for the next + /// render. + /// + /// The caller reads the live count from the actual limiter (see + /// [`crate::rate_limit::EvidenceRateLimiter::tracked_key_count`]) + /// immediately before calling this, so the published gauge reflects the + /// limiter's state at scrape time rather than a value cached from an + /// earlier request. + pub(crate) fn record_rate_limiter_tracked_keys(&self, count: usize) { + self.rate_limiter_tracked_keys + .store(count, Ordering::Relaxed); + } + + /// Publish the audit chain's current footprint for the next render. + /// + /// Read live from the chain immediately before calling this, for the same + /// reason as the rate-limiter gauge. + pub(crate) fn record_audit_storage_usage(&self, usage: AuditStorageUsage) { + self.audit_segments.store(usage.segments, Ordering::Relaxed); + self.audit_bytes.store(usage.bytes, Ordering::Relaxed); + } + /// Render the Prometheus text exposition of the current registry. pub(crate) fn render(&self) -> String { let series = self @@ -281,6 +347,30 @@ impl Metrics { value.requests )); } + body.push_str( + "# HELP evidence_rate_limiter_tracked_keys Pseudonym keys currently tracked by the rate limiter.\n", + ); + body.push_str("# TYPE evidence_rate_limiter_tracked_keys gauge\n"); + body.push_str(&format!( + "evidence_rate_limiter_tracked_keys {}\n", + self.rate_limiter_tracked_keys.load(Ordering::Relaxed) + )); + body.push_str( + "# HELP evidence_audit_segments Audit chain segments on disk, sealed and active.\n", + ); + body.push_str("# TYPE evidence_audit_segments gauge\n"); + body.push_str(&format!( + "evidence_audit_segments {}\n", + self.audit_segments.load(Ordering::Relaxed) + )); + body.push_str( + "# HELP evidence_audit_bytes Bytes occupied by the audit chain across every segment.\n", + ); + body.push_str("# TYPE evidence_audit_bytes gauge\n"); + body.push_str(&format!( + "evidence_audit_bytes {}\n", + self.audit_bytes.load(Ordering::Relaxed) + )); body } } @@ -306,6 +396,26 @@ pub(crate) fn metrics_app(metrics: Arc) -> Router { } async fn render_metrics(State(metrics): State>) -> Response { + // Sampled fresh on every scrape rather than cached from request + // handling, so the gauge reflects the limiter's state at scrape time. + // A registry built without a limiter (see `Metrics::default`) has + // nothing to sample and leaves the gauge at its initial zero. + if let Some(rate_limiter) = &metrics.rate_limiter { + metrics.record_rate_limiter_tracked_keys(rate_limiter.tracked_key_count().await); + } + if let Some(audit) = &metrics.audit { + // A failed read leaves the previous values standing rather than + // publishing a zero that would read as an empty chain. It is logged so + // the staleness is visible instead of silent; the sampled path is + // operator material and carries no request content. + match audit.storage_usage().await { + Ok(usage) => metrics.record_audit_storage_usage(usage), + Err(error) => tracing::warn!( + %error, + "audit storage usage could not be sampled for the capacity gauges" + ), + } + } let mut response = (StatusCode::OK, metrics.render()).into_response(); response .headers_mut() @@ -320,6 +430,10 @@ async fn metrics_route_absent() -> Response { #[cfg(test)] mod tests { use super::*; + use crate::audit::{ + AuditAuthority, AuditDecision, AuditPhase, AuditSubject, AuthorityKind, EvidenceAuditEvent, + ResponseProtection, + }; #[test] fn series_labels_stay_bounded_by_the_closed_route_and_method_sets() { @@ -348,6 +462,232 @@ mod tests { assert!(rendered.contains("status=\"client_error\",error=\"malformed_request\"} 2\n")); } + #[test] + fn rate_limiter_tracked_keys_gauge_is_a_single_unlabeled_series() { + let metrics = Metrics::default(); + // Populate unrelated request series first, to prove the gauge does + // not multiply per label the way the request counter and duration + // histogram do. + metrics.record( + "/health", + "GET", + StatusCategory::Success, + NO_ERROR, + Duration::from_millis(1), + ); + metrics.record( + "/v1/evidence", + "POST", + StatusCategory::ClientError, + ProblemCode::MalformedRequest.code(), + Duration::from_millis(1), + ); + metrics.record_rate_limiter_tracked_keys(42); + + let rendered = metrics.render(); + assert_eq!( + rendered + .matches("evidence_rate_limiter_tracked_keys") + .count(), + 3, // HELP line, TYPE line, and exactly one value line + "the gauge is emitted once regardless of how many request series exist" + ); + assert!(rendered.contains("\nevidence_rate_limiter_tracked_keys 42\n")); + assert!( + !rendered.contains("evidence_rate_limiter_tracked_keys{"), + "the gauge must carry no labels" + ); + } + + #[tokio::test] + async fn rate_limiter_tracked_keys_gauge_reflects_keys_added_through_the_limiter_api() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"); + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + + let metrics = Metrics::default(); + metrics.record_rate_limiter_tracked_keys(limiter.tracked_key_count().await); + + let rendered = metrics.render(); + assert!(rendered.contains("\nevidence_rate_limiter_tracked_keys 3\n")); + } + + /// The gauge must come from the live limiter at scrape time, not from a + /// value recorded during earlier request handling. Drive a real limiter + /// through its public API, wire it into a registry the way production + /// startup does, and scrape it through the actual `/metrics` router + /// rather than calling `render` directly. + #[tokio::test] + async fn metrics_endpoint_samples_the_live_rate_limiter_at_scrape_time() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = Arc::new( + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 60, + burst_per_principal: 2, + failed_selector_attempts_per_principal_authority_per_minute: 2, + }) + .expect("limiter builds"), + ); + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + let expected = limiter.tracked_key_count().await; + assert_eq!( + expected, 3, + "three distinct tracked keys precede the scrape" + ); + + let (_directory, audit) = scrape_audit_log().await; + let metrics = Arc::new(Metrics::new(Arc::clone(&limiter), audit)); + let server = axum_test::TestServer::new(metrics_app(metrics)); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains(&format!( + "\nevidence_rate_limiter_tracked_keys {expected}\n" + ))); + + // A key tracked after the registry was built is still visible on the + // next scrape, proving the value is sampled live rather than cached + // from construction time. + limiter + .check_request("pseudonym-c") + .await + .expect("third principal"); + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains("\nevidence_rate_limiter_tracked_keys 4\n")); + } + + /// A representative record, so the footprint the gauges report reflects a + /// real audit line rather than an artificially short one. + fn scrape_audit_event(log: &EvidenceAuditLog) -> EvidenceAuditEvent { + EvidenceAuditEvent::new( + "01K1EXAMPLE0000000000000000".to_string(), + AuditPhase::AccessAttempt, + "urn:example:requirement:v1".to_string(), + format!("sha256:{}", "0".repeat(64)), + "casework".to_string(), + log.pseudonym("requester-v1", "urn:example:trust", b"principal-canary") + .expect("pseudonym builds"), + AuditAuthority { + kind: AuthorityKind::Statutory, + grant_pseudonym: None, + }, + vec![AuditSubject { + role: "subject".to_string(), + selector_profile: "person-v1".to_string(), + selector_bundle_pseudonym: Some( + log.pseudonym("subject-v1", "casework", b"selector-canary") + .expect("pseudonym builds"), + ), + }], + ResponseProtection::Signed, + AuditDecision::Authorized, + 5, + ) + } + + /// A durable chain over a temporary directory, for tests that need the + /// metrics registry's live audit sampling. The `TempDir` is returned + /// because dropping it removes the segments out from under the sink. + async fn scrape_audit_log() -> (tempfile::TempDir, Arc) { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("audit.jsonl"); + let log = EvidenceAuditLog::initialize( + &path, + 4096, + b"0123456789abcdef0123456789abcdef".to_vec(), + 1, + ) + .await + .expect("audit initializes"); + (directory, Arc::new(log)) + } + + #[tokio::test] + async fn metrics_endpoint_samples_the_live_audit_chain_at_scrape_time() { + use crate::rate_limit::{EvidenceRateLimiter, RateLimitConfig}; + + let limiter = Arc::new( + EvidenceRateLimiter::new(RateLimitConfig { + requests_per_principal_per_minute: 10, + burst_per_principal: 10, + failed_selector_attempts_per_principal_authority_per_minute: 10, + }) + .expect("limiter builds"), + ); + let (_directory, audit) = scrape_audit_log().await; + let metrics = Arc::new(Metrics::new(limiter, Arc::clone(&audit))); + let server = axum_test::TestServer::new(metrics_app(metrics)); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!( + body.contains("\nevidence_audit_segments 1\n"), + "an untouched chain reports its active segment" + ); + assert!( + body.contains("\nevidence_audit_bytes 0\n"), + "an untouched chain occupies no bytes" + ); + assert!( + !body.contains("evidence_audit_segments{") && !body.contains("evidence_audit_bytes{"), + "the capacity gauges stay unlabeled under V1-I33" + ); + + // Append past the rotation threshold, so the next scrape has to show + // both a new segment and a larger footprint. This is what proves the + // gauges are sampled live rather than cached from construction. + for _ in 0..24 { + audit + .append(scrape_audit_event(&audit)) + .await + .expect("event appends"); + } + let usage = audit.storage_usage().await.expect("usage reads"); + assert!( + usage.segments > 1, + "the appended volume must roll the chain" + ); + + let response = server.get("/metrics").await; + response.assert_status_ok(); + let body = response.text(); + assert!(body.contains(&format!("\nevidence_audit_segments {}\n", usage.segments))); + assert!(body.contains(&format!("\nevidence_audit_bytes {}\n", usage.bytes))); + } + #[test] fn duration_buckets_are_cumulative_and_carry_an_infinite_bound() { let metrics = Metrics::default(); diff --git a/crates/registry-evidence/src/rate_limit.rs b/crates/registry-evidence/src/rate_limit.rs index 6aca7b043..dac51e990 100644 --- a/crates/registry-evidence/src/rate_limit.rs +++ b/crates/registry-evidence/src/rate_limit.rs @@ -143,6 +143,18 @@ impl EvidenceRateLimiter { } Ok(()) } + + /// Total pseudonym keys currently tracked across both maps, toward the + /// shared [`MAX_TRACKED_KEYS`] capacity ceiling each map enforces. + /// + /// Each lock is held only long enough to read `.len()`; no other work + /// happens in either critical section, since the request path contends + /// on these same locks. + pub async fn tracked_key_count(&self) -> usize { + let requests_len = self.requests.lock().await.len(); + let selector_failures_len = self.selector_failures.lock().await.len(); + requests_len + selector_failures_len + } } fn validate_pseudonym_key(key: &str) -> Result<(), RateLimitError> { @@ -248,6 +260,34 @@ mod tests { .expect("other authority remains available"); } + #[tokio::test] + async fn tracked_key_count_reports_the_total_across_both_maps() { + let limiter = limiter(); + assert_eq!(limiter.tracked_key_count().await, 0); + + limiter + .check_request("pseudonym-a") + .await + .expect("first principal"); + limiter + .check_request("pseudonym-b") + .await + .expect("second principal"); + limiter + .record_selector_failure("authority-a") + .await + .expect("first failure"); + + assert_eq!(limiter.tracked_key_count().await, 3); + + // Reusing an already-tracked key does not grow the count. + limiter + .check_request("pseudonym-a") + .await + .expect("existing principal"); + assert_eq!(limiter.tracked_key_count().await, 3); + } + #[tokio::test] async fn selector_failure_budget_is_shared_across_request_contexts() { let limiter = limiter(); diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 87b5b7408..9a7e8401c 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -147,11 +147,11 @@ pub struct EvidenceRuntime { runtime_revision: String, authenticator: Authenticator, sources: BTreeMap, - audit: EvidenceAuditLog, + audit: Arc, signer: EvidenceSigner, jwks: JwksDocument, subject_binding_secret: ProtectedSecret, - rate_limiter: EvidenceRateLimiter, + rate_limiter: Arc, } impl std::fmt::Debug for EvidenceRuntime { @@ -277,6 +277,7 @@ impl EvidenceRuntime { .map_err(|_| RuntimeInitializationError::RateLimit)?, }) .map_err(|_| RuntimeInitializationError::RateLimit)?; + let rate_limiter = Arc::new(rate_limiter); Ok(Self { kernel, @@ -285,7 +286,7 @@ impl EvidenceRuntime { authenticator: authenticator_override .unwrap_or_else(|| Authenticator::from_config(&bundle.config.authentication)), sources, - audit, + audit: Arc::new(audit), signer, jwks, subject_binding_secret, @@ -309,6 +310,25 @@ impl EvidenceRuntime { &self.jwks } + /// The rate limiter whose tracked-key count backs the + /// `evidence_rate_limiter_tracked_keys` gauge on the metrics listener. + /// + /// Returned as a shared handle, independent of this runtime's own + /// lifetime, so the metrics listener can hold it and sample it fresh on + /// every scrape. + pub(crate) fn rate_limiter(&self) -> Arc { + Arc::clone(&self.rate_limiter) + } + + /// The audit chain, for the capacity gauge. + /// + /// Shared for the same reason as the rate limiter: the metrics listener + /// samples the chain's on-disk footprint at scrape time rather than + /// caching a value taken at startup. + pub(crate) fn audit(&self) -> Arc { + Arc::clone(&self.audit) + } + #[cfg(test)] pub(crate) fn replace_signer_for_test(&mut self, signer: EvidenceSigner) { self.signer = signer; diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index d0a57eb5b..4bdfc4eca 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -86,6 +86,15 @@ struct PreparedAcceptance { audit_path: PathBuf, } +/// A prepared bundle and runtime file with no opinion about what serves the +/// configured source origin. +struct PreparedFixture { + temporary: TempDir, + bundle_root: PathBuf, + runtime_path: PathBuf, + audit_path: PathBuf, +} + struct FailAfterSelfTestSigner { delegate: LocalJwkSigner, calls: AtomicUsize, @@ -3792,6 +3801,29 @@ async fn acceptance_runtime() -> AcceptanceRuntime { } async fn prepare_acceptance(binding_secret: &str) -> PreparedAcceptance { + let server = MockServer::start().await; + let prepared = prepare_fixture( + binding_secret, + &server.uri(), + &FixtureCeilings::deployment_defaults(), + ); + PreparedAcceptance { + temporary: prepared.temporary, + bundle_root: prepared.bundle_root, + runtime_path: prepared.runtime_path, + server, + audit_path: prepared.audit_path, + } +} + +/// Copy the acceptance bundle into a temporary root, point every source at +/// `source_origin`, write the synthetic secrets and the runtime configuration +/// under `ceilings`, then make the bundle and runtime file read-only. +fn prepare_fixture( + binding_secret: &str, + source_origin: &str, + ceilings: &FixtureCeilings, +) -> PreparedFixture { let temporary = tempfile::tempdir().expect("temporary acceptance root"); let bundle_root = temporary.path().join("bundle"); let runtime_path = temporary.path().join("runtime.yaml"); @@ -3807,8 +3839,8 @@ async fn prepare_acceptance(binding_secret: &str) -> PreparedAcceptance { } copy_tree(&fixture_root(), &bundle_root); - let server = MockServer::start().await; - rewrite_deployment_values(&bundle_root, &server.uri()); + rewrite_deployment_values(&bundle_root, source_origin); + apply_fixture_ceilings(&bundle_root, ceilings); write_secret( &secret_root, "audit-hash-key", @@ -3821,15 +3853,20 @@ async fn prepare_acceptance(binding_secret: &str) -> PreparedAcceptance { write_secret(&secret_root, "source-c-username", BASIC_USER); write_secret(&secret_root, "source-c-password", BASIC_PASSWORD); write_secret(&secret_root, "source-d-token", BEARER); - write_runtime_config(&runtime_path, &bundle_root, &secret_root, &audit_path); + write_runtime_config( + &runtime_path, + &bundle_root, + &secret_root, + &audit_path, + ceilings, + ); make_file_read_only(&runtime_path); make_read_only(&bundle_root); - PreparedAcceptance { + PreparedFixture { temporary, bundle_root, runtime_path, - server, audit_path, } } @@ -4328,11 +4365,72 @@ fn rewrite_deployment_values(bundle_root: &Path, source_origin: &str) { fs::write(path, text).expect("deployment-only fixture rewrite succeeds"); } +/// The deployment ceilings a prepared fixture runs under. +/// +/// Every field here is a production-meaningful default in the acceptance +/// bundle or runtime file. They exist as a struct only so a throughput +/// measurement can lift the ones that would otherwise be the thing measured; +/// the lifted values are measurement scaffolding and are not a recommended +/// deployment posture. +struct FixtureCeilings { + maximum_concurrent_requests: u32, + audit_maximum_file_bytes: u64, + requests_per_principal_per_minute: u64, + burst_per_principal: u64, + source_concurrency_limit: u16, +} + +impl FixtureCeilings { + /// The values the tracked acceptance bundle and the acceptance runtime + /// file carry. Every existing fixture runs under exactly these. + fn deployment_defaults() -> Self { + Self { + maximum_concurrent_requests: 64, + audit_maximum_file_bytes: 10_485_760, + requests_per_principal_per_minute: 60, + burst_per_principal: 10, + source_concurrency_limit: 8, + } + } +} + +/// Rewrite the copied bundle's rate limits and per-source outbound concurrency +/// to `ceilings`. The tracked fixture under `products/evidence/fixtures` is +/// never touched: only the temporary copy is, and only before it is sealed +/// read-only. +fn apply_fixture_ceilings(bundle_root: &Path, ceilings: &FixtureCeilings) { + let path = bundle_root.join("evidence.yaml"); + let mut text = fs::read_to_string(&path).expect("copied configuration is readable"); + replace_exact( + &mut text, + "requestsPerPrincipalPerMinute: 60", + &format!( + "requestsPerPrincipalPerMinute: {}", + ceilings.requests_per_principal_per_minute + ), + 1, + ); + replace_exact( + &mut text, + "burstPerPrincipal: 10", + &format!("burstPerPrincipal: {}", ceilings.burst_per_principal), + 1, + ); + replace_exact( + &mut text, + "concurrencyLimit: 8", + &format!("concurrencyLimit: {}", ceilings.source_concurrency_limit), + 4, + ); + fs::write(path, text).expect("deployment-only ceiling rewrite succeeds"); +} + fn write_runtime_config( runtime_path: &Path, bundle_root: &Path, secret_root: &Path, audit_path: &Path, + ceilings: &FixtureCeilings, ) { let document = format!( r#"version: 1 @@ -4343,7 +4441,7 @@ listener: tlsTermination: operator-controlled-upstream trustProxyIdentityHeaders: false maximumRequestBytes: 65536 - maximumConcurrentRequests: 64 + maximumConcurrentRequests: {} requestTimeoutMilliseconds: 10000 shutdownGraceMilliseconds: 30000 secretProviders: @@ -4351,14 +4449,16 @@ secretProviders: root: {} auditStorage: path: {} - maximumFileBytes: 10485760 + maximumFileBytes: {} outboundTls: systemRoots: true trustProfiles: {{}} "#, bundle_root.display(), + ceilings.maximum_concurrent_requests, secret_root.display(), audit_path.display(), + ceilings.audit_maximum_file_bytes, ); fs::write(runtime_path, document).expect("immutable runtime configuration is written"); } @@ -4845,3 +4945,503 @@ fn released_evidence_ids(audit: &str) -> BTreeSet { }) .collect() } + +// Sustained end-to-end throughput measurement. + +/// Requests offered simultaneously by the sustained driver. +/// +/// Two durable audit appends sit on every request's critical path, and the +/// audit sink commits in groups: appends that arrive while a durable write is +/// in flight join the next batch instead of each paying an `fsync`. Its rate +/// therefore rises with the number of concurrent appenders and collapses +/// toward one `fsync` per append when only a few are in flight, so the offered +/// concurrency has to keep batches full. 128 also leaves Little's law room: +/// sustaining 1000 requests per second needs roughly `1000 * latency_seconds` +/// requests in flight, so 128 covers per-request latencies up to about 128 ms. +const SUSTAINED_CONCURRENCY: usize = 128; + +/// The measured window. Ten seconds is long enough for group-commit batching, +/// connection reuse, and the verifier and source caches to reach steady state, +/// and short enough that the check stays runnable on demand rather than +/// becoming a nightly job. +const SUSTAINED_WINDOW: Duration = Duration::from_secs(10); + +/// An unmeasured window that absorbs first-request script compilation, lazy +/// initialization, connection establishment, and audit file growth. +const SUSTAINED_WARMUP: Duration = Duration::from_secs(3); + +/// The end-to-end rate this check exists to prove, in requests per second. +const SUSTAINED_TARGET_RPS: f64 = 1000.0; + +/// How far the constant source's own ceiling must sit above the Evidence +/// result before the run is read as a measurement of Evidence. +/// +/// A source held at a fraction `f` of its own ceiling contributes about `f` of +/// the saturation, so a factor of five keeps the source below 20 percent +/// utilization while the service under test is at 100 percent. Below this +/// factor the harness is close enough to the result to be part of what was +/// measured, and the run is reported as inconclusive rather than as a pass or +/// a failure. +const SUSTAINED_SOURCE_HEADROOM: f64 = 5.0; + +/// The one constant body the sustained source returns. It satisfies the +/// projection and fact schema the acceptance bundle declares for that source. +const CONSTANT_SOURCE_BODY: &str = r#"{"total":1,"date_of_birth":"2000-01-01"}"#; + +/// The ceilings the sustained fixture runs under. +/// +/// Each lifted value is a production-meaningful default that would otherwise +/// become the thing measured, not a recommended deployment posture: +/// +/// - `requestsPerPrincipalPerMinute: 60` with `burstPerPrincipal: 10` is one +/// request per second per principal, so an unlifted run measures the rate +/// limiter returning `rate_limited`. +/// - `maximumConcurrentRequests: 64` admits fewer requests than this driver +/// offers, so an unlifted run measures admission queueing. +/// - `concurrencyLimit: 8` per source caps outbound calls in flight, so an +/// unlifted run measures the source semaphore. +/// - `maximumFileBytes: 10485760` rotates the audit segment several times +/// inside a measured window, so an unlifted run measures segment rotation. +/// +/// Deployments should keep the tracked defaults and tune from real traffic. +fn sustained_ceilings() -> FixtureCeilings { + FixtureCeilings { + maximum_concurrent_requests: 512, + audit_maximum_file_bytes: 1_073_741_824, + requests_per_principal_per_minute: 1_000_000, + burst_per_principal: 100_000, + source_concurrency_limit: 256, + } +} + +/// The upstream source used for sustained measurement: one `axum` handler +/// returning a constant JSON body over a real socket. +/// +/// A matching mock server puts request matching, shared-state locking, and +/// per-call allocation on the measured path, and a rate measured through one +/// describes the harness rather than Evidence. This handler matches nothing, +/// locks nothing, and allocates only its response. +struct ConstantSource { + origin: String, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, +} + +async fn start_constant_source() -> ConstantSource { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the constant source listener binds"); + let address = listener + .local_addr() + .expect("the constant source listener has an address"); + let router = axum::Router::new().route( + "/v1/facts", + axum::routing::post(|| async { + // Built explicitly so the response carries exactly one + // `Content-Type`, which is what the source client requires. + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(CONSTANT_SOURCE_BODY)) + .expect("the constant source response builds") + }), + ); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + ConstantSource { + origin: format!("http://{address}"), + shutdown, + serving, + } +} + +/// One flat-out window against one endpoint. Every attempt lands in exactly +/// one of `statuses` or `transport_failures`, so nothing is dropped. +#[derive(Default)] +struct SustainedOutcome { + statuses: BTreeMap, + latencies: Vec, + transport_failures: Vec, + elapsed: Duration, +} + +impl SustainedOutcome { + fn attempted(&self) -> usize { + self.latencies.len() + self.transport_failures.len() + } + + fn released(&self) -> usize { + self.statuses.get(&200).copied().unwrap_or_default() + } + + /// Attempts that did not return 200, including transport failures. + fn unexpected(&self) -> usize { + self.attempted() - self.released() + } + + fn rate(&self) -> f64 { + if self.elapsed.is_zero() { + return 0.0; + } + self.released() as f64 / self.elapsed.as_secs_f64() + } + + /// Nearest-rank p50, p95, and p99 over the whole window. + fn latency_percentiles(&self) -> (Duration, Duration, Duration) { + if self.latencies.is_empty() { + return (Duration::ZERO, Duration::ZERO, Duration::ZERO); + } + let mut sorted = self.latencies.clone(); + sorted.sort_unstable(); + let at = |fraction: f64| { + let rank = (fraction * sorted.len() as f64).ceil() as usize; + sorted[rank.clamp(1, sorted.len()) - 1] + }; + (at(0.50), at(0.95), at(0.99)) + } + + /// A short, operator-readable account of everything that was not a 200. + fn failure_summary(&self) -> String { + let statuses: Vec = self + .statuses + .iter() + .filter(|(status, _)| **status != 200) + .map(|(status, count)| format!("{status} x{count}")) + .collect(); + let mut summary = if statuses.is_empty() { + "no non-2xx statuses".to_owned() + } else { + statuses.join(", ") + }; + if let Some(first) = self.transport_failures.first() { + summary.push_str(&format!( + "; {} transport failures, first: {first}", + self.transport_failures.len() + )); + } + summary + } +} + +/// Issue requests flat out for `window` across `SUSTAINED_CONCURRENCY` closed +/// loop workers. Worker `n` presents `authorizations[n]`, so the caller decides +/// whether load spreads across principals or concentrates on one. +/// +/// Both the service and the constant source are driven through this function +/// with the same client, worker count, header set, and window, so the two +/// reported rates are comparable. +async fn drive_flat_out( + client: &reqwest::Client, + endpoint: &str, + body: &bytes::Bytes, + authorizations: &[String], + window: Duration, +) -> SustainedOutcome { + assert_eq!( + authorizations.len(), + SUSTAINED_CONCURRENCY, + "one authorization per worker" + ); + let endpoint = Arc::new(endpoint.to_owned()); + let started = Instant::now(); + let deadline = started + window; + let mut workers = Vec::with_capacity(SUSTAINED_CONCURRENCY); + for authorization in authorizations { + let client = client.clone(); + let endpoint = Arc::clone(&endpoint); + let body = body.clone(); + let authorization = authorization.clone(); + workers.push(tokio::spawn(async move { + let mut attempts: Vec> = Vec::new(); + while Instant::now() < deadline { + let issued = Instant::now(); + let sent = client + .post(endpoint.as_str()) + .header("authorization", authorization.as_str()) + .header("content-type", "application/json") + .body(body.clone()) + .send() + .await; + attempts.push(match sent { + // Draining the body returns the connection to the pool. A + // body that fails to arrive is a failed request, not a + // successful one with a footnote. + Ok(response) => { + let status = response.status().as_u16(); + match response.bytes().await { + Ok(_) => Ok((status, issued.elapsed())), + Err(error) => Err(error.to_string()), + } + } + Err(error) => Err(error.to_string()), + }); + } + attempts + })); + } + + let mut outcome = SustainedOutcome::default(); + for worker in workers { + for attempt in worker.await.expect("a sustained load worker completes") { + match attempt { + Ok((status, latency)) => { + *outcome.statuses.entry(status).or_default() += 1; + outcome.latencies.push(latency); + } + Err(failure) => outcome.transport_failures.push(failure), + } + } + } + outcome.elapsed = started.elapsed(); + outcome +} + +/// The acceptance runtime behind a real TCP listener, with a constant +/// in-process upstream source, driven by a real HTTP client over sockets. +struct SustainedFixture { + _temporary: TempDir, + source: ConstantSource, + address: std::net::SocketAddr, + client: reqwest::Client, + /// One access token per worker, signed once outside every measured window. + authorizations: Vec, + shutdown: tokio::sync::oneshot::Sender<()>, + serving: tokio::task::JoinHandle>, + runtime: Arc, + audit_path: PathBuf, +} + +impl SustainedFixture { + async fn start() -> Self { + let source = start_constant_source().await; + let prepared = prepare_fixture( + "subject-binding-secret-canary-32-bytes-minimum", + &source.origin, + &sustained_ceilings(), + ); + let runtime = Arc::new( + EvidenceRuntime::initialize_with_authenticator(&prepared.runtime_path, authenticator()) + .await + .expect("the sustained load runtime initializes"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the sustained load listener binds"); + let address = listener + .local_addr() + .expect("the sustained load listener has an address"); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let serving = tokio::spawn({ + let runtime = Arc::clone(&runtime); + async move { + serve_listener_for_test(runtime, listener, async move { + let _ = shutdown_rx.await; + }) + .await + } + }); + + let client = reqwest::Client::builder() + .pool_max_idle_per_host(SUSTAINED_CONCURRENCY) + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .expect("the sustained load client builds"); + + // One principal per worker keeps the rate limiter's tracked key set + // small and its per-check pruning cheap, so neither the token bucket + // nor its map becomes the thing measured. + let authorizations = (0..SUSTAINED_CONCURRENCY) + .map(|worker| { + format!( + "Bearer {}", + access_token_for(&format!("sustained-principal-{worker:04}"), None) + ) + }) + .collect(); + + Self { + _temporary: prepared.temporary, + source, + address, + client, + authorizations, + shutdown, + serving, + runtime, + audit_path: prepared.audit_path, + } + } + + /// Drive the whole Evidence request path for `window`. + async fn drive_service(&self, window: Duration) -> SustainedOutcome { + let body = bytes::Bytes::from( + serde_json::to_vec(&adult_request()).expect("the sustained request serializes"), + ); + drive_flat_out( + &self.client, + &format!("http://{}/v1/evidence", self.address), + &body, + &self.authorizations, + window, + ) + .await + } + + /// Drive the constant source directly to establish the harness ceiling the + /// Evidence result has to sit well below. + /// + /// The probe opens its own connections and gets no warm-up, so it + /// understates the source. That is the safe direction: it can only shrink + /// the reported headroom, never inflate it. + async fn drive_source(&self, window: Duration) -> SustainedOutcome { + let body = bytes::Bytes::from( + serde_json::to_vec(&adult_source_request()).expect("the probe body serializes"), + ); + drive_flat_out( + &self.client, + &format!("{}/v1/facts", self.source.origin), + &body, + &self.authorizations, + window, + ) + .await + } + + /// Stop the listener and the source, drain detached evaluations, and return + /// the durable audit file. + async fn shutdown(self) -> String { + let _ = self.shutdown.send(()); + self.serving + .await + .expect("the sustained listener task completes") + .expect("the sustained listener shuts down cleanly"); + let _ = self.source.shutdown.send(()); + self.source + .serving + .await + .expect("the constant source task completes") + .expect("the constant source shuts down cleanly"); + drop(self.runtime); + fs::read_to_string(&self.audit_path).expect("the sustained audit file is readable") + } +} + +/// Sustain 1000 end-to-end requests per second through the whole request path. +/// +/// Every measured request runs token verification, rate limiting, Rhai request +/// preparation, one outbound source call, Rhai extraction, evidence +/// construction, Ed25519 signing, and two durable audit appends, over real +/// sockets against the real router. At 1000 requests per second that is 2000 +/// audit appends per second. +/// +/// The upstream source is a constant in-process handler whose own ceiling is +/// measured in the same run, under the same client, worker count, header set, +/// and window shape. When that ceiling is not at least +/// `SUSTAINED_SOURCE_HEADROOM` times the Evidence result, the harness is part +/// of what was measured and the run is reported as inconclusive rather than as +/// a pass or a failure. +/// +/// The target holds with and without `--release`; the recorded figures in +/// `products/evidence/OPERATOR-CONTRACT.md` come from the optimized build. +/// +/// ```text +/// cargo test --release -p registry-evidence --lib -- --ignored --nocapture sustained_load_holds_one_thousand_requests_per_second +/// ``` +#[tokio::test(flavor = "multi_thread")] +#[ignore = "opt-in sustained throughput target; host-specific and long running"] +async fn sustained_load_holds_one_thousand_requests_per_second() { + let fixture = SustainedFixture::start().await; + + let warmup = fixture.drive_service(SUSTAINED_WARMUP).await; + assert_eq!( + warmup.unexpected(), + 0, + "warm-up must already be clean; observed {}", + warmup.failure_summary() + ); + + let measured = fixture.drive_service(SUSTAINED_WINDOW).await; + let source = fixture.drive_source(SUSTAINED_WINDOW).await; + + let rate = measured.rate(); + let source_rate = source.rate(); + let headroom = if rate > 0.0 { source_rate / rate } else { 0.0 }; + let percentiles = measured.latency_percentiles(); + println!( + "\n=== Evidence sustained throughput ===\n\ + host : {} logical cores\n\ + offered concurrency : {SUSTAINED_CONCURRENCY} in flight, {} principals\n\ + measured window : {:.1}s after a {:.1}s warm-up\n\ + \n\ + requests released : {} in {:.2}s\n\ + achieved rate : {rate:.0} rps ({:.0} audit appends/s)\n\ + target : {SUSTAINED_TARGET_RPS:.0} rps\n\ + non-2xx and failures : {} ({})\n\ + \n\ + latency p50 : {:.2} ms\n\ + latency p95 : {:.2} ms\n\ + latency p99 : {:.2} ms\n\ + \n\ + constant source rate : {source_rate:.0} rps standalone, {} non-2xx and failures\n\ + source headroom : {headroom:.1}x over Evidence (validity floor {SUSTAINED_SOURCE_HEADROOM:.1}x)\n\ + =====================================\n", + std::thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get), + SUSTAINED_CONCURRENCY, + SUSTAINED_WINDOW.as_secs_f64(), + SUSTAINED_WARMUP.as_secs_f64(), + measured.released(), + measured.elapsed.as_secs_f64(), + rate * 2.0, + measured.unexpected(), + measured.failure_summary(), + percentiles.0.as_secs_f64() * 1000.0, + percentiles.1.as_secs_f64() * 1000.0, + percentiles.2.as_secs_f64() * 1000.0, + source.unexpected(), + ); + + assert_eq!( + measured.unexpected(), + 0, + "sustained load must not shed requests; observed {}", + measured.failure_summary() + ); + assert_eq!( + source.unexpected(), + 0, + "the constant source probe must not shed requests; observed {}", + source.failure_summary() + ); + assert!( + headroom >= SUSTAINED_SOURCE_HEADROOM, + "INCONCLUSIVE: the constant source sustained {source_rate:.0} rps against Evidence at \ + {rate:.0} rps, only {headroom:.1}x. Below {SUSTAINED_SOURCE_HEADROOM:.1}x the harness is \ + part of what was measured, so this run is neither a pass nor a failure" + ); + assert!( + rate >= SUSTAINED_TARGET_RPS, + "sustained {rate:.0} rps, below the {SUSTAINED_TARGET_RPS:.0} rps target" + ); + + // Both audit appends are on the request's critical path, so a released + // assertion that skipped one would be a silently cheaper request. + let released = warmup.released() + measured.released(); + let audit = fixture.shutdown().await; + assert_eq!( + audit.matches("\"phase\":\"access-attempt\"").count(), + released, + "one access-attempt record per released assertion" + ); + assert_eq!( + audit.matches("\"phase\":\"disclosure-release\"").count(), + released, + "one disclosure-release record per released assertion" + ); +} diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index fc60d8097..ff1a5b871 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -124,6 +124,11 @@ fn build_app_with_tracker_at( let request_timeout = Duration::from_millis(listener.request_timeout_milliseconds); let maximum_concurrent_requests = listener.maximum_concurrent_requests as usize; let evaluations = EvaluationTracker::default(); + // Captured before `runtime` moves into the evidence app's state, so the + // metrics registry can sample the same rate limiter the evidence routes + // use, on the separate opt-in metrics listener. + let rate_limiter = runtime.rate_limiter(); + let audit = runtime.audit(); let state = Arc::new(ServerState { runtime, maximum_request_bytes, @@ -145,7 +150,7 @@ fn build_app_with_tracker_at( .fallback(unknown_route) .method_not_allowed_fallback(unknown_route) .with_state(state); - let metrics = Arc::new(Metrics::default()); + let metrics = Arc::new(Metrics::new(rate_limiter, audit)); ( response_layers(routes, Arc::clone(&metrics)), evaluations, diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index dda68fd34..16831dc86 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -264,8 +264,13 @@ fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &Te .expect("test signature was just found"); let prefix = &source[..item_start]; let attribute_window = &prefix[prefix.len().saturating_sub(160)..]; + // The parenthesized form is a test item too. A concurrency invariant can + // only be proven by a test that actually runs on several threads, so + // `#[tokio::test(flavor = "multi_thread", ...)]` has to be traceable. assert!( - attribute_window.contains("#[test]") || attribute_window.contains("#[tokio::test]"), + attribute_window.contains("#[test]") + || attribute_window.contains("#[tokio::test]") + || attribute_window.contains("#[tokio::test("), "{entry_id} reference {} is not a test item", test.name ); diff --git a/crates/registry-platform-audit/src/lib.rs b/crates/registry-platform-audit/src/lib.rs index 56402bb2f..052d15b28 100644 --- a/crates/registry-platform-audit/src/lib.rs +++ b/crates/registry-platform-audit/src/lib.rs @@ -86,7 +86,17 @@ impl AuditEnvelope { Self::new_with_hasher(record, prev_hash, &AuditChainHasher::unkeyed_dev_only()) } - fn new_with_hasher( + /// Build the next envelope in a chain without writing it anywhere. + /// + /// [`ChainState::append`] is the usual way to extend a chain and should be + /// preferred. This exists for a sink that has to own the chain head itself, + /// because it advances the head and claims a place in a pending batch under + /// one lock so that a durable write can cover many records at once. Callers + /// taking that route are responsible for the ordering [`ChainState`] would + /// otherwise guarantee: `prev_hash` must be the previous record's + /// `record_hash`, and `hasher` must be the same hasher for the chain's + /// whole life. + pub fn new_with_hasher( record: Value, prev_hash: Option<[u8; 32]>, hasher: &AuditChainHasher, diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index a02bff752..b2bfdc238 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -185,6 +185,18 @@ request-rate scope deliberately excludes purpose, audience, and requirement so a caller cannot multiply its budget by varying them. Per-client quotas are a gateway responsibility. +Rate limits are tracked per process, in in-process memory, never shared across +replicas. Running N instances behind a load balancer therefore multiplies +every configured limit by N; this matters most for the failed-selector budget, +since that budget is the selector-enumeration defense rather than merely a +throughput knob. A restart also resets every budget to full, because buckets +are keyed on an in-memory monotonic clock rather than persisted. Tracked keys +are bounded at 100,000; a new principal beyond that ceiling is refused with a +capacity error until entries age out of the prune window. Reaching it requires +100,000 distinct authenticated principals within the window, so treat it as a +capacity ceiling worth alerting on rather than a practical denial-of-service +vector. + The listener request timeout bounds admission, concurrency queueing, and body collection. It is not a total evaluation deadline. Once a protected evaluation starts, Evidence lets it finish under the separately bounded OIDC and source @@ -342,6 +354,21 @@ selector profile identifiers and values, source requests and responses, authority grants, Rhai inputs, credentials, tokens, and disclosed values are excluded from logs, metrics, traces, snapshots, panics, and errors. +Audit and operational logging are separate channels and operators must not +confuse them. The audit chain is the accountability record: durable, complete, +tamper-evident, and it has no severity levels and no way to turn records off. +Both records every request writes, the access-attempt event durable before any +source read and the disclosure-release event durable before response release +as described above, are pinned by frozen Version 1 security invariants and are +not configurable. The `tracing` channel is the operational and diagnostic +record: it has levels, it is buffered and lossy, and it is cheap. The rule for +operators and integrators is: accountability facts belong in the audit chain +and never only in tracing, and operational noise belongs in tracing and never +in the audit chain. If an adopter needs more detail than the frozen audit +record carries, which some regulators require, the correct shape is a separate +operational log keyed by the audit record's `eventId`, not a verbosity setting +on the chain. + The serving process writes those records as line-delimited JSON on standard output, one per served request, and `EVIDENCE_LOG` selects verbosity with a default of `info`. Offline commands print their own result and emit no @@ -369,81 +396,171 @@ rotation, and chain verification for the selected durable sink. A deployment profile may require more reviewed metadata or retention, but it cannot silently weaken the native privacy contract. -At process startup the runtime verifies the complete keyed JSONL chain and -captures the exact audit file identity, length, modification fingerprint, and -verified tail. Steady-state appends and readiness probes validate that pinned -identity and fingerprint plus the expected tail and length without rescanning -the growing file. Any external replacement or modification makes readiness and -future appends fail closed. A restart performs the complete keyed-chain -verification again. Operators should also run their governed offline chain -verification during backup, restore, and incident procedures. +Exactly one Evidence process may write a given audit path. The sink takes an +exclusive OS advisory lock on `.lock` at startup; a second +process pointed at the same path fails at startup with a sink-locked error +rather than starting and corrupting the chain. The reason is structural, not +defensive: the audit log is a keyed hash chain whose head is held in process +memory, so two concurrent writers would interleave records and destroy tamper +evidence. Deployment shape follows from this: one replica per audit path, +active/passive rather than active/active. Use a readiness probe and +restart-on-failure to recover from a crashed writer, never a second concurrent +replica. + +At process startup the runtime recovers the chain head from the newest sealed +segment, if one exists, by reading only that segment's last record, then fully +verifies only the active segment from that head. Restart time is therefore +bounded by the active segment rather than by the volume of retained history: +it does not grow as sealed segments accumulate. The accepted tradeoff is that +corruption inside an already sealed segment is not detected at startup; only an +out-of-band verification pass over the whole audit directory, sealed segments +included, detects it. Steady-state appends and readiness probes validate the +active segment's pinned identity and fingerprint plus the expected tail and +length without rescanning the growing file. Any external replacement or +modification of the active segment or the lock file makes readiness and future +appends fail closed. Operators should run that out-of-band verification, +`evidence verify-audit`, covering every segment, during backup, restore, and +incident procedures, and on whatever cadence their audit retention policy +requires; it is what proves sealed history was not tampered with. ## Audit chain rotation and rollback -`auditStorage.maximumFileBytes` is a hard ceiling. The runtime enforces it when -it opens the file at startup, before every append, and on every readiness -probe. A deployment that reaches the ceiling fails closed: appends are refused, -so evidence requests fail, and readiness reports the service unavailable. -Version 1 has no online rotation, so the operator must rotate the chain during -a planned stop before the ceiling is reached. - -Rotation is a stop-and-rename procedure for three reasons. The service holds an -exclusive advisory lock on `.lock` for its whole life, so no -second process can write the same chain. The running process pins the open -audit file by identity and fingerprint, so a rename underneath a running -process makes readiness and every later append fail closed rather than silently -continue. Both the audit file and its lock file must stay owner-only mode -`0600`, singly linked, regular files, so copy-and-truncate, hard links, and -symlinks are all rejected. - -1. Watch the audit file length against `auditStorage.maximumFileBytes` and - schedule the window with headroom. The ceiling is not a rotation trigger; it - is an outage. -2. Stop the service with SIGTERM, which is what a service manager and a - container runtime both send, or with Ctrl-C for an interactive process. The - server stops accepting connections, finishes the evaluations already - admitted, completes their audit writes, and exits successfully. - `listener.shutdownGraceMilliseconds` is the operational target for that - drain, not a cancellation boundary: an evaluation already inside the runtime - is allowed to finish so its audit and signing invariants hold. Confirm the - process has exited before continuing, which is also what releases the lock. -3. Archive the retired chain by rename, preserving owner and mode: - - ```sh - mv /var/lib/registry-evidence/audit/evidence.jsonl \ - /var/lib/registry-evidence/audit/evidence-.jsonl - ``` - - Leave `evidence.jsonl.lock` in place. It carries no chain state. -4. Record the archived file name, its byte length, its final record hash, and - the stop and start times in the operator change record. Each chain file - begins at genesis and is independently verifiable, and nothing inside the - new file points back at the archived one, so this record is the only link - between the retired chain and its successor. Without it the deployment has a - gap it cannot later explain. -5. Start the service again. Startup finds no file at the audit path, creates - one with mode `0600`, and begins a new keyed chain at genesis. -6. Verify before restoring traffic. `GET /ready` must return `200` on the new - chain, and the governed offline chain verification must pass over both the - archived file and the new file. Retain the archived file under the - deployment's audit retention rule. -7. Roll back by repeating step 2, renaming the archived chain back to the audit - path, and starting again. Startup reverifies the complete keyed chain, so a - restored file that was modified refuses to start rather than continuing on a - forked chain. Never concatenate, merge, or edit chain files, and never - restore a chain that a later process has already appended to. - -Readiness behavior across the window is deliberate. The service is stopped for -steps 3 and 4, so `/ready` does not answer at all and the operator must drain -traffic on the stop rather than wait for a readiness signal. After the start it -returns `200` only once the new chain is opened and verified along with the -subject-binding key, the signing provider, and every source credential. - -No event is lost and no record is ambiguous, provided the order above is kept. -The stop is graceful, so an admitted evaluation writes its disclosure-release -event before the process exits. The archive is a rename of an already closed -file, so nothing can be appended to a retired chain. The successor is a new -file starting at genesis, so no record belongs to two chains. +`auditStorage.maximumFileBytes` is a per-segment rotation threshold, not a +total ceiling on the chain. When an append would push the active segment past +it, the runtime seals the active segment and opens a new one at the configured +path, online, with no stop and no operator action. A deployment that reaches +the threshold keeps serving: the next append rotates and continues. Total disk +consumption is therefore unbounded, and retention, meaning how much sealed +history stays on disk and for how long, is entirely the operator's +responsibility; nothing in the runtime deletes or compacts a segment. The one +exception is a single record larger than `maximumFileBytes` on its own: that +record can never fit an empty segment, so it fails closed instead of rotating +forever looking for room it will never find. + +Segments are named by where they sit in the chain, not by when they were made. +The active segment, the one still being appended to, is always at the +configured ``. Each sealed segment is +`.`, where `` is an ascending, +zero-padded, eight-digit number starting at 1, so `evidence.jsonl.00000001` +precedes `evidence.jsonl.00000002` in both chain order and lexical order. +`.lock` is unchanged by any of this: it is the writer's +advisory lock file, never a segment, and carries no chain state. + +The chain spans every seam between segments. The chain head lives in the +running process's memory and survives rotation, so the first record written +into a new active segment carries the previous segment's last record hash as +its `prev_hash`, exactly as if no rotation had happened. A sealed segment is +not an independently verifiable chain that starts at genesis; only the very +first segment a deployment ever writes does that. Verifying a sealed segment on +its own, without the head it continued from, cannot succeed and is not a +supported operation. + +This makes the old stop-and-rename rotation procedure actively dangerous, and +it must not be used. Renaming the active file to an arbitrary name such as +`evidence-.jsonl` moves it outside the +`.` namespace the runtime recognizes, so the +runtime never sees it as a segment of this chain. On restart, startup recovers +the head from the newest segment still named `.`, +which is now the segment before the one that got renamed away, and begins a new +active segment continuing from that older head. The renamed-away file and the +new active segment then both contain a record claiming the same predecessor: a +silent fork, not a rotation, and the two branches are never reconciled. + +To archive sealed history, copy or hard-link sealed segments out to cold +storage, oldest sequence first, and never touch the active segment this way. A +sealed segment can be copied or hard-linked safely while the service keeps +running: the runtime opens the newest sealed segment exactly once, at startup, +to recover the chain head, and never reopens an older sealed segment +afterward. Do not rename a copy back into the `.` +namespace at a sequence that still has a segment on disk; that would collide +with, and could overwrite, real chain history. If a sealed segment is removed +from the audit directory once it has been archived elsewhere, record which +sequence was removed, its byte length, and its final record hash in the +operator change record. A removed segment leaves a gap in the sealed sequence, +and the offline verifier reports that gap explicitly rather than treating it as +silent history loss, but only if there is a record of what should be there to +compare against. + +Prefer archiving older sealed segments and leaving the newest one in place. The +newest sealed segment is what a restart reads to recover the chain head, so +removing it changes what the next start believes the chain continued from. In +the ordinary case that is caught: the active segment's first record names a +predecessor the remaining sealed tail does not match, and startup refuses to +begin on a fork. In the one case where it is not caught, the newest sealed +segment and the active segment are both gone, startup recovers from an older +sealed tail and allocates the next sequence from what is still on disk, so a +future rotation can seal a different segment under a sequence number the +archived one already used. Restoring that archive afterward collides with live +history. If the newest sealed segment must be archived and removed, treat +restoring it as part of the same procedure rather than optional cleanup. + +Out-of-band verification replays every segment across every seam. It is +`evidence verify-audit`, and it reads both the audit storage path and the hash +secret from the same runtime document and file secret provider the serving +process uses. The command takes no path and no secret flags of its own, only +the global `--runtime` (equivalently `REGISTRY_EVIDENCE_RUNTIME`), so it can +never be pointed at an audit chain the deployment does not own and never takes +a secret on a command line: + +``` +evidence --runtime /etc/registry-evidence/runtime.yaml verify-audit +``` + +A pass exits zero and prints `segments`, `records`, `sealed-sequence`, `head`, +and `active-segment`; `sealed-sequence` is the inclusive range of sealed +segment numbers, or `none` before the first rotation. The counts and the head +hash carry no request content, so the report is safe to capture into an +incident record. Any failure exits non-zero. + +Run against a running service, the command verifies sealed history only and +says so in `active-segment`, because reading the active segment while a writer +may be mid-append would race the write and risk reporting a partially written +final record as corruption; that is expected and is not itself a finding. To +prove the active segment too, stop the service first, as under Rollback below. +A gap in the sealed sequence, for example sequence 3 archived and removed while +1, 2, and 4 remain, is reported as a distinct missing-segment result naming the +absent sequence and stating that it is not corruption, so an operator can tell +deliberate archival apart from tampering. A genuine hash break, in the head +continuity between two adjacent sealed segments or within one segment's +records, is reported as chain verification failure and means exactly what it +always has. The same check is available to governed tooling built on the +runtime as the library call `verify_audit_chain`, which reports the equivalent +`first_sequence`, `last_sequence`, and `active_verified` fields directly. + +Rollback divides into restoring sealed history and restoring the active +segment, and only the second needs the service stopped. If a sealed segment was +archived and removed and needs to come back, copy or hard-link it back to its +original `.` name, unmodified; this is safe to do +live, for the same reason archiving is, since the runtime does not reopen old +sealed segments after startup. Restore from a copy whose byte length and final +record hash match what was recorded when it was archived, and re-run the +offline verifier afterward to confirm the gap has closed. Restoring or +replacing the active segment is different, because the running writer pins that +file by identity and inode: any replacement underneath a live process is +rejected by the sink's own pinned-identity check, and readiness and the next +append both fail closed rather than continuing on a file the process no longer +recognizes. To do it safely, stop the service first, with SIGTERM, which is +what a service manager and a container runtime both send, or with Ctrl-C for an +interactive process; the server stops accepting connections, finishes the +evaluations already admitted, completes their audit writes, and exits +successfully, and `listener.shutdownGraceMilliseconds` is the operational +target for that drain rather than a cancellation boundary. Confirm the process +has exited, which is also what releases the exclusive advisory lock on +`.lock`; that lock is why only one Evidence process can ever +write this chain, still held for the writer's whole life, and it is the +structural reason a second writer is refused rather than merely discouraged. +With the service stopped, replace the file at `` with the +restored content, preserving owner and mode `0600`, and start the service +again. Startup recovers the head from the newest sealed segment's tail as +always and verifies only the restored active segment against it, which proves +the restored file continues the chain correctly but proves nothing about sealed +history; run `evidence verify-audit` over the whole audit directory before +restoring traffic if the incident could plausibly have touched a sealed segment +too, while the service is still stopped so the active segment is proven as +well. Never restore an active segment that a later process has already +appended to: its first record's `prev_hash` would no longer match the sealed +tail, and the runtime refuses to start on the resulting fork rather than +silently accepting it. ## Metrics reference @@ -544,11 +661,20 @@ a same-pod or same-host collector is the shape that keeps the operator boundary the operator intended; any wider binding must be closed by a network policy, and the operator owns that control. -The series describe the HTTP boundary only. Version 1 publishes no source-call, -signing, credential-acquisition, or audit-sink series. A slow or failing -upstream source is visible only as evidence-request duration and as the problem -code the boundary returned; audit, signing, and source-credential health are -reported by `/ready` rather than by telemetry. +The two request-boundary series above describe the HTTP boundary only. Version +1 publishes no source-call, signing, credential-acquisition, or audit-sink +series. A slow or failing upstream source is visible only as evidence-request +duration and as the problem code the boundary returned; audit, signing, and +source-credential health are reported by `/ready` rather than by telemetry. + +A third series, `evidence_rate_limiter_tracked_keys`, is also published on the +same listener: a gauge reporting the current number of tracked rate-limit +keys. It carries none of the four request-boundary labels, since it reports a +process-wide capacity fact rather than a per-request outcome. Operators should +alert on it approaching the 100,000-key ceiling described under +[requester authority and purpose](#requester-authority-and-purpose), since a +deployment at that ceiling refuses new principals with a capacity error rather +than degrading gracefully. ## Startup and readiness @@ -675,6 +801,116 @@ request, both the bundle and that grant permit records the refusal phase for after-the-fact diagnosis; the caller never sees it. +## Measured throughput + +One end-to-end measurement is kept in the repository so capacity planning +starts from a number rather than an estimate. It drives the real router over +real sockets, and every request in it runs token verification, rate limiting, +Rhai request preparation, one outbound source call, Rhai extraction, evidence +construction, Ed25519 signing, and both durable audit appends. + +| Measurement | Value | +|---|---| +| Sustained rate | 7057 requests/second | +| Audit appends | 14 115 appends/second (two per request) | +| Latency p50 / p95 / p99 | 17.89 / 21.37 / 23.03 ms | +| Non-2xx responses | 0 | +| Offered concurrency | 128 requests in flight, 128 principals | +| Window | 10 s measured, after a 3 s unmeasured warm-up | +| Host | Apple M5 Max, 18 logical cores, macOS 26.4.1, optimized build | +| Date | 2026-08-03 | + +Reproduce with: + +```bash +cargo test --release -p registry-evidence --lib -- \ + --ignored --nocapture sustained_load_holds_one_thousand_requests_per_second +``` + +The row records one run. An independent repeat of it on the same host measured +6976 requests/second at a p50 of 17.75 ms, so treat the rate as carrying about +a percent of run-to-run variation rather than as an exact figure. The same +check passes on an unoptimized build at 3183 requests/second with a p50 of +40.11 ms. + +The measurement is only meaningful if the upstream source is not the thing +being measured, so the harness serves it from a minimal in-process handler +returning one constant JSON body and measures that handler's own standalone +ceiling in the same run, under the same client, worker count, header set, and +window. That ceiling was 145 273 requests/second, 20.6 times the Evidence +rate. The check refuses to report a pass or a failure below 5 times, and +reports the run as inconclusive instead. + +Latency here is a closed-loop consequence of the offered concurrency: 128 +requests in flight at 7057 requests/second is about 18 ms each. A deployment +offering less concurrency sees lower latency and a lower rate. The audit sink +commits in groups, so its rate rises with the number of appends in flight and +falls sharply when few are; a deployment that expects high throughput must let +requests overlap. + +The harness lifts four production-meaningful defaults that would otherwise +become the thing measured, and lifts them only in its own temporary copy of +the fixture bundle: the per-principal rate limits, `maximumConcurrentRequests`, +each source's outbound `concurrencyLimit`, and the audit segment's +`maximumFileBytes`. Those raised values are measurement scaffolding, not a +recommended deployment posture. Keep the shipped defaults and tune from +observed traffic. + +## Capacity planning + +The measured rate above is one host with one constant source. Sizing a real +deployment is a matter of finding which ceiling binds first, and for most +deployments it is not Evidence. + +Outbound source concurrency binds first whenever the provider is slower than +the in-process handler used for measurement. Each source's `concurrencyLimit` +is the number of requests Evidence will have outstanding to that source at +once, so sustained throughput through it is about `concurrencyLimit` divided by +the source's round-trip latency. A `concurrencyLimit` of 8 against a provider +answering in 20 ms sustains roughly 400 requests/second, and Evidence being +capable of thousands changes nothing about that. The field accepts 1 to 256 and +has no default: every bundle states it explicitly, because the right value is a +claim about what the provider tolerates rather than a number Evidence can pick. +Raising it moves load onto the provider, so raise it against the provider's own +documented or agreed limit, not against Evidence's spare capacity. + +`listener.maximumConcurrentRequests` is the admission ceiling, from 1 to 4096. +It is a semaphore over evaluations already accepted, not a connection limit and +not an instant refusal: a request arriving with every slot taken waits for one +within whatever remains of `listener.requestTimeoutMilliseconds`, and receives +a `503` problem response only if the budget runs out first. Two sizing errors +follow from that. Set well below the source concurrency, it leaves provider +capacity unused, since Evidence will not have enough evaluations in flight to +keep the source busy. Set far above what the sources can absorb, it does not +add throughput; it converts overload into queueing, which the caller sees as +rising latency and then as timeouts. Size it near the total concurrency the +configured sources can actually sustain, and treat `requestTimeoutMilliseconds` +as the decision about how long a caller should wait before being turned away. + +Two ceilings are not configured fields. Worker threads follow the host's +available parallelism, so vertical scaling changes the ceiling that CPU-bound +work, signing and Rhai evaluation, imposes; the runtime document does not carry +a thread count. Offered concurrency is not yours at all: it is what callers +send. The levers here bound what is admitted and what is dispatched onward, +never how much arrives. + +Throughput below expectations is therefore diagnosed by finding the binding +ceiling before changing anything, and the `error` label on +`evidence_http_requests_total` separates the three rejections: `rate_limited` +is the per-principal limiter, `service_unavailable` is the request timeout +budget running out, which under load is normally a request that never got an +admission slot, and `dependency_unavailable` is the source failing rather than +merely being slow. A saturated but healthy source produces +none of those. It appears only as `evidence_http_request_duration_seconds` +rising while the request count stays flat, because Evidence is waiting on the +provider and reporting success when the answer arrives; confirming that +diagnosis needs source latency observed at the provider, which is why the +`concurrencyLimit` arithmetic above is worth doing before traffic rather than +after. Because the audit sink commits in groups, a deployment held to few +requests in flight also pays a higher per-record audit cost than the table +above, which is a consequence of the low concurrency rather than a separate +problem to tune. + ## Verification and release limit A relying party or operator re-verifies a stored signed response offline with diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 950e0550a..54daeb679 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -174,7 +174,7 @@ cross_cutting: negative_test: sec-missing-or-writable-bundle-fails audit_order: threat: Source read or evidence release occurs without a durable accountability record. - enforcement: Access-attempt audit precedes credentials/source; disclosure-release audit follows signing and precedes release; both fail closed. The complete keyed chain is verified at startup, while steady-state readiness and append use pinned identity, modification fingerprint, expected length, and verified tail so audit cost does not grow quadratically. + enforcement: Access-attempt audit precedes credentials/source; disclosure-release audit follows signing and precedes release; both fail closed. The active segment is verified at startup against a chain head recovered from the newest sealed segment's last record, so restart cost is bounded by one segment rather than by all retained history; sealed history is proven out of band by the `verify-audit` command, which replays every segment from the same closed runtime file the service uses and reports a gap in the sealed sequence as archived-or-missing history rather than as corruption. Steady-state readiness and append use pinned identity, modification fingerprint, expected length, and verified tail so audit cost does not grow quadratically. Rotation seals the active segment and starts a new one online, and the in-memory head carries across the seam so the chain is continuous. Concurrent appends claim chain positions in enqueue order under one short lock that performs no input or output, then share one durable write, so a batch never forks the chain and no record is reported durable before its own bytes are synced. A failed durable write leaves the in-memory head ahead of the disk, so the sink refuses every later append for the life of the process rather than chaining onto a record the disk never received. Readiness compares the recorded fingerprint only while nothing is queued and everything enqueued is on disk, because an in-flight write validates its own pinned identity and resulting length; the comparison is read under the lock the writer advances it under, so the service's own traffic is never mistaken for external mutation. negative_test: sec-audit-order-and-failure secret_parsing: threat: YAML substitution or bundle values inject credential or key material. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 498a157d6..7460560f2 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -110,6 +110,20 @@ entries: - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_a_truncated_final_record} - {file: crates/registry-evidence/src/audit.rs, name: restart_rejects_the_wrong_audit_key} - {file: crates/registry-evidence/src/audit.rs, name: same_length_external_mutation_fails_readiness_and_future_appends} + - {file: crates/registry-evidence/src/audit.rs, name: appends_rotate_into_sealed_segments_and_the_chain_spans_the_seam} + - {file: crates/registry-evidence/src/audit.rs, name: a_restart_after_rotation_continues_from_the_sealed_tail} + - {file: crates/registry-evidence/src/audit.rs, name: a_missing_active_segment_recovers_from_the_sealed_tail} + - {file: crates/registry-evidence/src/audit.rs, name: a_corrupt_sealed_tail_is_rejected_at_startup} + - {file: crates/registry-evidence/src/audit.rs, name: sealed_segment_corruption_passes_startup_and_fails_the_verifier} + - {file: crates/registry-evidence/src/audit.rs, name: pathname_replacement_is_rejected_even_when_the_append_would_rotate} + - {file: crates/registry-evidence/src/audit.rs, name: an_archived_middle_segment_is_reported_as_missing_not_as_corruption} + - {file: crates/registry-evidence/src/audit.rs, name: concurrent_appends_extend_one_keyed_chain_without_forking} + - {file: crates/registry-evidence/src/audit.rs, name: concurrent_appends_share_durable_writes} + - {file: crates/registry-evidence/src/audit.rs, name: a_failed_durable_write_poisons_the_sink_instead_of_forking_the_chain} + - {file: crates/registry-evidence/src/audit.rs, name: a_poisoned_sink_fails_concurrent_waiters_instead_of_hanging_them} + - {file: crates/registry-evidence/src/audit.rs, name: readiness_holds_while_appends_are_in_flight} + - {file: crates/registry-evidence/src/main.rs, name: verify_audit_fails_on_sealed_segment_corruption} + - {file: crates/registry-evidence/src/main.rs, name: verify_audit_reports_an_archived_segment_as_missing_not_corrupt} - id: sec-secret-values-rejected-from-bundle tests: [{file: crates/registry-evidence/src/config.rs, name: yaml_names_and_secret_references_are_strict}] - id: sec-proxy-identity-header-rejected From eb182f2695d66b97be2b9562c34ff94db03958fc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:54:01 +0700 Subject: [PATCH 024/136] fix(evidence): validate deployment secret material in check evidence check accepted a deployment whose mounted secrets the server refuses at startup, so a signing key whose kid does not match the bundle's signing.activeKeyId passed check and then failed every start. Check now resolves and validates the audit, subject-binding, and signing material exactly as startup does, without opening the audit chain; source credentials stay unresolved because readiness owns them. Security review notes: startup validation is extracted into validate_secret_material, not altered; check gains read-only secret resolution with the same fixed value-free operator messages; no secret bytes reach output; startup error-class precedence now reports subject-binding and signing faults before audit chain faults when both are broken. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/main.rs | 20 +++- crates/registry-evidence/src/runtime.rs | 121 +++++++++++++++--------- crates/registry-evidence/tests/cli.rs | 96 +++++++++++++++++++ products/evidence/OPERATOR-CONTRACT.md | 7 +- 4 files changed, 197 insertions(+), 47 deletions(-) diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index e236df6e8..b210959f4 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -28,7 +28,10 @@ use registry_evidence::{ }, problem::ProblemCode, rhai_runtime::{DerivedConceptValue, DerivedValue, RequestParts}, - runtime::{source_failure_problem, EvidenceRuntime, RuntimeInitializationError}, + runtime::{ + source_failure_problem, validate_secret_material, EvidenceRuntime, + RuntimeInitializationError, + }, secrets::{SecretProvider, SecretResolver}, selector::{ resolve_offline_fixture_authorization, resolve_offline_fixture_subjects, @@ -75,7 +78,8 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// Validate and compile the complete immutable bundle. + /// Validate and compile the complete immutable bundle, and validate the + /// mounted secret material exactly as startup does. Check, /// Evaluate one bundle-owned fixture without source or credential access. Evaluate { @@ -181,6 +185,18 @@ async fn run(cli: Cli) -> Result { OfflineKernel::compile(Arc::clone(&bundle)) .map_err(|_| CliError("bundle compilation failed"))?; let _source_plans = compile_source_plans(&bundle.config, &runtime)?; + // Deployment secret material is validated exactly as startup + // validates it, without opening the audit chain, so a deployment + // the server would refuse fails check instead of first start. + // Source credentials stay unresolved: readiness owns them. + let secrets = SecretResolver::new( + [SecretProvider::File], + &runtime.config.secret_providers.file.root, + ) + .map_err(|_| runtime_initialization_error(RuntimeInitializationError::Secrets))?; + validate_secret_material(&bundle, &secrets) + .await + .map_err(runtime_initialization_error)?; println!( "Evidence deployment {} / {} passed check ({} requirements)", bundle.revision(), diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 9a7e8401c..807e4dcfd 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -9,6 +9,7 @@ use std::{ }; use chrono::Utc; +use registry_platform_audit::AuditHashSecret; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; use serde_json::{Map as JsonMap, Value}; use thiserror::Error; @@ -67,6 +68,77 @@ pub enum RuntimeInitializationError { RateLimit, } +/// Deployment secret material, resolved and validated exactly as service +/// startup validates it. +pub struct ValidatedSecretMaterial { + pub audit_secret: ProtectedSecret, + pub subject_binding_secret: ProtectedSecret, + pub signer: EvidenceSigner, + pub jwks: JwksDocument, +} + +/// Resolve and validate the audit, subject-binding, and signing secret +/// material a bundle names, with no side effects: nothing is written and no +/// audit chain is opened. Startup builds its runtime state from the returned +/// material, and `check` runs the same validation so a deployment whose +/// mounted secrets the server would refuse fails check instead of first +/// start. Source credentials are deliberately not resolved here: readiness +/// owns them. +pub async fn validate_secret_material( + bundle: &Bundle, + secrets: &SecretResolver, +) -> Result { + let audit_secret = secrets + .resolve(bundle.config.audit.hash_secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Audit)?; + AuditHashSecret::new(audit_secret.expose_secret().to_vec()) + .map_err(|_| RuntimeInitializationError::Audit)?; + + let subject_binding_secret = secrets + .resolve(bundle.config.subject_binding.secret_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Secrets)?; + validate_subject_binding_key( + subject_binding_secret.expose_secret(), + bundle.config.subject_binding.key_version, + &bundle.config.service.trust_domain, + ) + .map_err(|_| RuntimeInitializationError::Secrets)?; + + let signing_secret = secrets + .resolve(bundle.config.signing.active_key_ref.as_str()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let signing_json = str::from_utf8(signing_secret.expose_secret()) + .map_err(|_| RuntimeInitializationError::Signing)?; + let private_jwk = + PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; + let provider = Arc::new( + LocalJwkSigner::new(private_jwk).map_err(|_| RuntimeInitializationError::Signing)?, + ); + let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) + .await + .map_err(|_| RuntimeInitializationError::Signing)?; + let retired = bundle + .retired_public_jwks + .values() + .map(|value| { + serde_json::to_string(value) + .map_err(|_| RuntimeInitializationError::Signing) + .and_then(|json| { + PublicJwk::parse(&json).map_err(|_| RuntimeInitializationError::Signing) + }) + }) + .collect::, _>>()?; + let jwks = jwks_document(signer.public_jwk(), retired) + .map_err(|_| RuntimeInitializationError::Signing)?; + + Ok(ValidatedSecretMaterial { + audit_secret, + subject_binding_secret, + signer, + jwks, + }) +} + /// One safe failure classification for the public HTTP boundary. #[derive(Clone, Copy, PartialEq, Eq)] pub struct RuntimeFailure { @@ -200,55 +272,16 @@ impl EvidenceRuntime { .map_err(|_| RuntimeInitializationError::Secrets)?, ); - let audit_secret = secrets - .resolve(bundle.config.audit.hash_secret_ref.as_str()) - .map_err(|_| RuntimeInitializationError::Audit)?; + let material = validate_secret_material(&bundle, &secrets).await?; let audit = EvidenceAuditLog::initialize( &runtime_config.audit_storage.path, runtime_config.audit_storage.maximum_file_bytes, - audit_secret.expose_secret().to_vec(), + material.audit_secret.expose_secret().to_vec(), bundle.config.audit.hash_key_version, ) .await .map_err(|_| RuntimeInitializationError::Audit)?; - let subject_binding_secret = secrets - .resolve(bundle.config.subject_binding.secret_ref.as_str()) - .map_err(|_| RuntimeInitializationError::Secrets)?; - validate_subject_binding_key( - subject_binding_secret.expose_secret(), - bundle.config.subject_binding.key_version, - &bundle.config.service.trust_domain, - ) - .map_err(|_| RuntimeInitializationError::Secrets)?; - - let signing_secret = secrets - .resolve(bundle.config.signing.active_key_ref.as_str()) - .map_err(|_| RuntimeInitializationError::Signing)?; - let signing_json = str::from_utf8(signing_secret.expose_secret()) - .map_err(|_| RuntimeInitializationError::Signing)?; - let private_jwk = - PrivateJwk::parse(signing_json).map_err(|_| RuntimeInitializationError::Signing)?; - let provider = Arc::new( - LocalJwkSigner::new(private_jwk).map_err(|_| RuntimeInitializationError::Signing)?, - ); - let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) - .await - .map_err(|_| RuntimeInitializationError::Signing)?; - let retired = bundle - .retired_public_jwks - .values() - .map(|value| { - serde_json::to_string(value) - .map_err(|_| RuntimeInitializationError::Signing) - .and_then(|json| { - PublicJwk::parse(&json).map_err(|_| RuntimeInitializationError::Signing) - }) - }) - .collect::, _>>()?; - let jwks = jwks_document(signer.public_jwk(), retired) - .map_err(|_| RuntimeInitializationError::Signing)?; - let mut sources = BTreeMap::new(); for (source_id, source) in bundle.config.sources.iter() { let allowed_selector_sets = bundle.config.source_selector_sets(source_id); @@ -287,9 +320,9 @@ impl EvidenceRuntime { .unwrap_or_else(|| Authenticator::from_config(&bundle.config.authentication)), sources, audit: Arc::new(audit), - signer, - jwks, - subject_binding_secret, + signer: material.signer, + jwks: material.jwks, + subject_binding_secret: material.subject_binding_secret, rate_limiter, }) } diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 8d6cf40df..620aa8a83 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -25,6 +25,8 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) .expect("set private secret-root mode"); + stage_reference_secrets(&secret_root); + let runtime = fs::read_to_string(project.join("runtime.yaml")).expect("read runtime template"); let bundle_path = staged.path().join("bundle"); let bundle_directory = bundle_path.to_str().expect("temporary path is UTF-8"); @@ -68,6 +70,32 @@ fn actual_binary_checks_and_evaluates_an_immutable_project() { ); } +/// Stage the platform secrets the reference project's bundle names, with a +/// signing key generated for this run under the bundle's `activeKeyId`. +/// Source credentials stay absent: `check` must not resolve them. +fn stage_reference_secrets(secret_root: &Path) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let write = |name: &str, value: &str| { + let path = secret_root.join(name); + fs::write(&path, value).expect("write reference secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only secret mode"); + }; + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"evidence-signing-2026-01","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + write("audit-hmac-key", "audit-hash-secret-32-bytes-minimum-value"); + write( + "subject-binding-hmac-key", + "subject-binding-secret-32-bytes-minimum-value", + ); + write("signing-ed25519-private-jwk", &private_jwk); +} + /// One deployment failure class, with the exact operator text it must produce. /// /// The expected text is split into a prefix and a suffix so a case that @@ -270,6 +298,60 @@ fn check_names_a_safe_artifact_and_a_value_free_cause_for_every_failure_class() } } +/// Secret material the server would refuse at startup must already fail +/// `check`, with the same fixed operator message startup produces. Each case +/// stages the complete acceptance secret set and then breaks exactly one +/// piece of it. +#[test] +fn check_rejects_secret_material_the_server_would_refuse_at_startup() { + struct SecretFailureCase { + label: &'static str, + break_secrets: fn(&Deployment), + expected: &'static str, + } + let cases = [ + SecretFailureCase { + label: "signing key kid differs from the bundle's activeKeyId", + break_secrets: |deployment| deployment.write_mismatched_signing_key(), + expected: "evidence: runtime signing initialization failed\n", + }, + SecretFailureCase { + label: "audit hash key below the minimum length", + break_secrets: |deployment| deployment.write_secret("audit-hash-key", "short"), + expected: "evidence: runtime audit initialization failed\n", + }, + SecretFailureCase { + label: "subject binding key missing", + break_secrets: |deployment| deployment.remove("secrets/subject-binding-key"), + expected: "evidence: runtime secret initialization failed\n", + }, + ]; + + for case in cases { + let deployment = Deployment::stage("all-definitions"); + deployment.stage_acceptance_secrets(); + (case.break_secrets)(&deployment); + let output = deployment.check(); + + assert!( + !output.status.success(), + "{}: check accepted secret material the server would refuse", + case.label + ); + assert!( + output.stdout.is_empty(), + "{}: check wrote output for a refused deployment", + case.label + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, case.expected, + "{}: unexpected diagnostic", + case.label + ); + } +} + /// The documented audit rotation procedure, executed against the real binary. /// /// The procedure is: stop the service with SIGTERM, archive the audit file by @@ -938,6 +1020,20 @@ outboundTls: self.write_secret("source-d-token", "synthetic-source-token"); } + /// Overwrite the staged signing key with a fresh key whose kid is not + /// the bundle's `signing.activeKeyId`. + fn write_mismatched_signing_key(&self) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng); + let private_jwk = format!( + r#"{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"not-the-active-key","d":"{}","x":"{}"}}"#, + URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()) + ); + self.write_secret("signing-key", &private_jwk); + } + /// Start `serve` against the sealed deployment. fn serve(&self) -> Child { Command::new(env!("CARGO_BIN_EXE_evidence")) diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index b2bfdc238..b2dcd2eb6 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -709,7 +709,12 @@ keys appear at the JWKS endpoint. The audit JSONL path must be on storage whose append durability, permissions, capacity, backup, restore, retention, and keyed chain verification the operator owns. -`evidence check` validates and compiles the complete bundle. Fixture evaluation +`evidence check` validates and compiles the complete bundle, and resolves and +validates the mounted audit, subject-binding, and signing secret material +exactly as startup does, without opening the audit chain. A deployment whose +secret material startup would refuse, including a signing key whose `kid` does +not match `signing.activeKeyId`, fails check. Source credentials are not +resolved by check; readiness owns them. Fixture evaluation covers positive, negative, boundary, missing-data, source-failure, existence-disclosure, and anti-reconstruction behavior without a running source. From 7e6fe355d1578f58aede6741c6697e0a75c0ffcc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:54:13 +0700 Subject: [PATCH 025/136] feat(evidence): add a compose deployment for the adopter images A static reference docker-compose for the evidence service and the optional mint pairing: static private addresses on a user-defined network because the listener refuses wildcard binds, the deployment project mounted read-only with a container-shaped runtime file overlaid on the project's host-shaped one, and the audit chain in a named volume. The files are adopter-owned after copying; there is no generation contract. Verified end to end against the locally built images: check, both health endpoints, and audit persistence across a restart. Signed-off-by: Jeremi Joslin --- docker/compose/README.md | 56 ++++++++++++++++++++++ docker/compose/docker-compose.yaml | 76 ++++++++++++++++++++++++++++++ docker/compose/runtime.docker.yaml | 39 +++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 docker/compose/README.md create mode 100644 docker/compose/docker-compose.yaml create mode 100644 docker/compose/runtime.docker.yaml diff --git a/docker/compose/README.md b/docker/compose/README.md new file mode 100644 index 000000000..1aa8e2ecd --- /dev/null +++ b/docker/compose/README.md @@ -0,0 +1,56 @@ +# Compose deployment + +A static reference deployment for the images `docker/README.md` builds: +the `evidence` service, and optionally `mint` beside it. Copy this +directory into your operations tree and edit it there, or run it in place; +either way the files are yours after that, there is no regeneration +contract. + +## Layout + +- `docker-compose.yaml` wires one user-defined network with a static + private address for each service, mounts your deployment project + read-only, and keeps the audit chain in a named volume. +- `runtime.docker.yaml` is the container-shaped runtime file. Compose + mounts it over your project's host-shaped `runtime.yaml`, so the same + project directory serves local runs and container runs without edits. + Both files bind the same bundle bytes; only the environment differs. + +## Run + +Build the images from the repository root (see `docker/README.md`), then +point the compose file at a provisioned evidencectl project: + +```sh +EVIDENCE_PROJECT_DIR=/path/to/project docker compose -f docker-compose.yaml up +``` + +The project must already hold its bundle and key material +(`evidencectl new`, `evidencectl keygen ...`). The read-only project mount +satisfies the runtime's refusal of writable deployment inputs, so the +container path needs no host-side freeze; freezing the project on disk +remains good practice for the host path. + +Neither image declares a Docker `HEALTHCHECK` (distroless, no shell). +Probe `GET /health` over HTTP from your orchestrator, or uncomment the +`ports:` lines to reach the listeners from the host. + +## Mint + +For a project scaffolded with `--with-mint`, start both services: + +```sh +EVIDENCE_PROJECT_DIR=/path/to/project docker compose -f docker-compose.yaml --profile mint up +``` + +Set `listener.address` in the project's `mint/mint.yaml` to `0.0.0.0` (or +the static `172.28.0.11`) first; the scaffolded default of `127.0.0.1` is +unreachable from outside the container. Mint's other values (issuer, +audiences, claim names) already pair with the bundle and need no change. + +## What this is not + +TLS termination and public exposure stay upstream, behind your operator +network's proxy, which is why nothing here publishes a port by default. +These images and this file are not release artifacts; released images +follow the `release/docker/` reproducible-build path. diff --git a/docker/compose/docker-compose.yaml b/docker/compose/docker-compose.yaml new file mode 100644 index 000000000..142bd58f4 --- /dev/null +++ b/docker/compose/docker-compose.yaml @@ -0,0 +1,76 @@ +# Compose deployment for the adopter and development images built by +# docker/Dockerfile (see docker/README.md for the build commands and the +# container contract this file encodes). These images are not release +# evidence, and neither is this file. +# +# Required environment: +# EVIDENCE_PROJECT_DIR absolute path to an evidencectl deployment project +# (the directory holding runtime.yaml, bundle/ and +# secrets/) +# +# Optional environment: +# EVIDENCE_IMAGE image tag for the evidence service +# MINT_IMAGE image tag for the mint service +# MINT_PROJECT_DIR mint configuration directory; defaults to the +# mint/ directory a `--with-mint` scaffold creates +# inside the project +# +# The mint service only starts under the `mint` profile: +# docker compose --profile mint up +name: registry-evidence + +services: + evidence: + image: ${EVIDENCE_IMAGE:-registry-evidence} + read_only: true + restart: unless-stopped + volumes: + # The whole deployment project, read-only. The read-only mount is what + # satisfies the runtime's refusal of writable deployment inputs; no + # host-side freeze is required for the container path. + - ${EVIDENCE_PROJECT_DIR:?set EVIDENCE_PROJECT_DIR to your evidencectl deployment project directory}:/etc/registry-evidence:ro + # The container-shaped runtime file beside this compose file overlays + # the project's host-shaped runtime.yaml, so the project on disk needs + # no edits to run in a container. + - ./runtime.docker.yaml:/etc/registry-evidence/runtime.yaml:ro + # The append-only audit chain must outlive the container. + - evidence-audit:/var/lib/registry-evidence + networks: + evidence-net: + # The listener refuses wildcard and public bind addresses, so the + # service binds this static private address; runtime.docker.yaml's + # listener.bindHost must stay equal to it. + ipv4_address: 172.28.0.10 + # To reach the listener from the host, publish it explicitly; TLS and + # public exposure stay upstream concerns for the operator's proxy: + # ports: + # - "127.0.0.1:8080:8080" + + mint: + profiles: [mint] + image: ${MINT_IMAGE:-registry-mint} + read_only: true + restart: unless-stopped + environment: + # The scaffold names the configuration mint.yaml; the image default + # expects config.yaml. + MINT_CONFIG: /etc/registry-mint/mint.yaml + volumes: + - ${MINT_PROJECT_DIR:-${EVIDENCE_PROJECT_DIR}/mint}:/etc/registry-mint:ro + networks: + evidence-net: + ipv4_address: 172.28.0.11 + # Mint has no bind-address restriction, but the scaffolded mint.yaml + # listens on 127.0.0.1; set its listener.address to 0.0.0.0 (or + # 172.28.0.11) for container use. + # ports: + # - "127.0.0.1:8081:8081" + +networks: + evidence-net: + ipam: + config: + - subnet: 172.28.0.0/24 + +volumes: + evidence-audit: diff --git a/docker/compose/runtime.docker.yaml b/docker/compose/runtime.docker.yaml new file mode 100644 index 000000000..a1bc508c9 --- /dev/null +++ b/docker/compose/runtime.docker.yaml @@ -0,0 +1,39 @@ +# Container-shaped runtime configuration for the compose deployment beside +# this file. It is not governed content: it binds a reviewed bundle to one +# environment, exactly like the host-shaped runtime.yaml an evidencectl +# scaffold writes, over the same bundle bytes. +# +# docker-compose.yaml mounts this file over the project's runtime.yaml, so +# every path below is a container path fixed by the image contract: +# deployment inputs under /etc/registry-evidence, writable audit state under +# /var/lib/registry-evidence. The bindHost must stay equal to the static +# address docker-compose.yaml assigns the evidence service. +version: 1 + +bundleDirectory: /etc/registry-evidence/bundle + +listener: + bindHost: 172.28.0.10 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 + +# Secret files are read from this directory only: the project's secrets/ +# directory, arriving through the read-only project mount. +secretProviders: + file: + root: /etc/registry-evidence/secrets + +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 + +# Add a trust profile here only when a bundle source declares one; the two +# sets must match exactly. +outboundTls: + systemRoots: true + trustProfiles: {} From f69040d0c6eb3e177a4c0fa2ee938f80a1643e46 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:58:11 +0700 Subject: [PATCH 026/136] docs(evidence): route compose traffic on readiness, not liveness Signed-off-by: Jeremi Joslin --- docker/compose/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docker/compose/README.md b/docker/compose/README.md index 1aa8e2ecd..ab37d7137 100644 --- a/docker/compose/README.md +++ b/docker/compose/README.md @@ -32,8 +32,10 @@ container path needs no host-side freeze; freezing the project on disk remains good practice for the host path. Neither image declares a Docker `HEALTHCHECK` (distroless, no shell). -Probe `GET /health` over HTTP from your orchestrator, or uncomment the -`ports:` lines to reach the listeners from the host. +Probe over HTTP from your orchestrator: `GET /health` is liveness only, +and `GET /ready` is the gate that fails closed while any required +secret or source credential is absent, so route traffic on `/ready`. +Uncomment the `ports:` lines to reach the listeners from the host. ## Mint From 8927af5fcc9750939edf15ae5df0dc63d14b646d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 01:58:57 +0700 Subject: [PATCH 027/136] feat: add distroless adopter and development images for mint and evidence One multi-stage Dockerfile built from the repository root, with a cargo-chef recipe per binary so dependency compilation is a cacheable layer and an unrelated workspace manifest change invalidates neither image. Both images run as the distroless nonroot user with no shell. These are not release evidence; released images keep following the release/docker reproducible-build path. Signed-off-by: Jeremi Joslin --- .dockerignore | 5 +++ docker/Dockerfile | 99 +++++++++++++++++++++++++++++++++++++++++++++++ docker/README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 .dockerignore create mode 100644 docker/Dockerfile create mode 100644 docker/README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3a329f85c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.cargo-home +target +scratch +docs/site/node_modules diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..d731305cc --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,99 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +# SPDX-License-Identifier: Apache-2.0 + +# Adopter and development images for the `mint` and `evidence` services. +# +# These are not release evidence. Release images are assembled from +# release/docker with binaries produced outside Docker by +# release/scripts/build-release-binaries.sh under the pinned builder image. +# +# Build from the repository root: +# +# docker build -f docker/Dockerfile --target mint -t registry-mint . +# docker build -f docker/Dockerfile --target evidence -t registry-evidence . +# +# cargo-chef turns dependency compilation into an ordinary image layer keyed +# on the manifests and lockfile alone, so registry and CI layer caches +# survive source-only changes. Each binary gets its own recipe, filtered to +# its transitive dependency closure, so a manifest change in an unrelated +# workspace member invalidates neither image. + +# Keep the tag for humans and the digest for reproducible pulls. +FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS chef +RUN cargo install cargo-chef --locked --version 0.1.77 +WORKDIR /workspace + +# The planner reruns on any source change, but that is cheap: the recipes it +# emits depend only on the manifests and lockfile, so unchanged recipes keep +# the cook layers below cached. +FROM chef AS planner +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY products ./products +RUN cargo chef prepare --recipe-path recipe-mint.json --bin mint \ + && cargo chef prepare --recipe-path recipe-evidence.json --bin evidence + +FROM chef AS mint-builder +COPY --from=planner /workspace/recipe-mint.json recipe.json +# No --locked here: the bin-filtered recipe carries a lockfile trimmed to the +# closure, which cargo would refuse to reconcile. The final build is --locked. +RUN cargo chef cook --release --recipe-path recipe.json --bin mint +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY products ./products +RUN cargo build --release --locked -p registry-mint --bin mint \ + && mkdir -p /workspace/runtime-root/etc/registry-mint \ + && chown -R 65532:65532 /workspace/runtime-root + +# Distroless cc keeps glibc and CA certificates while dropping shell/package tools. +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS mint + +COPY --from=mint-builder /workspace/runtime-root/ / +COPY --from=mint-builder /workspace/target/release/mint /usr/local/bin/mint +COPY LICENSE /licenses/registry-mint/LICENSE + +# The configuration is a startup-only deployment artifact; mount it read-only +# at /etc/registry-mint. Key material and the client registry live beside it +# at whatever paths the configuration names. +ENV MINT_CONFIG=/etc/registry-mint/config.yaml +EXPOSE 8081 + +# No HEALTHCHECK: distroless has no shell or curl and the binary has no +# healthcheck subcommand. The service serves GET /health for HTTP probes. +ENTRYPOINT ["/usr/local/bin/mint"] +CMD ["serve"] + +FROM chef AS evidence-builder +COPY --from=planner /workspace/recipe-evidence.json recipe.json +# No --locked here: the bin-filtered recipe carries a lockfile trimmed to the +# closure, which cargo would refuse to reconcile. The final build is --locked. +RUN cargo chef cook --release --recipe-path recipe.json --bin evidence +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY products ./products +RUN cargo build --release --locked -p registry-evidence --bin evidence \ + && mkdir -p \ + /workspace/runtime-root/etc/registry-evidence \ + /workspace/runtime-root/var/lib/registry-evidence/audit \ + && chown -R 65532:65532 /workspace/runtime-root + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS evidence + +COPY --from=evidence-builder /workspace/runtime-root/ / +COPY --from=evidence-builder /workspace/target/release/evidence /usr/local/bin/evidence +COPY LICENSE /licenses/registry-evidence/LICENSE + +WORKDIR /var/lib/registry-evidence + +# The runtime file, governed bundle, and secret root are startup-only +# deployment artifacts; mount them read-only under /etc/registry-evidence. +# Point the runtime's audit destination under /var/lib/registry-evidence, +# which is writable by the nonroot user. +ENV REGISTRY_EVIDENCE_RUNTIME=/etc/registry-evidence/runtime.yaml +EXPOSE 8080 + +# No HEALTHCHECK: distroless has no shell or curl and the binary has no +# healthcheck subcommand. The service serves GET /health for HTTP probes. +ENTRYPOINT ["/usr/local/bin/evidence"] +CMD ["serve"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..8b31dc787 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,96 @@ +# Adopter and development images + +Distroless container images for the `mint` and `evidence` services, built +entirely inside Docker from the repository root: + +```sh +docker build -f docker/Dockerfile --target mint -t registry-mint . +docker build -f docker/Dockerfile --target evidence -t registry-evidence . +``` + +These images are **not release evidence**. The released Notary and Relay +images are assembled from `release/docker/` with byte-reproducible binaries +built outside Docker by `release/scripts/build-release-binaries.sh`; if Mint +or Evidence images ever become release artifacts, they follow that path, not +this one. + +## Build architecture + +Both targets share one multi-stage file using +[cargo-chef](https://github.com/LukeMathWalker/cargo-chef) so dependency +compilation is an ordinary image layer, cacheable by registry-backed or CI +layer caches across ephemeral runners (which BuildKit `--mount=type=cache` +mounts are not). Each binary has its own recipe filtered to its transitive +dependency closure (`cargo chef prepare --bin`), so a manifest change in an +unrelated workspace member (for example Relay) invalidates neither image, and +neither image compiles heavyweight dependencies it does not use. + +The builder and runtime base images are pinned to the same digests the rest +of the repository uses: `rust:1.95-trixie` and +`gcr.io/distroless/cc-debian13:nonroot`. Both final images run as the +distroless `nonroot` user (65532) with no shell or package tools. + +## Running mint + +The configuration is a startup-only artifact. Mount it read-only at +`/etc/registry-mint` with the signing key and client registry beside it, at +the paths the configuration names (relative paths resolve against the +configuration file's directory). The listener address in the configuration +must be an IP the container can bind, for example `0.0.0.0`: + +```sh +docker run --rm \ + -v "$PWD/deploy/mint:/etc/registry-mint:ro" \ + -p 8081:8081 \ + registry-mint +``` + +`mint check` validates a deployment without opening a socket: + +```sh +docker run --rm -v "$PWD/deploy/mint:/etc/registry-mint:ro" registry-mint check +``` + +## Running evidence + +The runtime file, governed bundle, and secret root are startup-only +artifacts; mount them read-only under `/etc/registry-evidence`. The image +expects the operator runtime at `/etc/registry-evidence/runtime.yaml` +(`REGISTRY_EVIDENCE_RUNTIME`). The runtime's `bundleDirectory`, secret +provider `root`, audit `path`, and any `trustProfiles.*.caBundleFile` are +validated absolute paths, interpreted inside the container: every one of +them must resolve to a mount. Point the audit destination under +`/var/lib/registry-evidence`, which is writable by the nonroot user, and +give it a named volume; the audit chain is append-only state that must +outlive the container. + +The evidence and metrics listeners deliberately refuse wildcard and public +bind addresses: `bindHost` must be loopback, RFC 1918 private IPv4, or IPv6 +unique-local. `0.0.0.0` will not start, and `127.0.0.1` is unreachable +through Docker port publishing. Give the container a static private address +on a user-defined network and bind that: + +```sh +docker network create --subnet 172.28.0.0/24 registry-evidence-net +docker run --rm \ + --network registry-evidence-net --ip 172.28.0.10 \ + -v "$PWD/deploy/evidence:/etc/registry-evidence:ro" \ + -v evidence-audit:/var/lib/registry-evidence \ + registry-evidence +``` + +with `listener.bindHost: 172.28.0.10` in `runtime.yaml`. TLS and public +exposure are upstream concerns by design; front this listener with your +operator-network proxy. `evidence check` validates the bundle without +serving: + +```sh +docker run --rm -v "$PWD/deploy/evidence:/etc/registry-evidence:ro" registry-evidence check +``` + +## Health probes + +Neither image declares a Docker `HEALTHCHECK`: distroless has no shell or +curl, and neither binary has a healthcheck subcommand (the released Notary +and Relay binaries do). Both services serve `GET /health` on their listener; +use HTTP probes from your orchestrator. From 1d48796cc511d839c2f689625d6ffa8bc79741fa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:07:46 +0700 Subject: [PATCH 028/136] feat(evidence): declare source response shapes and add get_path Every source now carries a required responseSchema: a closed JSON Schema for the projected response, validated in Rust after projection and before the response reaches an extraction script. A response outside the shape its adapter was reviewed against is a source-protocol failure and no script runs, so hand-written protocol checking is no longer the only thing between a malformed response and fact construction. The response role relaxes two rules the fact and adapter-parameter roles keep, because the projected tree is not the wire response. It may require fewer members than it declares properties, since projection drops a leaf the record did not carry and an ambiguous page is decided before any record is read. A node may also write its type as the pair [T, "null"], because a source that reports an explicit null has that null carried through projection verbatim; it reaches the script as the same unit marker is_missing already reads. That pair is the only union the subset admits and only in this role. get_path resolves one bounded RFC 6901 pointer and answers with the missing marker, replacing chains of contains and type_of in the migrated adapters. A pointer that is not resolvable syntax, exceeds 256 bytes, or exceeds 16 segments is a script fault and fails the invocation rather than answering missing. Security-sensitive: this narrows what reaches bounded script execution and adds one enforcement point before extraction. It changes no authentication, authorization, signing, audit, or disclosure behaviour, and every source protocol failure stays publicly indistinguishable. Tracked as the source_response_shape invariant with a bound negative test. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/bundle.rs | 112 ++++++++-- crates/registry-evidence/src/config.rs | 89 +++++++- crates/registry-evidence/src/kernel.rs | 63 ++++++ crates/registry-evidence/src/rhai_runtime.rs | 211 +++++++++++++++++- crates/registry-evidence/src/runtime_tests.rs | 4 +- crates/registry-evidence/src/source.rs | 2 + .../tests/source_contracts.rs | 30 +++ crates/registry-evidencectl/src/scaffold.rs | 6 +- .../registry-evidencectl/templates/README.md | 2 +- .../bundle/adapters/source-a-extract.rhai | 26 ++- .../templates/bundle/evidence.yaml | 2 + .../bundle/schemas/response.schema.yaml | 19 ++ .../evidence-bundle/adapters/demo-source.rhai | 18 +- .../demo/evidence-bundle/evidence.yaml | 1 + .../schemas/response.schema.yaml | 6 + products/evidence/CONCEPT.md | 10 + .../evidence/contracts/bundle.schema.yaml | 3 +- .../evidence/contracts/primitive-library.yaml | 5 + products/evidence/contracts/rhai-abi.yaml | 2 +- .../contracts/security-invariant-matrix.yaml | 4 + .../contracts/security-test-traceability.yaml | 4 + .../evidence/contracts/source-contract.yaml | 15 +- .../adult-status/adapters/source-a.rhai | 18 +- .../acceptance/adult-status/evidence.yaml | 1 + .../adult-status/schemas/response.schema.yaml | 6 + .../adapters/adult-status-source.rhai | 12 +- .../legal-parent-relationship-source.rhai | 15 +- .../adapters/professional-licence-source.rhai | 14 +- .../adapters/residence-region-source.rhai | 12 +- .../acceptance/all-definitions/evidence.yaml | 4 + .../schemas/adult-status-response.schema.yaml | 6 + ...l-parent-relationship-response.schema.yaml | 27 +++ .../professional-licence-response.schema.yaml | 17 ++ .../residence-region-response.schema.yaml | 6 + .../adapters/source-d.rhai | 44 +--- .../legal-parent-relationship/evidence.yaml | 1 + .../schemas/response.schema.yaml | 27 +++ .../adapters/source-c.rhai | 26 +-- .../professional-licence/evidence.yaml | 1 + .../schemas/response.schema.yaml | 17 ++ .../residence-region/adapters/source-b.rhai | 18 +- .../acceptance/residence-region/evidence.yaml | 1 + .../schemas/response.schema.yaml | 6 + .../conformance/selectors/evidence.yaml | 5 + .../selectors/schemas/response.schema.yaml | 5 + .../supported-values/evidence.yaml | 1 + .../schemas/response.schema.yaml | 6 + .../dhis2-tracker-style/contract.yaml | 1 + .../schemas/response.schema.yaml | 51 +++++ .../source-shapes/flat-rest/contract.yaml | 1 + .../flat-rest/schemas/response.schema.yaml | 34 +++ .../contract.yaml | 1 + .../schemas/response.schema.yaml | 23 ++ .../reference/request-adapter/ADAPTER-API.md | 32 +++ .../deployment-projects/CONFIG.md | 20 ++ .../bundle/adapters/adult-status-extract.rhai | 49 +--- .../professional-licence-extract.rhai | 84 ++----- .../bundle/evidence.yaml | 4 + .../schemas/adult-status-response.schema.yaml | 60 +++++ .../professional-licence-response.schema.yaml | 100 +++++++++ .../bundle/adapters/birth-adult-extract.rhai | 28 +-- .../adapters/birth-parents-extract.rhai | 31 +-- .../bundle/evidence.yaml | 4 + .../schemas/birth-adult-response.schema.yaml | 39 ++++ .../birth-parents-response.schema.yaml | 49 ++++ .../dhis2-tracker/extract.rhai | 39 +--- .../dhis2-tracker/response.schema.yaml | 37 +++ .../request-adapter/dhis2-tracker/source.yaml | 1 + .../opencrvs-event-search/extract.rhai | 30 +-- .../response.schema.yaml | 28 +++ .../opencrvs-event-search/source.yaml | 1 + 71 files changed, 1320 insertions(+), 357 deletions(-) create mode 100644 crates/registry-evidencectl/templates/bundle/schemas/response.schema.yaml create mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/adult-status/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/professional-licence/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/acceptance/residence-region/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/conformance/selectors/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/conformance/supported-values/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/flat-rest/schemas/response.schema.yaml create mode 100644 products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index ad8f1b8c3..8c082b2dd 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -640,6 +640,7 @@ fn validate_file_closure( expected.insert(source.request.prepare_script.as_str().to_owned()); expected.insert(source.extract_script.as_str().to_owned()); expected.insert(source.request.adapter_parameters_schema.as_str().to_owned()); + expected.insert(source.response_schema.as_str().to_owned()); expected.insert(source.fact_schema.as_str().to_owned()); } for requirement in &config.requirements { @@ -906,6 +907,20 @@ fn reject_prohibited_script_capabilities(source: &str) -> Result<(), BundleError Ok(()) } +/// Which contract one bundle schema artifact carries. Configuration keeps the +/// three roles disjoint, so every path resolves to exactly one of them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SchemaRole { + /// Closed startup parameters; the empty parameter set is legitimate. + AdapterParameters, + /// Shape of one projected source response. Projection drops a missing + /// selected leaf, so a declared property may legitimately be absent. + Response, + /// Closed fact set handed to derivation, and the reviewed concept schemas held + /// to the same rule; every declared property is required. + Facts, +} + fn load_fact_schemas( config: &EvidenceConfig, files: &BTreeMap>, @@ -915,12 +930,18 @@ fn load_fact_schemas( .iter() .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) .collect::>(); + let response_paths = config + .sources + .iter() + .map(|(_, source)| source.response_schema.as_str()) + .collect::>(); let mut paths = config .sources .iter() .flat_map(|(_, source)| { [ source.fact_schema.as_str(), + source.response_schema.as_str(), source.request.adapter_parameters_schema.as_str(), ] }) @@ -929,9 +950,15 @@ fn load_fact_schemas( paths.extend(reviewed_schema_paths(config, files)?); let mut schemas = BTreeMap::new(); for path in paths { - let is_parameter_schema = parameter_paths.contains(path.as_str()); - let schema = load_fact_schema(&path, is_parameter_schema, files) - .map_err(|error| error.in_artifact(&path))?; + let role = if parameter_paths.contains(path.as_str()) { + SchemaRole::AdapterParameters + } else if response_paths.contains(path.as_str()) { + SchemaRole::Response + } else { + SchemaRole::Facts + }; + let schema = + load_fact_schema(&path, role, files).map_err(|error| error.in_artifact(&path))?; schemas.insert(path, schema); } for (_, source) in config.sources.iter() { @@ -944,7 +971,7 @@ fn load_fact_schemas( fn load_fact_schema( path: &str, - is_parameter_schema: bool, + role: SchemaRole, files: &BTreeMap>, ) -> Result { let bytes = files @@ -954,7 +981,7 @@ fn load_fact_schema( std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fact schema is not UTF-8"))?; let schema: JsonValue = serde_norway::from_str(text) .map_err(|_| invalid_artifact("fact schema YAML is invalid"))?; - validate_closed_schema(&schema, is_parameter_schema)?; + validate_closed_schema(&schema, role)?; JSONSchema::options() .with_draft(Draft::Draft202012) .should_validate_formats(true) @@ -985,7 +1012,8 @@ fn validate_adapter_parameters( Ok(()) } -fn validate_closed_schema(schema: &JsonValue, allow_empty_root: bool) -> Result<(), BundleError> { +fn validate_closed_schema(schema: &JsonValue, role: SchemaRole) -> Result<(), BundleError> { + let allow_empty_root = role == SchemaRole::AdapterParameters; let root = schema .as_object() .ok_or(invalid_artifact("fact schema must be an object"))?; @@ -1013,23 +1041,63 @@ fn validate_closed_schema(schema: &JsonValue, allow_empty_root: bool) -> Result< .map(JsonValue::as_str) .collect::>>() .ok_or(invalid_artifact("fact schema required fields are invalid"))?; - if required.len() != properties.len() - || properties - .keys() - .any(|property| !required.contains(property.as_str())) + if required + .iter() + .any(|field| !properties.contains_key(*field)) + || (role != SchemaRole::Response + && properties + .keys() + .any(|property| !required.contains(property.as_str()))) { return Err(invalid_artifact( "fact schema must require its exact closed field set", )); } - validate_schema_node(schema) + validate_schema_node(schema, role) +} + +/// Reads the one type a schema node declares, and returns `None` for a node that +/// declares a bounded const instead. +/// +/// A response schema may write that type as the pair `[T, "null"]`. Sources do +/// report an explicit null where they hold no value, and the projection carries +/// that null through verbatim, so a response shape has to be able to say so. The +/// pair is the only union the subset admits, and only in the response role: a +/// fact or an adapter parameter is never null. `null` reaches the script as the +/// same unit marker `is_missing` already reads, so one script test covers both an +/// absent leaf and an explicitly null one. +fn schema_node_type( + object: &JsonMap, + role: SchemaRole, +) -> Result, BundleError> { + match object.get("type") { + Some(JsonValue::String(name)) => Ok(Some(name.as_str())), + Some(JsonValue::Array(members)) => { + let [JsonValue::String(name), JsonValue::String(null_member)] = members.as_slice() + else { + return Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )); + }; + if role != SchemaRole::Response || null_member != "null" || name == "null" { + return Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )); + } + Ok(Some(name.as_str())) + } + Some(_) => Err(invalid_artifact( + "schema node type is outside the closed Version 1 subset", + )), + None => Ok(None), + } } -fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { +fn validate_schema_node(node: &JsonValue, role: SchemaRole) -> Result<(), BundleError> { let object = node .as_object() .ok_or(invalid_artifact("every schema node must be a typed object"))?; - let Some(value_type) = object.get("type").and_then(JsonValue::as_str) else { + let Some(value_type) = schema_node_type(object, role)? else { if object .keys() .all(|key| matches!(key.as_str(), "$schema" | "$id" | "const")) @@ -1114,17 +1182,20 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { .ok_or(invalid_artifact( "schema objects must declare required properties", ))?; - if required.len() != properties.len() - || properties - .keys() - .any(|property| !required.contains(property.as_str())) + if required + .iter() + .any(|field| !properties.contains_key(*field)) + || (role != SchemaRole::Response + && properties + .keys() + .any(|property| !required.contains(property.as_str()))) { return Err(invalid_artifact( "schema objects must require their exact property set", )); } for property in properties.values() { - validate_schema_node(property)?; + validate_schema_node(property, role)?; } } "array" => { @@ -1157,6 +1228,7 @@ fn validate_schema_node(node: &JsonValue) -> Result<(), BundleError> { object .get("items") .ok_or(invalid_artifact("schema arrays must close their item type"))?, + role, )?; } "string" => { @@ -1935,7 +2007,7 @@ mod tests { assert!(bundle.configuration_revision().starts_with("sha256:")); assert_eq!(bundle.configuration_revision().len(), 71); assert_eq!(bundle.scripts.len(), 3); - assert_eq!(bundle.fact_schemas.len(), 2); + assert_eq!(bundle.fact_schemas.len(), 3); assert_eq!(bundle.fixtures.len(), 1); set_tree_mode(directory.path(), 0o755, 0o444); @@ -1953,7 +2025,7 @@ mod tests { assert_eq!(bundle.config.requirements.len(), 4); assert_eq!(bundle.config.sources.len(), 4); assert_eq!(bundle.scripts.len(), 12); - assert_eq!(bundle.fact_schemas.len(), 8); + assert_eq!(bundle.fact_schemas.len(), 12); assert_eq!(bundle.fixtures.len(), 4); assert_eq!(bundle.codelists.len(), 3); diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 8e84b5083..d1af9889c 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -395,6 +395,14 @@ impl EvidenceConfig { let mut evidence_types = BTreeSet::new(); let mut concept_ids = BTreeSet::new(); let mut disclosure_families = BTreeSet::new(); + // One artifact carries one schema role for the whole bundle: reviewing + // it as a response contract must not silently also accept it as a fact + // or adapter-parameter contract somewhere else. + let response_schemas = self + .sources + .iter() + .map(|(_, source)| source.response_schema.as_str()) + .collect::>(); let fact_schemas = self .sources .iter() @@ -405,8 +413,11 @@ impl EvidenceConfig { .iter() .map(|(_, source)| source.request.adapter_parameters_schema.as_str()) .collect::>(); - if !fact_schemas.is_disjoint(¶meter_schemas) { - return invalid("fact and adapter-parameter schema roles must not overlap"); + if !fact_schemas.is_disjoint(¶meter_schemas) + || !response_schemas.is_disjoint(&fact_schemas) + || !response_schemas.is_disjoint(¶meter_schemas) + { + return invalid("source schema roles must not overlap across sources"); } for requirement in &self.requirements { requirement.validate()?; @@ -1366,6 +1377,9 @@ pub struct SourceConfig { pub tls_trust_profile: Option, pub authentication: SourceAuthentication, pub request: FixedRequest, + /// Shape contract for the projected response, validated by Rust before + /// extraction runs, so the script maps a response it can rely on. + pub response_schema: ArtifactPath, pub extract_script: ArtifactPath, pub fact_schema: ArtifactPath, } @@ -1394,9 +1408,16 @@ impl SourceConfig { "source adapter name must be a local identifier", ))?; debug_assert!(!adapter_id.is_empty()); + require_artifact_prefix(&self.response_schema, "schemas/")?; require_artifact_prefix(&self.fact_schema, "schemas/")?; - if self.fact_schema == self.request.adapter_parameters_schema { - return invalid("fact and adapter-parameter schemas must be distinct artifacts"); + let roles = [ + self.response_schema.as_str(), + self.fact_schema.as_str(), + self.request.adapter_parameters_schema.as_str(), + ]; + let distinct = roles.iter().collect::>(); + if distinct.len() != roles.len() { + return invalid("source schema roles must be distinct artifacts"); } Ok(()) } @@ -3482,6 +3503,66 @@ mod tests { assert!(EvidenceConfig::parse_yaml(misspelled.as_bytes()).is_err()); } + #[test] + fn source_schema_roles_are_mandatory_distinct_and_directory_scoped() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!(EvidenceConfig::parse_yaml(valid.as_bytes()).is_ok()); + + for (from, to) in [ + // The response contract is mandatory, so extraction never runs + // behind an undeclared response shape. + (" responseSchema: schemas/response.schema.yaml\n", ""), + // Every schema artifact is directory-scoped like the other roles. + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: adapters/response.schema.yaml", + ), + // One artifact cannot carry two schema roles inside one source. + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: schemas/facts.schema.yaml", + ), + ( + "responseSchema: schemas/response.schema.yaml", + "responseSchema: schemas/adapter-parameters.schema.yaml", + ), + ] { + let mutated = valid.replace(from, to); + assert_ne!(mutated, valid, "fixture mutation must remain effective"); + assert!( + EvidenceConfig::parse_yaml(mutated.as_bytes()).is_err(), + "{to}" + ); + } + } + + #[test] + fn schema_roles_do_not_overlap_across_sources() { + let valid = std::str::from_utf8(include_bytes!( + "../../../products/evidence/fixtures/acceptance/all-definitions/evidence.yaml" + )) + .expect("fixture is UTF-8"); + assert!(EvidenceConfig::parse_yaml(valid.as_bytes()).is_ok()); + + // One artifact validating a response for one source and facts for + // another would make a single review cover two different contracts. + let crossed = valid.replacen( + "responseSchema: schemas/adult-status-response.schema.yaml", + "responseSchema: schemas/residence-region-facts.schema.yaml", + 1, + ); + assert_ne!(crossed, valid, "fixture mutation must remain effective"); + assert!(matches!( + EvidenceConfig::parse_yaml(crossed.as_bytes()), + Err(ConfigError::Invalid( + "source schema roles must not overlap across sources" + )) + )); + } + #[test] fn yaml_names_and_secret_references_are_strict() { let valid = std::str::from_utf8(include_bytes!( diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs index 587718ac4..ec2ab0dcd 100644 --- a/crates/registry-evidence/src/kernel.rs +++ b/crates/registry-evidence/src/kernel.rs @@ -187,6 +187,7 @@ pub struct OfflineKernel { extractions: BTreeMap, request_parts_limits: BTreeMap, derivations: BTreeMap, + response_schemas: BTreeMap, fact_schemas: BTreeMap, reviewed_schemas: BTreeMap, codelist_handles: BTreeMap>, @@ -209,6 +210,7 @@ impl OfflineKernel { let mut preparations = BTreeMap::new(); let mut extractions = BTreeMap::new(); let mut request_parts_limits = BTreeMap::new(); + let mut response_schemas = BTreeMap::new(); let mut fact_schemas = BTreeMap::new(); for (source_id, source) in bundle.config.sources.iter() { let preparation = bundle @@ -231,6 +233,11 @@ impl OfflineKernel { compile_request_parts_limits(&source.request.preparation_limits)?, ); + let response_schema = bundle + .fact_schema(&source.response_schema) + .ok_or(KernelError::Bundle)?; + response_schemas.insert(source_id.to_owned(), compile_schema(response_schema)?); + let schema = bundle .fact_schema(&source.fact_schema) .ok_or(KernelError::Bundle)?; @@ -277,6 +284,7 @@ impl OfflineKernel { extractions, request_parts_limits, derivations, + response_schemas, fact_schemas, reviewed_schemas, codelist_handles, @@ -348,6 +356,17 @@ impl OfflineKernel { .sources .get(&requirement.source) .ok_or(KernelError::Bundle)?; + // The declared response shape is checked in Rust before any script sees + // the response, so extraction maps a response it can rely on and a + // provider that breaks its protocol fails closed the same way whether + // or not the script happens to test for it. + let response_schema = self + .response_schemas + .get(&requirement.source) + .ok_or(KernelError::Bundle)?; + if !response_schema.is_valid(source_response) { + return Err(KernelError::SourceProtocol); + } let parameters = serde_json::to_value(&source.request.adapter_parameters) .map_err(|_| KernelError::Bundle)?; self.runtime @@ -1194,6 +1213,50 @@ mod tests { } } + #[test] + fn a_projected_response_outside_its_declared_shape_fails_before_extraction() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let kernel = OfflineKernel::compile(Arc::clone(&bundle)).expect("kernel compiles"); + let requirement = &bundle.config.requirements[0]; + + // The declared response shape is the extraction contract. Rust refuses + // a response the script would otherwise have to re-check by hand, and + // the refusal is the same closed source-protocol class the script + // would have raised. + for response in [ + json!({}), + json!({"total": "1"}), + json!({"total": -1}), + json!({"total": 1, "unexpected": true}), + json!({"total": 1, "date_of_birth": 19700101}), + json!({"total": 1, "date_of_birth": "1970-13-01"}), + ] { + assert_eq!( + kernel.extract(&requirement.id, &response), + Err(KernelError::SourceProtocol), + "{response}" + ); + } + + // An absent optional leaf is legitimate after projection, so it stays a + // script decision rather than a shape violation. + assert_eq!( + kernel.extract(&requirement.id, &json!({"total": 1})), + Err(KernelError::Extraction) + ); + assert_eq!( + kernel.extract( + &requirement.id, + &json!({"total": 1, "date_of_birth": "1970-01-01"}) + ), + Ok(LookupResult::Match(BTreeMap::from([( + "date_of_birth".to_owned(), + json!("1970-01-01") + )]))) + ); + } + #[test] fn extraction_failures_and_invalid_outputs_fail_closed() { let copied = immutable_fixture("adult-status"); diff --git a/crates/registry-evidence/src/rhai_runtime.rs b/crates/registry-evidence/src/rhai_runtime.rs index e894630ae..590069522 100644 --- a/crates/registry-evidence/src/rhai_runtime.rs +++ b/crates/registry-evidence/src/rhai_runtime.rs @@ -42,6 +42,8 @@ pub const MAXIMUM_JSON_BODY_DEPTH: usize = 32; const MAXIMUM_BUCKETS: usize = 64; const MAXIMUM_ENTITY_REFERENCE_ITEMS: usize = 64; const MAXIMUM_REQUIRED_CODE_BYTES: usize = 64; +const MAXIMUM_POINTER_BYTES: usize = 256; +const MAXIMUM_POINTER_SEGMENTS: usize = 16; /// One past the largest signed 64-bit integer, as the exclusive magnitude bound for any /// ordinary floating-point number that crosses a runtime boundary. const INTEGER_MAGNITUDE_LIMIT: f64 = 9_223_372_036_854_775_808.0; @@ -691,7 +693,85 @@ fn register_evidence_primitives(engine: &mut Engine) { .register_fn("list_contains", list_contains) .register_fn("set_contains", set_contains) .register_fn("required", required) - .register_fn("is_missing", is_missing); + .register_fn("is_missing", is_missing) + .register_fn("get_path", get_path); +} + +/// Resolves one RFC 6901 JSON Pointer against a decoded value, the same pointer +/// grammar source projection already uses, so one document shape is described one way. +/// +/// Absence at any step is reported as the unit marker `is_missing` and `required` +/// already understand, because a projected response legitimately omits a selected +/// leaf. A malformed or oversized pointer is instead a fault in the script's own +/// text, so it fails closed rather than passing as absence. +fn get_path(value: Dynamic, pointer: &str) -> Result> { + let mut current = value; + for token in parse_json_pointer(pointer)? { + let next = if let Some(map) = current.read_lock::() { + map.get(token.as_str()).cloned() + } else if let Some(array) = current.read_lock::() { + array.get(reference_token_index(&token)?).cloned() + } else { + None + }; + match next { + Some(value) => current = value, + None => return Ok(Dynamic::UNIT), + } + } + Ok(current) +} + +/// Splits a pointer into its unescaped reference tokens under fixed ceilings. Index +/// tokens stay unchecked here because only the value being traversed says which +/// tokens address an array. +fn parse_json_pointer(pointer: &str) -> Result, Box> { + if pointer.len() > MAXIMUM_POINTER_BYTES { + return Err(primitive_error("pointer_out_of_bounds")); + } + if pointer.is_empty() { + return Ok(Vec::new()); + } + let Some(body) = pointer.strip_prefix('/') else { + return Err(primitive_error("invalid_pointer")); + }; + let tokens: Vec<&str> = body.split('/').collect(); + if tokens.len() > MAXIMUM_POINTER_SEGMENTS { + return Err(primitive_error("pointer_out_of_bounds")); + } + tokens.into_iter().map(unescape_reference_token).collect() +} + +fn unescape_reference_token(token: &str) -> Result> { + let mut unescaped = String::with_capacity(token.len()); + let mut characters = token.chars(); + while let Some(character) = characters.next() { + if character != '~' { + unescaped.push(character); + continue; + } + match characters.next() { + Some('0') => unescaped.push('~'), + Some('1') => unescaped.push('/'), + _ => return Err(primitive_error("invalid_pointer")), + } + } + Ok(unescaped) +} + +/// Accepts only the canonical decimal index RFC 6901 defines, which leaves no way to +/// express the negative index Rhai would otherwise count from the end of an array. +fn reference_token_index(token: &str) -> Result> { + let canonical = token == "0" + || (!token.is_empty() + && !token.starts_with('0') + && token.bytes().all(|byte| byte.is_ascii_digit())); + if !canonical { + return Err(primitive_error("invalid_pointer")); + } + token + .parse() + .map_err(|_| primitive_error("pointer_out_of_bounds")) } fn parse_date(input: &str) -> Result> { @@ -3089,6 +3169,135 @@ mod tests { .is_err()); } + #[test] + fn get_path_resolves_bounded_pointers_and_reports_absence_as_missing() { + let runtime = runtime(); + let response = json!({ + "total": 1, + "person": {"date_of_birth": "1970-01-01", "names": [{"given": "A"}, {"given": "B"}]}, + "a/b": "slash", + "m~n": "tilde" + }); + + fn resolved(runtime: &RhaiRuntime, response: &Value, body: &str) -> Value { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: {body} }} }} + }}" + )) + .expect("compiles"); + match runtime.extract(&script, response, &json!({}), &|_: &Value| true) { + Ok(LookupResult::Match(facts)) => facts["value"].clone(), + other => panic!("expected a match, got {other:?}"), + } + } + + for (body, expected) in [ + (r#"get_path(source_response, "/total")"#, json!(1)), + ( + r#"get_path(source_response, "/person/date_of_birth")"#, + json!("1970-01-01"), + ), + ( + r#"get_path(source_response, "/person/names/1/given")"#, + json!("B"), + ), + (r#"get_path(source_response, "/a~1b")"#, json!("slash")), + (r#"get_path(source_response, "/m~0n")"#, json!("tilde")), + ( + r#"get_path(get_path(source_response, "/person"), "/date_of_birth")"#, + json!("1970-01-01"), + ), + (r#"len(get_path(source_response, ""))"#, json!(4)), + ] { + assert_eq!(resolved(&runtime, &response, body), expected, "{body}"); + } + + // Absence of any kind is the script's decision to make, so it arrives as the + // same unit marker `is_missing` and `required` already understand. + for body in [ + r#"get_path(source_response, "/absent")"#, + r#"get_path(source_response, "/person/absent/deeper")"#, + r#"get_path(source_response, "/person/names/9")"#, + r#"get_path(source_response, "/total/deeper")"#, + ] { + assert_eq!( + resolved(&runtime, &response, &format!("is_missing({body})")), + json!(true), + "{body}" + ); + } + + let unavailable = runtime + .compile_extraction( + r#"fn extract(source_response, parameters) { + #{ outcome: "match", facts: #{ value: required(get_path(source_response, "/absent"), "required_fact_missing") } } + }"#, + ) + .expect("compiles"); + assert_eq!( + runtime.extract(&unavailable, &response, &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Unavailable) + ); + } + + #[test] + fn get_path_rejects_malformed_and_oversized_pointers() { + let runtime = runtime(); + let response = json!({"total": 1, "person": {"names": [{"given": "A"}]}}); + + let long_pointer = format!("/{}", "x".repeat(MAXIMUM_POINTER_BYTES)); + let deep_pointer = "/x".repeat(MAXIMUM_POINTER_SEGMENTS + 1); + for pointer in [ + "total", // no leading separator + "/person/names/-1", // negative index, however it was written + "/person/names/01", // non-canonical index + "/person/names/0x1", // non-numeric index + "/person/names/ 0", // padded index + "/bad~", // dangling escape + "/bad~2", // undefined escape + long_pointer.as_str(), // beyond the pointer byte ceiling + deep_pointer.as_str(), // beyond the pointer segment ceiling + ] { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: get_path(source_response, \"{pointer}\") }} }} + }}" + )) + .expect("compiles"); + assert_eq!( + runtime.extract(&script, &response, &json!({}), &|_: &Value| true), + Err(RhaiRuntimeError::Invocation), + "{pointer}" + ); + } + + // The ceilings admit the largest well-formed pointer, so the rejections above + // are boundary decisions rather than an accidentally narrower primitive. + for pointer in [ + format!("/{}", "x".repeat(MAXIMUM_POINTER_BYTES - 1)), + "/x".repeat(MAXIMUM_POINTER_SEGMENTS), + ] { + let script = runtime + .compile_extraction(&format!( + "fn extract(source_response, parameters) {{ + #{{ outcome: \"match\", facts: #{{ value: is_missing(get_path(source_response, \"{pointer}\")) }} }} + }}" + )) + .expect("compiles"); + assert_eq!( + runtime.extract(&script, &response, &json!({}), &|_: &Value| true), + Ok(LookupResult::Match(BTreeMap::from([( + "value".to_string(), + json!(true) + )]))), + "{pointer}" + ); + } + } + #[test] fn all_four_acceptance_script_pairs_run_through_one_runtime() { let runtime = runtime(); diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index d0a57eb5b..997362227 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -3321,8 +3321,8 @@ async fn runtime_rejects_an_extra_extracted_fact_before_derivation_or_release() let mut adapter = fs::read_to_string(&adapter_path).expect("adapter is readable"); replace_exact( &mut adapter, - "facts: #{date_of_birth: source_response[\"date_of_birth\"]}", - "facts: #{date_of_birth: source_response[\"date_of_birth\"], unexpected_private_fact: \"PrivacyCanary\"}", + "facts: #{date_of_birth: date_of_birth}", + "facts: #{date_of_birth: date_of_birth, unexpected_private_fact: \"PrivacyCanary\"}", 1, ); fs::write(adapter_path, adapter).expect("test adapter replacement succeeds"); diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index 65aad5d2a..130a58ba5 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -1502,6 +1502,7 @@ mod tests { "maximumResponseBytes": 1024, "concurrencyLimit": 1 }, + "responseSchema": "schemas/response.schema.yaml", "extractScript": "adapters/extract.rhai", "factSchema": "schemas/facts.schema.yaml" })) @@ -1630,6 +1631,7 @@ mod tests { "maximumResponseBytes": 1024, "concurrencyLimit": 1 }, + "responseSchema": "schemas/response.schema.yaml", "extractScript": "adapters/extract.rhai", "factSchema": "schemas/facts.schema.yaml" })) diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index a18169c31..4524bcc32 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -89,6 +89,7 @@ fn source_config( "maximumResponseBytes": 65536, "concurrencyLimit": 4 }, + "responseSchema": "schemas/response.schema.yaml", "extractScript": "adapters/extract.rhai", "factSchema": "schemas/facts.schema.yaml" })) @@ -810,6 +811,15 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a .expect("fact schema parses"); let fact_schema = jsonschema::JSONSchema::compile(&fact_schema_value).expect("fact schema compiles"); + let response_schema_value: Value = serde_norway::from_str( + &fs::read_to_string(directory.join(source.response_schema.as_str())) + .expect("response schema is readable"), + ) + .expect("response schema parses"); + let response_schema = jsonschema::JSONSchema::options() + .should_validate_formats(true) + .compile(&response_schema_value) + .expect("response schema compiles"); let parameters = serde_json::to_value(&source.request.adapter_parameters) .expect("adapter parameters serialize"); let (script_selectors, transport_selectors) = shape_selectors(id); @@ -943,6 +953,26 @@ async fn every_frozen_source_shape_executes_through_production_materialization_a .execute(&transport_selectors, &prepared) .await .expect("frozen shape executes through the production transport"); + // Every committed cardinality response of the shape has to sit inside the + // declared response schema, because the runtime refuses the response + // before extraction otherwise. + for case in ["match", "no-match", "ambiguous", "missing-fact"] { + let recorded: Value = serde_json::from_slice( + &fs::read(directory.join(format!("responses/{case}.json"))) + .expect("cardinality response fixture is readable"), + ) + .expect("cardinality response fixture is JSON"); + let recorded = project_fixture_response(&source, &recorded) + .expect("cardinality response fixture projects"); + assert!( + response_schema.is_valid(&recorded), + "{id}: committed {case} response is outside the declared response schema" + ); + } + assert!( + response_schema.is_valid(&projected), + "{id}: transport-backed response is outside the declared response schema" + ); let facts = match runtime .extract(&extraction, &projected, ¶meters, &fact_schema) .expect("projected transport response extracts") diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index b094845a2..72767b6a9 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -85,7 +85,7 @@ struct ProjectFile { template: &'static str, } -const PROJECT_FILES: [ProjectFile; 10] = [ +const PROJECT_FILES: [ProjectFile; 11] = [ ProjectFile { relative: "README.md", template: include_str!("../templates/README.md"), @@ -118,6 +118,10 @@ const PROJECT_FILES: [ProjectFile; 10] = [ relative: "bundle/schemas/adapter-parameters.schema.yaml", template: include_str!("../templates/bundle/schemas/adapter-parameters.schema.yaml"), }, + ProjectFile { + relative: "bundle/schemas/response.schema.yaml", + template: include_str!("../templates/bundle/schemas/response.schema.yaml"), + }, ProjectFile { relative: "bundle/schemas/facts.schema.yaml", template: include_str!("../templates/bundle/schemas/facts.schema.yaml"), diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md index 58e6586a6..c3879e458 100644 --- a/crates/registry-evidencectl/templates/README.md +++ b/crates/registry-evidencectl/templates/README.md @@ -13,7 +13,7 @@ real system yet, and every identifier is in the `urn:example:scaffold` namespace evidence.yaml the deployment contract adapters/ request preparation and fact extraction (Rhai) derivations/ requirement derivation (Rhai) - schemas/ closed adapter-parameter and fact schemas + schemas/ closed adapter-parameter, response, and fact schemas fixtures/ synthetic acceptance cases runtime.yaml process-local paths and listener, not governed secrets/ key material, created empty with mode 0700 diff --git a/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai b/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai index 9609019e9..890b8e3ef 100644 --- a/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai +++ b/crates/registry-evidencectl/templates/bundle/adapters/source-a-extract.rhai @@ -2,24 +2,26 @@ // exactly one record, and emits only the narrow facts the derivation needs. // A response the adapter does not understand is a protocol error rather than a // silent no_match, so a source change cannot be mistaken for an answer. +// +// The response schema has already rejected anything outside the declared shape, +// so this script carries no presence or type checks. What is left is the part a +// shape cannot state: how the fields relate, and what an absent optional leaf +// means for this requirement. fn extract(source_response, parameters) { - if !source_response.contains("total") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 { - throw("source_protocol_error"); - } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { + // The source says it found nothing, so it must not also return a record. if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { + if total > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("event_date") { + // get_path reads one RFC 6901 pointer and reports an absent leaf as missing, + // which composes with is_missing here and with required(...) elsewhere. + let event_date = get_path(source_response, "/event_date"); + if is_missing(event_date) { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["event_date"]) != "string" { - throw("source_protocol_error"); - } - #{outcome: "match", facts: #{event_date: source_response["event_date"]}} + #{outcome: "match", facts: #{event_date: event_date}} } diff --git a/crates/registry-evidencectl/templates/bundle/evidence.yaml b/crates/registry-evidencectl/templates/bundle/evidence.yaml index 7fb337416..f9147838e 100644 --- a/crates/registry-evidencectl/templates/bundle/evidence.yaml +++ b/crates/registry-evidencectl/templates/bundle/evidence.yaml @@ -123,6 +123,8 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/response.schema.yaml extractScript: adapters/source-a-extract.rhai factSchema: schemas/facts.schema.yaml diff --git a/crates/registry-evidencectl/templates/bundle/schemas/response.schema.yaml b/crates/registry-evidencectl/templates/bundle/schemas/response.schema.yaml new file mode 100644 index 000000000..843db5568 --- /dev/null +++ b/crates/registry-evidencectl/templates/bundle/schemas/response.schema.yaml @@ -0,0 +1,19 @@ +# Closed schema for the projected source response of source-a. The runtime +# checks it before the extract script runs, so the script maps a response whose +# shape it can rely on and never re-checks presence or type by hand. +# +# Unlike the facts schema, a declared property need not be required: projection +# drops a selected leaf the source did not return, so an optional leaf here is +# the honest description of the boundary. Require only what the source always +# returns, and let the script decide what an absent leaf means. +type: object +additionalProperties: false +required: [total] +properties: + total: + type: integer + minimum: 0 + maximum: 1000000 + event_date: + type: string + format: date diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai index e19794042..a2af00768 100644 --- a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai +++ b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai @@ -1,19 +1,13 @@ fn extract(source_response, parameters) { - if !source_response.contains("total") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 { - throw("source_protocol_error"); - } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("official_residence_code") { + if total > 1 { return #{outcome: "ambiguous"}; } + let official_residence_code = get_path(source_response, "/official_residence_code"); + if is_missing(official_residence_code) { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["official_residence_code"]) != "string" { - throw("source_protocol_error"); - } - #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} + #{outcome: "match", facts: #{official_residence_code: official_residence_code}} } diff --git a/crates/registry-mint/demo/evidence-bundle/evidence.yaml b/crates/registry-mint/demo/evidence-bundle/evidence.yaml index 23504c1a4..4709b3a7a 100644 --- a/crates/registry-mint/demo/evidence-bundle/evidence.yaml +++ b/crates/registry-mint/demo/evidence-bundle/evidence.yaml @@ -63,6 +63,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/demo-source.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml new file mode 100644 index 000000000..70d83308f --- /dev/null +++ b/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 073b7540f..db0f5d6fe 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -513,6 +513,7 @@ sources: redirects: deny timeout: PT3S maximum_response_bytes: 65536 + response_schema: schemas/civil-registry-response.schema.yaml extract_script: adapters/civil-registry-extract.rhai fact_schema: schemas/civil-registry-facts.schema.yaml @@ -569,6 +570,15 @@ array order and length are preserved, and missing leaves remain missing. The acquisition posture still describes the pre-projection wire response. Exact projection grammar and conflict rules are part of the reviewed adapter ABI. +Rust then validates the projected tree against the source's required response +schema, a closed JSON Schema in the same subset as the adapter-parameter and +fact schemas. A response outside the shape the adapter was reviewed against is +a source-protocol failure and no script runs, so hand-written protocol checking +is not the only thing standing between a malformed response and fact +construction. What stays with the script is what a shape cannot state, such as +how a reported total agrees with the records returned and which values must +agree with the closed adapter parameters. + A Version 1 source must provide a bounded lookup that can establish zero, one, or multiple results from the configured selector. If an existing system cannot do that safely, a governed intermediary such as an existing integration layer diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 7ac1041fa..184afb803 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -186,7 +186,7 @@ $defs: source: type: object additionalProperties: false - required: [transport, baseUrl, posture, authentication, request, extractScript, factSchema] + required: [transport, baseUrl, posture, authentication, request, responseSchema, extractScript, factSchema] properties: transport: {const: http-json} baseUrl: @@ -197,6 +197,7 @@ $defs: tlsTrustProfile: {$ref: '#/$defs/local-id'} authentication: {$ref: '#/$defs/source-authentication'} request: {$ref: '#/$defs/fixed-request'} + responseSchema: {$ref: '#/$defs/relative-path'} extractScript: {$ref: '#/$defs/relative-path'} factSchema: {$ref: '#/$defs/relative-path'} source-authentication: diff --git a/products/evidence/contracts/primitive-library.yaml b/products/evidence/contracts/primitive-library.yaml index da7d45922..cfb43063d 100644 --- a/products/evidence/contracts/primitive-library.yaml +++ b/products/evidence/contracts/primitive-library.yaml @@ -18,6 +18,8 @@ resource_limits: maximum_query_pairs: 64 maximum_query_name_bytes: 64 maximum_query_value_bytes: 4096 + maximum_pointer_bytes: 256 + maximum_pointer_segments: 16 maximum_json_body_depth: 32 timeout_behavior: operation limit is normative; any outer wall-time limit only aborts and never changes results primitives: @@ -87,6 +89,9 @@ primitives: is_missing: signature: Option -> boolean behavior: Explicit missing-value test with no implicit coercion. + get_path: + signature: '[value, string] -> Option' + behavior: Resolves one bounded RFC 6901 JSON Pointer against a local value and returns the value found, or the missing marker when any segment resolves to nothing. Only ~0 and ~1 escapes are recognized. An array segment must be a non-negative decimal integer with no leading zero. A pointer that is not resolvable syntax, exceeds 256 bytes, or exceeds 16 segments is a script fault and fails the invocation rather than answering missing. global_rules: - Primitives are pure, deterministic, typed, bounded, and domain-neutral. - Strings are compared as exact UTF-8 values; no Unicode normalization, case folding, transliteration, or phonetics occurs. diff --git a/products/evidence/contracts/rhai-abi.yaml b/products/evidence/contracts/rhai-abi.yaml index 1371326c0..0dba7296c 100644 --- a/products/evidence/contracts/rhai-abi.yaml +++ b/products/evidence/contracts/rhai-abi.yaml @@ -33,7 +33,7 @@ functions: extraction: signature: extract(projected_source_response, adapter_parameters) -> LookupResult input: - projected_source_response: Parsed JSON from the one completed evidence-data request after pre-projection response bounds and the Rust-enforced extended JSON Pointer allowlist. The projected tree handed to extraction is limited to 65536 serialized bytes; the 1 MiB wire-response maximum bounds the pre-projection response and the two limits are compatible layers. Integer tokens outside the signed 64-bit range fail before Rhai rather than converting to a precision-losing float; provider identifiers outside that range must be represented as strings. + projected_source_response: Parsed JSON from the one completed evidence-data request after pre-projection response bounds, the Rust-enforced extended JSON Pointer allowlist, and validation against the source's declared responseSchema, so a response outside its declared shape never reaches this script. The projected tree handed to extraction is limited to 65536 serialized bytes; the 1 MiB wire-response maximum bounds the pre-projection response and the two limits are compatible layers. Integer tokens outside the signed 64-bit range fail before Rhai rather than converting to a precision-losing float; provider identifiers outside that range must be represented as strings. adapter_parameters: A fresh copy of the same closed non-secret parameters supplied to preparation. prohibited: - selectors or prepared request parts diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 950e0550a..adc2c92d8 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -224,6 +224,10 @@ cross_cutting: threat: The configured JWKS path and the served discovery route drift apart. enforcement: Bundle validation pins the one Version 1 discovery path and the served route returns the runtime key set at exactly that configured path. negative_test: sec-jwks-route-config-parity + source_response_shape: + threat: A source response outside the shape its adapter was reviewed against reaches the extraction script, so a defect or omission in hand-written protocol checking becomes the only thing standing between a malformed response and fact construction. + enforcement: Every source declares a required responseSchema in the closed Version 1 subset. Rust validates the projected response against it after projection and before conversion to Rhai; a response outside its declared shape is a source-protocol failure and no script runs. + negative_test: sec-source-response-shape-enforced script_resource_exhaustion: threat: A hostile or defective reviewed script consumes unbounded execution inside the shared process. enforcement: The engine's normative operation ceiling terminates the invocation with a closed value-free error. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 498a157d6..656be2dcb 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -191,6 +191,10 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: a_reset_transport_failure_yields_exactly_one_connection_attempt} - id: sec-jwks-route-config-parity tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: configured_jwks_path_is_mechanically_the_served_route}] + - id: sec-source-response-shape-enforced + tests: + - {file: crates/registry-evidence/src/kernel.rs, name: a_projected_response_outside_its_declared_shape_fails_before_extraction} + - {file: crates/registry-evidence/src/config.rs, name: source_schema_roles_are_mandatory_distinct_and_directory_scoped} - id: sec-script-operation-exhaustion tests: [{file: crates/registry-evidence/src/rhai_runtime.rs, name: operation_exhaustion_terminates_a_hostile_script_with_a_value_free_error}] - id: sec-reserved-header-aliases-closed diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index db612b747..2de0ff6a4 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -4,7 +4,7 @@ ownership: governed_bundle: - fixed source origin, acquisition posture, authentication kind, and logical secret references - fixed method and fixed path, or complete-segment pathTemplate and pathBindings - - fixed non-secret headers, closed selectorInputs, prepareScript, extractScript, adapter parameters and schemas + - fixed non-secret headers, closed selectorInputs, prepareScript, extractScript, adapter parameters, and the adapter-parameter, response, and fact schemas - preparation channel policy and bounds, response projection, redirect denial, timeout, response byte limit, and concurrency limit - optional logical TLS trust-profile name runtime_yaml: @@ -14,7 +14,7 @@ ownership: runtime_override: prohibited scripts: prepare: Renders only ordered query pairs and at most one JSON body from minimized authorized selectors and closed parameters. - extract: Maps only the bounded projected response and closed parameters to the closed lookup union. + extract: Maps only the bounded projected response, already inside its declared response schema, and closed parameters to the closed lookup union. prohibited: source, origin, method, path, headers, authentication, credentials, TLS, proxy, redirect, retry, pagination, concurrency, or request-count authority evidence_data_request: count: Exactly one per evaluation after successful authorization, durable access audit, and complete RequestParts validation. @@ -104,6 +104,16 @@ projection: - Empty, invalid, duplicate, decoded-duplicate, ancestor/descendant-overlap, wildcard-overlap, or structurally incompatible paths fail startup. - Wire byte and parsing bounds apply before projection; Rhai bounds apply again to the projected tree. posture_rule: The declared acquisition posture describes the pre-projection wire response. Local projection never upgrades a record-transformed source claim. +response_shape: + owner: Core runtime. + stage: After projection and before conversion to Rhai, so no response outside its declared shape reaches a script. + artifact: One required bundle-relative responseSchema per source, in the same closed JSON Schema subset as adapterParametersSchema and factSchema and validated by the same startup checks. + role_relaxation: A response schema may list fewer required members than it declares properties, because projection legitimately drops a selected leaf the record did not carry. The adapter-parameter and fact roles keep the exact required-equals-properties rule. + nullable_form: A response schema node may write its type as the pair [T, "null"]. A source reports an explicit null where it holds no value and projection carries that null through verbatim, so the shape has to be able to say so. This is the only union the subset admits and only in the response role; null reaches the script as the same unit marker is_missing already reads. + division_of_labour: + schema: Member presence where the shape can guarantee it, member types, array bounds and uniqueness, string bounds and formats, and enumerated or constant values. + script: Relations between fields the shape cannot state, such as how a reported total agrees with the records returned, page-count arithmetic, and which values must agree with the closed adapter parameters. + failure: A projected response outside its declared shape is a source-protocol failure, indistinguishable in public output from any other one. cardinality: preferred: Provider count plus at most one bounded result, field-projected where supported. fallback: At most two minimally projected results solely to distinguish a unique result from ambiguity. @@ -159,6 +169,7 @@ acquisition_postures: failure_classes: transport: [401, 403, 429, 5xx, timeout, redirect, invalid-json, duplicate-json-member, wrong-media-type, oversized-response] projection: [invalid-pointer, missing-intermediate, mistyped-intermediate, projected-input-oversized] + response_shape: [missing-required-member, unexpected-member, wrong-member-type, out-of-bounds-value, oversized-array, duplicate-array-member] semantic: [no-match, ambiguous, required-fact-missing, wrong-fact-type, unknown-controlled-code] graphql_envelope_rule: Any top-level errors member, including partial data with errors, fails closed. persistence: diff --git a/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai index 0ab3a08f7..0901f981e 100644 --- a/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai +++ b/products/evidence/fixtures/acceptance/adult-status/adapters/source-a.rhai @@ -1,21 +1,15 @@ fn extract(source_response, parameters) { - if !source_response.contains("total") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 { - throw("source_protocol_error"); - } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { + if total > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("date_of_birth") { + let date_of_birth = get_path(source_response, "/date_of_birth"); + if is_missing(date_of_birth) { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["date_of_birth"]) != "string" { - throw("source_protocol_error"); - } - #{outcome: "match", facts: #{date_of_birth: source_response["date_of_birth"]}} + #{outcome: "match", facts: #{date_of_birth: date_of_birth}} } diff --git a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml index 00c0f23d0..66472080b 100644 --- a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml +++ b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml @@ -57,6 +57,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/source-a.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/acceptance/adult-status/schemas/response.schema.yaml b/products/evidence/fixtures/acceptance/adult-status/schemas/response.schema.yaml new file mode 100644 index 000000000..a8eb01a7f --- /dev/null +++ b/products/evidence/fixtures/acceptance/adult-status/schemas/response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + date_of_birth: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai index c85666367..c5251e15e 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/adult-status-source.rhai @@ -1,11 +1,11 @@ fn extract(source_response, parameters) { - if !source_response.contains("total") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 { throw("source_protocol_error"); } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("date_of_birth") { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["date_of_birth"]) != "string" { throw("source_protocol_error"); } - #{outcome: "match", facts: #{date_of_birth: source_response["date_of_birth"]}} + if total > 1 { return #{outcome: "ambiguous"}; } + let date_of_birth = get_path(source_response, "/date_of_birth"); + if is_missing(date_of_birth) { return #{outcome: "match", facts: #{}}; } + #{outcome: "match", facts: #{date_of_birth: date_of_birth}} } diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai index 016cf8734..9f789bd10 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/legal-parent-relationship-source.rhai @@ -1,7 +1,7 @@ fn extract(source_response, parameters) { - if len(source_response) != 2 || !source_response.contains("total") || !source_response.contains("records") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 || type_of(source_response["records"]) != "array" || source_response["records"].len > parameters["resultLimit"] { throw("source_protocol_error"); } let total = source_response["total"]; let records = source_response["records"]; + if records.len > parameters["resultLimit"] { throw("source_protocol_error"); } if total == 0 { if records.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -10,13 +10,12 @@ fn extract(source_response, parameters) { if records.len < 2 { throw("source_protocol_error"); } return #{outcome: "ambiguous"}; } - if records.len != 1 || type_of(records[0]) != "map" { throw("source_protocol_error"); } + if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - if len(record) != 5 || !record.contains("returned_child_reference") || !record.contains("parent_references") || !record.contains("reference_namespace") || !record.contains("relationship_set_contract") || !record.contains("relationship_set_complete") || type_of(record["returned_child_reference"]) != "string" || type_of(record["parent_references"]) != "array" || type_of(record["reference_namespace"]) != "string" || type_of(record["relationship_set_contract"]) != "string" || type_of(record["relationship_set_complete"]) != "bool" || record["relationship_set_complete"] != parameters["relationshipSetComplete"] || record["reference_namespace"] != parameters["referenceNamespace"] || record["relationship_set_contract"] != parameters["relationshipSetContract"] || record["parent_references"].len > 2 { throw("source_protocol_error"); } - let seen = []; - for reference in record["parent_references"] { - if type_of(reference) != "string" || reference == "" || list_contains(seen, reference) { throw("source_protocol_error"); } - seen.push(reference); - } + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits either one is a source this adapter does not understand. + if is_missing(record["returned_child_reference"]) || is_missing(record["parent_references"]) { throw("source_protocol_error"); } + if record["reference_namespace"] != parameters["referenceNamespace"] || record["relationship_set_contract"] != parameters["relationshipSetContract"] || record["relationship_set_complete"] != parameters["relationshipSetComplete"] { throw("source_protocol_error"); } #{outcome: "match", facts: #{returned_child_reference: record["returned_child_reference"], parent_references: record["parent_references"], reference_namespace: record["reference_namespace"], relationship_set_contract: record["relationship_set_contract"], relationship_set_complete: record["relationship_set_complete"]}} } diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai index 82ef8fab9..fddcae016 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/professional-licence-source.rhai @@ -1,7 +1,7 @@ fn extract(source_response, parameters) { - if len(source_response) != 2 || !source_response.contains("total") || !source_response.contains("records") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 || type_of(source_response["records"]) != "array" || source_response["records"].len > parse_integer(parameters["resultLimit"]) { throw("source_protocol_error"); } let total = source_response["total"]; let records = source_response["records"]; + if records.len > parse_integer(parameters["resultLimit"]) { throw("source_protocol_error"); } if total == 0 { if records.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -10,11 +10,13 @@ fn extract(source_response, parameters) { if records.len < 2 { throw("source_protocol_error"); } return #{outcome: "ambiguous"}; } - if records.len != 1 || type_of(records[0]) != "map" { throw("source_protocol_error"); } - let record = records[0]; + if records.len != 1 { throw("source_protocol_error"); } let facts = #{}; - if record.contains("licence_state") { facts["licence_state"] = record["licence_state"]; } - if record.contains("valid_from") { facts["valid_from"] = record["valid_from"]; } - if record.contains("valid_until") { facts["valid_until"] = record["valid_until"]; } + let licence_state = get_path(source_response, "/records/0/licence_state"); + if !is_missing(licence_state) { facts["licence_state"] = licence_state; } + let valid_from = get_path(source_response, "/records/0/valid_from"); + if !is_missing(valid_from) { facts["valid_from"] = valid_from; } + let valid_until = get_path(source_response, "/records/0/valid_until"); + if !is_missing(valid_until) { facts["valid_until"] = valid_until; } #{outcome: "match", facts: facts} } diff --git a/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai index 8bf3d0c45..eda80f7e1 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai +++ b/products/evidence/fixtures/acceptance/all-definitions/adapters/residence-region-source.rhai @@ -1,11 +1,11 @@ fn extract(source_response, parameters) { - if !source_response.contains("total") || type_of(source_response["total"]) != "i64" || source_response["total"] < 0 { throw("source_protocol_error"); } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("official_residence_code") { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["official_residence_code"]) != "string" { throw("source_protocol_error"); } - #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} + if total > 1 { return #{outcome: "ambiguous"}; } + let official_residence_code = get_path(source_response, "/official_residence_code"); + if is_missing(official_residence_code) { return #{outcome: "match", facts: #{}}; } + #{outcome: "match", facts: #{official_residence_code: official_residence_code}} } diff --git a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml index 732e3331b..b6d9eb818 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml @@ -81,6 +81,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/adult-status-response.schema.yaml extractScript: adapters/adult-status-source.rhai factSchema: schemas/adult-status-facts.schema.yaml source-b: @@ -104,6 +105,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/residence-region-response.schema.yaml extractScript: adapters/residence-region-source.rhai factSchema: schemas/residence-region-facts.schema.yaml source-c: @@ -130,6 +132,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 131072 concurrencyLimit: 8 + responseSchema: schemas/professional-licence-response.schema.yaml extractScript: adapters/professional-licence-source.rhai factSchema: schemas/professional-licence-facts.schema.yaml source-d: @@ -158,6 +161,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/legal-parent-relationship-response.schema.yaml extractScript: adapters/legal-parent-relationship-source.rhai factSchema: schemas/legal-parent-relationship-facts.schema.yaml diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-response.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-response.schema.yaml new file mode 100644 index 000000000..a8eb01a7f --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/adult-status-response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + date_of_birth: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-response.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-response.schema.yaml new file mode 100644 index 000000000..21cd65577 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/legal-parent-relationship-response.schema.yaml @@ -0,0 +1,27 @@ +type: object +additionalProperties: false +required: [total, records] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + records: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + # Projection drops a leaf the record did not carry, and an ambiguous page + # is decided before any record is read, so no leaf can be required here. + # Which leaves a single matched record must carry stays with the script. + required: [] + properties: + returned_child_reference: {type: string, minLength: 1, maxLength: 96} + parent_references: + type: array + minItems: 0 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {type: string, minLength: 1, maxLength: 128} + relationship_set_contract: {type: string, minLength: 1, maxLength: 128} + relationship_set_complete: {type: boolean} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-response.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-response.schema.yaml new file mode 100644 index 000000000..bacd66c70 --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/professional-licence-response.schema.yaml @@ -0,0 +1,17 @@ +type: object +additionalProperties: false +required: [total, records] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + records: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [] + properties: + licence_state: {type: string, minLength: 1, maxLength: 32} + valid_from: {type: string, format: date} + valid_until: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-response.schema.yaml b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-response.schema.yaml new file mode 100644 index 000000000..70d83308f --- /dev/null +++ b/products/evidence/fixtures/acceptance/all-definitions/schemas/residence-region-response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai index 340882d3c..d96ad60e6 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/adapters/source-d.rhai @@ -1,15 +1,9 @@ fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("total") || - !source_response.contains("records") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 || - type_of(source_response["records"]) != "array" || - source_response["records"].len > parameters["resultLimit"] { - throw("source_protocol_error"); - } let total = source_response["total"]; let records = source_response["records"]; + if records.len > parameters["resultLimit"] { + throw("source_protocol_error"); + } if total == 0 { if records.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -18,34 +12,20 @@ fn extract(source_response, parameters) { if records.len < 2 { throw("source_protocol_error"); } return #{outcome: "ambiguous"}; } - if records.len != 1 || type_of(records[0]) != "map" { + if records.len != 1 { throw("source_protocol_error"); } + let record = records[0]; + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits either one is a source this adapter does not understand. + if is_missing(record["returned_child_reference"]) || + is_missing(record["parent_references"]) { throw("source_protocol_error"); } - let record = records[0]; - if len(record) != 5 || - !record.contains("returned_child_reference") || - !record.contains("parent_references") || - !record.contains("reference_namespace") || - !record.contains("relationship_set_contract") || - !record.contains("relationship_set_complete") || - type_of(record["returned_child_reference"]) != "string" || - type_of(record["parent_references"]) != "array" || - type_of(record["reference_namespace"]) != "string" || - type_of(record["relationship_set_contract"]) != "string" || - type_of(record["relationship_set_complete"]) != "bool" || - record["relationship_set_complete"] != parameters["relationshipSetComplete"] || - record["reference_namespace"] != parameters["referenceNamespace"] || + if record["reference_namespace"] != parameters["referenceNamespace"] || record["relationship_set_contract"] != parameters["relationshipSetContract"] || - record["parent_references"].len > 2 { + record["relationship_set_complete"] != parameters["relationshipSetComplete"] { throw("source_protocol_error"); } - let seen = []; - for reference in record["parent_references"] { - if type_of(reference) != "string" || reference == "" || list_contains(seen, reference) { - throw("source_protocol_error"); - } - seen.push(reference); - } #{outcome: "match", facts: #{ returned_child_reference: record["returned_child_reference"], parent_references: record["parent_references"], diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml index 0e447b4c3..2bb5ff059 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml @@ -48,6 +48,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/source-d.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/response.schema.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/response.schema.yaml new file mode 100644 index 000000000..21cd65577 --- /dev/null +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/schemas/response.schema.yaml @@ -0,0 +1,27 @@ +type: object +additionalProperties: false +required: [total, records] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + records: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + # Projection drops a leaf the record did not carry, and an ambiguous page + # is decided before any record is read, so no leaf can be required here. + # Which leaves a single matched record must carry stays with the script. + required: [] + properties: + returned_child_reference: {type: string, minLength: 1, maxLength: 96} + parent_references: + type: array + minItems: 0 + maxItems: 2 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + reference_namespace: {type: string, minLength: 1, maxLength: 128} + relationship_set_contract: {type: string, minLength: 1, maxLength: 128} + relationship_set_complete: {type: boolean} diff --git a/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai index d80e9a9b1..c24e4b509 100644 --- a/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai +++ b/products/evidence/fixtures/acceptance/professional-licence/adapters/source-c.rhai @@ -1,15 +1,9 @@ fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("total") || - !source_response.contains("records") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 || - type_of(source_response["records"]) != "array" || - source_response["records"].len > parse_integer(parameters["resultLimit"]) { - throw("source_protocol_error"); - } let total = source_response["total"]; let records = source_response["records"]; + if records.len > parse_integer(parameters["resultLimit"]) { + throw("source_protocol_error"); + } if total == 0 { if records.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -18,14 +12,14 @@ fn extract(source_response, parameters) { if records.len < 2 { throw("source_protocol_error"); } return #{outcome: "ambiguous"}; } - if records.len != 1 || type_of(records[0]) != "map" { - throw("source_protocol_error"); - } - let record = records[0]; + if records.len != 1 { throw("source_protocol_error"); } let facts = #{}; - if record.contains("licence_state") { facts["licence_state"] = record["licence_state"]; } - if record.contains("valid_from") { facts["valid_from"] = record["valid_from"]; } - if record.contains("valid_until") { facts["valid_until"] = record["valid_until"]; } + let licence_state = get_path(source_response, "/records/0/licence_state"); + if !is_missing(licence_state) { facts["licence_state"] = licence_state; } + let valid_from = get_path(source_response, "/records/0/valid_from"); + if !is_missing(valid_from) { facts["valid_from"] = valid_from; } + let valid_until = get_path(source_response, "/records/0/valid_until"); + if !is_missing(valid_until) { facts["valid_until"] = valid_until; } #{ outcome: "match", facts: facts diff --git a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml index be697d550..9c13b1bd7 100644 --- a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml +++ b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml @@ -36,6 +36,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 131072 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/source-c.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/acceptance/professional-licence/schemas/response.schema.yaml b/products/evidence/fixtures/acceptance/professional-licence/schemas/response.schema.yaml new file mode 100644 index 000000000..bacd66c70 --- /dev/null +++ b/products/evidence/fixtures/acceptance/professional-licence/schemas/response.schema.yaml @@ -0,0 +1,17 @@ +type: object +additionalProperties: false +required: [total, records] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + records: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [] + properties: + licence_state: {type: string, minLength: 1, maxLength: 32} + valid_from: {type: string, format: date} + valid_until: {type: string, format: date} diff --git a/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai index e19794042..a2af00768 100644 --- a/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai +++ b/products/evidence/fixtures/acceptance/residence-region/adapters/source-b.rhai @@ -1,19 +1,13 @@ fn extract(source_response, parameters) { - if !source_response.contains("total") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 { - throw("source_protocol_error"); - } - if source_response["total"] == 0 { + let total = source_response["total"]; + if total == 0 { if len(source_response) != 1 { throw("source_protocol_error"); } return #{outcome: "no_match"}; } - if source_response["total"] > 1 { return #{outcome: "ambiguous"}; } - if !source_response.contains("official_residence_code") { + if total > 1 { return #{outcome: "ambiguous"}; } + let official_residence_code = get_path(source_response, "/official_residence_code"); + if is_missing(official_residence_code) { return #{outcome: "match", facts: #{}}; } - if type_of(source_response["official_residence_code"]) != "string" { - throw("source_protocol_error"); - } - #{outcome: "match", facts: #{official_residence_code: source_response["official_residence_code"]}} + #{outcome: "match", facts: #{official_residence_code: official_residence_code}} } diff --git a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml index 6ef618e91..35d9a3e82 100644 --- a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml +++ b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml @@ -34,6 +34,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/source-b.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/acceptance/residence-region/schemas/response.schema.yaml b/products/evidence/fixtures/acceptance/residence-region/schemas/response.schema.yaml new file mode 100644 index 000000000..70d83308f --- /dev/null +++ b/products/evidence/fixtures/acceptance/residence-region/schemas/response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/conformance/selectors/evidence.yaml b/products/evidence/fixtures/conformance/selectors/evidence.yaml index 016e1f96a..bfb5f73c0 100644 --- a/products/evidence/fixtures/conformance/selectors/evidence.yaml +++ b/products/evidence/fixtures/conformance/selectors/evidence.yaml @@ -85,6 +85,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/classification-source.rhai factSchema: schemas/facts.schema.yaml context-source: @@ -109,6 +110,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/context-source.rhai factSchema: schemas/facts.schema.yaml grant-source: @@ -133,6 +135,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/grant-source.rhai factSchema: schemas/facts.schema.yaml relationship-source: @@ -162,6 +165,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/relationship-source.rhai factSchema: schemas/facts.schema.yaml opaque-source: @@ -186,6 +190,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/response.schema.yaml extractScript: adapters/opaque-source.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/conformance/selectors/schemas/response.schema.yaml b/products/evidence/fixtures/conformance/selectors/schemas/response.schema.yaml new file mode 100644 index 000000000..4e9673451 --- /dev/null +++ b/products/evidence/fixtures/conformance/selectors/schemas/response.schema.yaml @@ -0,0 +1,5 @@ +type: object +additionalProperties: false +required: [matched] +properties: + matched: {type: boolean} diff --git a/products/evidence/fixtures/conformance/supported-values/evidence.yaml b/products/evidence/fixtures/conformance/supported-values/evidence.yaml index 62f612e6c..3d43de967 100644 --- a/products/evidence/fixtures/conformance/supported-values/evidence.yaml +++ b/products/evidence/fixtures/conformance/supported-values/evidence.yaml @@ -72,6 +72,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 2 + responseSchema: schemas/response.schema.yaml extractScript: adapters/source.rhai factSchema: schemas/facts.schema.yaml authorityProfiles: diff --git a/products/evidence/fixtures/conformance/supported-values/schemas/response.schema.yaml b/products/evidence/fixtures/conformance/supported-values/schemas/response.schema.yaml new file mode 100644 index 000000000..71d1d5335 --- /dev/null +++ b/products/evidence/fixtures/conformance/supported-values/schemas/response.schema.yaml @@ -0,0 +1,6 @@ +type: object +additionalProperties: false +required: [total] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + marker: {type: string, minLength: 1, maxLength: 32} diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml index 8fa9a1207..a30a9a3b6 100644 --- a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/contract.yaml @@ -47,6 +47,7 @@ validated_source_definition: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 4 + responseSchema: schemas/response.schema.yaml extractScript: adapters/nested-paged-rest.rhai factSchema: schemas/facts.schema.yaml reference_shape: DHIS2 Tracker 2.43 diff --git a/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/response.schema.yaml b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/response.schema.yaml new file mode 100644 index 000000000..4ef7aa0e3 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/dhis2-tracker-style/schemas/response.schema.yaml @@ -0,0 +1,51 @@ +# Shape of the projected tracker response, checked before extraction runs. +# The prepared request asks for page 1 of size 2, so the pager echo is a +# constant here rather than a comparison the script has to repeat. +type: object +additionalProperties: false +required: [pager, trackedEntities] +properties: + pager: + type: object + additionalProperties: false + required: [page, pageSize, total, pageCount] + properties: + page: + type: integer + const: 1 + pageSize: + type: integer + const: 2 + total: + type: integer + minimum: 0 + maximum: 2 + pageCount: + type: integer + minimum: 0 + maximum: 1 + trackedEntities: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [attributes] + properties: + attributes: + type: array + minItems: 0 + maxItems: 64 + items: + type: object + additionalProperties: false + required: [attribute, value] + properties: + attribute: + type: string + minLength: 1 + maxLength: 64 + value: + type: string + maxLength: 512 diff --git a/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml b/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml index 61ee06bae..28c5b01e1 100644 --- a/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml +++ b/products/evidence/fixtures/source-shapes/flat-rest/contract.yaml @@ -30,6 +30,7 @@ validated_source_definition: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 4 + responseSchema: schemas/response.schema.yaml extractScript: adapters/flat-rest.rhai factSchema: schemas/facts.schema.yaml request: diff --git a/products/evidence/fixtures/source-shapes/flat-rest/schemas/response.schema.yaml b/products/evidence/fixtures/source-shapes/flat-rest/schemas/response.schema.yaml new file mode 100644 index 000000000..250748b00 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/flat-rest/schemas/response.schema.yaml @@ -0,0 +1,34 @@ +# Shape of the projected lookup response, checked before extraction runs. +# The source reports an explicit null result where it found nothing, so the +# result leaf is written as the nullable pair the response subset admits; the +# script reads that null with is_missing, exactly as it reads an absent leaf. +type: object +additionalProperties: false +required: [total] +properties: + total: + type: integer + minimum: 0 + maximum: 1000000 + result: + type: [object, "null"] + additionalProperties: false + required: [] + properties: + fact_code: + type: string + minLength: 1 + maxLength: 32 + results: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [] + properties: + fact_code: + type: string + minLength: 1 + maxLength: 32 diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml index 977820538..1440131fa 100644 --- a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/contract.yaml @@ -43,6 +43,7 @@ validated_source_definition: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 4 + responseSchema: schemas/response.schema.yaml extractScript: adapters/event-search-json.rhai factSchema: schemas/facts.schema.yaml reference_shape: OpenCRVS v2 Event Search JSON diff --git a/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/response.schema.yaml b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/response.schema.yaml new file mode 100644 index 000000000..37e077ad0 --- /dev/null +++ b/products/evidence/fixtures/source-shapes/opencrvs-record-search-style/schemas/response.schema.yaml @@ -0,0 +1,23 @@ +# Shape of the projected event-search response, checked before extraction runs. +# dateOfEvent is optional because projection drops a leaf the record did not +# carry, which is the case this shape proves an adapter must handle. +type: object +additionalProperties: false +required: [total, results] +properties: + total: + type: integer + minimum: 0 + maximum: 1000000 + results: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [] + properties: + dateOfEvent: + type: string + format: date diff --git a/products/evidence/reference/request-adapter/ADAPTER-API.md b/products/evidence/reference/request-adapter/ADAPTER-API.md index a91393b3c..b45eb2085 100644 --- a/products/evidence/reference/request-adapter/ADAPTER-API.md +++ b/products/evidence/reference/request-adapter/ADAPTER-API.md @@ -102,6 +102,7 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + responseSchema: schemas/source-a-response.schema.yaml extractScript: adapters/source-a-extract.rhai factSchema: schemas/source-a-facts.schema.yaml requirements: @@ -228,6 +229,34 @@ An empty projection, duplicate path, invalid escape, overlapping ancestor and descendant paths, or path that cannot be reconciled with another selected path fails bundle validation. +### Declared response shape + +Every source declares a required `responseSchema`: a bundle-relative closed +JSON Schema, in the same subset as `adapterParametersSchema` and `factSchema`, +describing the projected tree. Rust validates the projected response against it +after projection and before conversion to Rhai, so a response outside the shape +the adapter was reviewed against never reaches a script; that is a +source-protocol failure like any other. + +Two rules differ from the fact and adapter-parameter roles, because the +projected tree is not the wire response: + +- It may require fewer members than it declares properties. Projection drops a + selected leaf the record did not carry, and a page decided ambiguous is never + read record by record, so a record on that page need not be complete. +- A node may write its type as the pair `[T, "null"]`. A source that reports an + explicit null where it holds no value has that null carried through + projection verbatim, and it reaches the script as the same unit marker + `is_missing` already reads. This is the only union the subset admits, and + only in this role: a fact and an adapter parameter are never null. + +State in the schema what a shape can state: member presence where it is +guaranteed, member types, array bounds and uniqueness, string bounds and +formats, and enumerated or constant values. What remains for the script is what +a shape cannot state, such as how a reported total agrees with the records +returned, page-count arithmetic, and which values must agree with the closed +adapter parameters. + Response byte limits and JSON parsing bounds apply before projection, so the configured `maximumResponseBytes` describes the wire body Evidence is willing to read. The projected tree is bounded separately: it must serialize to at most @@ -518,6 +547,7 @@ Rust can supply through derivation context. | `required` | `(value, safe_error_code) -> value`; unit becomes the closed unavailable outcome; the code must match `[a-z][a-z0-9_]*` and be at most 64 ASCII bytes | | `required` code handling | The bundle-owned code is validated for shape and then discarded. It documents the reviewed script; it never reaches the public problem, audit, logs, or the raised signal, which is a host-private, unforgeable, uncatchable value | | `is_missing` | `value -> bool`; true only for unit | +| `get_path` | `(value, json_pointer) -> value`; resolves one RFC 6901 pointer and returns unit when any segment resolves to nothing. Only `~0` and `~1` escapes; an array segment is a non-negative decimal integer with no leading zero. A pointer that is not resolvable syntax, exceeds 256 bytes, or exceeds 16 segments is a script fault and fails the invocation rather than answering missing | `NumericBucket` has exactly `minimumInclusive: Decimal`, `maximumExclusive: Decimal`, and `code: string`. `LegalLocalTime` is another @@ -597,6 +627,8 @@ These engine ceilings apply independently of script logic: | Entries per codelist | 4,096 | | Numeric buckets | 64 | | Entity-reference seeds in one derived value | 64 | +| `get_path` pointer | 256 bytes | +| `get_path` pointer segments | 16 | | Entity-reference seed input | 512 bytes | | `required` error code | 64 ASCII bytes | | Exact decimal precision | 28 significant digits | diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 73056ee6d..ccee14632 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -231,6 +231,7 @@ sources: kind: static-bearer tokenRef: secret:file/source-token request: {} + responseSchema: schemas/source-a-response.schema.yaml extractScript: adapters/source-a-extract.rhai factSchema: schemas/source-a-facts.schema.yaml ``` @@ -243,9 +244,28 @@ sources: | `tlsTrustProfile` | no | Logical profile name bound by `runtime.yaml`. Omission uses configured system roots only. | | `authentication` | yes | One closed source-authentication profile below. | | `request` | yes | One fixed evidence-data request plan. | +| `responseSchema` | yes | Bundle-relative closed JSON Schema for the projected source response. Checked before `extract/2` runs. | | `extractScript` | yes | Bundle-relative Rhai script implementing `extract/2`. | | `factSchema` | yes | Bundle-relative closed JSON Schema for match facts. | +`responseSchema` states the shape the adapter was reviewed against, so the +script never has to prove it by hand. A response outside that shape is a +source-protocol failure and no script runs. Two rules differ from the fact and +adapter-parameter roles, because the projected tree is not the wire response: + +- A response schema may require fewer members than it declares properties. + Projection drops a selected leaf the record did not carry, and a page decided + ambiguous is never read record by record, so a record on that page need not + be complete. +- A response schema node may write its type as the pair `[T, "null"]`. A source + that reports an explicit null where it holds no value has that null carried + through projection verbatim; the script reads it with `is_missing`, exactly as + it reads an absent leaf. This is the only union the subset admits. + +What stays with the script is what a shape cannot state: how a reported total +agrees with the records returned, page-count arithmetic, uniqueness across +fields, and which values must agree with the closed adapter parameters. + ### Source authentication All secret references are logical. Rust resolves them only after authorization, diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai index 23bba942f..cb99dc4c8 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai @@ -1,33 +1,11 @@ -// The provider reports the full pager only because the prepared request asks -// for it. Requiring every declared pager field, and requiring the page count -// to agree with the total, keeps a truncated or inconsistent envelope from -// being read as an authoritative no-match or unique match. +// The response schema has already refused any envelope outside the declared +// pager and record shape. What is left is the part a shape cannot state: +// requiring the page count to agree with the total, so a truncated or +// inconsistent envelope is not read as an authoritative no-match or unique +// match, and matching the one attribute this requirement names. fn extract(source_response, parameters) { - if !source_response.contains("pager") || - !source_response.contains("trackedEntities") || - type_of(source_response["pager"]) != "map" || - type_of(source_response["trackedEntities"]) != "array" { - throw("source_protocol_error"); - } - let pager = source_response["pager"]; let records = source_response["trackedEntities"]; - if pager.len() != 4 || - !pager.contains("page") || - !pager.contains("pageSize") || - !pager.contains("total") || - !pager.contains("pageCount") || - type_of(pager["page"]) != "i64" || - type_of(pager["pageSize"]) != "i64" || - type_of(pager["total"]) != "i64" || - type_of(pager["pageCount"]) != "i64" || - pager["page"] != 1 || - pager["pageSize"] != 2 || - pager["total"] < 0 || - records.len > 2 { - throw("source_protocol_error"); - } - let total = pager["total"]; if pager["pageCount"] != (total + pager["pageSize"] - 1) / pager["pageSize"] { throw("source_protocol_error"); @@ -44,25 +22,12 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - if !record.contains("trackedEntity") || - type_of(record["trackedEntity"]) != "string" || - !record.contains("attributes") || - type_of(record["attributes"]) != "array" { - throw("source_protocol_error"); - } - + // The attribute is named by an adapter parameter, and a register that + // reports it twice has no single value to read, so this loop stays. let date_of_birth = (); let matches = 0; for attribute in record["attributes"] { - if type_of(attribute) != "map" || - !attribute.contains("attribute") || - !attribute.contains("value") { - throw("source_protocol_error"); - } if attribute["attribute"] == parameters["dateOfBirthAttribute"] { - if type_of(attribute["value"]) != "string" { - throw("source_protocol_error"); - } date_of_birth = attribute["value"]; matches += 1; } diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai index 547d3a98c..539c717b5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai @@ -1,23 +1,20 @@ // The licence register nests the governing state one level deeper than the // population register: the tracked entity carries the licence validity dates, // its enrollment in the licence programme carries the licence state, and that -// enrollment's events carry any recorded practice restriction. Extraction reads -// exactly that nesting and nothing else. +// enrollment's events carry any recorded practice restriction. The response +// schema states that nesting, so this script reads it without re-checking it, +// and keeps only the readings a shape cannot express. fn licence_enrollments(record, parameters) { let selected = []; - if !record.contains("enrollments") { + // A tracked entity with no enrollment at all is a record carrying no + // licence state, not a malformed response, so absence is read as an empty + // selection. + let enrollments = get_path(record, "/enrollments"); + if is_missing(enrollments) { return selected; } - if type_of(record["enrollments"]) != "array" { - throw("source_protocol_error"); - } - for enrollment in record["enrollments"] { - if type_of(enrollment) != "map" || - !enrollment.contains("program") || - type_of(enrollment["program"]) != "string" { - throw("source_protocol_error"); - } + for enrollment in enrollments { if enrollment["program"] == parameters["program"] { selected.push(enrollment); } @@ -38,21 +35,12 @@ fn unrecorded_restriction(parameters) { } fn restriction_recorded(enrollment, parameters) { - if !enrollment.contains("events") { + let events = get_path(enrollment, "/events"); + if is_missing(events) { return unrecorded_restriction(parameters); } - if type_of(enrollment["events"]) != "array" { - throw("source_protocol_error"); - } let stage_present = false; - for event in enrollment["events"] { - if type_of(event) != "map" || - !event.contains("programStage") || - !event.contains("status") || - type_of(event["programStage"]) != "string" || - type_of(event["status"]) != "string" { - throw("source_protocol_error"); - } + for event in events { if event["programStage"] == parameters["restrictionStage"] { if event["status"] == parameters["completedEventStatus"] { return true; @@ -67,31 +55,8 @@ fn restriction_recorded(enrollment, parameters) { } fn extract(source_response, parameters) { - if !source_response.contains("pager") || - !source_response.contains("trackedEntities") || - type_of(source_response["pager"]) != "map" || - type_of(source_response["trackedEntities"]) != "array" { - throw("source_protocol_error"); - } - let pager = source_response["pager"]; let records = source_response["trackedEntities"]; - if pager.len() != 4 || - !pager.contains("page") || - !pager.contains("pageSize") || - !pager.contains("total") || - !pager.contains("pageCount") || - type_of(pager["page"]) != "i64" || - type_of(pager["pageSize"]) != "i64" || - type_of(pager["total"]) != "i64" || - type_of(pager["pageCount"]) != "i64" || - pager["page"] != 1 || - pager["pageSize"] != 2 || - pager["total"] < 0 || - records.len > 2 { - throw("source_protocol_error"); - } - let total = pager["total"]; if pager["pageCount"] != (total + pager["pageSize"] - 1) / pager["pageSize"] { throw("source_protocol_error"); @@ -108,34 +73,19 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - if !record.contains("trackedEntity") || - type_of(record["trackedEntity"]) != "string" || - !record.contains("attributes") || - type_of(record["attributes"]) != "array" { - throw("source_protocol_error"); - } - + // The validity attributes are named by adapter parameters, and a register + // that reports one of them twice has no single value to read, so this loop + // stays. let valid_from = (); let valid_until = (); let from_matches = 0; let until_matches = 0; for attribute in record["attributes"] { - if type_of(attribute) != "map" || - !attribute.contains("attribute") || - !attribute.contains("value") { - throw("source_protocol_error"); - } if attribute["attribute"] == parameters["validFromAttribute"] { - if type_of(attribute["value"]) != "string" { - throw("source_protocol_error"); - } valid_from = attribute["value"]; from_matches += 1; } if attribute["attribute"] == parameters["validUntilAttribute"] { - if type_of(attribute["value"]) != "string" { - throw("source_protocol_error"); - } valid_until = attribute["value"]; until_matches += 1; } @@ -156,10 +106,6 @@ fn extract(source_response, parameters) { } if enrollments.len == 1 { let enrollment = enrollments[0]; - if !enrollment.contains("status") || - type_of(enrollment["status"]) != "string" { - throw("source_protocol_error"); - } facts["licence_state"] = enrollment["status"]; let restricted = restriction_recorded(enrollment, parameters); if type_of(restricted) == "bool" { diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml index 387d9057f..2dfc62d33 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml @@ -97,6 +97,8 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/adult-status-response.schema.yaml extractScript: adapters/adult-status-extract.rhai factSchema: schemas/adult-status-facts.schema.yaml professional-licence-register: @@ -156,6 +158,8 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/professional-licence-response.schema.yaml extractScript: adapters/professional-licence-extract.rhai factSchema: schemas/professional-licence-facts.schema.yaml authorityProfiles: diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml new file mode 100644 index 000000000..0ccbdc0dc --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml @@ -0,0 +1,60 @@ +# Shape of the projected tracker response, checked before extraction runs. +# The prepared request asks for page 1 of size 2, so the pager echo is a +# constant here rather than a comparison the script has to repeat. Whether the +# page count agrees with the total stays with the script: that is arithmetic +# between fields, not a shape. +type: object +additionalProperties: false +required: [pager, trackedEntities] +properties: + pager: + type: object + additionalProperties: false + required: [page, pageSize, total, pageCount] + properties: + page: + type: integer + const: 1 + pageSize: + type: integer + const: 2 + total: + type: integer + minimum: 0 + maximum: 1000000 + pageCount: + type: integer + minimum: 0 + maximum: 1000000 + trackedEntities: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [trackedEntity, attributes] + properties: + trackedEntity: + type: string + minLength: 1 + maxLength: 64 + attributes: + # A closed schema must bound every array. This ceiling is a shape + # limit on one tracked entity's attribute list, well above what the + # programme records, not a statement about which attributes matter. + type: array + minItems: 0 + maxItems: 64 + items: + type: object + additionalProperties: false + required: [attribute, value] + properties: + attribute: + type: string + minLength: 1 + maxLength: 64 + value: + type: string + maxLength: 512 diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml new file mode 100644 index 000000000..bc3ae91fb --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml @@ -0,0 +1,100 @@ +# Shape of the projected tracker response, checked before extraction runs. +# The prepared request asks for page 1 of size 2, so the pager echo is a +# constant here rather than a comparison the script has to repeat. Whether the +# page count agrees with the total stays with the script: that is arithmetic +# between fields, not a shape. +type: object +additionalProperties: false +required: [pager, trackedEntities] +properties: + pager: + type: object + additionalProperties: false + required: [page, pageSize, total, pageCount] + properties: + page: + type: integer + const: 1 + pageSize: + type: integer + const: 2 + total: + type: integer + minimum: 0 + maximum: 1000000 + pageCount: + type: integer + minimum: 0 + maximum: 1000000 + trackedEntities: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + # enrollments is optional because a tracked entity may hold none, which + # the script reads as a record carrying no licence state. + required: [trackedEntity, attributes] + properties: + trackedEntity: + type: string + minLength: 1 + maxLength: 64 + attributes: + # A closed schema must bound every array. This ceiling is a shape + # limit on one tracked entity's attribute list, well above what the + # programme records, not a statement about which attributes matter. + type: array + minItems: 0 + maxItems: 64 + items: + type: object + additionalProperties: false + required: [attribute, value] + properties: + attribute: + type: string + minLength: 1 + maxLength: 64 + value: + type: string + maxLength: 512 + enrollments: + type: array + minItems: 0 + maxItems: 16 + items: + type: object + additionalProperties: false + # The projection selects program and status for every enrollment, + # so an enrollment returned without either is a source shape this + # adapter does not understand. events is optional: an enrollment + # that recorded none is the case the restriction reading covers. + required: [program, status] + properties: + program: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + minLength: 1 + maxLength: 64 + events: + type: array + minItems: 0 + maxItems: 64 + items: + type: object + additionalProperties: false + required: [programStage, status] + properties: + programStage: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + minLength: 1 + maxLength: 64 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai index 61d6fb399..aef626d56 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-adult-extract.rhai @@ -1,16 +1,11 @@ +// The response schema has already refused anything outside the declared shape, +// so what remains here is what a shape cannot state: how total relates to the +// records returned, and which values must agree with the adapter parameters. fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("total") || - !source_response.contains("results") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 || - type_of(source_response["results"]) != "array" || - source_response["results"].len > parameters["resultLimit"] { - throw("source_protocol_error"); - } - let total = source_response["total"]; let results = source_response["results"]; + if results.len > parameters["resultLimit"] { throw("source_protocol_error"); } + if total == 0 { if results.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -22,14 +17,13 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; - if !result.contains("type") || - !result.contains("status") || - result["type"] != parameters["eventType"] || + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. The date field is named by an + // adapter parameter, so its presence is parameter-dependent besides. + if result["type"] != parameters["eventType"] || result["status"] != parameters["registeredStatus"] || - !result.contains("trackingId") || - type_of(result["trackingId"]) != "string" || - !result.contains(parameters["dateField"]) || - type_of(result[parameters["dateField"]]) != "string" { + is_missing(result["trackingId"]) || + !result.contains(parameters["dateField"]) { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai index 7fffdd951..916daf387 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai @@ -1,16 +1,12 @@ +// The response schema has already refused anything outside the declared shape, +// so what remains here is what a shape cannot state: how total relates to the +// records returned, which values must agree with the adapter parameters, and +// how many distinct parent references a registered record must carry. fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("total") || - !source_response.contains("results") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 || - type_of(source_response["results"]) != "array" || - source_response["results"].len > parameters["resultLimit"] { - throw("source_protocol_error"); - } - let total = source_response["total"]; let results = source_response["results"]; + if results.len > parameters["resultLimit"] { throw("source_protocol_error"); } + if total == 0 { if results.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -22,23 +18,18 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; - if !result.contains("type") || - !result.contains("status") || - result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] || - !result.contains("trackingId") || - type_of(result["trackingId"]) != "string" || - !result.contains("declaration") || - type_of(result["declaration"]) != "map" { + if result["type"] != parameters["eventType"] || + result["status"] != parameters["registeredStatus"] { throw("source_protocol_error"); } + // The reference fields are named by an adapter parameter, and two named + // fields must not resolve to the same person, so this loop stays. let parent_references = []; for field in parameters["parentReferenceFields"] { if result["declaration"].contains(field) { let reference = result["declaration"][field]; - if type_of(reference) != "string" || reference == "" || - list_contains(parent_references, reference) { + if list_contains(parent_references, reference) { throw("source_protocol_error"); } parent_references.push(reference); diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index fc5c8fcfa..0211f91f6 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -102,6 +102,8 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 262144 concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/birth-adult-response.schema.yaml extractScript: adapters/birth-adult-extract.rhai factSchema: schemas/birth-adult-facts.schema.yaml registered-birth-parents: @@ -164,6 +166,8 @@ sources: timeoutMilliseconds: 3000 maximumResponseBytes: 262144 concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/birth-parents-response.schema.yaml extractScript: adapters/birth-parents-extract.rhai factSchema: schemas/birth-parents-facts.schema.yaml authorityProfiles: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml new file mode 100644 index 000000000..53cc64688 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml @@ -0,0 +1,39 @@ +# Shape of the projected event-search response, checked before extraction runs. +# maxItems states the same page ceiling the request asks for, so an oversized +# page is refused before any record reaches the script. +type: object +additionalProperties: false +required: [total, results] +properties: + total: + type: integer + minimum: 0 + maximum: 1000000 + results: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + # Nothing can be required of every record here. Projection drops a leaf + # the record did not carry, and an ambiguous page is decided before any + # record is read, so a record on that page need not be complete. Which + # leaves a single matched record must carry stays with the script. + required: [] + properties: + type: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + minLength: 1 + maxLength: 64 + trackingId: + type: string + minLength: 1 + maxLength: 64 + dateOfEvent: + type: string + format: date diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml new file mode 100644 index 000000000..4203ebc18 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml @@ -0,0 +1,49 @@ +# Shape of the projected event-search response, checked before extraction runs. +# maxItems states the same page ceiling the request asks for, so an oversized +# page is refused before any record reaches the script. +type: object +additionalProperties: false +required: [total, results] +properties: + total: + type: integer + minimum: 0 + maximum: 1000000 + results: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [type, status, trackingId, declaration] + properties: + type: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + minLength: 1 + maxLength: 64 + trackingId: + type: string + minLength: 1 + maxLength: 64 + declaration: + type: object + additionalProperties: false + # Either parent reference may be absent from a registered record, so + # neither is required here. How many must be present, and whether the + # two may repeat one person, stays with the script: both are relations + # between fields that a shape cannot state. + required: [] + properties: + mother.personReference: + type: string + minLength: 1 + maxLength: 128 + father.personReference: + type: string + minLength: 1 + maxLength: 128 diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai b/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai index e068e1ec9..90be5ed38 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai +++ b/products/evidence/reference/request-adapter/dhis2-tracker/extract.rhai @@ -1,30 +1,12 @@ // Extraction runs with fresh state and receives no selectors or request parts. +// The response schema has already refused any envelope outside the declared +// pager and record shape, so what remains is the part a shape cannot state: +// the page count agreeing with the total, and the one attribute this +// requirement names. fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("pager") || - !source_response.contains("trackedEntities") { - throw("source_protocol_error"); - } - let pager = source_response["pager"]; let records = source_response["trackedEntities"]; - if len(pager) != 4 || - !pager.contains("page") || - !pager.contains("pageSize") || - !pager.contains("total") || - !pager.contains("pageCount") || - type_of(pager["page"]) != "i64" || - type_of(pager["pageSize"]) != "i64" || - type_of(pager["total"]) != "i64" || - type_of(pager["pageCount"]) != "i64" || - pager["page"] != 1 || - pager["pageSize"] != 2 || - pager["total"] < 0 || - pager["pageCount"] < 0 || - records.len > 2 { - throw("source_protocol_error"); - } let expected_page_count = (pager["total"] + pager["pageSize"] - 1) / pager["pageSize"]; @@ -46,25 +28,14 @@ fn extract(source_response, parameters) { return #{outcome: "ambiguous"}; } - if pager["total"] != 1 || records.len != 1 { + if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - if len(record) != 2 || - !record.contains("trackedEntity") || - !record.contains("attributes") { - throw("source_protocol_error"); - } - let found_status = false; let status_code = (); for attribute in record["attributes"] { - if len(attribute) != 2 || - !attribute.contains("attribute") || - !attribute.contains("value") { - throw("source_protocol_error"); - } if attribute["attribute"] == parameters["statusAttribute"] { if found_status { throw("source_protocol_error"); diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml new file mode 100644 index 000000000..38daa53c4 --- /dev/null +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml @@ -0,0 +1,37 @@ +# Shape of the projected tracker response, checked by Rust before extract/2 +# runs. The prepared request asks for page 1 of size 2, so the pager echo is a +# constant here rather than a comparison the script repeats. +type: object +additionalProperties: false +required: [pager, trackedEntities] +properties: + pager: + type: object + additionalProperties: false + required: [page, pageSize, total, pageCount] + properties: + page: {type: integer, const: 1} + pageSize: {type: integer, const: 2} + total: {type: integer, minimum: 0, maximum: 1000000} + pageCount: {type: integer, minimum: 0, maximum: 1000000} + trackedEntities: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [trackedEntity, attributes] + properties: + trackedEntity: {type: string, minLength: 1, maxLength: 64} + attributes: + type: array + minItems: 0 + maxItems: 64 + items: + type: object + additionalProperties: false + required: [attribute, value] + properties: + attribute: {type: string, minLength: 1, maxLength: 64} + value: {type: string, maxLength: 512} diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml index 49cc065e0..d4d74aea1 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml +++ b/products/evidence/reference/request-adapter/dhis2-tracker/source.yaml @@ -50,5 +50,6 @@ request: timeoutMilliseconds: 3000 maximumResponseBytes: 65536 concurrencyLimit: 4 +responseSchema: response.schema.yaml extractScript: extract.rhai factSchema: facts.schema.yaml diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai index 5c2332d92..49c6f894f 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai @@ -1,20 +1,17 @@ // Extraction validates one exact child-event result and returns only the // configured complete parent-reference set. Relationship comparison belongs // to the selector-aware requirement derivation. +// +// The response schema has already refused anything outside the declared shape, +// so what remains is the part a shape cannot state: how total relates to the +// records returned, which values must agree with the adapter parameters, and +// how many distinct parent references a registered record must carry. fn extract(source_response, parameters) { - if len(source_response) != 2 || - !source_response.contains("total") || - !source_response.contains("results") || - type_of(source_response["total"]) != "i64" || - source_response["total"] < 0 || - type_of(source_response["results"]) != "array" || - source_response["results"].len > parameters["resultLimit"] { - throw("source_protocol_error"); - } - let total = source_response["total"]; let results = source_response["results"]; + if results.len > parameters["resultLimit"] { throw("source_protocol_error"); } + if total == 0 { if results.len != 0 { throw("source_protocol_error"); } return #{outcome: "no_match"}; @@ -26,14 +23,8 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; - if !result.contains("type") || - !result.contains("status") || - result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] || - !result.contains("trackingId") || - type_of(result["trackingId"]) != "string" || - !result.contains("declaration") || - type_of(result["declaration"]) != "map" { + if result["type"] != parameters["eventType"] || + result["status"] != parameters["registeredStatus"] { throw("source_protocol_error"); } @@ -41,8 +32,7 @@ fn extract(source_response, parameters) { for field in parameters["parentReferenceFields"] { if result["declaration"].contains(field) { let reference = result["declaration"][field]; - if type_of(reference) != "string" || reference == "" || - list_contains(parent_references, reference) { + if list_contains(parent_references, reference) { throw("source_protocol_error"); } parent_references.push(reference); diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml new file mode 100644 index 000000000..75d8853b5 --- /dev/null +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml @@ -0,0 +1,28 @@ +# Shape of the projected event-search response, checked by Rust before +# extract/2 runs. Neither parent reference is required: a registered record may +# name one parent or two, and how many suffice is a requirement decision the +# script keeps. +type: object +additionalProperties: false +required: [total, results] +properties: + total: {type: integer, minimum: 0, maximum: 1000000} + results: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [type, status, trackingId, declaration] + properties: + type: {type: string, minLength: 1, maxLength: 64} + status: {type: string, minLength: 1, maxLength: 64} + trackingId: {type: string, minLength: 1, maxLength: 64} + declaration: + type: object + additionalProperties: false + required: [] + properties: + mother.personReference: {type: string, minLength: 1, maxLength: 128} + father.personReference: {type: string, minLength: 1, maxLength: 128} diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml index 1584619bb..06db7da48 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/source.yaml @@ -60,5 +60,6 @@ request: timeoutMilliseconds: 3000 maximumResponseBytes: 262144 concurrencyLimit: 4 +responseSchema: response.schema.yaml extractScript: extract.rhai factSchema: facts.schema.yaml From 3a3c24ac6ec1e3142fa1d3a5c1df3cb24367e9cb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:18:35 +0700 Subject: [PATCH 029/136] docs: add Notary retirement and Evidence onboarding plan, DoD, and /goal Track the approved consolidation plan as a repo-level artifact: retire Notary, keep Relay, land Evidence onboarding with CI-verified tutorials, and prove the Evidence-over-Relay composition. The /goal command drives execution one DoD item at a time and keeps the plan's checklist and status log current. Narrow the .claude/ ignore so only the shared project commands directory is tracked. Signed-off-by: Jeremi Joslin --- .claude/commands/goal.md | 29 +++ .gitignore | 6 +- ...tary-retirement-and-evidence-onboarding.md | 175 ++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 .claude/commands/goal.md create mode 100644 plans/notary-retirement-and-evidence-onboarding.md diff --git a/.claude/commands/goal.md b/.claude/commands/goal.md new file mode 100644 index 000000000..73fcac858 --- /dev/null +++ b/.claude/commands/goal.md @@ -0,0 +1,29 @@ +--- +description: Execute the next step of the Notary retirement and Evidence onboarding plan +argument-hint: [DoD item id, e.g. B5, or a workstream letter; empty picks the next item] +--- + +Work the tracked plan at `plans/notary-retirement-and-evidence-onboarding.md`. + +1. Read the plan in full: decisions, constraints, DoD checklist, dependency + order, status log. The decisions are settled; do not relitigate them. +2. Select work. If `$ARGUMENTS` names a DoD item or workstream, target it. + Otherwise pick the first unchecked item whose dependencies are satisfied, + preferring workstream B (onboarding is the standing priority). +3. Announce the selected item and its intended shape in one short message, + then execute. Only stop for input when the item is security-sensitive, + requires a decision the plan reserves for Jeremi (A1's Mint-for-Relay + branch, G4 re-approval), or turns out to conflict with a constraint. +4. For code: TDD, failing test first. The frozen Evidence V1 rules in + `AGENTS.md` and `products/evidence/AGENTS.md` apply; composition work + must not touch Evidence production code. +5. Verify with the gates listed in the plan's Verification section for every + area touched. All must pass; paste the evidence in the report. +6. Update the plan file in the same commit as the work: tick the DoD + checkbox, append a dated status log line (absolute dates). +7. Commit with `git commit -s` and a conventional prefix. Stage only files + belonging to this item; never sweep unrelated worktree changes. +8. Report: what changed, verification evidence, and the next unblocked item. + +Never: modify frozen Evidence V1 contracts, hand-edit generated artifacts, +scrub Notary from history pages, or log secrets. diff --git a/.gitignore b/.gitignore index 1ccb102e4..69648d06c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,10 @@ __pycache__/ .DS_Store -# Local agent tooling (private skills are symlinked here; never commit) -/.claude/ +# Local agent tooling (private skills are symlinked here; never commit). +# Only the shared project commands in /.claude/commands/ are tracked. +/.claude/* +!/.claude/commands/ /.playwright-mcp/ /worktrees/ /*.png diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md new file mode 100644 index 000000000..805eff2e9 --- /dev/null +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -0,0 +1,175 @@ +# Notary retirement and Evidence onboarding + +Retire Registry Notary, keep Registry Relay, and make Evidence the +documented, demoable assertion product with an onboarding-first docs rework. +This file is the single tracked plan and Definition of Done for that effort. +Execute it with the `/goal` command (`.claude/commands/goal.md`), which works +one DoD item at a time and updates this file as items complete. + +Created 2026-08-03. Owner: Jeremi Joslin. + +## Decisions already made (do not relitigate) + +1. Notary retires: crates, product material, docs product surface, CI gates. + History pages (changelog, decision records) keep the name. +2. Relay stays a supported product. Evidence keeps its own name; the frozen + V1 wire identifiers and CCCEV alignment pin it. No rename. +3. The public story is two doors: Relay ("let this system read specific + data") and Evidence ("let this system learn only a fact"), with Mint as + supporting token issuance. +4. Evidence-over-Relay (Evidence consuming a Relay-protected API as a fixed + HTTP source) is a blessed pattern and must work, at minimum for demos. +5. Docs get an onboarding-first rework: two-door start page, an Evidence + tutorial track (E1-E6), and tutorials that stay CI-verified. +6. The Evidence tutorial gate is evidencectl-fixtures-based, separate from + the registryctl authoring gate. +7. solmara-lab is rebuilt as the composed demo (Relay + Mint + Evidence). +8. The archive story for retired Notary material is deferred. + +## Constraints + +- Evidence V1 stays frozen. The composition work must need zero Evidence + production-code changes; if a change appears necessary, stop and review + with Jeremi before writing it. +- Source-product neutrality and sanitized-mock rules from + `products/evidence/AGENTS.md` and the root `AGENTS.md` apply throughout. +- Generated artifacts are regenerated by their documented generators, never + hand-edited. Endpoint changes ship with their regenerated OpenAPI. +- Every commit is DCO-signed (`git commit -s`) with a conventional prefix. +- Changes to authentication, signing, audit, release provenance, or + deployment defaults carry explicit review notes. +- Never sweep unrelated in-flight worktree changes into a commit. +- History is not scrubbed: only the product surface drops Notary. + +## Workstreams and Definition of Done + +Each checkbox is one unit of work. Done means implemented, verified with the +gates for its area (see Verification), and committed. + +### A. Composition proof (Evidence over Relay) + +- [ ] A1. Token topology verified and recorded: establish whether Relay + embeds a client-credentials token endpoint (`registry-platform-sts`) + that Evidence's `SourceAuthentication::Oauth2ClientCredentials` can + target, or whether IdP-less deployments need Mint to issue for Relay + (a security-sensitive Relay change requiring review). Record the + outcome in the status log below. +- [ ] A2. An ordinary sanitized Relay-shaped mock test in + `crates/registry-evidence` proves a full signed assertion over an + OAuth client-credentials source, with zero production-code changes. +- [ ] A3. A reference deployment-project example for the Relay-backed + pattern exists under `products/evidence/reference`. + +### B. Evidence onboarding (docs site, evidencectl, CI) + +- [ ] B1. Site plumbing: `openapi-sources.yaml` entry for the generated + Evidence OpenAPI with a Redoc reference page; contracts wired into the + data-driven reference; Operate content from `OPERATOR-CONTRACT.md`; + Security content from the invariant matrix and test traceability; + a "Registry Evidence" Configure group. +- [ ] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an + existing gate or add one). +- [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or + tightly linked to the frozen contracts so it cannot drift. +- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job; + Evidence tutorials must pass it to merge. +- [ ] B5. Tutorials E1-E5 published and gated: first assertion via fixtures + (keygen, scaffold, fixture run); serve assertions over HTTP with a + Mint token; author an acceptance definition using the coequal neutral + examples; connect an institution source via sanitized local mock; + verify an assertion as a consumer. +- [ ] B6. Tutorial E6 (move Evidence to production signing) published, + derived from `OPERATOR-CONTRACT.md`. +- [ ] B7. Mint has real docs presence: a Configure page and a reference page. +- [ ] B8. Onboarding spine: glossary disambiguates Evidence (product) from + the retired Notary evidence credentials; `start/when-to-use` presents + the two doors; the quickstart ends in an Evidence assertion over the + Relay-protected API (this last flip needs A and D). + +### C. Notary deletion cascade + +- [ ] C1. Decisions on record before deletion: ROADMAP pilot line reframed + to Relay + Evidence; retirement decision page drafted (published in + C7); changelog entry drafted. +- [ ] C2. registryctl surgery complete: Notary compiler target, dev + credentials, deployment wiring, and release-lock entries removed; + registryctl builds and tests green as Relay-only tooling. +- [ ] C3. `registry-notary*` crates and `products/notary` deleted; Relay's + Notary dev-dependency contract tests dropped; workspace members, + `deny.toml`, and `Cargo.lock` updated; workspace green. +- [ ] C4. Platform crates with no remaining consumers deleted (candidates: + `oid4vci`, `pdp`, `replay`); verified with `cargo tree`, including + `registry-platform-testing` usage. +- [ ] C5. CI updated: Notary OpenAPI drift gate removed, Relay's kept, + Evidence gates confirmed present. +- [ ] C6. Release tooling: a new manifest without Notary components + validates; the source-model proof passes; the OpenID conformance + runner's fate decided and recorded (retire if it was OID4VCI-only). + Old manifests untouched. +- [ ] C7. Docs surface removal: authored Notary pages and the mirrored + `products/registry-notary` docset removed along with their + `repo-docs.yaml`, `docsets.yaml`, and `openapi-sources.yaml` config; + every removed URL redirects to its Evidence equivalent or the + retirement page; retirement page published; site description updated. + Ships with or after B1, in the same release as the code-pin advance. +- [ ] C8. Repo docs updated: `AGENTS.md` (Evidence boundary section + simplified), `CONTRIBUTING.md`, `README.md`. + +### D. solmara-lab rebuild (separate repo: registrystack/solmara-lab) + +- [ ] D1. Compose runs Relay + Mint + Evidence: spreadsheet source through + Relay, Evidence assertion over Relay's API, smoke suite green. +- [ ] D2. `tutorials/first-run-with-solmara-lab` rewritten against the + rebuilt demo and passing its gate. + +### E. Shared docs rewrites + +- [ ] E1. Heavy pages rewritten (~20: architecture, boundaries-and-map, + glossary, rs-terms, rs-arc-g, rs-sec-g, rs-op-posture, threat-model, + hardening-checklist, known-limitations, records-stay-home, + disclosure-modes-and-computed-answers, integration-patterns, + single-node-compose-behind-proxy, retention-and-persistent-state, + approve-initial-baseline, rotate-credentials-and-trust, + inspect-and-diagnose, environment-variables, errors, api-stability). +- [ ] E2. Light-touch pages updated (~25 pages with 1-9 Notary mentions). +- [ ] E3. Final sweep: a case-insensitive Notary search over the docs + product surface matches only history pages and the retirement page. + +### Global gates (checked at the end, not per item) + +- [ ] G1. Full verification suite green: `cargo fmt --check`, `cargo check + --locked --workspace --all-targets`, `cargo clippy --workspace + --all-targets -- -D warnings`, `cargo test --locked --workspace`, + `cargo deny check`, both Evidence scripts, release unit tests plus + manifest validation and the source-model check, docs `npm test` and + `npm run check`. +- [ ] G2. Every removed docs URL redirects; no product-surface page + describes Notary as current. +- [ ] G3. All tutorials pass their CI gates. +- [ ] G4. Frozen Evidence V1 contracts are byte-identical to the state at + plan creation, or a re-approval is recorded here. +- [ ] G5. Review notes exist for every security-sensitive change. + +## Dependency order + +A1 → A2 → A3. A → D. B1 → C7. C1 → C2 → C3 → C4. D → the B8 quickstart +flip. E ships with or after C7. Everything else is parallel; B has no +upstream dependencies and is the standing priority (onboarding first). + +## Verification (match to touched area) + +- Rust: `cargo fmt --check`, `cargo check --locked --workspace + --all-targets`, `cargo test --locked -p `, workspace tests when + platform crates change, `cargo clippy` before calling an item done. +- Evidence: `products/evidence/scripts/check-contracts.sh` and + `products/evidence/scripts/check-source-neutrality.sh`. +- Release: `python3 -m unittest release/scripts/test_registry_release.py`, + `release/scripts/registry-release validate release/manifests/.yaml`, + `REGISTRY_RELEASE_SOURCE_MODE=monorepo + release/scripts/check-release-source-model.sh` and its unit tests. +- Docs site (from `docs/site/`): `npm test` and `npm run check`. + +## Status log + +- 2026-08-03: Plan approved by Jeremi in session; `/goal` command added. + Execution not started. From 0feb5bde2ea6d719a6d0bcb6ba90dd347d092800 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:45:47 +0700 Subject: [PATCH 030/136] feat(evidence): add a curl | bash installer for the Evidence toolset evidencectl gains an install.sh mirroring the registryctl installer conventions: it installs evidence, evidencectl, and mint together or not at all, verifies every release asset against SHA256SUMS before anything reaches the install directory, refuses unpinned or non-canonical release tags, and supports an offline EVIDENCECTL_ASSET_DIR mode for higher-assurance installs after release/VERIFY.md. Eleven offline bash tests drive the script through fake curl and uname binaries; shellcheck and shfmt clean. Ticks F1 in plans/notary-retirement-and-evidence-onboarding.md. Review notes: distribution tooling that verifies release artifacts. It performs integrity verification only (SHA256SUMS) and states so; authenticity verification remains the documented release/VERIFY.md path. Failed or interrupted installs roll back to the previous toolset. No production code changes. Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/install.sh | 250 ++++++++++ .../tests/install_script.rs | 445 ++++++++++++++++++ ...tary-retirement-and-evidence-onboarding.md | 66 ++- 3 files changed, 756 insertions(+), 5 deletions(-) create mode 100644 crates/registry-evidencectl/install.sh create mode 100644 crates/registry-evidencectl/tests/install_script.rs diff --git a/crates/registry-evidencectl/install.sh b/crates/registry-evidencectl/install.sh new file mode 100644 index 000000000..9155ba8c9 --- /dev/null +++ b/crates/registry-evidencectl/install.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="registrystack/registry-stack" +binaries=(evidence evidencectl mint) +default_version="" +script_name="${BASH_SOURCE[0]:-}" +script_name="${script_name##*/}" +filename_version="" +if [[ "$script_name" =~ ^evidencectl-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))-install\.sh$ ]]; then + default_version="${BASH_REMATCH[1]}" + filename_version="$default_version" +fi +version="${EVIDENCECTL_VERSION:-$default_version}" +if [ -n "$filename_version" ] && + [ -n "${EVIDENCECTL_VERSION:-}" ] && + [ "$EVIDENCECTL_VERSION" != "$filename_version" ]; then + echo "Refusing a release override that does not match the versioned installer asset." >&2 + exit 1 +fi +install_dir="${EVIDENCECTL_INSTALL_DIR:-$HOME/.local/bin}" +asset_dir="${EVIDENCECTL_ASSET_DIR:-}" + +usage() { + cat </release/VERIFY.md + +Environment: + EVIDENCECTL_VERSION Release tag to install. A versioned installer + asset requires the matching filename release. + EVIDENCECTL_INSTALL_DIR Install directory. Defaults to ~/.local/bin. + EVIDENCECTL_ASSET_DIR Read already-downloaded release assets from this + directory instead of downloading them. +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "evidencectl installer needs '$1'." >&2 + exit 1 + fi +} + +if [ -z "$version" ]; then + echo "No release is pinned for this installer copy." >&2 + echo "Evidence binaries ship with releases that include them; set" >&2 + echo "EVIDENCECTL_VERSION to a pinned vMAJOR.MINOR.PATCH tag or run the" >&2 + echo "versioned evidencectl--install.sh asset from a release." >&2 + exit 1 +fi +if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Refusing non-canonical release tag '$version'." >&2 + echo "Set EVIDENCECTL_VERSION to a pinned vMAJOR.MINOR.PATCH tag." >&2 + exit 1 +fi + +need uname +if [ -z "$asset_dir" ]; then + need curl +fi + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os/$arch" in +Linux/x86_64 | Linux/amd64) + os_label="linux" + arch_label="amd64" + ;; +Linux/arm64 | Linux/aarch64) + os_label="linux" + arch_label="arm64" + ;; +Darwin/arm64 | Darwin/aarch64) + os_label="macos" + arch_label="arm64" + ;; +*) + printf 'No prebuilt Evidence toolset asset is published for %s/%s.\n' "$os" "$arch" >&2 + printf 'Supported platforms: Linux amd64, Linux arm64, and macOS arm64.\n' >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' "$repo" "$version" >&2 + exit 1 + ;; +esac + +base_url="https://github.com/${repo}/releases/download/${version}" +verify_url="https://github.com/${repo}/blob/${version}/release/VERIFY.md" +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t evidencectl)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +download() { + local src="$1" + local dest="$2" + if [ -n "$asset_dir" ]; then + local name="${src##*/}" + if [ ! -f "$asset_dir/$name" ]; then + return 1 + fi + cp "$asset_dir/$name" "$dest" + else + curl -fsSL "$src" -o "$dest" 2>/dev/null + fi +} + +if [ -n "$asset_dir" ]; then + printf 'Installing verified local Evidence toolset %s assets for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +else + printf 'Downloading the Evidence toolset %s for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +fi + +for binary in "${binaries[@]}"; do + asset="${binary}-${version}-${os_label}-${arch_label}" + if ! download "$base_url/$asset" "$tmpdir/$asset"; then + printf 'Could not read the published %s %s binary for %s/%s.\n' \ + "$binary" "$version" "$os_label" "$arch_label" >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + fi +done + +if ! download "$base_url/SHA256SUMS" "$tmpdir/SHA256SUMS"; then + echo "Could not download SHA256SUMS for checksum verification." >&2 + exit 1 +fi + +sha256_file() { + local path="$1" + local result + if command -v shasum >/dev/null 2>&1; then + result="$(shasum -a 256 "$path")" + elif command -v sha256sum >/dev/null 2>&1; then + result="$(sha256sum "$path")" + else + echo "evidencectl installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 + exit 1 + fi + printf '%s\n' "${result%% *}" +} + +verify_asset() { + local name="$1" + local expected_hash actual_hash + expected_hash="$(awk -v asset="$name" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" + if [ -z "$expected_hash" ]; then + echo "SHA256SUMS has no entry for $name" >&2 + exit 1 + fi + actual_hash="$(sha256_file "$tmpdir/$name")" + if [ "$actual_hash" != "$expected_hash" ]; then + echo "Checksum verification failed for $name" >&2 + echo "Expected: $expected_hash" >&2 + echo "Actual: $actual_hash" >&2 + exit 1 + fi +} + +for binary in "${binaries[@]}"; do + verify_asset "${binary}-${version}-${os_label}-${arch_label}" +done +printf 'Integrity checks passed: %s binaries matched SHA256SUMS.\n' "${#binaries[@]}" +cat <&2 + ;; +esac diff --git a/crates/registry-evidencectl/tests/install_script.rs b/crates/registry-evidencectl/tests/install_script.rs new file mode 100644 index 000000000..5878efd92 --- /dev/null +++ b/crates/registry-evidencectl/tests/install_script.rs @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use tempfile::TempDir; + +const TEST_VERSION: &str = "v9.8.7"; +const BINARIES: [&str; 3] = ["evidence", "evidencectl", "mint"]; + +#[cfg(unix)] +#[test] +fn installer_refuses_to_run_without_a_pinned_release() { + let fixture = InstallerFixture::new(); + let output = fixture.command_without_version().output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("EVIDENCECTL_VERSION"), "stderr: {stderr}"); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn installer_rejects_noncanonical_release_tags() { + for tag in ["9.8.7", "v9.8", "latest", "v09.8.7", "v9.8.7-rc1"] { + let fixture = InstallerFixture::for_release(tag); + let output = fixture.run(); + assert!(!output.status.success(), "tag {tag} must be refused"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("non-canonical"), + "tag {tag} stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); + } +} + +#[cfg(unix)] +#[test] +fn installer_help_describes_the_toolset_and_verification_contract() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command_without_version(); + command.arg("--help"); + let output = command.output().unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "evidence runtime", + "evidencectl adopter", + "mint token issuer", + "SHA256SUMS", + "EVIDENCECTL_ASSET_DIR", + "release/VERIFY.md", + ] { + assert!(stdout.contains(expected), "help must mention {expected}"); + } +} + +#[cfg(unix)] +#[test] +fn versioned_installer_asset_selects_its_own_release_without_an_override() { + let fixture = InstallerFixture::new(); + let versioned = fixture + .temp_path() + .join(format!("evidencectl-{TEST_VERSION}-install.sh")); + fs::copy(installer_path(), &versioned).unwrap(); + let output = fixture.command_for(&versioned, false).output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); +} + +#[cfg(unix)] +#[test] +fn versioned_installer_asset_rejects_a_mismatched_release_override() { + let fixture = InstallerFixture::new(); + let versioned = fixture + .temp_path() + .join(format!("evidencectl-{TEST_VERSION}-install.sh")); + fs::copy(installer_path(), &versioned).unwrap(); + let mut command = fixture.command_for(&versioned, false); + command.env("EVIDENCECTL_VERSION", "v1.2.3"); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Refusing a release override"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); +} + +#[cfg(unix)] +#[test] +fn installer_checksum_verifies_and_installs_all_three_binaries() { + let fixture = InstallerFixture::new(); + let output = fixture.run(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Integrity checks passed"), + "stdout: {stdout}" + ); + assert!( + stdout.contains("Authenticity check not performed"), + "stdout: {stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn verified_local_asset_mode_installs_without_network_downloads() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.env("EVIDENCECTL_ASSET_DIR", fixture.release_dir()); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + fixture.assert_toolset_installed(); + assert!( + !fixture.fake_curl_log().exists(), + "asset-dir mode must not download" + ); +} + +#[cfg(unix)] +#[test] +fn unsupported_platform_fails_before_download_without_a_partial_install() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.env("FAKE_UNAME_S", "SunOS"); + let output = command.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No prebuilt Evidence toolset asset"), + "stderr: {stderr}" + ); + assert!( + !fixture.fake_curl_log().exists(), + "must fail before download" + ); + assert!(!fixture.install_dir().exists(), "nothing may be installed"); +} + +#[cfg(unix)] +#[test] +fn missing_checksum_entry_refuses_the_whole_install() { + let fixture = InstallerFixture::new(); + fixture.rewrite_sums_without("evidencectl"); + let output = fixture.run(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("SHA256SUMS has no entry"), + "stderr: {stderr}" + ); + fixture.assert_nothing_installed(); +} + +#[cfg(unix)] +#[test] +fn checksum_failure_preserves_the_existing_toolset() { + let fixture = InstallerFixture::new(); + fixture.preinstall_previous_toolset(); + fixture.corrupt_release_asset("mint"); + let output = fixture.run(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Checksum verification failed"), + "stderr: {stderr}" + ); + fixture.assert_previous_toolset_intact(); +} + +#[cfg(unix)] +#[test] +fn partial_replacement_rolls_back_the_previous_toolset() { + let fixture = InstallerFixture::new(); + fixture.preinstall_previous_toolset(); + let output = fixture.run_with_second_mv_failure(); + assert!(!output.status.success()); + fixture.assert_previous_toolset_intact(); +} + +#[cfg(unix)] +struct InstallerFixture { + _temp: TempDir, + fake_bin: PathBuf, + release_dir: PathBuf, + install_dir: PathBuf, + version: String, +} + +#[cfg(unix)] +impl InstallerFixture { + fn new() -> Self { + Self::for_release(TEST_VERSION) + } + + fn for_release(version: &str) -> Self { + let temp = TempDir::new().unwrap(); + let fake_bin = temp.path().join("fake-bin"); + let release_dir = temp.path().join("release"); + let install_dir = temp.path().join("install"); + fs::create_dir_all(&fake_bin).unwrap(); + fs::create_dir_all(&release_dir).unwrap(); + write_executable( + &fake_bin.join("curl"), + r#"#!/usr/bin/env bash +set -euo pipefail +url="" +dest="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) dest="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [[ -n "${FAKE_CURL_LOG:-}" ]]; then + printf '%s\n' "$url" >> "$FAKE_CURL_LOG" +fi +cp "${FAKE_RELEASE_DIR}/${url##*/}" "$dest" +"#, + ); + write_executable( + &fake_bin.join("uname"), + r#"#!/usr/bin/env bash +case "${1:-}" in + -s) printf '%s\n' "${FAKE_UNAME_S:-Linux}" ;; + -m) printf '%s\n' "${FAKE_UNAME_M:-x86_64}" ;; + *) exit 1 ;; +esac +"#, + ); + let fixture = Self { + _temp: temp, + fake_bin, + release_dir, + install_dir, + version: version.to_string(), + }; + fixture.write_release_assets(); + fixture + } + + fn write_release_assets(&self) { + let mut checksums = Vec::new(); + for binary in BINARIES { + let asset = self.asset_name(binary); + let path = self.release_dir.join(&asset); + fs::write(&path, format!("{binary} release binary\n")).unwrap(); + checksums.push(format!("{} {}\n", sha256(&path), asset)); + } + fs::write(self.release_dir.join("SHA256SUMS"), checksums.concat()).unwrap(); + } + + fn asset_name(&self, binary: &str) -> String { + format!("{binary}-{}-linux-amd64", self.version) + } + + fn rewrite_sums_without(&self, excluded: &str) { + let mut checksums = Vec::new(); + for binary in BINARIES { + if binary == excluded { + continue; + } + let asset = self.asset_name(binary); + let path = self.release_dir.join(&asset); + checksums.push(format!("{} {}\n", sha256(&path), asset)); + } + fs::write(self.release_dir.join("SHA256SUMS"), checksums.concat()).unwrap(); + } + + fn corrupt_release_asset(&self, binary: &str) { + let path = self.release_dir.join(self.asset_name(binary)); + fs::write(&path, b"tampered bytes\n").unwrap(); + } + + fn preinstall_previous_toolset(&self) { + fs::create_dir_all(&self.install_dir).unwrap(); + for binary in BINARIES { + fs::write( + self.install_dir.join(binary), + format!("{binary} previous binary\n"), + ) + .unwrap(); + } + } + + fn assert_previous_toolset_intact(&self) { + for binary in BINARIES { + let contents = fs::read_to_string(self.install_dir.join(binary)).unwrap(); + assert_eq!( + contents, + format!("{binary} previous binary\n"), + "{binary} must keep its previous contents" + ); + } + } + + fn assert_toolset_installed(&self) { + for binary in BINARIES { + let path = self.install_dir.join(binary); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, format!("{binary} release binary\n")); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "{binary} must be executable"); + } + } + + fn assert_nothing_installed(&self) { + for binary in BINARIES { + assert!( + !self.install_dir.join(binary).exists(), + "{binary} must not be installed" + ); + } + } + + fn run(&self) -> std::process::Output { + self.command().output().unwrap() + } + + fn run_with_second_mv_failure(&self) -> std::process::Output { + write_executable( + &self.fake_bin.join("mv"), + r#"#!/usr/bin/env bash +set -euo pipefail +count=0 +if [[ -f "$FAKE_MV_COUNT_FILE" ]]; then + read -r count < "$FAKE_MV_COUNT_FILE" +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$FAKE_MV_COUNT_FILE" +if [[ "$count" -eq 2 ]]; then + exit 73 +fi +exec "$REAL_MV" "$@" +"#, + ); + let mut command = self.command(); + command + .env("FAKE_MV_COUNT_FILE", self._temp.path().join("mv-count")) + .env("REAL_MV", "/bin/mv"); + command.output().unwrap() + } + + fn fake_curl_log(&self) -> PathBuf { + self._temp.path().join("curl-log") + } + + fn temp_path(&self) -> &Path { + self._temp.path() + } + + fn release_dir(&self) -> &Path { + &self.release_dir + } + + fn install_dir(&self) -> &Path { + &self.install_dir + } + + fn command(&self) -> Command { + self.command_for(&installer_path(), true) + } + + fn command_without_version(&self) -> Command { + self.command_for(&installer_path(), false) + } + + fn command_for(&self, installer: &Path, set_version: bool) -> Command { + let path = format!( + "{}:{}", + self.fake_bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let mut command = Command::new("bash"); + command + .arg(installer) + .env("PATH", path) + .env("FAKE_RELEASE_DIR", &self.release_dir) + .env("FAKE_CURL_LOG", self.fake_curl_log()) + .env("EVIDENCECTL_INSTALL_DIR", &self.install_dir); + if set_version { + command.env("EVIDENCECTL_VERSION", &self.version); + } + command + } +} + +#[cfg(unix)] +fn installer_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh") +} + +#[cfg(unix)] +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +fn sha256(path: &Path) -> String { + for (program, args) in [("shasum", vec!["-a", "256"]), ("sha256sum", vec![])] { + if let Ok(output) = Command::new(program).args(args).arg(path).output() { + if output.status.success() { + return String::from_utf8(output.stdout) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string(); + } + } + } + panic!("test needs shasum or sha256sum"); +} diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 805eff2e9..5c6d58af7 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -71,13 +71,17 @@ gates for its area (see Verification), and committed. existing gate or add one). - [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. -- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job; - Evidence tutorials must pass it to merge. +- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job + that runs each Evidence tutorial from a clean container with only the + prerequisites the tutorial itself documents; Evidence tutorials must + pass it to merge. - [ ] B5. Tutorials E1-E5 published and gated: first assertion via fixtures (keygen, scaffold, fixture run); serve assertions over HTTP with a Mint token; author an acceptance definition using the coequal neutral examples; connect an institution source via sanitized local mock; - verify an assertion as a consumer. + verify an assertion as a consumer. Adopter outcome for E1: a fresh + machine completes it in 15 minutes or less using released binaries + installed via F1 (the released-binary form of this gate needs F3). - [ ] B6. Tutorial E6 (move Evidence to production signing) published, derived from `OPERATOR-CONTRACT.md`. - [ ] B7. Mint has real docs presence: a Configure page and a reference page. @@ -135,6 +139,42 @@ gates for its area (see Verification), and committed. - [ ] E3. Final sweep: a case-insensitive Notary search over the docs product surface matches only history pages and the retirement page. +### F. Adopter distribution and experience + +Context discovered 2026-08-03: releases already ship reproducible bare +binaries (`--linux-amd64` plus SHA256SUMS, cosign-signed at +promotion) built by `release/scripts/build-release-binaries.sh` in a pinned +builder image, and `crates/registryctl/install.sh` is already published as +the `registryctl--install.sh` release asset. Evidence rides that +channel; it does not get a parallel one. + +- [x] F1. `crates/registry-evidencectl/install.sh` exists, mirroring the + registryctl installer conventions: installs `evidence`, `evidencectl`, + and `mint` from a release, verifies every artifact against SHA256SUMS, + refuses unverified installs, has offline tests, and passes shellcheck + and shfmt. +- [ ] F2. The three binaries and the installer asset enter the release + channel: `build-release-binaries.sh` builds and checksums them, the + candidate workflow stages `evidencectl--install.sh`, and the + artifact inventory in `release/scripts/registry-release` accepts them + (optional at first), with its tests updated. Security-sensitive + (release provenance): review notes required. +- [ ] F3. First release shipping F2 verified end to end: curl | bash against + the published assets installs working binaries. Then flip the + inventory entries from optional to required at a minimum version, the + way `registryctl-installer` did at v0.14.0. +- [ ] F4. Platform coverage decision recorded: linux-amd64 is the + reproducible baseline; registryctl already publishes optional + macos-arm64 and linux-arm64 assets; decide and record the same + optional set for the Evidence binaries. +- [ ] F5. Personas named on the docs site (assertion provider, data + publisher, consumer/verifier, operator) and every tutorial labeled + with whose it is. +- [ ] F6. An Evidence errors and problems reference page exists for + adopters: what each public problem means and what to do about it. +- [ ] F7. An evaluate-stage page exists: what Evidence costs to run + (footprint, dependencies, operational burden, support window). + ### Global gates (checked at the end, not per item) - [ ] G1. Full verification suite green: `cargo fmt --check`, `cargo check @@ -153,8 +193,10 @@ gates for its area (see Verification), and committed. ## Dependency order A1 → A2 → A3. A → D. B1 → C7. C1 → C2 → C3 → C4. D → the B8 quickstart -flip. E ships with or after C7. Everything else is parallel; B has no -upstream dependencies and is the standing priority (onboarding first). +flip. E ships with or after C7. F1 → F2 → F3; F3 unlocks the +released-binary form of the B4/B5 clean-environment gate. Everything else +is parallel; B has no upstream dependencies and is the standing priority +(onboarding first). ## Verification (match to touched area) @@ -173,3 +215,17 @@ upstream dependencies and is the standing priority (onboarding first). - 2026-08-03: Plan approved by Jeremi in session; `/goal` command added. Execution not started. +- 2026-08-03: Adopter-POV review added workstream F (distribution and + adopter experience) and hardened B4/B5 into outcome-based gates. Jeremi + decided distribution is curl | bash installers over released binaries. +- 2026-08-03: Discovery while starting F: the release channel already + builds reproducible bare binaries with SHA256SUMS and cosign signing, + and registryctl already ships an installer asset. F was rewritten to + extend that channel (evidence, evidencectl, mint were simply missing + from it) instead of inventing a parallel one. F1 and F2 in progress. +- 2026-08-03: F1 done. The evidencectl installer mirrors the registryctl + conventions (toolset all-or-nothing install, SHA256SUMS verification, + asset-dir mode, staged install with rollback) with 11 offline tests; + shellcheck and shfmt clean. Found in passing: the rust-result shard + test in release/scripts/test_registry_release.py is stale on main + (ci.yml gained evidence-contracts); flagged separately, not fixed here. From 75f8c638d90b6c2df7463cb2b8810afd25dfaad2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:46:14 +0700 Subject: [PATCH 031/136] feat(release): ship the Evidence toolset through the release channel The reproducible builder now builds evidence, evidencectl, and mint and checksums them into dist/bin like the existing binaries. The candidate workflow stages evidencectl--install.sh beside the registryctl installer. The registry-release artifact inventory accepts the three binaries and the installer as optional artifacts, with per-platform optional backfill names and an installer filename guard; two unit tests cover acceptance and continued rejection of unknown artifacts. Ticks F2 in plans/notary-retirement-and-evidence-onboarding.md. Review notes: release provenance change. The new artifacts are optional in the inventory so every historical manifest still validates (registry-stack-beta-26.yaml re-validated); they flip to required at a minimum version only after the first release ships them end to end (plan item F3), the way registryctl-installer did at v0.14.0. The builder image, cargo flags, and checksum flow are unchanged; the new binaries follow the exact existing pattern. Verified: full test_registry_release run (only pre-existing rust-result shard failure, flagged separately), test_release_candidate, test_release_workflow_ structure, test_release_workflow_guard, test_check_release_source_model, manifest validation, source-model check, bash -n, shellcheck. Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 3 ++ ...tary-retirement-and-evidence-onboarding.md | 10 +++++- release/scripts/build-release-binaries.sh | 14 ++++++++ release/scripts/registry-release | 18 ++++++++++ release/scripts/test_registry_release.py | 36 +++++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index cee2b431e..ebe24d93d 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -680,6 +680,9 @@ jobs: installer="registryctl-${{ needs.validate.outputs.tag }}-install.sh" cp crates/registryctl/install.sh "candidate/bundle-root/${installer}" chmod 0755 "candidate/bundle-root/${installer}" + evidencectl_installer="evidencectl-${{ needs.validate.outputs.tag }}-install.sh" + cp crates/registry-evidencectl/install.sh "candidate/bundle-root/${evidencectl_installer}" + chmod 0755 "candidate/bundle-root/${evidencectl_installer}" for name in registry-notary registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 5c6d58af7..60f4d671e 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -153,7 +153,7 @@ channel; it does not get a parallel one. and `mint` from a release, verifies every artifact against SHA256SUMS, refuses unverified installs, has offline tests, and passes shellcheck and shfmt. -- [ ] F2. The three binaries and the installer asset enter the release +- [x] F2. The three binaries and the installer asset enter the release channel: `build-release-binaries.sh` builds and checksums them, the candidate workflow stages `evidencectl--install.sh`, and the artifact inventory in `release/scripts/registry-release` accepts them @@ -229,3 +229,11 @@ is parallel; B has no upstream dependencies and is the standing priority shellcheck and shfmt clean. Found in passing: the rust-result shard test in release/scripts/test_registry_release.py is stale on main (ci.yml gained evidence-contracts); flagged separately, not fixed here. +- 2026-08-03: F2 done. build-release-binaries.sh builds and checksums + evidence, evidencectl, and mint in the pinned builder; the candidate + workflow stages evidencectl--install.sh beside the registryctl + installer; the registry-release inventory accepts the four artifacts + as optional (flip to required at a minimum version is F3), with two + new unit tests. Historical manifests still validate (beta-26 checked). + The product README documents installation. Next in F: F3 needs a real + release; F4-F7 are open. diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 86b6c18fa..34f028767 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -95,6 +95,14 @@ docker run --rm \ --features registry-notary-server/registry-notary-cel cp target/release/registry-notary-cel-worker "dist/bin/registry-notary-cel-worker-${RELEASE_TAG}-linux-amd64" cp target/release/registry-notary-cel-worker dist/image-bin/registry-notary-cel-worker + + cargo build --release --locked \ + -p registry-evidence \ + -p registry-evidencectl \ + -p registry-mint + cp target/release/evidence "dist/bin/evidence-${RELEASE_TAG}-linux-amd64" + cp target/release/evidencectl "dist/bin/evidencectl-${RELEASE_TAG}-linux-amd64" + cp target/release/mint "dist/bin/mint-${RELEASE_TAG}-linux-amd64" ' printf '%s\n' "${release_builder_image}" > "${repo_root}/dist/image-bin/RELEASE_BUILDER_IMAGE" @@ -105,6 +113,9 @@ chmod 0755 \ "${repo_root}/dist/bin/registry-relay-rhai-worker-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-cel-worker-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/evidence-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/evidencectl-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/mint-${tag}-linux-amd64" \ "${repo_root}/dist/image-bin/registry-notary" \ "${repo_root}/dist/image-bin/registry-notary-cel-worker" \ "${repo_root}/dist/image-bin/registry-relay" \ @@ -113,6 +124,9 @@ chmod 0755 \ ( cd -- "${repo_root}/dist/bin" sha256sum -- \ + "evidence-${tag}-linux-amd64" \ + "evidencectl-${tag}-linux-amd64" \ + "mint-${tag}-linux-amd64" \ "registry-manifest-${tag}-linux-amd64" \ "registry-notary-${tag}-linux-amd64" \ "registry-notary-cel-worker-${tag}-linux-amd64" \ diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 6abde7785..d7e04b235 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -51,6 +51,13 @@ EXACT_ARTIFACT_INVENTORY = { } OPTIONAL_EXACT_ARTIFACTS = { "registryctl-installer", + # The Evidence toolset joins the release channel as optional artifacts + # until the first release ships it end to end; then these move into the + # exact inventory at a minimum version, like registryctl-installer did. + "evidence", + "evidencectl", + "mint", + "evidencectl-installer", } RELEASE_PLAN_SCHEMA = "registry-release.plan.v1" CURRENT_DOCS_VERSION = "main source (unreleased)" @@ -418,6 +425,9 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i f"registryctl-{tag}-macos-arm64", f"registryctl-{tag}-linux-arm64", ] + for evidence_binary in ("evidence", "evidencectl", "mint"): + for platform_label in ("linux-amd64", "linux-arm64", "macos-arm64"): + optional_binary_names.append(f"{evidence_binary}-{tag}-{platform_label}") worker_binary_names = [ f"registry-relay-rhai-worker-{tag}-linux-amd64", f"registry-notary-cel-worker-{tag}-linux-amd64", @@ -1103,6 +1113,7 @@ def render_capsule( expected_image_lock_name = f"registryctl-v{version}-image-lock.json" expected_docs_archive_name = f"registry-docs-v{version}.tar.gz" expected_installer_name = f"registryctl-v{version}-install.sh" + expected_evidencectl_installer_name = f"evidencectl-v{version}-install.sh" for path in sorted(binary_dir.iterdir()) if binary_dir.exists() else []: if not path.is_file() or path.name == "SHA256SUMS": continue @@ -1118,6 +1129,10 @@ def render_capsule( raise ValueError( f"unexpected registryctl installer filename {path.name}; expected {expected_installer_name}" ) + if path.name.startswith("evidencectl-v") and path.name.endswith("-install.sh") and path.name != expected_evidencectl_installer_name: + raise ValueError( + f"unexpected evidencectl installer filename {path.name}; expected {expected_evidencectl_installer_name}" + ) file_sha256 = verified_binary_sha256(path, checksums) sbom = binary_sbom_dir / f"{path.name}.spdx.json" if not sbom.is_file(): @@ -1145,6 +1160,9 @@ def render_capsule( elif path.name == expected_installer_name: file_evidence["kind"] = "registryctl-installer" release_files.append(file_evidence) + elif path.name == expected_evidencectl_installer_name: + file_evidence["kind"] = "evidencectl-installer" + release_files.append(file_evidence) else: binaries.append(file_evidence) diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 80561457a..662094a4b 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -2224,6 +2224,36 @@ def test_validate_requires_registryctl_installer_for_v0_14_and_later(self) -> No ) self.assertEqual(0, accepted.returncode, accepted.stderr) + def test_validate_accepts_declared_evidence_toolset_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + manifest = write_manifest( + Path(tmp), + version="0.17.0", + include_evidence_toolset=True, + ) + result = run_tool("validate", str(manifest)) + + self.assertEqual(0, result.returncode, result.stderr) + + def test_validate_still_rejects_unknown_artifacts_beside_the_evidence_toolset( + self, + ) -> None: + with tempfile.TemporaryDirectory() as tmp: + manifest = write_manifest( + Path(tmp), + version="0.17.0", + include_evidence_toolset=True, + ) + contents = manifest.read_text(encoding="utf-8") + contents = contents.replace( + "evidencectl-installer:", "registry-lab: '0.17.0'\n evidencectl-installer:" + ) + manifest.write_text(contents, encoding="utf-8") + rejected = run_tool("validate", str(manifest)) + + self.assertNotEqual(0, rejected.returncode) + self.assertIn("unexpected registry-lab", rejected.stderr) + def test_render_registryctl_image_lock_from_exact_release_evidence(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -3764,6 +3794,7 @@ def write_manifest( version: str = "0.8.0", include_registryctl_image_lock: bool | None = None, include_registryctl_installer: bool | None = None, + include_evidence_toolset: bool = False, ) -> Path: if source_tag is None: source_tag = f"v{version}" @@ -3794,6 +3825,11 @@ def write_manifest( include_registryctl_installer = version_tuple >= (0, 14, 0) if include_registryctl_installer: artifacts["registryctl-installer"] = version + if include_evidence_toolset: + artifacts["evidence"] = version + artifacts["evidencectl"] = version + artifacts["mint"] = version + artifacts["evidencectl-installer"] = version manifest = { "stack": { "release": "beta-6", From cf43ffda0fe9cde75ab7b206fcbe6d09aaea6ed7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:46:21 +0700 Subject: [PATCH 032/136] docs(evidence): document how adopters install the Evidence toolset The product README explains the release-asset installer (pinned tag, three binaries together or not at all, SHA256SUMS integrity check, the release/VERIFY.md path plus EVIDENCECTL_ASSET_DIR for authenticity), its environment variables, and the source build alternative. Signed-off-by: Jeremi Joslin --- products/evidence/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/products/evidence/README.md b/products/evidence/README.md index fc1e4b43f..b5760e52e 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -86,6 +86,38 @@ re-implements evaluation, signing, or verification. It must not depend on `registry-notary*`, and its source and scaffold templates are covered by the same source-product and domain neutrality checks as the runtime. +## Installing the toolset + +Releases that include the Evidence toolset publish reproducible bare binaries +named `---` (for example `evidence-v1.2.0-linux-amd64`) +plus a `SHA256SUMS` file that is cosign-signed at promotion. Older releases do +not carry these assets. For a release that does, install the pinned installer +asset directly: + +```sh +curl -fsSL https://github.com/registrystack/registry-stack/releases/download//evidencectl--install.sh | bash +``` + +The installer installs the three-binary Evidence toolset, the `evidence` +runtime, `evidencectl` adopter tooling, and the `mint` token issuer, together +or not at all, verifying every asset against `SHA256SUMS` before anything +reaches the install directory. It supports Linux amd64, Linux arm64, and +macOS arm64. It checks integrity, not authenticity: for a higher-assurance +install, follow [`release/VERIFY.md`](../../release/VERIFY.md) for the pinned +tag, then rerun the installer with `EVIDENCECTL_ASSET_DIR` pointed at that +verified directory. + +Three environment variables configure the installer: `EVIDENCECTL_VERSION` +pins a `vMAJOR.MINOR.PATCH` tag, `EVIDENCECTL_INSTALL_DIR` sets the install +directory (default `~/.local/bin`), and `EVIDENCECTL_ASSET_DIR` installs from +a locally verified asset directory instead of downloading. + +To build the toolset from source instead: + +```sh +cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint +``` + ## Discovering available evidence An authenticated caller lists the complete Evidence request shapes it can From a816db5a7277abfb99363d5d9c793796d7c72537 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:48:10 +0700 Subject: [PATCH 033/136] docs: record that root CI already drift-checks the Evidence OpenAPI Ticks B2 in the plan: the evidence-contracts CI job regenerates every Evidence contract, the OpenAPI document included, and byte-diffs the committed products/evidence/generated/ artifacts, triggered for crate and product-path changes alike. No new gate needed. Signed-off-by: Jeremi Joslin --- plans/notary-retirement-and-evidence-onboarding.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 60f4d671e..a096ae35f 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -67,7 +67,7 @@ gates for its area (see Verification), and committed. data-driven reference; Operate content from `OPERATOR-CONTRACT.md`; Security content from the invariant matrix and test traceability; a "Registry Evidence" Configure group. -- [ ] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an +- [x] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an existing gate or add one). - [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. @@ -237,3 +237,10 @@ is parallel; B has no upstream dependencies and is the standing priority new unit tests. Historical manifests still validate (beta-26 checked). The product README documents installation. Next in F: F3 needs a real release; F4-F7 are open. +- 2026-08-03: B2 confirmed done with no new code. Root CI's + evidence-contracts job (.github/workflows/ci.yml) runs + products/evidence/scripts/check-contracts.sh, which regenerates every + Evidence contract including registry-evidence.openapi.json and + byte-diffs against products/evidence/generated/. The classifier + (.github/scripts/ci_changes.py) fires it for registry-evidence, + registry-evidencectl, and products/evidence/ changes. From 3d3776465119f19018aa28957ebae032e4ceb9c2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 02:54:10 +0700 Subject: [PATCH 034/136] fix(evidence): stop response schemas requiring leaves projection may drop A response schema that required a projected leaf of every record turned an ordinary ambiguous page into a source-protocol failure: the page is refused from the pager before any record is read, so a record on it need not be complete. Relax the record shapes to require nothing, move the presence checks a single matched record must pass into the extract scripts, and add fixture cases covering both sides. Projection guarantees intermediate containers and only drops terminal leaves, so requiring an intermediate is redundant and guarding one in script is unreachable; state that rule in the source contract's response_shape. Reported by the tutorial writer as finding 23. Signed-off-by: Jeremi Joslin --- .../evidence/contracts/source-contract.yaml | 1 + .../bundle/adapters/adult-status-extract.rhai | 9 ++++++++ .../professional-licence-extract.rhai | 9 ++++++++ .../bundle/fixtures/adult-status-cases.yaml | 8 +++++-- .../fixtures/professional-licence-cases.yaml | 8 +++++-- .../schemas/adult-status-response.schema.yaml | 9 +++++++- .../professional-licence-response.schema.yaml | 11 +++++++--- .../adapters/birth-parents-extract.rhai | 8 ++++++- .../registered-parent-references-cases.yaml | 22 +++++++++++++++---- .../registered-parent-relationship-cases.yaml | 22 +++++++++++++++---- .../schemas/birth-adult-response.schema.yaml | 8 +++---- .../birth-parents-response.schema.yaml | 9 +++++++- .../dhis2-tracker/response.schema.yaml | 9 +++++++- .../opencrvs-event-search/extract.rhai | 8 ++++++- .../response.schema.yaml | 9 +++++++- 15 files changed, 125 insertions(+), 25 deletions(-) diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 2de0ff6a4..4d7fba62c 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -109,6 +109,7 @@ response_shape: stage: After projection and before conversion to Rhai, so no response outside its declared shape reaches a script. artifact: One required bundle-relative responseSchema per source, in the same closed JSON Schema subset as adapterParametersSchema and factSchema and validated by the same startup checks. role_relaxation: A response schema may list fewer required members than it declares properties, because projection legitimately drops a selected leaf the record did not carry. The adapter-parameter and fact roles keep the exact required-equals-properties rule. + required_rule: Projection already decides which members a schema may require. A selected leaf can be absent, so requiring one turns an ordinary incomplete record into a source-protocol failure and must instead be checked by the script on the records that have to carry it. An intermediate container cannot be absent, because projection rejects the response before the shape is read, so requiring one only repeats the projection. nullable_form: A response schema node may write its type as the pair [T, "null"]. A source reports an explicit null where it holds no value and projection carries that null through verbatim, so the shape has to be able to say so. This is the only union the subset admits and only in the response role; null reaches the script as the same unit marker is_missing already reads. division_of_labour: schema: Member presence where the shape can guarantee it, member types, array bounds and uniqueness, string bounds and formats, and enumerated or constant values. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai index cb99dc4c8..978ec7825 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai @@ -22,6 +22,15 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; + // The shape cannot require this of every record, because the ambiguous + // page is decided from the pager before any record is read. A single + // matched record with no reference of its own is a source this adapter + // does not understand. attributes needs no check: it carries projected + // children, so a record without it never survives projection. + if is_missing(record["trackedEntity"]) { + throw("source_protocol_error"); + } + // The attribute is named by an adapter parameter, and a register that // reports it twice has no single value to read, so this loop stays. let date_of_birth = (); diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai index 539c717b5..dd8c42d01 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai @@ -73,6 +73,15 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; + // The shape cannot require this of every record, because the ambiguous + // page is decided from the pager before any record is read. A single + // matched record with no reference of its own is a source this adapter + // does not understand. attributes needs no check: it carries projected + // children, so a record without it never survives projection. + if is_missing(record["trackedEntity"]) { + throw("source_protocol_error"); + } + // The validity attributes are named by adapter parameters, and a register // that reports one of them twice has no single value to read, so this loop // stays. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml index 78849303d..57a1dac78 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml @@ -139,8 +139,12 @@ cases: trackedEntities: - trackedEntity: Tei00000001 attributes: [] - - trackedEntity: Tei00000002 - attributes: [] + # The second record carries no reference of its own, only the + # attribute list the projection insists on. The pager decides this page + # before any record is read, so a response shape that demanded a + # complete record here would turn an ordinary ambiguous lookup into a + # source-protocol failure. + - attributes: [] expected: lookup: ambiguous publicProblem: evidence_not_available diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml index ff61fcb07..b02f4072b 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml @@ -285,8 +285,12 @@ cases: - trackedEntity: Tei00000001 attributes: [] enrollments: [] - - trackedEntity: Tei00000002 - attributes: [] + # The second record carries no reference of its own, only the lists + # the projection insists on. The pager decides this page before any + # record is read, so a response shape that demanded a complete record + # here would turn an ordinary ambiguous lookup into a source-protocol + # failure. + - attributes: [] enrollments: [] expected: lookup: ambiguous diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml index 0ccbdc0dc..184d03f48 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml @@ -33,7 +33,14 @@ properties: items: type: object additionalProperties: false - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. What a single matched record must carry stays with the + # script. attributes is not listed either: projection rejects a record + # missing an intermediate its children hang from, so saying so here would + # only repeat the projection. + required: [] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml index bc3ae91fb..b17a3debd 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml @@ -33,9 +33,14 @@ properties: items: type: object additionalProperties: false - # enrollments is optional because a tracked entity may hold none, which - # the script reads as a record carrying no licence state. - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. attributes and enrollments are not listed either, for a + # different reason: projection rejects a record missing an intermediate + # its children hang from, so saying so here would only repeat the + # projection. + required: [] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai index 916daf387..c810fa14e 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai @@ -18,8 +18,14 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits one is a source this adapter does not understand. declaration is + // not checked here: it carries projected children, so a record without it + // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] { + result["status"] != parameters["registeredStatus"] || + is_missing(result["trackingId"]) { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml index 61fcd5362..f0aa2bc45 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml @@ -84,6 +84,10 @@ cases: derivationRuns: false signed: false - id: ambiguous-child + # The second record carries no leaf at all, only the declaration the + # projection insists on. An ambiguous page is refused before any record is + # read, so a response shape that demanded a complete record here would turn + # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -91,15 +95,25 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - type: birth - status: REGISTERED - trackingId: TRACKING-SYNTHETIC-002 - declaration: {father.personReference: PERSON-SYNTHETIC-B} + - declaration: {} expected: lookup: ambiguous publicProblem: evidence_not_available derivationRuns: false signed: false + - id: negative-matched-record-without-tracking-id + # The other side of the same rule: what a page need not carry, a single + # matched record must, and the script is what says so. + response: + total: 1 + results: + - type: birth + status: REGISTERED + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: negative-duplicate-parent-reference response: total: 1 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml index 8dfcaa054..ef5a70bc5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -100,6 +100,10 @@ cases: derivationRuns: false signed: false - id: ambiguous-child + # The second record carries no leaf at all, only the declaration the + # projection insists on. An ambiguous page is refused before any record is + # read, so a response shape that demanded a complete record here would turn + # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -107,15 +111,25 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - type: birth - status: REGISTERED - trackingId: TRACKING-SYNTHETIC-002 - declaration: {father.personReference: PERSON-SYNTHETIC-B} + - declaration: {} expected: lookup: ambiguous publicProblem: evidence_not_available derivationRuns: false signed: false + - id: matched-record-without-tracking-id + # The other side of the same rule: what a page need not carry, a single + # matched record must, and the script is what says so. + response: + total: 1 + results: + - type: birth + status: REGISTERED + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: missing-parent-set response: total: 1 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml index 53cc64688..c5b42eba8 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml @@ -16,10 +16,10 @@ properties: items: type: object additionalProperties: false - # Nothing can be required of every record here. Projection drops a leaf - # the record did not carry, and an ambiguous page is decided before any - # record is read, so a record on that page need not be complete. Which - # leaves a single matched record must carry stays with the script. + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. required: [] properties: type: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml index 4203ebc18..f1808d22c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml @@ -16,7 +16,14 @@ properties: items: type: object additionalProperties: false - required: [type, status, trackingId, declaration] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. + # declaration is the one member a record cannot omit, because projection + # rejects a record missing an intermediate its children hang from, so + # saying so here would only repeat the projection. + required: [] properties: type: type: string diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml index 38daa53c4..fba4b7291 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml @@ -21,7 +21,14 @@ properties: items: type: object additionalProperties: false - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. What a single matched record must carry stays with the + # script. attributes is not listed either: projection rejects a record + # missing an intermediate its children hang from, so saying so here would + # only repeat the projection. + required: [] properties: trackedEntity: {type: string, minLength: 1, maxLength: 64} attributes: diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai index 49c6f894f..e1a42f85e 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai @@ -23,8 +23,14 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits one is a source this adapter does not understand. declaration is + // not checked here: it carries projected children, so a record without it + // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] { + result["status"] != parameters["registeredStatus"] || + is_missing(result["trackingId"]) { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml index 75d8853b5..b9a2d6b67 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml @@ -14,7 +14,14 @@ properties: items: type: object additionalProperties: false - required: [type, status, trackingId, declaration] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. + # declaration is the one member a record cannot omit, because projection + # rejects a record missing an intermediate its children hang from, so + # saying so here would only repeat the projection. + required: [] properties: type: {type: string, minLength: 1, maxLength: 64} status: {type: string, minLength: 1, maxLength: 64} From 30b153740279cd512c0139b2ada480051d31c9d5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 03:00:57 +0700 Subject: [PATCH 035/136] feat(release): build the Evidence toolset for the optional platforms The candidate workflow's native build-platforms matrix (macos-arm64, linux-arm64) now builds evidence, evidencectl, and mint beside registryctl and stages them into the platform payload. The assemble step and the artifact inventory accept the per-platform names generically, so no other step changes. Ticks F4 in plans/notary-retirement-and-evidence-onboarding.md: linux-amd64 stays the required reproducible baseline; linux-arm64 and macos-arm64 are optional, matching registryctl. Review notes: release provenance change scoped to the existing native matrix job; toolchain pin, targets, and upload flow unchanged. The registryctl version-verification step stays registryctl-only. Verified: test_release_candidate, test_release_workflow_structure, test_release_workflow_guard (63 tests), YAML parse. Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 9 ++++++++- plans/notary-retirement-and-evidence-onboarding.md | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ebe24d93d..6cb26898e 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -372,7 +372,8 @@ jobs: rustup toolchain install 1.95.0 \ --profile minimal --target "${{ matrix.target }}" cargo build --release --locked \ - -p registryctl --target "${{ matrix.target }}" + -p registryctl -p registry-evidence -p registry-evidencectl \ + -p registry-mint --target "${{ matrix.target }}" mkdir -p platform asset="registryctl-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/registryctl" "platform/${asset}" @@ -380,6 +381,12 @@ jobs: release/scripts/registry-release verify-registryctl-binary-version \ "platform/${asset}" \ --version "${{ needs.validate.outputs.version }}" + for evidence_binary in evidence evidencectl mint; do + asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" + cp "target/${{ matrix.target }}/release/${evidence_binary}" \ + "platform/${asset}" + chmod 0755 "platform/${asset}" + done mkdir -p candidate-platform mv platform candidate-platform/platform diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index a096ae35f..3414a5468 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -163,7 +163,7 @@ channel; it does not get a parallel one. the published assets installs working binaries. Then flip the inventory entries from optional to required at a minimum version, the way `registryctl-installer` did at v0.14.0. -- [ ] F4. Platform coverage decision recorded: linux-amd64 is the +- [x] F4. Platform coverage decision recorded: linux-amd64 is the reproducible baseline; registryctl already publishes optional macos-arm64 and linux-arm64 assets; decide and record the same optional set for the Evidence binaries. @@ -244,3 +244,10 @@ is parallel; B has no upstream dependencies and is the standing priority byte-diffs against products/evidence/generated/. The classifier (.github/scripts/ci_changes.py) fires it for registry-evidence, registry-evidencectl, and products/evidence/ changes. +- 2026-08-03: F4 decided and implemented. Platform coverage matches + registryctl exactly: linux-amd64 is the required reproducible + baseline from the pinned builder; linux-arm64 and macos-arm64 ship + as optional native-runner assets from the candidate workflow's + build-platforms matrix, which now also builds evidence, evidencectl, + and mint. The assemble step and the F2 inventory already accept the + per-platform names generically. From 155af8c6f279796df1f341954338192575e8ff5f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 03:07:14 +0700 Subject: [PATCH 036/136] docs(evidence): record group commit as implemented in PERFORMANCE.md The file still described group commit as deferred work and presented the pre-batching 122-161 rps runs as the current baseline, while audit.rs commits appends in groups and OPERATOR-CONTRACT.md records the sustained 7057 requests/second measurement. The baseline rows stay as the labeled before-figure; the group-commit section now states the implemented behavior and its preserved properties, and points at the OPERATOR-CONTRACT measurement. The Linux re-measure caveat stays. Signed-off-by: Jeremi Joslin --- products/evidence/PERFORMANCE.md | 64 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/products/evidence/PERFORMANCE.md b/products/evidence/PERFORMANCE.md index ee05e13d5..3f5a3eaa0 100644 --- a/products/evidence/PERFORMANCE.md +++ b/products/evidence/PERFORMANCE.md @@ -1,15 +1,17 @@ # Evidence Performance -Status: Measured baseline and deferred work, not a Version 1 contract -Date: 2026-08-02 +Status: Measured, with group commit implemented; not a Version 1 contract +Date: 2026-08-03 ## Purpose Evidence trades request throughput for audit durability. This file records what -that trade costs, how it was measured, and the one change that would recover -most of the cost without weakening the guarantee. Nothing here is a Version 1 -commitment. Throughput is not a Definition of Done row and is not a `CONCEPT.md` -non-goal; it is ordinary engineering work that has been deliberately deferred. +that trade costs, how it was measured, and the change that recovered most of +the cost without weakening the guarantee: group commit in the audit sink, +implemented in `crates/registry-evidence/src/audit.rs`. The current end-to-end +measurement lives in `OPERATOR-CONTRACT.md` under "Measured throughput". +Nothing here is a Version 1 commitment. Throughput is not a Definition of Done +row and is not a `CONCEPT.md` non-goal. ## The guarantee that sets the ceiling @@ -27,11 +29,14 @@ call `sync_all`, so their records sit in the page cache and are lost on power failure. Evidence is the only one of the three that survives that failure, and the ceiling below is the price of it. -## Measured baseline +## Measured baseline before group commit -Measured with `soak_reports_request_throughput_against_the_audit_ceiling` in +This section records the measurement that motivated group commit and is kept +as the before-figure. Measured with +`soak_reports_request_throughput_against_the_audit_ceiling` in `crates/registry-evidence/src/runtime_tests.rs`. Two release-profile runs, 512 -requests at 32 concurrent, against a local mock source: +requests at 32 concurrent, against a local mock source, with each append +taking its own barrier: | | run 1 | run 2 | |---|---|---| @@ -66,38 +71,37 @@ audit path. Nothing else is shared between requests. N processes with N distinct audit paths therefore give N times the throughput with no code change. Only vertical throughput is capped. -## Deferred work: group commit +## Group commit The lever for vertical throughput is batching the barrier, not removing it. +The audit sink in `crates/registry-evidence/src/audit.rs` implements this: +appends that arrive while a durable write is in flight form the next batch, +and one `fsync` covers the whole batch. There is no timer and no configured +window; a batch is exactly what queued behind the in-flight barrier, so the +sink degrades to one barrier per append when requests do not overlap. -Today each append takes the chain mutex, writes, and fsyncs alone. Under -concurrency the appends already queue, so the records that queue behind an -in-flight barrier could be written and covered by a single subsequent barrier. -One fsync would then serve many records instead of one. - -Properties that must survive the change: +Properties that survived the change, each held by tests in `audit.rs`: - durability before release: an append resolves only after the barrier that covers its own bytes has completed, so no caller receives evidence ahead of its durable record; - chain ordering: records are hash-linked in the order they were chained, and - the on-disk order matches; + the on-disk order matches; batching must not drop or duplicate a record; - fail-closed: a failed barrier fails every append it covers, and none of them may report success; - fork detection: the pinned-path, fingerprint, and tail checks in - `DurableJsonlSink::write` still bracket the batched write. - -Expected gain is roughly the batch size, bounded by concurrent arrivals, so it -scales with load rather than helping a single idle request. - -### Preconditions - -1. Re-measure on the target Linux host. If the Linux ceiling already clears the - deployment's required rate, do not do this work. -2. Treat it as a security-sensitive change to audit integrity. It needs explicit - review notes and focused negative tests for each property above, per the - root `AGENTS.md` rules and the phase-3 invariant discipline in - `products/evidence/AGENTS.md`. + `DurableJsonlSink::write` still bracket the batched write, and a batch that + crosses the segment bound is split so each segment stays self-consistent. + +The gain scales with concurrent arrivals rather than helping a single idle +request. With group commit in place, the end-to-end measurement in +`OPERATOR-CONTRACT.md` under "Measured throughput" sustained 7057 +requests/second at 128 concurrent on the same host class that measured the +baseline rows in this file, with both durable audit appends per request kept. + +The macOS caveat still applies to every figure in this file and in +`OPERATOR-CONTRACT.md`: re-measure on the target Linux host before quoting +production numbers. ## Regression baseline From 3338a439fe4df1601a8d6a98082d2357fed3db06 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 08:43:13 +0700 Subject: [PATCH 037/136] docs(site): add the Evidence onboarding content surface New pages: the RS-PR-EVIDENCE protocol profile (56 requirements, each citing its frozen contract file), the first-assertion tutorial (E1, commands and outputs verified against the built binaries), Configure Evidence, Evidence security model (every invariant-matrix id covered and traced to named tests), Evaluate Evidence, the Evidence problems reference (all nine public problem types), and Registry Mint configure and reference pages. The when-to-use page gains the Evidence door and the four adopter personas; the glossary disambiguates Evidence (the product), Notary evidence credentials, the Evidence toolset, and Registry Mint. Signed-off-by: Jeremi Joslin --- .../src/content/docs/configure/evidence.mdx | 222 +++++++ docs/site/src/content/docs/configure/mint.mdx | 206 ++++++ .../docs/reference/evidence-problems.mdx | 65 ++ .../src/content/docs/reference/glossary.mdx | 18 +- docs/site/src/content/docs/reference/mint.mdx | 213 ++++++ .../src/content/docs/security/evidence.mdx | 238 +++++++ .../src/content/docs/spec/rs-pr-evidence.mdx | 624 ++++++++++++++++++ .../content/docs/start/evaluate-evidence.mdx | 256 +++++++ .../src/content/docs/start/when-to-use.mdx | 16 +- .../tutorials/first-evidence-assertion.mdx | 157 +++++ 10 files changed, 2010 insertions(+), 5 deletions(-) create mode 100644 docs/site/src/content/docs/configure/evidence.mdx create mode 100644 docs/site/src/content/docs/configure/mint.mdx create mode 100644 docs/site/src/content/docs/reference/evidence-problems.mdx create mode 100644 docs/site/src/content/docs/reference/mint.mdx create mode 100644 docs/site/src/content/docs/security/evidence.mdx create mode 100644 docs/site/src/content/docs/spec/rs-pr-evidence.mdx create mode 100644 docs/site/src/content/docs/start/evaluate-evidence.mdx create mode 100644 docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx diff --git a/docs/site/src/content/docs/configure/evidence.mdx b/docs/site/src/content/docs/configure/evidence.mdx new file mode 100644 index 000000000..c20e1b4f7 --- /dev/null +++ b/docs/site/src/content/docs/configure/evidence.mdx @@ -0,0 +1,222 @@ +--- +title: Configure Evidence +description: Shape an Evidence deployment project's runtime file, governed bundle, key material, immutability, and validation with evidence check. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: how-to +locale: en +standards_referenced: [] +--- + +Configuring Evidence means assembling one deployment project: a governed `bundle/` of YAML, +Rhai scripts, schemas, and fixtures, plus a process-local `runtime.yaml` that binds it to a +listener, a secret root, and audit storage. Both are trusted, startup-only artifacts. Evidence +loads them once, compiles and validates them together, and refuses to reload, merge, or +partially serve a later change. `evidence check` is the gate every edit to the project must pass +before `evidence serve` runs it. + +## The project layout + +`evidencectl new ` scaffolds a neutral deployment project +(`crates/registry-evidencectl/src/scaffold.rs`): + +```text +/ + bundle/ governed, reviewed, mounted read-only + evidence.yaml the bundle document + adapters/ request preparation and extraction scripts (Rhai) + derivations/ requirement derivation scripts (Rhai) + schemas/ adapter-parameter, response, and fact schemas + fixtures/ synthetic acceptance cases + runtime.yaml process-local paths and listener, not governed + secrets/ key material, created empty and owner-only + audit/ audit records the running service writes +``` + +Two more bundle directories are allowed but not scaffolded by default: `codelists/` for +controlled-code, category, and bucket-scheme tables, and `public-keys/` for retired public +signing JWKs (`products/evidence/contracts/bundle.schema.yaml`, `bundle_layout`). A requirement +that declares a controlled-code concept or a rotated signing key adds the directory it needs. + +Passing `--with-mint` also renders a paired `mint/` directory: a Registry Mint configuration and +an example client registration, for a deployment with no identity provider of its own. Registry +Mint is a supporting service, not a fourth Evidence pattern, and the dependency runs one way: +Registry Mint issues the access tokens the bundle's `authentication` block verifies, never the +reverse (`AGENTS.md`). + +## The runtime file + +`runtime.yaml` binds one governed bundle to one process: where the bundle lives, what the +listener binds to, where secrets and audit storage sit, and which private CA files a source may +trust. It is process-local operator configuration, not part of the reviewed bundle, and +`products/evidence/contracts/runtime.schema.yaml` is its schema of record. + +Top-level required fields: + +| Field | Binds | +| --- | --- | +| `version` | Fixed at `1`. | +| `bundleDirectory` | Absolute path to the governed `bundle/` directory. | +| `listener` | `bindHost` (loopback, private IPv4, or unique-local IPv6 only), `port`, `tlsTermination` (fixed at `operator-controlled-upstream`: TLS terminates ahead of Evidence), `trustProxyIdentityHeaders` (fixed `false`: proxy-supplied identity is never trusted), plus request-size, concurrency, timeout, and shutdown-grace bounds. | +| `secretProviders.file.root` | The owner-only directory the file secret provider resolves every `secret:file/` reference beneath. | +| `auditStorage` | `path` for the active audit segment and `maximumFileBytes`, a per-segment rotation threshold. | +| `outboundTls` | `systemRoots` (fixed `true`) plus named `trustProfiles`, each a local id bound to one CA bundle file a source can select through `tlsTrustProfile`. | + +An optional `metricsListener` (`bindHost`, `port`) serves `GET /metrics` on a second private +binding; absent by default, it is documented in `products/evidence/OPERATOR-CONTRACT.md` under +Metrics reference rather than in the public Evidence contract. + +`runtime.yaml` cannot override anything the bundle governs. The schema's `ownership` block closes +the allowed set to the bundle directory, listener binding and process limits, the optional +metrics listener binding, the file-secret root, audit path and rotation bound, and logical +private-CA file bindings. Every other field, including service identity, authentication, +authority, sources, disclosure, and signing policy, belongs to the bundle alone. + +## The bundle + +`bundle/evidence.yaml` is the single governed contract Evidence compiles and validates as one +atomic revision (`products/evidence/contracts/bundle.schema.yaml`). Its top-level sections: + +| Section | Declares | +| --- | --- | +| `service`, `issuer` | The technical provider and the legal issuing authority, both URIs. | +| `authentication` | The one trusted OIDC access-token profile: issuer, audiences, token type, algorithms, JWKS URI, and the claim names for principal, requester tags, evidence audience, grant id, and grant authority. | +| `audit` | Keyed JSONL audit: the hash secret reference, key version, and fixed fail-closed behavior. | +| `subjectBinding` | The secret reference and key version behind the audience-scoped entity-reference HMAC. | +| `rateLimits` | Per-principal request, burst, and failed-selector-attempt bounds. | +| `signing` | The active EdDSA signing key reference, retired public JWK files, the fixed JWKS path, and assertion validity and clock-skew bounds. | +| `responseFormats` (optional) | The bundle-wide ceiling on releasable serializations; defaults to signed JWS alone and must always include it. | +| `selectorProfiles` | Named, bounded scalar field sets a subject role may be looked up by. | +| `sources` | Named fixed HTTP JSON sources: transport, base URL, acquisition posture, authentication, the fixed request shape, and the response, extraction, and fact schemas bound to it. | +| `authorityProfiles` | Named requester-tag gates: which grants (requirement, purpose, audience, subject roles, selector profiles, value origins) a caller carrying those tags holds. | +| `requirements` | The assertion definitions themselves: id, source, purposes, subject roles, reference frameworks, evidence type, validity, derivation script and parameters, output concepts, fixtures, and disclosure-guard family. | + +Everything under `bundle/` is mounted read-only and covered by one revision digest computed over +the exact bundle bytes; there is no partial edit, hot reload, or fallback +(`bundle.schema.yaml`, `atomic_revision`). Allowed bundle directories are `adapters/`, +`derivations/`, `schemas/`, `codelists/`, `fixtures/`, and `public-keys/`; symlinks and any file +outside them fail startup (`bundle.schema.yaml`, `bundle_layout`). + +Evidence's four coequal acceptance definitions are adult status, residence region, professional +licence status, and legal-parent relationship (`products/evidence/README.md`, +`products/evidence/OPERATOR-CONTRACT.md`). You shape each as an ordinary `requirements` entry +with its own source, derivation script, and fixtures. The four are presented as coequal, and none +of them is a privileged domain type, a Rust built-in operation, or a special route. + +## What Rhai scripts may and may not do + +A deployment project supplies three kinds of Rhai script, one per source or requirement, and Rust +compiles and reviews all of them at startup (`products/evidence/contracts/rhai-abi.yaml`). +Scripts are trusted, reviewed, immutable bundle artifacts; that trust extends to provider and +requirement semantics only, and it never grants transport, credential, authorization, signing, +audit, or evidence-construction authority. + +### Preparation, extraction, derivation + +| Script | Signature | Owns | Never touches | +| --- | --- | --- | --- | +| `prepare` (adapters) | `(source_required_selectors, adapter_parameters) -> RequestParts` | Rendering an ordered query and a bounded JSON body from already authorized selector values. | Source origin, method, path, headers, credentials, TLS, proxy, timeout, redirects, or request count; those stay Rust-owned. | +| `extract` (adapters) | `(projected_source_response, adapter_parameters) -> LookupResult` | Reading one already-projected, schema-validated response and returning `match` with facts, `no_match`, or `ambiguous`. | Choosing a candidate, scoring, counting, or emitting any diagnostic beyond the closed outcome. | +| `derive` (derivations) | `(facts, declared_authorized_selectors, evaluation_context) -> ConceptValueSet` | Comparing facts against declared authorized selectors and producing at most 16 typed concept values. | Requester, purpose, audience, authority, grant, token, credential, or a logging, audit, or signing handle. | + +Every stage runs on fresh invocation state; nothing carries over between requests or between +stages. Failures map to one closed, value-free class per stage (`adapter_input_error`, +`source_protocol_error`, `derivation_input_error`), and raw Rhai errors never reach a public +response. + +### Capability boundary + +Scripts get fresh local variables, bounded same-file helper functions, pure expressions, and +bounded array, map, and string construction. They cannot reach the filesystem, environment, +network, process, or a module system; they get no ambient clock, timezone, randomness, UUID, +logging, or printing; and they cannot define anonymous functions, dispatch dynamically, or index +an array with a computed negative offset (`products/evidence/contracts/rhai-abi.yaml`, +`capabilities`). + +Everything a script computes with runs through a fixed primitive library: date and instant +parsing, exact-decimal arithmetic, calendar addition, bucket lookup, codelist lookup, list and set +membership, and a handful of bounded string and array operations, each pure, deterministic, and +domain-neutral (`products/evidence/contracts/primitive-library.yaml`). Ordinary floating-point +values stay usable inside preparation and extraction, but a derived concept value can never be an +ordinary Rhai float: exact numbers use the declared integer or the Rust-owned `Decimal` type. +`entity_reference_seed` wraps a source-derived value in a protected type Rhai can construct but +never read back, print, or serialize; only Rust projects it into an audience-scoped reference +after output validation. + +## Key material + +`evidencectl keygen` writes deployment key material as owner-only files and never prints private +bytes (`crates/registry-evidencectl/src/keygen.rs`): + +```sh +evidencectl keygen signing --out-dir "" --kid "" +evidencectl keygen secret --out "/audit-hmac-key" +evidencectl keygen secret --out "/subject-binding-hmac-key" +evidencectl keygen holder --out-dir "" --kid "" +``` + +`keygen signing` writes an Ed25519 private JWK (mode `0600`) and its public counterpart (mode +`0644`). The `--kid` you pass must equal `signing.activeKeyId` in `bundle/evidence.yaml`, or you +copy the printed thumbprint-derived kid back into that field. `keygen secret` writes one 32-byte +raw secret for the audit hash key or the subject-binding HMAC key; either backs a +`secret:file/` reference in the bundle. `keygen holder` writes an Ed25519 holder keypair +used only for the SD-JWT VC confirmation binding. Every private file lands at mode `0600`, and +its containing directory is created or normalized to mode `0700`. + +`evidencectl jwks --out ...` assembles a public JWKS document from a set +of public JWK files: it validates each as public-only, so any file carrying a private member is +rejected outright, rejects conflicting keys that share a `kid`, and merges duplicates that are +byte-identical (`crates/registry-evidencectl/src/jwks.rs`). Use it to build the pinned JWKS +document `evidence verify --jwks ` checks a stored response against, separate from the +JWKS Evidence itself serves live at `/.well-known/evidence/jwks.json`. + +Source credentials are not part of key material. You obtain them from the source system itself +and write them into the secret root as owner-only files, resolved through the bundle's +`secret:file/` references at readiness rather than at `check`. + +## Immutability + +Evidence refuses to start from a deployment input it could write to, and reports a +non-immutable-input error rather than starting on it (`products/evidence/OPERATOR-CONTRACT.md`; +`crates/registry-evidencectl/templates/README.md`). Version 1 permits no reload, merge, mutation, +governed-field override, or fallback bundle or runtime file: a project is either the exact bytes +Evidence loaded at startup, or it is a different revision that requires a restart. + +Freeze the project before validating or serving it: + +```sh +chmod -R a-w bundle && chmod 444 runtime.yaml +``` + +The bundle directory and every file beneath it carry no write bits, `runtime.yaml` is mode `444`, +and the secret root stays owner-only at mode `0700` with mode `0600` secret files. Evidence +Version 1 supports Unix targets only because these invariants rely on owner, mode, no-follow, +link-count, and open-file-identity checks the platform provides +(`products/evidence/contracts/runtime.schema.yaml`, `platform`). To edit the project again, +restore write permission, make the change, and freeze it again before the next `evidence check`. + +## Validating a project + +Two offline commands prove a project before it ever binds a port: + +```sh +evidence --runtime runtime.yaml check +evidence --runtime runtime.yaml evaluate --fixture "bundle/fixtures/.yaml" +``` + +`evidence check` loads, compiles, and validates the complete bundle and runtime file together: +every selector, role, profile, authority, and source binding resolves, every script compiles +against the frozen ABI, and mounted secret and signing material parses, all without opening the +audit chain or contacting a source. `evidence evaluate` replays one fixture file's synthetic +cases through the reviewed adapter, derivation, and output gate, with no source and no network +involved. + +`evidencectl fixtures run --project ` drives both for you across the whole project: it runs +`check`, then `evaluate` against every fixture path the bundle's requirements reference, and +reports `PASS` or `FAIL` per step (`crates/registry-evidencectl/src/fixtures.rs`). A +`fixtures run --project .` run against a scaffolded and frozen project reports PASS for `check` +and for each fixture case. Both `check` and `fixtures run` must pass before `evidence serve` runs +the revision. diff --git a/docs/site/src/content/docs/configure/mint.mdx b/docs/site/src/content/docs/configure/mint.mdx new file mode 100644 index 000000000..615c51c28 --- /dev/null +++ b/docs/site/src/content/docs/configure/mint.mdx @@ -0,0 +1,206 @@ +--- +title: Configure Registry Mint +description: Configure Registry Mint to issue short-lived, audience-bound access tokens to registered clients when a deployment has no identity provider. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: how-to +locale: en +standards_referenced: [] +--- + +Configure Registry Mint when a deployment needs to hand short-lived access tokens to a closed +set of registered machine clients and has no identity provider to issue them. + +## When to use Registry Mint + +Registry Mint is a supporting service, not a product pattern of its own. Use it when a resource +server such as Evidence needs signed, expiring, audience-bound tokens and standing up a +general-purpose identity provider is not an option for the deployment. + +Registry Mint answers a narrower question than a shared JWKS can. A pooled key set can only say +that a token was signed by a trusted key; it cannot say which caller signed it or what that +caller is permitted to assert. Registry Mint splits the two questions across two places: the +client registry binds a client id to that client's own public keys and to the authority Registry +Mint will assert for it, and the token endpoint verifies an incoming request against the keys of +the client it claims to be, then writes the authority from the registry, never from the request. + +## When not to use Registry Mint + +Skip Registry Mint when an identity provider already issues client-credentials tokens for the +deployment. Registry Mint exists only for the case where none does; pointing Evidence at an +existing IdP's token endpoint and JWKS does not require Registry Mint at all. + +Registry Mint is also not a place to route caller identity for people. It authenticates +registered machine clients by private key, and any token bound to one named person rides inside +a client's own signed request rather than a separate login. If a deployment needs a person to +authenticate directly, that is an identity provider's job, not Registry Mint's. + +## Before you start + +You need: + +- The registered clients this deployment will serve: one client id, principal, evidence + audience, and set of requester tags per client. +- A private JWK signing key for Registry Mint itself, in the algorithm the deployment will run + (`EdDSA`, `ES256`, or `RS256`), stored in a file owner-only and not reachable through a + symlink. `crates/registry-mint/src/secretfile.rs` enforces both at load time. +- A private JWK per client. Registry Mint only ever stores and reads each client's public half; + keep the private half with the client. +- A directory to hold one registration file per client. +- The claim names the resource server (Evidence, for example) reads its principal, requester + tags, evidence audience, and grant pair from, so Registry Mint's `accessTokens.claims` can be + set to match them exactly. +- TLS in front of Registry Mint. Registry Mint serves plain HTTP and expects TLS termination it + does not manage; Evidence in turn requires the token issuer and its key set to be HTTPS, with + no exception for loopback (`crates/registry-mint/demo/README.md`). + +## Configure the deployment + +Registry Mint reads one YAML document. Every relative path in it resolves against the document's +own directory, and every field in it is startup-only: changing issuer identity, signing keys, +the listener, or token policy means restarting the process. + +```yaml +version: 1 +issuer: https://mint.example.org +listener: + address: 127.0.0.1 + port: 8081 +signing: + algorithm: EdDSA + activeKeyId: mint-2026-01 + activeKeyFile: secrets/signing.jwk +accessTokens: + audiences: [evidence] + lifetimeSeconds: 300 + claims: + principal: sub + requesterTags: evidence_tags + evidenceAudience: evidence_audience + grantId: evidence_grant_id + grantAuthority: evidence_authority +clientAssertion: + audience: https://mint.example.org/token + algorithms: [EdDSA] +clients: + directory: clients +``` + +`issuer` must be an `https` URL with a host and no credentials, query, or fragment; resource +servers compare it exactly. `accessTokens.lifetimeSeconds` is bounded to `60..=3600`: short +enough that a leaked token expires quickly, long enough to survive verifier clock skew. +`accessTokens.claims` must match the resource server's own claim names field for field, because +that is the only place the two configurations have to agree. `clientAssertion.audience` is the +value every client's signed request must carry as its own `aud`, which stops a request built for +one endpoint from being replayed at another. + +Delegated tokens, bound to one named subject a client acts on behalf of, are a further optional +step layered on top of this base configuration. `crates/registry-mint/README.md` covers the +`accessTokens.claims.actor` field and the per-client `delegation` block that step needs; this +walkthrough covers the base, undelegated flow. + +For the complete field list, including every default, see the +[Registry Mint reference](../../reference/mint/). + +## Register a client + +Add one file per client under the directory named in `clients.directory`: + +```yaml +clientId: health-desk +principal: service:health-desk +evidenceAudience: https://evidence.example.org +requesterTags: [health-desk, region-north] +keys: + - kty: OKP + crv: Ed25519 + kid: health-desk-2026-01 + x: "" +``` + +`keys` accepts public JWKs only; a document carrying a private key member is rejected outright. +Loading the client registry is all-or-nothing, so one malformed registration fails the whole +load rather than serving a partial registry. + +Validate the deployment before opening a socket: + +```sh +mint check --config /etc/mint/mint.yaml +``` + +`check` loads the configuration, the signing key, and the client registry, then exits. Both +`check` and `serve` accept `MINT_CONFIG` in place of `--config`. + +## Start Registry Mint + +```sh +mint serve --config /etc/mint/mint.yaml +``` + +Onboarding, offboarding, and caller key rotation only need the client registry reloaded, not the +process restarted: send the running process `SIGHUP` and it re-reads `clients.directory`, +keeping the previous registry in place if the new one fails to load. + +## Obtain a token + +A client authenticates with the `client_credentials` grant and `private_key_jwt` client +authentication (RFC 7523): it signs a short-lived JWT assertion with its own private key and +posts it to the token endpoint. + +```sh +curl -sS https://mint.example.org/token \ + -d grant_type=client_credentials \ + -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ + --data-urlencode "client_assertion=" +``` + +The assertion must carry `iss` and `sub` equal to the client id, `aud` equal to the configured +`clientAssertion.audience`, a unique `jti`, and `iat`/`exp` inside +`clientAssertion.maximumLifetimeSeconds`. Every `jti` is accepted once; presenting the same +assertion twice is refused. + +The `mint token` subcommand builds and sends that request for local testing. It is a client +tool: it signs with the caller's own key and never touches Registry Mint's signing key. + +```sh +mint token --url https://mint.example.org/token \ + --client-id health-desk --key ./dev/health-desk.jwk +``` + +It prints the access token alone on stdout, so `TOKEN=$(mint token ...)` is the whole usage. + +## Verify the deployment + +Request a token and confirm the response shape: + +```sh +curl -sS https://mint.example.org/token \ + -d grant_type=client_credentials \ + -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ + --data-urlencode "client_assertion=" +``` + +```json +{ + "access_token": "SYNTHETIC_FIXTURE_TOKEN", + "token_type": "Bearer", + "expires_in": 300 +} +``` + +Confirm the published key set resolves at the configured `signing.jwksPath` (default +`/.well-known/jwks.json`), and that `GET /ready` returns success once at least one client is +registered. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| The token request fails with `401 invalid_client` | Registry Mint collapses every client authentication failure, an unknown client id, a bad signature, a replayed `jti`, an expired assertion, into this one code, so the endpoint cannot be used to probe which client ids are registered. | Check the client id, the signing key, the assertion's `iat`/`exp`, and that the `jti` has not already been used. | +| The token request fails with `400 unsupported_grant_type` | `grant_type` is missing or is not exactly `client_credentials`. | Send `grant_type=client_credentials` in the form body. | +| `mint check` or `mint serve` refuses to start over the signing key | The key file is not a regular, owner-only, single-link file, or its `kid` and algorithm do not match `signing.activeKeyId` and `signing.algorithm`. | Regenerate or re-permission the private JWK so it is owned by the running user, unreadable by group and other, and not a symlink. | +| Evidence rejects a token that Registry Mint minted | `accessTokens.claims` on Registry Mint and the resource server's own claim-name configuration name different claims for the same authority field. | Align every claim name (`principal`, `requesterTags`, `evidenceAudience`, `grantId`, `grantAuthority`, and `actor` where used) between the two configurations. | +| `GET /ready` returns `503` | No client is currently registered, or the client registry directory failed to load. | Add at least one valid registration under `clients.directory` and check the startup or reload log for the failing file. | diff --git a/docs/site/src/content/docs/reference/evidence-problems.mdx b/docs/site/src/content/docs/reference/evidence-problems.mdx new file mode 100644 index 000000000..3dd20fd8f --- /dev/null +++ b/docs/site/src/content/docs/reference/evidence-problems.mdx @@ -0,0 +1,65 @@ +--- +title: Evidence errors and problems reference +description: What each application/problem+json response from the Evidence assertion service means and how to respond to it. +status: current +owner: registry-docs +source_repos: [registry-stack] +last_reviewed: "2026-08-03" +doc_type: reference +locale: en +standards_referenced: [] +--- + +Evidence returns errors as problem details with the media type `application/problem+json`. The closed set of problem types, their HTTP status, and their exact body shape are frozen in `products/evidence/contracts/problem-contract.yaml` (contract `registry.evidence.public-problem/v1`) and generated into `products/evidence/generated/problem-v1.schema.json`; both files are the source of truth for this page. The `evidence-contracts` CI job byte-diffs a freshly regenerated copy of both files against the committed copies on every change (`products/evidence/scripts/check-contracts.sh`, wired into `.github/workflows/ci.yml`), so this reference cannot drift from a released Evidence binary without the build failing. + +`POST /v1/evidence` can return any problem documented on this page. `GET /v1/evidence-definitions` returns a narrower subset: `malformed_request`, `authentication_failed`, `rate_limited`, and `service_unavailable`. + +## Reading a problem response + +A problem body carries exactly five members: `type`, `title`, `status`, `code`, and `operation`, and no others. There is no `detail` member. Evidence's contract closes the body with `additionalProperties: false` and explicitly excludes request bodies or selector values, principal or credential inputs, source URLs or responses, script input or output, and any candidate count, score, hint, or comparison detail. Branch client code on the stable `code` member; `type` follows the fixed pattern `https://registrystack.org/problems/evidence/`. + +```json +{ + "type": "https://registrystack.org/problems/evidence/evidence_not_available", + "title": "Evidence could not be produced", + "status": 422, + "code": "evidence_not_available", + "operation": "01ARZ3NDEKTSV4RRFFQ69G5FAV" +} +``` + +The `operation` member is a ULID-shaped opaque identifier (pattern `^[0-9A-HJKMNP-TV-Z]{26}$`) that Evidence generates for the request; it is never taken from the caller. It matches the `X-Request-Id` response header on both successful and failing responses, and it is the identifier to quote to a deployment operator for support correlation. + +## Caller request problems + +These problem types indicate something about the request itself. Correct the request and resubmit; retrying an unchanged request returns the same problem. + +| Code | Status | Meaning | Retry helps | What to do | +| --- | --- | --- | --- | --- | +| `malformed_request` | 400 | The request body is not valid JSON, or it does not match the documented `EvidenceRequest` schema: an unknown field, a wrong type, or a structurally invalid request nonce or holder key. | No | Validate the request body against the schema published at `/openapi.json`, and remove any field the schema does not declare. | +| `invalid_selector` | 400 | The request's selector does not match any of the configured alternative profiles for the requested requirement. | No | Fetch `/v1/evidence-definitions` and match the request's selector to one of the profiles listed there for the requirement. | +| `authentication_failed` | 401 | No credential was supplied, more than one was supplied, the credential could not be parsed, or the bearer token did not verify. Evidence returns a `WWW-Authenticate: Bearer` header alongside this problem. | No | Supply exactly one `Authorization: Bearer` token, and confirm it has not expired and was issued for this deployment. | +| `response_format_not_acceptable` | 406 | The `Accept` header fell outside the closed negotiation matrix: a duplicate, a combination of types, a quality parameter, or an unrecognized value. Missing `Accept`, `*/*`, and exactly `application/jose+json` all select the default signed response and do not trigger this problem. | No | Send at most one recognized media type in `Accept`: `application/jose+json`, `application/vnd.registrystack.evidence-unsigned+json`, or `application/dc+sd-jwt`, with no quality parameter and no combination with another type. | +| `rate_limited` | 429 | The caller exceeded the configured per-principal request rate, or the separate per-principal-authority failed-selector rate (`crates/registry-evidence/src/rate_limit.rs`). | Yes, after backoff | Wait for the duration in the `Retry-After` header (this deployment sends `1` second) before retrying. | + +## Requests Evidence cannot satisfy + +A well-formed, authenticated request can still fail to produce evidence. Both problem types below intentionally withhold the specific reason: Evidence collapses several internal conditions into one public shape so that a response cannot be used to learn whether a record, grant, or requirement exists. + +| Code | Status | Meaning | Retry helps | What to do | +| --- | --- | --- | --- | --- | +| `not_authorized` | 403 | The authenticated caller's grant does not permit this requirement, this subject binding, or the negotiated response format, or the requirement identifier is unknown. Evidence does not reveal which of these applied, and an unknown requirement identifier is indistinguishable from a real one the caller is not authorized for. | No, without a grant change | Confirm the requirement identifier against `/v1/evidence-definitions` for the authenticated caller. If it is present there, ask the deployment operator to verify the grant covers this requirement, subject binding, and response format. | +| `evidence_not_available` | 422 | The source produced no unique matching record, the match was ambiguous, a required fact was missing, or a derivation input could not be resolved. Evidence collapses all four conditions into this one code and applies uniform, bounded processing so response timing does not indicate which condition occurred. | No, not by resubmitting the same request unchanged | Confirm with the subject that the requested fact exists in the source system. Report a persistent, unexpected result to the deployment operator with the `operation` value; Evidence does not reveal which internal condition produced it. | + +## Source and deployment availability + +Both problem types below are transient 503 responses. Evidence assigns them by where the failure occurred, not by how long it lasted. + +| Code | Status | Meaning | Retry helps | What to do | +| --- | --- | --- | --- | --- | +| `dependency_unavailable` | 503 | The configured external source did not respond usably: unreachable, timed out, wrong credential, wrong media type, an oversized response, malformed JSON, or an error envelope. Evidence maps every source-boundary failure to this one code (`crates/registry-evidence/src/runtime.rs`, `source_failure_problem`). | Yes, after backoff | Retry with backoff. If it persists, report it to the deployment operator with the `operation` value: the connected source system is unavailable, not the request. | +| `service_unavailable` | 503 | A transient failure inside the Evidence deployment itself: script execution, signing, audit-log writes, or discovery and configuration lookups. This code is also returned by the unauthenticated `/openapi.json` and `/ready` endpoints when the document or readiness state cannot be produced. | Yes, after backoff | Retry with backoff. If it persists, report it to the deployment operator with the `operation` value. | + +## Source + +This page transcribes `products/evidence/contracts/problem-contract.yaml` and `products/evidence/generated/problem-v1.schema.json`, and the runtime decisions in `crates/registry-evidence/src/problem.rs`, `crates/registry-evidence/src/runtime.rs`, and `crates/registry-evidence/src/server.rs` that choose which code a given failure returns. The full negotiation and per-operation status mapping is in `products/evidence/generated/registry-evidence.openapi.json`. diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index 7655a0616..aeb681952 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -105,6 +105,15 @@ Product names are always in English, including on future translated pages.
environment
Private bindings and operational settings for one Registry Stack project deployment target. An environment does not change the project's stable intent.
+
Evidence (product)
+
The minimum-disclosure assertion service in this monorepo. Crate: `crates/registry-evidence`; product material: `products/evidence/`. Given authenticated authority, an authorized purpose, and a predefined requirement, Evidence serves a signed assertion that answers the requirement, not the source record, plus an SD-JWT VC serialization of that same stateless assertion under a frozen Version 1 profile. The SD-JWT VC format is never a credential lifecycle: no issuance session, holder-binding ceremony, status list, or revocation. Evidence is not a Registry Notary mode, rewrite, or reduced configuration and does not inherit the Notary product model.
+ +
evidence credential (Registry Notary)
+
Registry Notary usage of "evidence": in the Evidence Gateway runtime, a Notary claim evaluation can result in credential issuance, governed by a credential profile and delivered through OID4VCI and SD-JWT VC. This Notary-scoped meaning is distinct from Evidence (product): Registry Notary evidence names a credential with an issuance lifecycle, while Evidence names a stateless signed assertion with none.
+ +
Evidence toolset
+
The three released binaries `evidence`, `evidencectl`, and `mint`. Releases that include the toolset publish reproducible binaries alongside a cosign-signed `SHA256SUMS` file, and the installer installs all three together or not at all after verifying every asset. `evidencectl` shells out to `evidence` for every Evidence semantic decision and never re-implements evaluation, signing, or verification. `mint`, built from the `registry-mint` crate, issues the access tokens `evidence` verifies.
+
Evidence Gateway
Governed runtime path for evidence responses. A Relay read or consultation and a Notary claim evaluation pass trusted request and evidence context through configured authorization and disclosure policy before returning or denying a response. Registry-backed Notary claims consume compiler-pinned Relay results.
@@ -208,7 +217,7 @@ Product names are always in English, including on future translated pages.
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
registry stack
-
The four formal stack products: Registry Platform, Registry Relay, Registry Manifest, and Registry Notary. Use lowercase when referring to the concept.
+
The formal stack products: Registry Platform, Registry Relay, Registry Manifest, Registry Notary, and Evidence, with Registry Mint as supporting token issuance. Use lowercase when referring to the concept.
purpose-bound request
Registry Stack product term for a request that carries or is evaluated against purpose limitation, policy-based access control, or context-aware authorization. Relay records the `Data-Purpose` header in audit records where present.
@@ -225,6 +234,9 @@ Product names are always in English, including on future translated pages.
Registry Manifest
Rust workspace for modeling, validating, and rendering standards-facing service, registry, form, and policy metadata without running Registry Relay. Provides a library (`registry-manifest-core`) and a CLI (`registry-manifest-cli`). Repo slug: `registry-manifest`.
+
Registry Mint
+
Small supporting service, not a fourth registry stack pattern, that issues short-lived, audience-bound access tokens to registered machine clients using the `client_credentials` grant with `private_key_jwt` client authentication, so a resource server such as Evidence can require signed tokens without standing up a general-purpose identity provider. The client registry binds each client id to its own keys and to the authority Registry Mint asserts for it. Registry Mint's tests drive Evidence's authenticator; the dependency runs one way only, and Evidence does not depend on Registry Mint. Crate: `crates/registry-mint`; binary: `mint`.
+
Registry Platform
Shared Rust workspace for registry security and operational primitives, including auth helpers, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC helpers, and test fixtures. Repo slug: `registry-platform`.
@@ -315,8 +327,8 @@ Product names are always in English, including on future translated pages. ## Style notes -- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary) and the adopter demo name (Solmara Lab) are always title case. -- Repo slugs (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `solmara-lab`) are always lowercase and monospace. +- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary, Registry Mint) and the adopter demo name (Solmara Lab) are always title case. The assertion product's name is Evidence, capitalized as a proper noun. +- Repo slugs and crate names (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `registry-evidence`, `registry-mint`, `solmara-lab`) are always lowercase and monospace. - Legacy underscore forms (`registry_relay`) and old repo names (`decentralized-evidence-demo`) appear only in historical pages or `rename_status` fields. - The glossary provides a reference for standards acronyms but does not replace per-page first-use expansion. diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx new file mode 100644 index 000000000..c91764c58 --- /dev/null +++ b/docs/site/src/content/docs/reference/mint.mdx @@ -0,0 +1,213 @@ +--- +title: Registry Mint reference +description: Configuration fields, the token endpoint contract, and the Evidence verification path for the Registry Mint token issuer. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: reference +locale: en +standards_referenced: [] +--- + +Registry Mint is the `mint` binary built from the `registry-mint` crate. It issues short-lived +access tokens to registered machine clients, for deployments that have callers and no identity +provider. + +## Contract status + +Registry Stack as a whole is a pre-1.0 technical release, and Registry Mint's surfaces carry that +same status individually: they do not appear among the covered surfaces listed in +[API stability and versioning](../api-stability/), so nothing on this page is a compatibility +promise. Configuration fields, the token endpoint shape, and CLI flags may change without a major +release until Registry Mint is added to that covered-surfaces table. + +What is stable in practice: the `client_credentials` grant with `private_key_jwt` client +authentication (RFC 7523), the collapse of every client-authentication failure to +`invalid_client`, and the rule that authority is written from the client registry and never from +the caller's own request. Those are structural properties this crate exists to hold, not +incidental implementation choices. + +## Source of truth + +The `registry-mint` crate at `crates/registry-mint/` is the source of truth for every claim on +this page. Configuration parsing and validation live in `crates/registry-mint/src/config.rs` and +`crates/registry-mint/src/clients.rs`, the HTTP surface in +`crates/registry-mint/src/server.rs`, token minting in `crates/registry-mint/src/token.rs`, and +the public error shape in `crates/registry-mint/src/error.rs`. `crates/registry-mint/README.md` +covers the same surface in prose. Where this page and the crate disagree, the crate wins. + +## Configuration reference + +One YAML document, loaded by `MintConfig::load`. Fields use `camelCase` keys, reject unknown +fields, and every relative path resolves against the configuration file's own directory. The +following tables group the surface by the struct that owns each field. + +### Top level + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `version` | integer | required | Must equal `1`. | +| `issuer` | string (URL) | required | Must be `https`, have a host, and carry no credentials, query, or fragment. | +| `listener` | object | required | See [Listener](#listener). | +| `signing` | object | required | See [Signing](#signing). | +| `accessTokens` | object | required | See [Access tokens](#access-tokens). | +| `clientAssertion` | object | required | See [Client assertion](#client-assertion). | +| `clients` | object | required | See [Clients](#clients). | + +### Listener + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `address` | string (IP address) | required | Parsed as an `IpAddr`. | +| `port` | integer (`u16`) | required | | +| `maximumRequestBytes` | integer (`u32`) | `16384` | | +| `requestTimeoutMilliseconds` | integer (`u64`) | `5000` | | + +### Signing + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `algorithm` | enum: `EdDSA`, `ES256`, `RS256` | required | Shared by minted tokens and accepted client assertions. | +| `activeKeyId` | string (1..=256 bytes) | required | Must match the `kid` of the private JWK at `activeKeyFile`. | +| `activeKeyFile` | path | required | Private JWK. Must be a regular, owner-only, single-link file. | +| `retiredPublicJwkFiles` | list of paths | `[]` | Public JWKs of keys that no longer sign but may still have live tokens. | +| `jwksPath` | string | `/.well-known/jwks.json` | Must start with `/`. | + +### Access tokens + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `audiences` | list of strings (1..=16 entries, 1..=512 bytes each) | required | Written as the minted token's `aud`. | +| `lifetimeSeconds` | integer (`u64`) | required | Bounded `60..=3600`. | +| `claims` | object | required | See [Access token claim names](#access-token-claim-names). | + +### Access token claim names + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `principal` | string | `sub` | | +| `requesterTags` | string | required | | +| `evidenceAudience` | string | required | | +| `grantId` | string | required | | +| `grantAuthority` | string | required | | +| `actor` | string, optional | none | Required only to issue delegated tokens. | + +These names must match the resource server's own claim-name configuration exactly. Claim names +must be distinct, must not shadow the registered JWT claims Registry Mint writes itself +(`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`, `client_id`), and `sub` may only be used for +`principal`. + +### Client assertion + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `audience` | string (URL with host) | required | The value client assertions must carry as their own `aud`. | +| `maximumLifetimeSeconds` | integer (`u64`) | `300` | Bounded `30..=600`. | +| `algorithms` | list of enum: `EdDSA`, `ES256`, `RS256` | required, non-empty | Accepted client assertion signature algorithms. | +| `replayCacheEntries` | integer (`usize`) | `8192` | Minimum `256`. | + +### Clients + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `directory` | path | required | Directory of per-client registration files. The one reloadable part of the configuration: `SIGHUP` re-reads it in place. | + +### Client registration fields (`clients/*.yaml`) + +One file per client, parsed by `crates/registry-mint/src/clients.rs`. + +| Field | Type | Default | Notes | +| --- | --- | --- | --- | +| `clientId` | string | required | | +| `principal` | string (<=512 bytes) | required | | +| `evidenceAudience` | string | required | | +| `requesterTags` | list of strings (<=32 entries) | required | | +| `grant` | object: `id`, `authority` | none, optional | Required together or not at all. | +| `delegation` | object: `actors`, `subjectClaims` | none, optional | Enables delegated tokens bound to one subject; see `crates/registry-mint/README.md`. | +| `keys` | list of public JWKs (<=8 entries) | required | A document carrying a private key member is rejected. | + +## Token endpoint contract + +### Request + +```text +POST /token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +&client_assertion= +``` + +The path `/token` is fixed and not configurable. `grant_type` must equal `client_credentials` +exactly, and `client_assertion_type` must equal +`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` exactly. `client_assertion` is a +compact JWS whose payload carries `iss` and `sub` equal to the client id, `aud` equal to the +configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside +`clientAssertion.maximumLifetimeSeconds`. Every `jti` is accepted once. + +### Response + +`200 OK`, `Content-Type: application/json`: + +```json +{ + "access_token": "SYNTHETIC_FIXTURE_TOKEN", + "token_type": "Bearer", + "expires_in": 300 +} +``` + +`access_token` is a compact JWS with header `{"alg": "", "typ": "at+jwt", +"kid": ""}`. Its claims carry the standard `iss`, `aud`, `iat`, `nbf`, +`exp`, `jti`, `client_id`, and `sub` (the principal), plus the authority claims named under +`accessTokens.claims`. `expires_in` equals `accessTokens.lifetimeSeconds`. + +### Errors + +Every response body is `{"error": ""}`, from +`crates/registry-mint/src/error.rs`: + +| Code | Status | When | +| --- | --- | --- | +| `invalid_request` | `400` | A required form field is missing, duplicated, or the assertion type is unsupported. | +| `unsupported_grant_type` | `400` | `grant_type` is missing or not `client_credentials`. | +| `invalid_client` | `401` | Every client authentication failure: unknown client id, bad signature, replayed `jti`, expired assertion. Registry Mint collapses these into one code so the endpoint cannot be used to probe which client ids are registered. Carries a `WWW-Authenticate: Bearer error="invalid_client"` header. | +| `server_error` | `500` | An internal failure, such as a misconfigured delegation the startup check is meant to catch. | + +### Other endpoints + +| Path | Purpose | +| --- | --- | +| `GET ` (default `/.well-known/jwks.json`) | Public keys for verifying minted tokens, `application/jwk-set+json`. | +| `GET /.well-known/oauth-authorization-server` | Metadata pointing at the token endpoint and the key set. | +| `GET /health` | Liveness. | +| `GET /ready` | Readiness. Returns `503` while no client is registered. | + +## How Evidence verifies these tokens + +Evidence's own `authentication` configuration block, defined in +`crates/registry-evidence/src/config.rs` (`AuthenticationConfig`), names the same claims Registry +Mint writes: `principalClaim`, `requesterTagsClaim`, `evidenceAudienceClaim`, `grantIdClaim`, +`grantAuthorityClaim`, and an optional `actorClaim`, alongside `issuer`, `audiences`, +`tokenTypes`, `algorithms`, and `jwksUri`. Evidence verifies a presented token against that +configuration in `crates/registry-evidence/src/auth.rs` (`Authenticator`), reading each claim by +the name configured there rather than any hardcoded name. Setting `authentication.issuer` and +`authentication.jwksUri` to Registry Mint's own `issuer` and published key set, and setting each +claim name to match `accessTokens.claims` on Registry Mint, is what lets one token flow between +the two. + +This is proven by tests, not only by matching configuration. `registry-mint`'s +`tests/evidence_compatibility.rs` drives the real Registry Mint router over a real on-disk +deployment and feeds the minted token to Evidence's own authenticator; +`tests/delegated_subject_binding.rs` does the same for delegated, subject-bound tokens, running +Evidence's own entitlement match and selector resolution over a token minted by the real Registry +Mint router. The dependency runs one way only: Registry Mint's tests exercise Evidence's +authenticator, and Evidence does not depend on Registry Mint. + +## Next + +- [Configure Registry Mint](../../configure/mint/) +- [API stability and versioning](../api-stability/) diff --git a/docs/site/src/content/docs/security/evidence.mdx b/docs/site/src/content/docs/security/evidence.mdx new file mode 100644 index 000000000..1f9502c8d --- /dev/null +++ b/docs/site/src/content/docs/security/evidence.mdx @@ -0,0 +1,238 @@ +--- +title: Evidence security model +description: "The security invariants Evidence enforces, how each traces to a named test, and the duties that remain with the operator." +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: explanation +locale: en +standards_referenced: [] +--- + +Evidence's security model is contract-driven. The invariant matrix in +`products/evidence/contracts/security-invariant-matrix.yaml` is the source of +truth for what the runtime guarantees, `products/evidence/contracts/security-test-traceability.yaml` +binds every invariant to the named tests that prove it, and continuous +integration regenerates and byte-diffs both contracts through the +`evidence-contracts` job, which runs +`products/evidence/scripts/check-contracts.sh`. A change that narrows a +guarantee or drops its test fails that job rather than merging unnoticed. + +## What the invariant matrix guarantees + +The matrix pins 33 numbered invariants, `V1-I01` through `V1-I33`, each with a +rule, the threat it closes, its enforcement, and one `negative_test` id. This +section groups them by theme and cites each id in backticks; the matrix itself +is the complete, normative text. + +### Closed request and requirement scope + +Evidence evaluates only predefined, versioned requirement revisions +(`V1-I01`). The request schema is closed, so callers cannot supply +thresholds, expressions, scripts, paths, headers, source fields, relationship +types, adapter parameters, or response projections (`V1-I02`). Because a +bundle enabling several requirements at once can reconstruct a protected +value across them, the complete enabled bundle is reviewed as one disclosure +surface (`V1-I03`). + +### Authentication and authorization + +Principals and attributes derive only from configured, validated +authentication sources, and missing data denies rather than falling back to +another claim (`V1-I04`). One authorization decision binds requester, +optional actor, requirement revision, purpose, every role, profile, and +origin tuple, authority, and audience together, so partial matches across +grants cannot be unioned into access (`V1-I05`). Selector profiles and values +are provider-lookup inputs only, never proof of authority (`V1-I06`), and a +caller-supplied consent, approval, or grant reference cannot create authority +on its own (`V1-I07`). Callers cannot choose selector fields, operators, +weights, thresholds, normalization, or query plans; named profiles close the +exact field set (`V1-I08`). + +### Source access and the Rhai boundary + +Source calls are fixed by trusted configuration and executed only by the +core, closing off SSRF, credential forwarding, and script-directed +networking (`V1-I09`). Provider lookup exposes only `match`, `no_match`, or +`ambiguous`; Evidence never surfaces or chooses candidates (`V1-I10`). Rhai +scripts return one closed lookup result and declared typed concept values +only (`V1-I11`), and the core rejects undeclared concepts, extra fields, and +any value that violates its type, codelist, cardinality, precision, or size +before evidence construction (`V1-I12`). Public evidence is assembled only by +the core after that validation, so a script cannot inject envelope fields, +subject identifiers, or unsupported claims (`V1-I25`). + +### Fail-closed behavior and non-disclosure + +Missing facts, undefined decisions, script failures, audit failures, and +evaluation failures all fail closed rather than releasing partial or +unaudited evidence (`V1-I13`). Raw source responses are never persisted or +logged (`V1-I14`), and selector, source, and disclosed values never appear in +logs or native audit (`V1-I15`). No-match, ambiguous, missing-fact, and +inconsistent-derivation outcomes collapse to one public problem shape by +default, so a caller cannot use response shape, message, or timing as an +existence oracle for registry membership (`V1-I16`). Subject bindings are +scoped to their audience, purpose, role, and selector bundle, so the same +subject is not linkable across purposes or relying parties (`V1-I17`). + +### Configuration and process tenancy + +Configuration is immutable for the life of the serving process: there is no +runtime override, reload, merge, or fallback path (`V1-I18`). One process +serves one operator-controlled trust domain, with one service trust domain, +issuer governance boundary, bundle lifecycle, signer, and audit boundary +(`V1-I19`). Rate controls are defense in depth rather than a substitute for +safe bundle design; combination validation runs independently of configured +rate limits (`V1-I20`). + +### Signing and response integrity + +A signed flattened JWS over the exact evidence payload is mandatory, +available to every authorized grant, and the default response; unsigned +output exists only through its own exact media type when both the bundle and +the matched grant permit it (`V1-I21`). Missing or failed signing never falls +back to unsigned evidence (`V1-I22`). Private signing material is owned by +the core alone and stays absent from bundle values, Rhai, logs, audit, and +errors (`V1-I23`). A signature authenticates the provider and payload +integrity; it does not assert legal-signature status or source truth +(`V1-I24`). + +### Discovery, nonce handling, and response-format authorization + +Evidence-definition discovery is authenticated and requester-scoped; it never +creates authority or exposes deployment internals such as selectors, source +plans, or credentials (`V1-I26`). Every request carries one canonical +32-byte random nonce that is echoed into the evidence payload but never +stored, uniqueness-checked, or exposed to authorization, rate limits, Rhai, +source requests, logs, metrics, traces, or native audit (`V1-I27`). The +unsigned response format is authorized only when the immutable bundle +enables it and the one complete matched grant also permits it; API +selection, runtime configuration, or other grants create no permission on +their own (`V1-I28`). The final immutable response bytes exist before the +disclosure-release audit is durably accepted, and those are the exact bytes +released afterward (`V1-I29`). Every native audit event records the closed +response-protection mode, and a signing key identity is present exactly for +signed release (`V1-I30`). + +### Verification and token binding + +Strict signed verification compares the response against independently +retained expectations, the expected nonce, the expected unordered set of +unique role-bound subject bindings, and the expected concept identifiers, +forms, and cardinalities, and returns one generic policy-mismatch error +rather than revealing which comparison failed (`V1-I31`). An access token +carrying a proof-of-possession confirmation claim is denied rather than +accepted as an ordinary bearer token, because Evidence validates no sender +proof and accepting one would discard the constraint the token was issued +under (`V1-I32`). + +### Telemetry + +Operational telemetry is off by default, served only on a separate +operator-private listener, and every series label is drawn from a closed set +(route template, method, status category, problem code) rather than from +request content (`V1-I33`). + +### Cross-cutting controls + +The matrix also pins 21 cross-cutting controls, keyed by name rather than a +`V1-I` id, each with its own negative test. They cover bundle and secret +trust (`config_trust`, `secret_parsing`), durable audit ordering +(`audit_order`), listener exposure and outbound transport +(`transport_identity`, `outbound_tls_and_proxy`, `transport_pinning`), secret +file identity (`secret_file_identity`), the closed runtime file +(`runtime_ownership_split`), request- and script-boundary details +(`request_preparation`, `subject_role_order`, `source_response_shape`, +`script_resource_exhaustion`, `reserved_header_aliases`, +`jwks_route_parity`, `unsigned_envelope_distinct`, `exact_decimal`, +`entity_reference_projection`), and the SD-JWT VC serialization +(`sd_jwt_vc_projection_integrity`, `sd_jwt_vc_format_authorization`, +`sd_jwt_vc_holder_key_closed`, `sd_jwt_vc_claims_closed`). + +## How test traceability works + +Each invariant's `negative_test` id in the matrix is a key into +`products/evidence/contracts/security-test-traceability.yaml`, which lists +the file and function name of every test that proves it. A negative test may +be split across several functions, but the matrix states one binding review +rule: a test may be split, never weakened or deleted. + +The mapping is not narrative. `crates/registry-evidence/tests/security_contract_traceability.rs` +reads both contracts and checks three things: every matrix id has a +traceability entry and every traceability entry maps to a matrix id, so +neither can drift from the other; each referenced test file exists and +contains a matching `fn (` signature; and that signature sits under a +`#[test]` or `#[tokio::test]` attribute rather than a plain function that +happens to share a name. A traceability entry that names a deleted or +renamed test fails this check before it fails anything else. + +```bash +products/evidence/scripts/check-contracts.sh +``` + +Three concrete examples: + +- `V1-I04` (principals and attributes derive only from configured + authentication sources) traces to `sec-missing-principal-no-fallback`, + proven by `missing_principal_never_falls_back_to_client_id_or_azp` in + `crates/registry-evidence/src/runtime_tests.rs`. +- `V1-I23` (private signing material stays core-owned and absent from bundle + values, Rhai, logs, audit, and errors) traces to + `sec-private-key-canary-unreachable`, proven by + `yaml_names_and_secret_references_are_strict` in + `crates/registry-evidence/src/config.rs` and + `jwks_contains_public_material_only` in + `crates/registry-evidence/src/signing.rs`. +- `V1-I29` (final immutable response bytes exist before the disclosure-release + audit is durably accepted) traces to `sec-release-bytes-pre-audited`, + proven by four tests in `crates/registry-evidence/src/runtime_tests.rs`: + `disclosure_audit_failure_prevents_signed_response_release`, + `disclosure_audit_failure_prevents_unsigned_response_release`, + `signing_failure_returns_a_problem_and_never_an_unsigned_body`, and + `sd_jwt_signing_failure_no_fallback_format`. + +## What the operator must uphold + +The invariant matrix describes what the `evidence` binary enforces in code. +`products/evidence/OPERATOR-CONTRACT.md` states duties that remain with the +operator because no service-side control can hold them for a deployment it +does not otherwise touch. + +Owner-only key files. Each secret file below the configured +`secretProviders.file.root` must be a regular, non-symlink file owned by the +service identity with mode `0600`; the file provider rejects anything else. +Audit and subject-binding secret files must contain independently generated +raw key bytes and be at least 32 bytes. + +Immutable bundle and runtime file. The operator mounts one reviewed governed +bundle and one closed `runtime.yaml` read-only at startup; the bundle +directory, the runtime file, and every captured artifact must be +non-writable to the service process, with directories and files carrying no +write bits. There is no runtime upload, editor, approval API, hot reload, +merge, mutation, governed-field override, or fallback bundle or runtime +file: a new revision is a new deployment, not a live change. + +Secret handling. Source credentials and private signing material reach +Evidence only through the secret-reference mechanism and must not appear in +YAML values, Rhai, command arguments, environment dumps, logs, audit, +errors, snapshots, or generated contracts. The operator configures exactly +one active signing key, whose `kid` matches `signing.activeKeyId`, and +retains each retired public key in the published JWKS for at least the +maximum assertion validity plus allowed clock skew. + +## Report a vulnerability + +Suspected Evidence vulnerabilities, including credential disclosure, +authentication bypass, audit redaction failure, source connector data +leakage, and signing-key handling bugs, go through the private disclosure +process in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/main/SECURITY.md), +never a public issue or pull request. See +[Report a vulnerability](report-a-vulnerability/) for the complete in-scope +list and reporting steps. + +## Next + +- [Security overview](../) +- [Report a vulnerability](report-a-vulnerability/) diff --git a/docs/site/src/content/docs/spec/rs-pr-evidence.mdx b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx new file mode 100644 index 000000000..6a650bcf5 --- /dev/null +++ b/docs/site/src/content/docs/spec/rs-pr-evidence.mdx @@ -0,0 +1,624 @@ +--- +title: "RS-PR-EVIDENCE: Evidence protocol" +description: "The normative HTTP protocol contract for Evidence: discovery, authentication, the closed request contract, acceptance definitions, minimum disclosure, the signed JWS and SD-JWT VC response formats, problem reporting, audit gates, and immutable deployment input." +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: specification +doc_id: RS-PR-EVIDENCE +category: normative +evidence: verified +locale: en +standards_referenced: + - sd-jwt-vc + - cccev + - openapi + - json-schema +layer: + - evaluation +audience: + - integrator + - operator + - specification editor +--- + +This document defines the HTTP protocol contract that Evidence exposes: how a caller authenticates, +discovers the request shapes it may invoke, submits one closed request against a predefined +requirement, and receives the smallest sufficient assertion in an authorized response format, plus +the problem, audit, and configuration-immutability behavior every request carries. +Evidence is a minimum-disclosure assertion service over data institutions already hold. +It returns the answer a requirement asks for, not the record behind it. + +Every requirement statement in this document restates a frozen Version 1 contract that lives in the +repository, and names that contract file beside it. +The contract files are the source of truth; this document is their protocol-level reading. +Where a contract carries a numeric limit, a field name, or an enumerated value, this document points +at the file rather than copying the value, so the two cannot drift apart. + +Evidence is a separate product from Registry Relay and Registry Notary. +It is the Evidence counterpart to [RS-PR-RELAY](../rs-pr-relay/) and +[RS-PR-NOTARY](../rs-pr-notary/), and it is not a Registry Notary mode, rewrite, or reduced +configuration (`products/evidence/README.md`). + +The key words in this document are interpreted per [RS-DOC](../rs-doc/) Section 2. +Defined terms are used per [RS-TERMS](../rs-terms/). + +{/* TODO[evidence]: RS-ARC-G Section 3 names Registry Manifest, Registry Relay, and Registry Notary + as components and does not yet define an Evidence component, so this document deliberately does + not claim to refine a numbered RS-ARC-G requirement. Add that relationship once RS-ARC-G places + Evidence in the architecture. */} + +{/* TODO[evidence]: src/data/standards.yaml records sd-jwt-vc, cccev, openapi, and json-schema + without listing registry-evidence under `used_by`. The register entries need an Evidence row + before this page's standards claims are traceable from the register itself. */} + +## Version history + +| Version | Date | Status | Change | +| --- | --- | --- | --- | +| 0.1.0 | 2026-08-03 | draft | Initial profile derived from the frozen V1 contracts. | + +## 1. Scope and references + +This specification covers Evidence's externally observable protocol behavior: + +- The service surface, the generated OpenAPI document, and requester-scoped definition discovery. +- Authentication of callers and the single authorization decision that gates a request. +- The closed request contract and the request nonce. +- Evaluation, the four coequal acceptance definitions, and the output gate. +- Disclosure minimization, subject binding, and the combined-bundle disclosure surface. +- The two response encodings: the signed JSON Web Signature (JWS) assertion and the SD-JWT VC + (SD-JWT-based Verifiable Credentials) serialization of that same assertion. +- The problem contract, including existence collapse. +- The audit obligation and its two durable gates. +- The immutability of deployment input. + +This specification does not define: + +- Exact routes and schemas: The generated OpenAPI document, + `products/evidence/generated/registry-evidence.openapi.json`, is the authoritative route and schema + reference. This document states behavior a schema cannot, and does not restate request and response + shapes that would drift from the generated source. +- Bundle authoring: How an operator declares requirements, selector profiles, authority profiles, + sources, and scripts belongs to `products/evidence/contracts/bundle.schema.yaml` and the trusted + request-adapter reference under `products/evidence/reference/request-adapter/`. +- Deployment duties: Supported deployment shape, secret and key custody, readiness, audit storage, + and rotation belong to `products/evidence/OPERATOR-CONTRACT.md`. +- Verifier procedure: The relying-party policy document and the offline `evidence verify` command are + specified by `products/evidence/contracts/verification-policy.schema.yaml`. + +Evidence Version 1 has explicit non-goals, recorded in +`products/evidence/contracts/README.md` and `products/evidence/README.md`. +It stops before documents, credential issuance protocols and credential lifecycle, status lists and +revocation, OpenID for Verifiable Credential Issuance (OID4VCI) in any part, presentation-side +verification, replay or nonce state beyond the stateless request-nonce echo, server-issued +challenges, federation, delegated agents, workflow, public or federated catalogs, runtime bundle +mutation, source planning, multi-source fulfillment, and an application database. +The SD-JWT VC output is a second encoding of one stateless assertion under a frozen profile; it is +never a credential lifecycle. +Evidence must not depend on `registry-notary*`. +Registry Relay's protocol is specified by RS-PR-RELAY and Registry Notary's by RS-PR-NOTARY; neither +governs Evidence, and Evidence inherits no requirement from either. + +## 2. Service surface and discovery + +Evidence is one `registry-evidence` crate, one `evidence` binary, one serving process, and one +operator-controlled trust domain (`products/evidence/README.md`). + +REQ-PR-EVIDENCE-001: The reachable HTTP surface MUST be exactly the operations declared in +`products/evidence/generated/registry-evidence.openapi.json`: one evidence operation, one +definition-discovery operation, public key discovery, JWT VC Issuer Metadata, liveness, readiness, +and publication of that OpenAPI document itself. +A conforming deployment MUST NOT add a route outside that generated document. + +REQ-PR-EVIDENCE-002: Definition discovery MUST be authenticated, rate limited, and requester-scoped. +The response MUST contain only complete request shapes that match exactly one configured authority +path, and MUST omit unentitled and ambiguous shapes, so an unentitled caller receives an empty list +(`products/evidence/contracts/definitions.schema.yaml`, invariant `V1-I26` in +`products/evidence/contracts/security-invariant-matrix.yaml`). + +REQ-PR-EVIDENCE-003: Discovery MUST NOT create authority and MUST NOT expose deployment internals. +It MUST NOT reveal source plans, scripts, credentials, requester tags, authority-profile identifiers, +selector values, codelist values, or unrelated definitions, MUST perform no provider request, and +MUST NOT emit an evidence-data audit event +(`products/evidence/contracts/definitions.schema.yaml`, invariant `V1-I26`). + +REQ-PR-EVIDENCE-004: Public key discovery MUST serve public keys only, and JWT VC Issuer Metadata +MUST carry only the members named in `products/evidence/contracts/sd-jwt-vc-profile.yaml`. +Neither document is a trust anchor: a verifier MUST pin the provider identity and key set through +governed verifier configuration (`products/evidence/contracts/jws-profile.yaml`, +`products/evidence/contracts/sd-jwt-vc-profile.yaml`). + +## 3. Authentication and authorization + +Evidence runs one authentication kind, `oidc-access-token`, whose issuer, audiences, accepted token +types, algorithms, JWKS URI, and claim paths are fixed in the immutable bundle +(`products/evidence/contracts/bundle.schema.yaml`). + +REQ-PR-EVIDENCE-005: The evidence and definition-discovery operations MUST require exactly one +`Authorization` header carrying one bearer token, verified against the configured authentication +profile (`products/evidence/generated/registry-evidence.openapi.json`, +`products/evidence/contracts/bundle.schema.yaml`). + +REQ-PR-EVIDENCE-006: The principal MUST derive only from the one configured principal claim. +A missing claim MUST deny, with no client-identifier, authorized-party, header, or request fallback +(`products/evidence/contracts/authority-context.schema.yaml`, invariant `V1-I04`). + +REQ-PR-EVIDENCE-007: One authorization decision MUST cover the complete request. +The exact inputs are the principal, the optional actor, the exact requirement revision, the purpose, +the audience, the authority profile and kind, the optional authenticated grant identifier, and the +complete set of role, selector-profile, and value-origin tuples. +Permissions MUST NOT be unioned across entitlements, and denial MUST occur before source credential +resolution or source access (`products/evidence/contracts/authority-context.schema.yaml`, invariant +`V1-I05`). + +REQ-PR-EVIDENCE-008: Caller data MUST NOT create authority. +Possession of an identifier or a demographic tuple MUST NOT authorize a lookup, and a caller-supplied +consent, approval, or grant reference MUST NOT escalate authority; a grant identifier is accepted +only from authenticated context and must already be bound to the matched entitlement +(`products/evidence/contracts/selector-contract.yaml`, +`products/evidence/contracts/authority-context.schema.yaml`, invariants `V1-I06` and `V1-I07`). + +REQ-PR-EVIDENCE-009: Selector values MUST be absent for the authenticated-context and +authenticated-grant value origins and present only for the request origin. +Context-derived and grant-derived values MUST resolve only through the complete configured claim map +over the already verified token (`products/evidence/contracts/selector-contract.yaml`, +`products/evidence/contracts/request.schema.yaml`). + +REQ-PR-EVIDENCE-010: An access token carrying a proof-of-possession confirmation claim MUST be +denied before any authenticated context is constructed, and MUST NOT be accepted as an ordinary +bearer token (invariant `V1-I32`). + +REQ-PR-EVIDENCE-011: Rate controls MUST be applied per principal using the configured request, burst, +and failed-selector limits in `products/evidence/contracts/bundle.schema.yaml`. +They are defense in depth: bundle-combination validation under REQ-PR-EVIDENCE-025 remains mandatory +independently of any configured rate limit (invariant `V1-I20`). + +## 4. Request contract + +REQ-PR-EVIDENCE-012: The request body MUST validate against +`products/evidence/contracts/request.schema.yaml`, which closes the member set and rejects unknown +members. +Transport validation MUST be followed by named-profile validation, which closes the exact field +names, scalar types, byte and numeric bounds, aggregate size, value origin, and source placements +declared by the profile (`products/evidence/contracts/selector-contract.yaml`). + +REQ-PR-EVIDENCE-013: A caller MUST NOT supply thresholds, expressions, scripts, paths, headers, +source fields, relationship types, adapter parameters, or response projections. +Preparation, extraction, derivation, source transport, and concept projection are bundle-owned +(invariant `V1-I02`). + +REQ-PR-EVIDENCE-014: Every request MUST carry one request nonce in the canonical form fixed by +`products/evidence/contracts/request.schema.yaml`, generated independently per request by a +cryptographically secure random source. +Evidence MUST echo it into the assertion payload and cover it by the signature, and MUST NOT store +it, uniqueness-check it, or expose it to authorization, rate limits, scripts, source requests, logs, +metrics, traces, or native audit (invariant `V1-I27`). +Nonce reuse is not rejected, so the nonce is transaction binding only for a relying party that +independently retained the original request (`products/evidence/contracts/evidence.schema.yaml`). + +REQ-PR-EVIDENCE-015: Only a predefined requirement, at an exact enabled revision, MAY be evaluated. +The request resolver MUST reject anything else before authorization, and unknown and unauthorized +requirement identifiers MUST be indistinguishable to a caller lacking authorization +(invariant `V1-I01`, `products/evidence/contracts/problem-contract.yaml`). + +REQ-PR-EVIDENCE-016: Subject roles MUST be resolved by name, not by array position. +Duplicate, missing, unknown, or wrong-profile roles MUST fail before credential acquisition or source +access, and accepted roles MUST be canonicalized to requirement declaration order +(`products/evidence/contracts/request.schema.yaml`, +`products/evidence/contracts/selector-contract.yaml`). + +## 5. Evaluation and acceptance definitions + +One evaluation runs a fixed pipeline. Rust owns every trust decision; the trusted bundle scripts own +only requirement-specific preparation, extraction, and derivation. + +```mermaid +flowchart LR + request["Closed request
requirement, purpose, subjects, nonce"] + authz["One authorization decision
complete entitlement match"] + access["Access audit
durably accepted"] + source["One fixed source request
core-owned transport"] + derive["Bounded derivation
prepare, extract, derive"] + gate["Output gate
exact concept set and forms"] + sign["Construct and sign
core-owned payload"] + release["Release audit, then exact bytes"] + request --> authz --> access --> source --> derive --> gate --> sign --> release +``` + +The diagram restates the ordering the contracts fix: authorization precedes audit, access audit +precedes credential acquisition and source access, the output gate precedes evidence construction, +and the disclosure-release audit precedes release of the exact serialized bytes. + +REQ-PR-EVIDENCE-017: Rust MUST own authentication, authorization, minimized script inputs, fixed +source execution, response projection, output validation, evidence construction, response protection, +and audit. +Scripts MUST be confined to the closed `prepare`, `extract`, and `derive` entry points and MUST NOT +perform input or output (`products/evidence/contracts/rhai-abi.yaml`, +`products/evidence/contracts/primitive-library.yaml`, invariant `V1-I11`). + +REQ-PR-EVIDENCE-018: Exactly one evidence-data request MUST be made per evaluation, after successful +authorization and durable access audit. +Origin, method, path, fixed headers, authentication, TLS trust, redirect denial, proxy denial, +timeout, response byte limit, concurrency, and the one-request ceiling are fixed by trusted +configuration and executed only by the core +(`products/evidence/contracts/source-contract.yaml`, invariant `V1-I09`). + +REQ-PR-EVIDENCE-019: Provider lookup MUST return only the closed union of match, no-match, and +ambiguous outcomes, and only a match MAY carry facts. +Evidence MUST NOT expose or choose candidates, and MUST NOT emit candidate counts, scores, +confidence, hints, diagnostics, or comparisons +(`products/evidence/contracts/rhai-abi.yaml`, invariant `V1-I10`). + +REQ-PR-EVIDENCE-020: Adult status, controlled residence region, professional licence status, and +legal-parent relationship are coequal full-path acceptance definitions. +All four MUST pass the same offline and production path on one revision before Version 1 is called +implemented. +None MAY become a Rust domain type, a built-in operation, a special route, or a preferred +implementation phase (`products/evidence/README.md`, `products/evidence/CONCEPT.md`). + +REQ-PR-EVIDENCE-021: The derivation result MUST pass the output gate before evidence construction. +The exact concept identifier set, declared form, lexical type, numeric precision and range, codelist +or scheme version and membership, collection cardinality and uniqueness, structured schema, and size +bounds are closed by `products/evidence/contracts/supported-value-forms.yaml` and the selected +concept declaration. +Undeclared concepts, extra fields, and out-of-contract values MUST be rejected +(`products/evidence/contracts/evidence.schema.yaml`, invariant `V1-I12`). + +REQ-PR-EVIDENCE-022: Missing facts, undefined decisions, script failures, audit failures, and +evaluation failures MUST fail closed. +Partial, stale, fabricated, or unaudited evidence MUST NOT be released (invariant `V1-I13`). + +## 6. Disclosure minimization + +REQ-PR-EVIDENCE-023: The assertion MUST carry only the members declared by +`products/evidence/contracts/evidence.schema.yaml`, which closes the payload and rejects additional +properties. +Subject selector profiles and selector values MUST NOT appear in evidence. +The disclosed content is the declared supported values for the selected requirement, never the source +record (`products/evidence/contracts/cccev-field-mapping.yaml`). + +REQ-PR-EVIDENCE-024: A subject MUST appear only as a role-bound, audience-scoped opaque binding. +The binding MUST be a domain-separated keyed derivation over the audience, purpose, role, profile, +binding-key version, and complete canonical selector bundle, so it is not globally linkable across +relying parties or purposes +(`products/evidence/contracts/evidence.schema.yaml`, invariant `V1-I17`). + +REQ-PR-EVIDENCE-025: The complete enabled bundle MUST be reviewed as one disclosure surface. +Individually safe definitions MUST be rejected when their combination reconstructs a protected value, +and startup bundle-combination validation MUST enforce the reviewed decision +(invariant `V1-I03`, `products/evidence/contracts/bundle.schema.yaml`). + +REQ-PR-EVIDENCE-026: Raw source responses MUST NOT be persisted or logged, and selector, source, and +disclosed values MUST NOT appear in logs or native audit. +Response ownership is bounded and in memory, with centralized structured-log redaction +(invariants `V1-I14` and `V1-I15`). + +REQ-PR-EVIDENCE-027: Purpose MUST NOT narrow disclosure. +A requirement returns the same concepts and disclosure forms for every purpose authorized to invoke +it; a purpose that justifies a coarser answer needs its own requirement and its own place in the +combined disclosure review (`products/evidence/OPERATOR-CONTRACT.md`). + +## 7. Response formats and signing + +Evidence releases one stateless assertion. +The bundle-level and grant-level `responseFormats` permission decides which serializations may carry +it (`products/evidence/contracts/bundle.schema.yaml`). + +REQ-PR-EVIDENCE-028: The signed flattened JWS format MUST be available to every authorized grant and +MUST be the default result. +The Accept matrix is closed and MUST be resolved before source access; the exact media types and +their selection rules are fixed by `products/evidence/contracts/jws-profile.yaml`. +Every response varies on Accept and remains no-store (invariant `V1-I21`). + +REQ-PR-EVIDENCE-029: A response format other than the signed default MUST be released only when both +the immutable bundle and the one complete matched grant permit it. +Format selection creates no permission, and a refusal MUST use the ordinary authorization problem +without revealing which layer withheld it +(`products/evidence/contracts/jws-profile.yaml`, +`products/evidence/contracts/sd-jwt-vc-profile.yaml`, invariant `V1-I28`). + +REQ-PR-EVIDENCE-030: Missing or failed signing MUST NOT fall back to unsigned output or to any other +response format. +A signing failure MUST surface as the safe transient failure defined by +`products/evidence/contracts/jws-profile.yaml` (invariant `V1-I22`). + +REQ-PR-EVIDENCE-031: Private signing material MUST be core-owned and absent from bundle values, +scripts, logs, audit, and errors. +The bundle MUST reference the active key only through a supported runtime secret provider, retired +public keys MUST be public JWKs, and a key identifier MUST NOT be reused for different key material +(`products/evidence/contracts/jws-profile.yaml`, invariant `V1-I23`). + +REQ-PR-EVIDENCE-032: The final immutable response bytes MUST exist before the disclosure-release +audit is durably accepted, and the exact pre-audited bytes MUST be the bytes released afterward, for +every response format (`products/evidence/contracts/jws-profile.yaml`, invariant `V1-I29`). + +### 7.1 Signed flattened JWS + +REQ-PR-EVIDENCE-033: The signed response MUST be a flattened JWS JSON serialization carrying exactly +the members required by `products/evidence/contracts/jws-profile.yaml`, with the unprotected header +prohibited. +The protected header MUST carry exactly the allowlisted members with the allowlisted algorithm, and +MUST NOT carry any of the key-reference or criticality members that file prohibits. + +REQ-PR-EVIDENCE-034: The payload MUST be the exact UTF-8 assertion bytes, base64url encoded without +padding. +A verifier MUST verify the signature before parsing or acting on payload claims, and MUST validate +the payload against the committed assertion schema before applying relying-procedure policy +(`products/evidence/contracts/jws-profile.yaml`, +`products/evidence/contracts/evidence.schema.yaml`). + +REQ-PR-EVIDENCE-035: A signature authenticates the technical provider and payload integrity only. +It MUST NOT be read as asserting legal-signature status, source truth, holder binding, or single-use +semantics, and cryptographic authenticity MUST be reported separately from current validity +(`products/evidence/contracts/jws-profile.yaml`, invariants `V1-I24` and `V1-I31`). + +REQ-PR-EVIDENCE-036: The unsigned envelope MUST be a separately typed, visibly unsigned document with +the member set fixed by `products/evidence/contracts/jws-profile.yaml`. +It carries no integrity protection, is never later-verifiable evidence, and MUST NOT be produced as a +fallback from any signed-path failure. +The strict JWS verifier MUST reject it, and Version 1 MUST NOT use an unsecured JWS, an empty +signature, or a JWS-shaped unsigned object. + +### 7.2 SD-JWT VC serialization + +REQ-PR-EVIDENCE-037: The SD-JWT VC format MUST be a serialization of the same Version 1 assertion and +MUST NOT introduce a credential lifecycle. +Everything before serialization is the unchanged path: one authorization decision, fixed source +execution, bounded derivation, output validation, audience-scoped subject binding, and the same +durable access and disclosure-release audit ordering +(`products/evidence/contracts/sd-jwt-vc-profile.yaml`). + +REQ-PR-EVIDENCE-038: The profile non-goals in +`products/evidence/contracts/sd-jwt-vc-profile.yaml` MUST NOT be implemented, stubbed, +feature-flagged, or left as an extension seam. +They include OID4VCI in any part, persistent issuance state, status lists and revocation, +presentation-side verification and key-binding JWT validation, wallet onboarding and attestation, +holder-scoped or cross-verifier subject identifiers, and reissuance, refresh, batch issuance, or +persistent credential identifiers. + +REQ-PR-EVIDENCE-039: The credential claim set MUST be closed. +The always-disclosed claims, the one-disclosure-per-supported-value rule, the digest ordering, the +fresh per-disclosure salt, and the prohibited claims are fixed by +`products/evidence/contracts/sd-jwt-vc-profile.yaml`. +The issuer MUST NOT append a key-binding JWT. + +REQ-PR-EVIDENCE-040: A holder confirmation claim MUST be present only when the caller supplied a +holder public key in the request, and Evidence MUST NOT validate a presentation. +The accepted key type and the prohibited private members are fixed by +`products/evidence/contracts/request.schema.yaml` and +`products/evidence/contracts/sd-jwt-vc-profile.yaml`; a key carrying any private member, a +non-allowlisted algorithm, or an unparseable body MUST fail before credential acquisition or source +access. + +REQ-PR-EVIDENCE-041: The credential's subject binding MUST remain audience-scoped, so the credential +is meaningful only to the relying party named in the assertion's audience. +Adopter-facing material MUST state this limit rather than implying a general multi-verifier +credential (`products/evidence/contracts/sd-jwt-vc-profile.yaml`). + +## 8. Problem reporting + +REQ-PR-EVIDENCE-042: A failure MUST be reported as `application/problem+json` +([RFC 9457](https://www.rfc-editor.org/info/rfc9457)) carrying exactly the members and one of the +codes declared by `products/evidence/contracts/problem-contract.yaml`. +The prohibited content list in that file MUST hold: no request body or selector material, no +principal, actor, grant, token, credential, or authorization input, no source detail, no script +detail, no supported value or subject binding, and no candidate count, score, hint, or comparison. + +REQ-PR-EVIDENCE-043: The unresolved internal classes named in +`products/evidence/contracts/problem-contract.yaml` MUST collapse by default to one public code with +the same status, title, and body shape. +Processing and response handling MUST be uniform and bounded to avoid class-dependent delay, and +existence MAY be disclosed only as a separately authorized fixed concept, never as error detail +(invariant `V1-I16`). + +REQ-PR-EVIDENCE-044: A duplicate, combined, parameterized, quality-weighted, or unknown content +negotiation MUST return the negotiation problem before source access, and a recognized but +unpermitted format request MUST return the ordinary authorization problem +(`products/evidence/contracts/problem-contract.yaml`). + +REQ-PR-EVIDENCE-045: Transient failures MUST be mapped to the public transient codes in +`products/evidence/contracts/problem-contract.yaml`. +A retry hint is permitted only for bounded transient failures and MUST NOT be derived from protected +source content. + +## 9. Audit behavior + +REQ-PR-EVIDENCE-046: Evidence MUST durably accept an access-attempt event after authorization and +before credential acquisition or source access, and a disclosure-release event after the final +immutable response bytes are serialized and before those exact bytes are released. +A sink failure MUST block the applicable step +(`products/evidence/contracts/audit-event.schema.yaml`, invariants `V1-I13` and `V1-I29`). + +REQ-PR-EVIDENCE-047: Every native audit event MUST validate against +`products/evidence/contracts/audit-event.schema.yaml`, which closes the member set, the phase and +decision enumerations, and the conditional members. + +REQ-PR-EVIDENCE-048: Every native event MUST record the closed response-protection mode resolved with +authorization. +A signing key identity MUST be present exactly for cryptographically protected disclosure release and +MUST be absent for unsigned output +(`products/evidence/contracts/audit-event.schema.yaml`, invariant `V1-I30`). + +REQ-PR-EVIDENCE-049: Audit MUST NOT record the values listed under the never-record rule in +`products/evidence/contracts/audit-event.schema.yaml`, including raw principal, actor, grant, +selector, source, and supported values, the request nonce, credentials and bodies, candidate counts +and comparisons, and script inputs, outputs, or signing material. +Identity MUST travel only as domain-separated keyed pseudonyms (invariant `V1-I15`). + +REQ-PR-EVIDENCE-050: Authentication, unmatched-authority, and invalid-selector failures occur before +a privacy-safe authority and complete selector bundle exist, so Evidence MUST NOT fabricate a native +event from that untrusted or protected request material +(`products/evidence/contracts/audit-event.schema.yaml`). + +REQ-PR-EVIDENCE-051: The audit chain MUST be keyed and verified at startup and after restart. +Any external replacement or modification MUST fail readiness and close future appends until a restart +completes full keyed-chain verification +(`products/evidence/contracts/audit-event.schema.yaml`). + +## 10. Immutability of deployment input + +REQ-PR-EVIDENCE-052: Configuration MUST be immutable for the serving process lifetime. +Evidence MUST load one read-only atomic governed bundle and one separately digested closed runtime +file at startup; runtime override, reload, merge, fallback, and mutation paths MUST NOT exist +(invariant `V1-I18`, `products/evidence/contracts/runtime.schema.yaml`). + +REQ-PR-EVIDENCE-053: The runtime file MUST own only the process-local bindings enumerated under +`ownership.allowed` in `products/evidence/contracts/runtime.schema.yaml` and MUST NOT override +governed semantics or source authority. +Unknown keys MUST be rejected at every level. + +REQ-PR-EVIDENCE-054: A missing, writable, or unreviewed bundle MUST NOT be treated as trusted +configuration. +Version 1 has no in-bundle trust override, and absence or a failed immutability check MUST fail +readiness (`products/evidence/contracts/security-invariant-matrix.yaml`, cross-cutting config trust). + +REQ-PR-EVIDENCE-055: One process MUST serve one operator-controlled trust domain, with one service +trust domain, issuer governance boundary, bundle lifecycle, signer, and audit boundary +(invariant `V1-I19`). + +REQ-PR-EVIDENCE-056: The bundle revision MUST be a digest over the complete atomic bundle bytes and +layout manifest, and MUST be carried in every assertion and in every native audit event in the form +fixed by `products/evidence/contracts/evidence.schema.yaml` and +`products/evidence/contracts/audit-event.schema.yaml` +(`products/evidence/contracts/cccev-field-mapping.yaml`). + +## 11. Limitations + +These constraints are stated so a reader does not infer a capability from the route list or the +credential format that the frozen Version 1 contracts do not provide. + +- Credential lifecycle: The SD-JWT VC output adds a response format only. No offer, code, nonce, + status, revocation, presentation verification, or persisted credential state exists + (REQ-PR-EVIDENCE-037, REQ-PR-EVIDENCE-038). +- Cross-verifier use: The credential subject binding is audience-scoped, so it is meaningful to one + relying party and correlatable across none (REQ-PR-EVIDENCE-041). +- Nonce semantics: The request nonce is uninterpreted correlation data. Reuse is not rejected, and + the nonce is not replay prevention (REQ-PR-EVIDENCE-014). +- Unsigned output: Transport-authenticated convenience only, never later-verifiable evidence and + never a fallback from a signed-path failure (REQ-PR-EVIDENCE-036). +- Signature meaning: Provider authentication and payload integrity only, not legal-signature status, + source truth, or holder possession (REQ-PR-EVIDENCE-035). +- Identity resolution: Lookup is match, no-match, or ambiguous. Evidence is not an + identity-resolution engine and returns no candidate material (REQ-PR-EVIDENCE-019). +- Purpose attestation: A declared purpose is an authorized selection from the granted set, not an + identity-provider attestation, unless the operator issues a distinct requester tag per purpose + (`products/evidence/OPERATOR-CONTRACT.md`). +- Rate-limit scope: Rate controls are per process and in memory, so replicas multiply every + configured limit and a restart resets every budget + (`products/evidence/OPERATOR-CONTRACT.md`, REQ-PR-EVIDENCE-011). +- Disclosure-family review: The declared disclosure families are a trusted bundle-review attestation, + not a semantic classifier. Combined-surface review is an operator duty + (`products/evidence/OPERATOR-CONTRACT.md`, REQ-PR-EVIDENCE-025). +- Telemetry: Metrics are off by default and, when enabled, are served on a separate operator-private + listener that the public evidence contract does not describe + (`products/evidence/contracts/runtime.schema.yaml`, invariant `V1-I33`). +- Platform: Version 1 supports Unix targets only, because its secret and audit invariants require + owner, mode, no-follow, link-count, and file-identity guarantees + (`products/evidence/contracts/runtime.schema.yaml`). +- Standards claims: The CCCEV alignment is a documented mapping with explicit Evidence extensions, + and the SD-JWT VC output follows a frozen local profile + (`products/evidence/contracts/cccev-field-mapping.yaml`, + `products/evidence/contracts/sd-jwt-vc-profile.yaml`). + +## Conformance + +An Evidence deployment conforms to this specification when it: + +- exposes only the generated OpenAPI surface, keeps definition discovery authenticated and + requester-scoped, grants no authority through discovery, and publishes key material as + non-anchoring discovery (REQ-PR-EVIDENCE-001, REQ-PR-EVIDENCE-002, REQ-PR-EVIDENCE-003, + REQ-PR-EVIDENCE-004); +- authenticates every protected operation with one bearer token, derives the principal only from the + configured claim, and resolves one complete authorization decision without unioning entitlements + (REQ-PR-EVIDENCE-005, REQ-PR-EVIDENCE-006, REQ-PR-EVIDENCE-007); +- refuses authority from caller data, enforces value origins, denies sender-constrained tokens, and + applies rate controls as defense in depth (REQ-PR-EVIDENCE-008, REQ-PR-EVIDENCE-009, + REQ-PR-EVIDENCE-010, REQ-PR-EVIDENCE-011); +- validates the closed request, rejects caller-supplied query material, enforces the request nonce + contract, admits only predefined requirement revisions, and resolves subject roles by name + (REQ-PR-EVIDENCE-012, REQ-PR-EVIDENCE-013, REQ-PR-EVIDENCE-014, REQ-PR-EVIDENCE-015, + REQ-PR-EVIDENCE-016); +- keeps trust decisions in Rust with scripts confined to the closed entry points, makes exactly one + fixed source request per evaluation, returns only the closed lookup union, and fails closed + (REQ-PR-EVIDENCE-017, REQ-PR-EVIDENCE-018, REQ-PR-EVIDENCE-019, REQ-PR-EVIDENCE-022); +- treats adult status, controlled residence region, professional licence status, and legal-parent + relationship as coequal full-path acceptance definitions with no privileged domain type, and + enforces the output gate before evidence construction (REQ-PR-EVIDENCE-020, REQ-PR-EVIDENCE-021); +- discloses only declared supported values, binds subjects as audience-scoped opaque values, reviews + the enabled bundle as one disclosure surface, keeps source and selector values out of logs and + audit, and does not vary disclosure by purpose (REQ-PR-EVIDENCE-023, REQ-PR-EVIDENCE-024, + REQ-PR-EVIDENCE-025, REQ-PR-EVIDENCE-026, REQ-PR-EVIDENCE-027); +- makes signed JWS the mandatory default, gates every other format on bundle and grant permission, + never falls back on signing failure, keeps private key material core-owned, and releases only + pre-audited bytes (REQ-PR-EVIDENCE-028, REQ-PR-EVIDENCE-029, REQ-PR-EVIDENCE-030, + REQ-PR-EVIDENCE-031, REQ-PR-EVIDENCE-032); +- serializes the signed format with the closed header and payload rules, states the limited meaning + of a signature, and keeps the unsigned envelope separately typed and never a fallback + (REQ-PR-EVIDENCE-033, REQ-PR-EVIDENCE-034, REQ-PR-EVIDENCE-035, REQ-PR-EVIDENCE-036); +- serializes SD-JWT VC as the same assertion under the frozen profile, implements none of its + non-goals, closes the claim set, embeds a holder key only when supplied, and keeps the binding + audience-scoped (REQ-PR-EVIDENCE-037, REQ-PR-EVIDENCE-038, REQ-PR-EVIDENCE-039, + REQ-PR-EVIDENCE-040, REQ-PR-EVIDENCE-041); +- reports failures as closed problem documents, collapses unresolved classes, refuses unacceptable + negotiation before source access, and bounds retry hints (REQ-PR-EVIDENCE-042, + REQ-PR-EVIDENCE-043, REQ-PR-EVIDENCE-044, REQ-PR-EVIDENCE-045); +- audits at both durable gates, validates every event against the closed schema, records the + response-protection mode and signing key identity correctly, records no protected value, fabricates + no event from untrusted material, and verifies the keyed chain (REQ-PR-EVIDENCE-046, + REQ-PR-EVIDENCE-047, REQ-PR-EVIDENCE-048, REQ-PR-EVIDENCE-049, REQ-PR-EVIDENCE-050, + REQ-PR-EVIDENCE-051); +- treats deployment input as immutable, restricts the runtime file to process-local bindings, fails + readiness on an untrusted bundle, serves one trust domain, and carries the bundle revision in every + assertion and audit event (REQ-PR-EVIDENCE-052, REQ-PR-EVIDENCE-053, REQ-PR-EVIDENCE-054, + REQ-PR-EVIDENCE-055, REQ-PR-EVIDENCE-056). + +Conformance to this specification does not imply conformance to any external standard cited in the +`standards_referenced` frontmatter field. +Each standard's adoption mode and scope are documented in the +[standards register](../../reference/standards/). + +## Evidence + +This specification is `verified`: every requirement restates a frozen contract file a reader can +open, and those contracts are held to the implementation by a continuous-integration gate, per +RS-DOC REQ-DOC-014. +The two open items are marked with author comments in the source of this page: RS-ARC-G does not yet +define an Evidence component, and the standards register does not yet list `registry-evidence` under +the entries this page references. + +- The frozen source contracts live in `products/evidence/contracts/`, indexed by + `products/evidence/contracts/README.md`. Each requirement in Sections 2 through 10 names the file + it restates. +- The route and schema surface is `products/evidence/generated/registry-evidence.openapi.json`, and + the running service publishes that same document. +- The `evidence-contracts` job in `.github/workflows/ci.yml` runs + `products/evidence/scripts/check-contracts.sh`, which regenerates the artifacts under + `products/evidence/generated/` from the `registry-evidence` crate into a temporary directory and + fails on any byte difference from the committed set. The same job runs + `products/evidence/scripts/check-source-neutrality.sh`. +- Every trust and privacy invariant cited by identifier in this document is a row in + `products/evidence/contracts/security-invariant-matrix.yaml`, carrying its threat, Rust enforcement + point, and required negative test. +- `products/evidence/contracts/security-test-traceability.yaml` and + `products/evidence/contracts/acceptance-test-traceability.yaml` resolve those named requirements to + exact executable Rust tests; the package contract test rejects a missing, extra, duplicated, or + stale reference. +- Product framing, the Version 1 boundary, and the four coequal acceptance definitions are stated in + `products/evidence/README.md` and `products/evidence/CONCEPT.md`. +- Operational duties referenced by Sections 3, 6, and 11 are stated in + `products/evidence/OPERATOR-CONTRACT.md`. + +## Next + +{/* TODO[evidence]: ../../reference/apis/registry-evidence/ is not published yet. The internal link + check will fail until that reference page lands. */} + +- [Registry Evidence API reference](../../reference/apis/registry-evidence/) is the route-level + reference and the link to the generated OpenAPI document. +- [RS-ARC-G](../rs-arc-g/) places the registry stack services in one architecture. +- [RS-SEC-G](../rs-sec-g/) holds the cross-product security model this protocol sits inside. +- [RS-PR-NOTARY](../rs-pr-notary/) specifies Registry Notary, the separate product that owns + credential issuance. diff --git a/docs/site/src/content/docs/start/evaluate-evidence.mdx b/docs/site/src/content/docs/start/evaluate-evidence.mdx new file mode 100644 index 000000000..2477f4d7e --- /dev/null +++ b/docs/site/src/content/docs/start/evaluate-evidence.mdx @@ -0,0 +1,256 @@ +--- +title: Evaluate Evidence +description: A cost accounting of running Evidence, what it needs to start, what it does not depend on, how it deploys today, and what operating it demands, for someone deciding whether to commit to it. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: explanation +locale: en +standards_referenced: [] +--- + +This page is for someone sizing up Evidence before committing infrastructure, +security review, and operational capacity to it. It assumes the fit question +from [When Registry Stack fits](../when-to-use/) is already settled and asks +the next one: what does running it actually cost. + +## Runtime footprint + +Evidence is one crate, `registry-evidence`, and one binary, `evidence` +(`crates/registry-evidence/Cargo.toml`). There is no separate control plane, +worker process, or sidecar: one process serves one operator-controlled trust +domain (`products/evidence/README.md`). + +At startup the process reads two inputs, both mounted read-only: a closed +operator `runtime.yaml` that binds one listener, bundle directory, secret +root, audit destination, and local TLS trust files; and an immutable, +reviewed evidence bundle directory holding the deployment's YAML, Rhai +scripts, schemas, codelists, and fixtures. Neither input may be writable to +the service process; startup and readiness fail when either is incomplete, +inconsistent, or mutable (`products/evidence/OPERATOR-CONTRACT.md`). + +It also needs key material on local disk before it can start: an Ed25519 +signing key whose `kid` matches `signing.activeKeyId`, plus two independently +generated raw secrets of at least 32 bytes, one for the audit hash chain and +one for subject-binding pseudonyms. All three live under an owner-only (mode +`0700`) secret root, each file mode `0600` +(`products/evidence/OPERATOR-CONTRACT.md`). + +`evidencectl new` scaffolds this exact layout: + +```text +project_root/ + bundle/ governed, reviewed, mounted read-only + evidence.yaml the deployment contract + adapters/ request preparation and fact extraction (Rhai) + derivations/ requirement derivation (Rhai) + schemas/ closed adapter-parameter, response, and fact schemas + fixtures/ synthetic acceptance cases + runtime.yaml process-local paths and listener, not governed + secrets/ key material, created empty with mode 0700 + audit/ audit records written by the service +``` + +(`crates/registry-evidencectl/templates/README.md`) + +## Dependencies + +Evidence does not run against a database. `products/evidence/OPERATOR-CONTRACT.md` +states it directly: "Evidence Version 1 has no application database and +persists no selector, source, evidence, or response data." The one place +Evidence writes durable state is the audit trail, and that is a keyed JSONL +hash chain on local storage, not a database table. The module that implements +it says so in its own header comment: "Fail-closed native Evidence audit with +a durable keyed JSONL chain" (`crates/registry-evidence/src/audit.rs`). An +external durable audit service may own that storage instead, but nothing in +the runtime requires a database engine to reach it. + +The same boundary excludes a message broker and workers; `products/evidence/README.md` +lists both among what Version 1 does not include. `products/evidence/CONCEPT.md` +states the infrastructure implication for the native deployment target +directly: "It does not require Kubernetes, a message broker, a database, OPA, +or a service mesh." + +Two dependencies remain real, even though neither ships with Evidence: + +- An identity provider. Evidence verifies bearer tokens against one + configured OIDC issuer; it does not issue tokens itself. Where no identity + provider exists, Registry Mint fills that gap: it "issues the access + tokens a resource server such as Evidence verifies, for deployments with no + identity provider." The dependency runs one way: "Mint's tests drive + Evidence's authenticator; Evidence does not depend on Mint" (root + `AGENTS.md`). +- The authoritative source system each requirement calls. Evidence executes + one fixed, bounded HTTP JSON request per source and never fans out to + additional systems; it depends on that source answering within its + configured timeout and concurrency limit, not on any data store Evidence + itself owns. + +## Deployment options today + +Build the toolset from source with +`cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint`, +or, for a tagged release that publishes it, install reproducible bare +binaries through the pinned installer script +(`products/evidence/README.md`). + +Container images exist for both `evidence` and its companion `mint` token +issuer. `docker/Dockerfile` builds them as distroless images +(`gcr.io/distroless/cc-debian13:nonroot`, user `65532`, no shell, no package +tools) from one multi-stage file that uses cargo-chef so dependency +compilation caches independently per binary. Neither image declares a Docker +`HEALTHCHECK`, because distroless has no shell or `curl` and neither binary +has a healthcheck subcommand; the orchestrator probes `GET /health` over HTTP +instead (`docker/README.md`). + +These images are explicitly not release evidence. `docker/README.md` notes +that the released Notary and Relay images come from a separate, +byte-reproducible path (`release/docker/`, built outside Docker); if Mint or +Evidence images become release artifacts, they will follow that path, not +this one. + +A static compose reference deployment sits beside the images, in +`docker/compose/`. `docker-compose.yaml` wires one user-defined network with +a static private address per service (the listener refuses wildcard and +public bind addresses, so each service needs a real address to bind to), +mounts an `evidencectl`-provisioned project read-only, and keeps the audit +chain in a named volume so it outlives the container. `runtime.docker.yaml` +is a container-shaped runtime file that overlays the project's host-shaped +one, so the same bundle bytes serve local and containerized runs without +edits (`docker/compose/README.md`). The compose deployment is framed as +something to own, not regenerate: "the files are yours after that, there is +no regeneration contract" (`docker/compose/README.md`). Starting `mint` +alongside `evidence` is one `--profile mint` flag away, for deployments with +no identity provider. + +## Operational burden + +### Keys + +`evidencectl keygen` generates the signing key and the two HMAC secrets; +nothing about generation happens automatically on deploy. Rotation is the +operator's job too: a deployment keeps one active signing key at a time and +must retain every retired public key in the published JWKS for at least the +maximum assertion validity plus allowed clock skew, or a verifier holding an +older cached assertion will fail to check it +(`products/evidence/OPERATOR-CONTRACT.md`). + +### Deployment inputs + +Both `runtime.yaml` and the bundle directory must be non-writable before +Evidence will start; a read-only mount is preferred, and the reference +scaffold freezes them with `chmod -R a-w` on the bundle directory and +`chmod 444` on `runtime.yaml`. Editing either input means unfreezing, +editing, and refreezing, then rerunning `evidence check` +(`crates/registry-evidencectl/templates/README.md`, +`products/evidence/OPERATOR-CONTRACT.md`). There is no hot reload, override +layer, or runtime mutation API. + +### Audit trail + +The audit sink takes an exclusive OS advisory lock on its path at startup, +so exactly one Evidence process may write a given audit path at a time; +scaling horizontally means N processes with N distinct audit paths, run +active/passive rather than active/active. Segment rotation +(`auditStorage.maximumFileBytes`) happens online with no operator action, but +retention, backup, restore, and chain verification stay entirely the +operator's responsibility: nothing in the runtime deletes or compacts a +sealed segment. `evidence verify-audit` is the out-of-band command for +proving sealed history was not tampered with, and the operator contract +documents specific rollback hazards in detail, most notably that renaming or +replacing the active segment incorrectly can silently fork the chain +(`products/evidence/OPERATOR-CONTRACT.md`). + +### Readiness and liveness are different questions + +`GET /health` is liveness only. `GET /ready` is the gate that "fails closed +while any required secret or source credential is absent" +(`docker/compose/README.md`); it rechecks the subject-binding key, the +signing provider, the pinned audit sink, and every source credential, +including a bounded OAuth client-credentials bootstrap where that +authentication kind is configured. Neither check sends a request to a source +or probes a source data endpoint (`products/evidence/OPERATOR-CONTRACT.md`). +Route traffic on `/ready`, not `/health`. + +Telemetry is opt-in: leaving `metricsListener` unset in `runtime.yaml` serves +none of it, and setting it only opens a second, private-address-only +listener (`products/evidence/OPERATOR-CONTRACT.md`). + +## Performance posture + +`products/evidence/PERFORMANCE.md` states its own scope plainly: nothing in +it is a Version 1 contract. There is no throughput commitment to evaluate +against, only kept measurements. + +The cost that shapes throughput comes from durability, not from the HTTP or +scripting layers. Evidence must durably accept the access-attempt audit +record before reading a source and the disclosure-release record before +returning a response, so every successful request pays two durable audit +appends. The audit sink commits appends in groups: writes that arrive while +a disk barrier is in flight form the next batch, and one sync covers the +whole batch, while every append still resolves only after the barrier that +covers its own bytes (`crates/registry-evidence/src/audit.rs`). + +The measurement kept in `products/evidence/OPERATOR-CONTRACT.md` under +"Measured throughput" sustained 7057 requests per second at 128 concurrent +requests with zero non-2xx responses and a 17.89 ms p50, on an Apple M5 Max +under macOS, with every request running token verification, rate limiting, +request preparation, one source call, extraction, signing, and both durable +audit appends. Because the sink batches only what overlaps, a deployment +offering little concurrency sees lower rates; the before-group-commit +baseline in `PERFORMANCE.md` (122 to 161 requests per second at 32 +concurrent with one barrier per append) shows the floor that behavior +approaches. + +Both files call the macOS figures conservative in one direction and +unproven in the other: on macOS the sync call issues `F_FULLFSYNC`, a true +device write barrier, while the same call on Linux is an ordinary `fsync`. +`PERFORMANCE.md` says to re-measure on the target Linux host before quoting +production numbers. + +{/* TODO[evidence]: no Linux-host throughput measurement is committed to the +repository yet; re-measure on the target host before using a number for a +production sizing decision. */} + +A second lever works without any tuning: because the audit sink takes an +exclusive lock per path, N processes with N distinct audit paths give N +times the throughput. The sustained-rate measurement reproduces with +`cargo test --release -p registry-evidence --lib -- --ignored --nocapture sustained_load_holds_one_thousand_requests_per_second`. + +## Support window and stability + +Registry Stack overall is pre-1.0: "APIs and deployment contracts may +change" (root `AGENTS.md`). Evidence does not carry a separate, more +permissive statement. + +Within that, Evidence's Version 1 assertion contract is treated as +implemented rather than exploratory. `products/evidence/README.md` gives its +status as "implemented Version 1 contracts, runtime, reference deployments, +and reproducible Evidence-specific verification gates," and +`products/evidence/OPERATOR-CONTRACT.md` carries the matching "Implemented +Version 1 operator contract" status. Four assertion cases, adult status, +residence region, professional licence status, and legal-parent +relationship, are coequal acceptance definitions; the product is not +considered implemented while only a subset of them passes +(`products/evidence/AGENTS.md`). + +What stays explicitly out of scope remains so until a separately approved +profile changes it: document evidence, credential-lifecycle features beyond +the SD-JWT VC serialization, multi-source fulfillment, the delegated-agent +grant profile, a public or federated catalog, and OOTS execution are all +named non-goals rather than roadmap items (`products/evidence/CONCEPT.md`). +`products/evidence/OPERATOR-CONTRACT.md` closes on the same note: "Future +profiles require a separately approved concept and plan." + +{/* TODO[evidence]: no published versioning or backward-compatibility policy +(for instance, how a future Version 2 would relate to Version 1 deployments) +was found in the cited files; confirm with product owners before promising +upgrade continuity. */} + +## Next + +- [When Registry Stack fits](../when-to-use/) +- [First Evidence assertion tutorial](../../tutorials/first-evidence-assertion/) +- [Evidence API reference](../../reference/apis/registry-evidence/) diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index 18a4b5e8e..d6ef34702 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -32,10 +32,22 @@ system that owns the data. | Caller needs | Use | Result | | --- | --- | --- | | Selected records or fields | Registry Relay | A protected, read-only API response | +| A signed minimum-disclosure answer about one subject | Evidence | A signed assertion carrying the answer, not the source record | | A bounded answer or status | Registry Notary | A claim result without the source record | -The two products can work together. Registry Relay obtains a limited source -result, and Registry Notary evaluates a reviewed claim over that result. +Registry Relay and Registry Notary can work together: Registry Relay obtains a +limited source result, and Registry Notary evaluates a reviewed claim over +that result. + +### Who does what + +- The assertion provider is an institution that answers requests with signed + facts through Evidence. +- The data publisher is an institution that exposes records through Registry + Relay. +- The consumer or verifier is a relying service that calls either door and + verifies the answers it receives. +- The operator is whoever runs the deployment. ## Registry Stack is not the right tool when diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx new file mode 100644 index 000000000..b8f7363e8 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -0,0 +1,157 @@ +--- +title: Get a first Evidence assertion +description: Install the Evidence toolset, scaffold a neutral deployment project, generate key material, and run the project's synthetic fixture cases offline. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: tutorial +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Use the released Evidence toolset to take a scaffolded deployment project from nothing to passing +assertion fixtures, entirely offline. The project you build here is the starting point for every +later Evidence tutorial. + + + +The scaffold and its fixture cases are synthetic. +Do not add production endpoints, source credentials, or personal data to this project. + +## Before you start + +Choose an exact published tag that ships the Evidence toolset from the +[Registry Stack releases](https://github.com/registrystack/registry-stack/releases), then +download its installer: + +```sh +tag="v.." +installer="evidencectl-${tag}-install.sh" +curl --proto '=https' --tlsv1.2 --fail --location \ + --output "$installer" \ + "https://github.com/registrystack/registry-stack/releases/download/${tag}/${installer}" +``` + +Releases older than the Evidence toolset do not carry these assets; the installer refuses to run +without a pinned tag and names the release page to check. + +```sh +bash "$installer" +``` + +The installer downloads the three toolset binaries (`evidence`, the runtime; `evidencectl`, the +adopter tooling; `mint`, the token issuer), verifies every one against the release's +`SHA256SUMS`, and installs them together or not at all. It checks integrity, not authenticity: +for a higher-assurance installation, follow `release/VERIFY.md` from the same tag first, then +rerun the installer with `EVIDENCECTL_ASSET_DIR` pointed at the verified directory. + +Confirm the toolset is on `PATH`: + +```sh +evidencectl --version +evidence --version +``` + +If your shell cannot find them, add the printed install directory (`~/.local/bin` unless you set +`EVIDENCECTL_INSTALL_DIR`) to `PATH` before continuing. + +## Scaffold a deployment project + +Create a fresh project and enter it: + +```sh +evidencectl new hello-evidence +cd hello-evidence +``` + +The scaffold prints where everything went and the exact commands that come next. It creates +`bundle/` (the governed deployment contract: acceptance definitions, adapters, derivations, +schemas, fixtures, codelists), `runtime.yaml` (process-local paths and the listener), `secrets/` +(empty, owner-only), `audit/`, and a project `README.md`. The bundle's one acceptance definition +is a neutral residence-region example; `evidencectl new --help` also documents `--with-mint`, +which pairs a Registry Mint configuration for the tutorial that serves assertions over HTTP. + +## Generate key material + +Generate the signing keypair and the two 32-byte secrets the runtime requires, exactly as the +scaffold's next steps print them: + +```sh +evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1 +evidencectl keygen secret --out secrets/audit-hmac-key +evidencectl keygen secret --out secrets/subject-binding-hmac-key +``` + +Expected output: + +```text +wrote secrets/signing-ed25519-private-jwk +wrote secrets/signing-ed25519-public.jwk.json +kid: scaffold-signing-key-1 +wrote secrets/audit-hmac-key +wrote secrets/subject-binding-hmac-key +``` + +Every file is owner-only, and private bytes are never printed. + +## Freeze the deployment input + +Evidence treats the bundle and runtime file as trusted, startup-only artifacts and refuses +mutable deployment input, so make them read-only before anything runs: + +```sh +chmod -R a-w bundle && chmod 444 runtime.yaml +``` + +If you skip this step, the next command fails with `deployment input is not immutable`. That +refusal is the runtime working as designed, not a broken install. + +## Run the fixtures + +Drive the `evidence` binary across the whole project: + +```sh +evidencectl fixtures run --project . +``` + +Expected output: + +```text +PASS: check +PASS: fixtures/cases.yaml +2 passed, 0 failed +``` + +Two things passed. `check` loaded, compiled, and validated the complete project: every selector, +role, and source binding resolves, every script compiles, and the key material you generated +parses. Then the bundle's synthetic fixture cases replayed through the real evaluation pipeline: +request preparation, extraction, and the residence-region derivation, with no source system and +no network. That is your first passing Evidence assertion run. + +## Cleanup + +Keep the project if you are continuing to the next tutorial. To remove it: + +```sh +cd .. +chmod -R u+w hello-evidence +rm -rf hello-evidence +``` + +## Next + +- [Configure Evidence](../../configure/evidence/) explains every part of the project you built. +- [Evaluate Evidence](../../start/evaluate-evidence/) covers what a real deployment costs to run. +- [Registry Evidence API](../../reference/apis/registry-evidence/) documents the HTTP surface a + served deployment exposes. +- [Evidence security model](../../security/evidence/) explains the invariants behind the + immutability refusal you saw. From 6442396181e13b8d2896ca80a54eb9fdfae753dd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 08:43:20 +0700 Subject: [PATCH 038/136] docs(evidence): quote the fixture placeholder in the operator contract The docs site's shell-fence gate parses every sh fence with /bin/sh -n once the operator contract is mirrored as a product page; the bare placeholder reads as a redirection there. Quoting it keeps the placeholder convention and parses. Signed-off-by: Jeremi Joslin --- products/evidence/OPERATOR-CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index b2dcd2eb6..9a2dcc249 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -682,7 +682,7 @@ Before production exposure, the operator runs: ```sh evidence check -evidence evaluate --fixture +evidence evaluate --fixture "" ``` All commands accept `--runtime `. The same path may be supplied From cd3b5bfa7a6a5cd05669ab00db0bb4e99ea7cb9b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 08:43:33 +0700 Subject: [PATCH 039/136] docs(site): wire the Registry Evidence product into the site plumbing repo-docs.yaml mirrors seven Evidence product pages from products/evidence at the current ref, the latest docset registers the product, and the OpenAPI pipeline pulls the generated registry-evidence.openapi.json (drift-checked in root CI) into a Redoc operations section beside a new narrative API page. The sidebar gains the Answer with Evidence flow, the Evidence security, problems, Mint, evaluate, and RS-PR-EVIDENCE entries, and a Registry Evidence product group. Archived docsets predate Evidence, so sync and fetch now filter docset-excluded docs before requiring a docset product pin, a repo absent from an archived docset keeps its current ref and rides the current shell (verified against v0.15.2: the Evidence throw is gone; that docset's remaining sync failure is a pre-existing Notary allowlist gap on main, flagged separately), and the Evidence product group splices optionally while generate-sidebar.test.mjs pins its presence for the current docset. Site suite: 267 tests, 0 failures; redocly lint clean for the new spec. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 40 +++++++ docs/site/package.json | 2 +- docs/site/redocly.yaml | 2 + docs/site/scripts/fetch-openapi.mjs | 7 ++ docs/site/scripts/generate-sidebar.test.mjs | 5 +- .../scripts/information-architecture.test.mjs | 1 + docs/site/scripts/sync-repo-docs.mjs | 6 +- .../src/content/docs/reference/apis/index.mdx | 19 ++- .../docs/reference/apis/registry-evidence.mdx | 109 ++++++++++++++++++ docs/site/src/data/docsets.yaml | 3 + docs/site/src/data/generated/docsets.json | 4 + .../src/data/generated/openapi-sources.json | 9 ++ docs/site/src/data/generated/sidebar.json | 34 ++++++ docs/site/src/data/openapi-sources.yaml | 7 ++ docs/site/src/data/repo-docs.yaml | 70 +++++++++++ 15 files changed, 313 insertions(+), 5 deletions(-) create mode 100644 docs/site/src/content/docs/reference/apis/registry-evidence.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 33397bf44..9cdd6dcbb 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -101,6 +101,13 @@ function generatedProduct(label) { if (!group) throw new Error(`generated sidebar group "${label}" not found`); return group; } + +// A product absent from this docset's generated sidebar (a product newer than +// an archived docset) yields no group instead of failing the build. +/** @param {string} label */ +function optionalGeneratedProduct(label) { + return productSidebar.find((/** @type {{ label: string }} */ entry) => entry.label === label) ?? null; +} const disabledSitemap = { name: '@astrojs/sitemap', hooks: {}, @@ -255,6 +262,11 @@ export default defineConfig({ schema: './openapi/registry-notary.openapi.json', sidebar: { label: 'Notary API operations', collapsed: true }, }, + { + base: 'reference/apis/evidence', + schema: './openapi/registry-evidence.openapi.json', + sidebar: { label: 'Evidence API operations', collapsed: true }, + }, ]), ], defaultLocale: 'root', @@ -296,6 +308,7 @@ export default defineConfig({ { label: 'Use your own spreadsheet', slug: 'tutorials/use-your-spreadsheet' }, { label: 'Expose spreadsheet evidence', slug: 'tutorials/verify-claim-registry-api' }, { label: 'When Registry Stack fits', slug: 'start/when-to-use' }, + { label: 'Evaluate Evidence', slug: 'start/evaluate-evidence' }, { label: 'Pre-1.0 cutover', slug: 'start/pre-1.0-cutover' }, ], }, @@ -311,6 +324,14 @@ export default defineConfig({ { label: 'Configuration fields', slug: 'reference/project-configuration' }, ], }, + { + label: 'Answer with Evidence', + items: [ + { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, + { label: 'Configure Evidence', slug: 'configure/evidence' }, + { label: 'Configure Registry Mint', slug: 'configure/mint' }, + ], + }, { label: 'Operate', collapsed: true, @@ -332,6 +353,7 @@ export default defineConfig({ collapsed: true, items: [ { label: 'Overview', slug: 'security' }, + { label: 'Evidence security model', slug: 'security/evidence' }, { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' }, { label: 'Security support window', slug: 'security/support-window' }, { label: 'Release trust', slug: 'security/openssf-evidence' }, @@ -353,11 +375,14 @@ export default defineConfig({ { label: 'Overview', slug: 'reference/apis' }, { label: 'Relay (narrative)', slug: 'reference/apis/registry-relay' }, { label: 'Notary (narrative)', slug: 'reference/apis/registry-notary' }, + { label: 'Evidence (narrative)', slug: 'reference/apis/registry-evidence' }, // Generated operation pages for each schema (theme-aware, searchable). ...openAPISidebarGroups, ], }, { label: 'Errors and status codes', slug: 'reference/errors' }, + { label: 'Evidence problems', slug: 'reference/evidence-problems' }, + { label: 'Registry Mint', slug: 'reference/mint' }, { label: 'Diagnostic catalogs', collapsed: true, @@ -387,6 +412,20 @@ export default defineConfig({ collapsed: true, items: generatedProduct('Manifest').items, }, + // Evidence entered the product docset after every archived + // docset was sealed, so its group is optional: absent when an + // archived docset's generated sidebar has no Evidence product. + // generate-sidebar.test.mjs pins its presence for the current + // docset, keeping the loud-failure property there. + ...(optionalGeneratedProduct('Evidence') + ? [ + { + label: 'Registry Evidence', + collapsed: true, + items: generatedProduct('Evidence').items, + }, + ] + : []), ], }, { label: 'Contracts', slug: 'reference/contracts' }, @@ -419,6 +458,7 @@ export default defineConfig({ { label: 'RS-DOC · Documentation framework', slug: 'spec/rs-doc' }, { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, + { label: 'RS-PR-EVIDENCE · Evidence protocol', slug: 'spec/rs-pr-evidence' }, { label: 'RS-PR-NOTARY · Notary protocol', slug: 'spec/rs-pr-notary' }, { label: 'RS-PR-REGISTRYCTL · registryctl contract', slug: 'spec/rs-pr-registryctl' }, { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, diff --git a/docs/site/package.json b/docs/site/package.json index ad10a921c..4b8ec3cf9 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -33,7 +33,7 @@ "check:markdown": "markdownlint-cli2", "check:style": "node scripts/run-vale.mjs src/content/docs README.md", "check:style:fixtures": "node scripts/check-vale-fixtures.mjs", - "check:openapi": "redocly lint registry-relay registry-notary", + "check:openapi": "redocly lint registry-relay registry-notary registry-evidence", "check:config-vocabulary": "scripts/check-stale-config-vocabulary.sh", "check:tutorial": "scripts/check-tutorial.sh", "check:tutorial:dry-run": "scripts/check-tutorial.sh --dry-run", diff --git a/docs/site/redocly.yaml b/docs/site/redocly.yaml index 247dd01e5..a160fd10e 100644 --- a/docs/site/redocly.yaml +++ b/docs/site/redocly.yaml @@ -5,6 +5,8 @@ apis: root: openapi/registry-relay.openapi.json registry-notary: root: openapi/registry-notary.openapi.json + registry-evidence: + root: openapi/registry-evidence.openapi.json rules: no-empty-servers: off no-unused-components: warn diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 39ebdabfe..afa0e0acd 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -23,6 +23,7 @@ import { promisify } from 'node:util'; import YAML from 'yaml'; import { applyDocsetRefs, + filterRepoDocsForDocset, getDocset, loadDocsets, selectedDocsetId, @@ -41,6 +42,7 @@ const cacheRoot = resolve(root, '.repo-docs-cache'); const SPEC_SOURCES = { 'registry-relay': 'openapi/registry-relay.openapi.json', 'registry-notary': 'openapi/registry-notary.openapi.json', + 'registry-evidence': 'products/evidence/generated/registry-evidence.openapi.json', }; function fail(message) { @@ -102,6 +104,11 @@ async function main() { } const docsets = await loadDocsets({ dataDir }); const docset = getDocset(docsets, selectedDocsetId(docsets)); + // Filter before applying docset refs, as sync-repo-docs.mjs does: a repo + // whose docs are all excluded from this docset must not count as an active + // repo the docset is required to pin. Such a repo keeps its repo-docs ref, + // so its spec rides the current shell the way hand-authored pages do. + filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for OpenAPI refs.`); diff --git a/docs/site/scripts/generate-sidebar.test.mjs b/docs/site/scripts/generate-sidebar.test.mjs index 6c8f0db47..2d73e47bb 100644 --- a/docs/site/scripts/generate-sidebar.test.mjs +++ b/docs/site/scripts/generate-sidebar.test.mjs @@ -155,7 +155,10 @@ test('product group labels drop the shared "Registry" prefix', () => { labels.every((l) => !/^Registry\b/.test(l)), `no group label should start with "Registry": ${labels.join(', ')}`, ); - assert.ok(labels.includes('Relay') && labels.includes('Notary'), labels.join(', ')); + assert.ok( + labels.includes('Relay') && labels.includes('Notary') && labels.includes('Evidence'), + labels.join(', '), + ); }); test('the real manifest yields one group per product with every doc present exactly once', async () => { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index d35141b57..184b43429 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -57,6 +57,7 @@ test('uses the adopter-first top-level flow in its published order', () => { assert.deepEqual(topLevelLabels(sidebarSource), [ 'Start', 'Connect an existing registry', + 'Answer with Evidence', 'Operate', 'Security', 'Reference', diff --git a/docs/site/scripts/sync-repo-docs.mjs b/docs/site/scripts/sync-repo-docs.mjs index ca07167ca..e69617e35 100644 --- a/docs/site/scripts/sync-repo-docs.mjs +++ b/docs/site/scripts/sync-repo-docs.mjs @@ -529,12 +529,16 @@ async function main() { const docsets = await loadDocsets({ dataDir }); validateRepoDocsMetadata(manifest, knownStandards, docsets); const docset = getDocset(docsets, selectedDocsetId(docsets)); + // Filter before applying docset refs: a repo whose docs are all excluded + // from this docset (a product newer than the docset, like registry-evidence + // in pre-Evidence archives) must not count as an active repo the docset is + // required to pin. + filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for product docs.`); } applyDocsetMetadataOverrides(manifest, docset); - filterRepoDocsForDocset(manifest, docset); // Clean and recreate the output dir so removed allowlist entries don't linger. await rm(outputRoot, { recursive: true, force: true }); diff --git a/docs/site/src/content/docs/reference/apis/index.mdx b/docs/site/src/content/docs/reference/apis/index.mdx index e1ec57b29..dfd504a95 100644 --- a/docs/site/src/content/docs/reference/apis/index.mdx +++ b/docs/site/src/content/docs/reference/apis/index.mdx @@ -1,12 +1,13 @@ --- title: API references -description: Generated API reference pages for Registry Relay and Registry Notary, built from pinned OpenAPI artifacts. +description: Generated API reference pages for Registry Relay, Registry Notary, and Evidence, built from pinned OpenAPI artifacts. wide: true status: current owner: registry-docs source_repos: - registry-relay - registry-notary + - registry-evidence last_reviewed: "2026-06-20" doc_type: reference locale: en @@ -16,7 +17,7 @@ standards_referenced: import OpenApiSourcesTable from '../../../../components/OpenApiSourcesTable.astro'; -Use this section to browse the HTTP API for Registry Relay or Registry Notary. Each page is built from a pinned OpenAPI artifact owned by the project it describes. +Use this section to browse the HTTP API for Registry Relay, Registry Notary, or Evidence. Each page is built from a pinned OpenAPI artifact owned by the project it describes. {/* Do not duplicate endpoint reference content in narrative pages. */} @@ -33,6 +34,8 @@ The table is generated from `src/data/openapi-sources.yaml`. evidence offering discovery, health and readiness endpoints, and optional standards adapters. - [Registry Notary API](./registry-notary/) documents the claim discovery, evaluation, batch evaluation, rendering, JWKS, service discovery, and credential issuance endpoints. +- [Registry Evidence API](./registry-evidence/) documents the assertion, requester-scoped + definition discovery, health, readiness, served-contract, and key discovery endpoints. ## Provenance and freshness @@ -51,3 +54,15 @@ cargo run -p registry-notary -- openapi > openapi/registry-notary.openapi.json ``` Regenerate and re-pin the artifact when the Notary API changes. + +[Evidence](../../products/registry-evidence/) generates its OpenAPI document with the other +Evidence contract artifacts: + +```sh +cargo run -p registry-evidence --example evidence-contracts -- --output "" +``` + +The committed copy lives in `products/evidence/generated/`, and root CI's `evidence-contracts` +job fails on any byte difference between the committed artifacts and a fresh generation. A running +Evidence service publishes the same document at `GET /openapi.json` with no authentication +required. diff --git a/docs/site/src/content/docs/reference/apis/registry-evidence.mdx b/docs/site/src/content/docs/reference/apis/registry-evidence.mdx new file mode 100644 index 000000000..0a984044d --- /dev/null +++ b/docs/site/src/content/docs/reference/apis/registry-evidence.mdx @@ -0,0 +1,109 @@ +--- +title: Registry Evidence API +description: "Narrative context for the Registry Evidence OpenAPI reference: authentication, response formats, the frozen Version 1 contract, and documented limitations." +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: reference +locale: en +standards_referenced: + - openapi + - sd-jwt-vc + - cccev +--- + +[Open the Registry Evidence API operations](../evidence/) + +The generated API reference linked above is the **authoritative route reference**. It is built from +Evidence's generated OpenAPI document, synced from the +[`registry-stack`](https://github.com/registrystack/registry-stack) monorepo at the commit pinned in +`src/data/repo-docs.yaml`. This page carries the context the specification does not: what the +service asserts, how authentication works, which response formats exist, and what stays out of +scope. + +For exact routes and schemas, read the generated API reference. For deployment and operator +duties, see the [Registry Evidence product documentation](../../../products/registry-evidence/). + +## What the API is + +Evidence is a minimum-disclosure assertion service. Given an authenticated requester, an +authorized purpose, a predefined requirement, and the configured selector data an authoritative +provider needs, `POST /v1/evidence` returns the smallest sufficient JSON assertion in an +authorized response format. `GET /v1/evidence-definitions` is an authenticated, requester-scoped +description of the complete request shapes the deployed bundle already authorizes; it is not a +public catalog. The remaining routes are operational: `GET /health`, `GET /ready`, +`GET /openapi.json`, the signing keys at `GET /.well-known/evidence/jwks.json`, and JWT VC issuer +metadata at `GET /.well-known/jwt-vc-issuer`. + +The assertion is the product's CCCEV-aligned domain object, expressed as a documented Evidence +JSON profile rather than RDF or XML (`products/evidence/CONCEPT.md`). The Version 1 contract is +frozen: the two versioned operations and their envelopes are the complete evidence surface. + +## Source of truth + +The OpenAPI document is a generated artifact committed at +`products/evidence/generated/registry-evidence.openapi.json`. Regenerate it into a separate +directory with: + +```sh +cargo run -p registry-evidence --example evidence-contracts -- --output "" +``` + +Root CI's `evidence-contracts` job runs `products/evidence/scripts/check-contracts.sh`, which +regenerates the contracts and fails on any byte difference from the committed artifacts, so the +committed document cannot drift from the code. The docs build then pulls the committed document at +the pinned ref (`scripts/fetch-openapi.mjs`) and renders it as the generated operations pages. + +A running Evidence service publishes the same document at `GET /openapi.json` with media type +`application/openapi+json`. The route requires no authentication and reaches no dependency; the +handler in `crates/registry-evidence/src/server.rs` takes no credential, and the unit test +`the_served_openapi_document_is_the_generated_release_artifact` in +`crates/registry-evidence/src/contracts.rs` pins the served bytes to the generated artifact. + +## Authentication + +The two evidence routes require exactly one `Authorization` header containing one Bearer token. +Evidence verifies OIDC access tokens against one reviewed profile: exactly one trusted issuer with +exact audience, token type, and algorithm allowlists, and one configured principal claim with no +`client_id`, `azp`, header, or request fallback (`products/evidence/OPERATOR-CONTRACT.md`). There +is no API-key mode. The operational routes take no credential and reveal no deployment +definitions or entitlements. + +## Response formats + +Version 1 governs three response formats; signed flattened JWS is the mandatory default: + +- `application/jose+json`: the signed flattened JWS, the default and the durable-verification + format. +- `application/vnd.registrystack.evidence-unsigned+json`: a visibly unsigned JSON envelope that a + governed authority grant must explicitly permit. It is transport-authenticated convenience data, + not later-verifiable evidence, and never a fallback from signing failure. +- `application/dc+sd-jwt`: the same assertion serialized as an SD-JWT VC under the frozen profile + in `products/evidence/contracts/sd-jwt-vc-profile.yaml`, with one disclosure per supported + value. Enabling it adds a serialization, not a credential lifecycle. + +## What the specification does not cover + +- Deployment definitions: the generated document describes the generic operations, envelopes, + media types, and safe problems. It contains no deployment's requirement definitions or + entitlements; those are discovered through the authenticated `GET /v1/evidence-definitions` + route. +- Credential lifecycle: there is no OID4VCI, credential offer, status list, holder proof, or + wallet interaction. The SD-JWT VC response format is a second encoding of one response. +- Wallet interoperability and presentation: outside the Version 1 boundary + (`products/evidence/SD-JWT-VC-DEMO.md`). + +## Behavior the schema cannot express + +- Audit durability: the access record is durably accepted before source access, and the + disclosure-release record is durably accepted before the response bytes reach the caller + (`products/evidence/contracts/security-invariant-matrix.yaml`; the measured cost is recorded in + `products/evidence/PERFORMANCE.md`). + +:::caution +Because release is gated on durable audit acceptance, an unavailable audit store surfaces as +request failure, not as a silently unaudited response. Monitor the audit store as part of the +operational runbook. +::: diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index 0417b6e5d..b43f55b77 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -22,6 +22,9 @@ docsets: registry-manifest: version: main source (unreleased) ref: HEAD + registry-evidence: + version: main source (unreleased) + ref: HEAD - id: v0.16.3 label: v0.16.3 path: /v/0.16.3/ diff --git a/docs/site/src/data/generated/docsets.json b/docs/site/src/data/generated/docsets.json index 7a002d6a3..ebbe597cd 100644 --- a/docs/site/src/data/generated/docsets.json +++ b/docs/site/src/data/generated/docsets.json @@ -27,6 +27,10 @@ "registry-manifest": { "version": "main source (unreleased)", "ref": "HEAD" + }, + "registry-evidence": { + "version": "main source (unreleased)", + "ref": "HEAD" } } }, diff --git a/docs/site/src/data/generated/openapi-sources.json b/docs/site/src/data/generated/openapi-sources.json index e0d36c381..97c90c760 100644 --- a/docs/site/src/data/generated/openapi-sources.json +++ b/docs/site/src/data/generated/openapi-sources.json @@ -16,5 +16,14 @@ "artifact": "openapi/registry-notary.openapi.json", "status": "pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples", "reference_path": "/reference/apis/notary/" + }, + { + "id": "registry-evidence", + "name": "Registry Evidence API", + "owner": "registry-evidence", + "source": "Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job.", + "artifact": "openapi/registry-evidence.openapi.json", + "status": "pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required.", + "reference_path": "/reference/apis/evidence/" } ] diff --git a/docs/site/src/data/generated/sidebar.json b/docs/site/src/data/generated/sidebar.json index 0093e73d4..c8775618c 100644 --- a/docs/site/src/data/generated/sidebar.json +++ b/docs/site/src/data/generated/sidebar.json @@ -194,5 +194,39 @@ "slug": "products/registry-manifest/reference" } ] + }, + { + "label": "Evidence", + "collapsed": true, + "items": [ + { + "label": "Overview", + "slug": "products/registry-evidence" + }, + { + "label": "Product concept", + "slug": "products/registry-evidence/concept" + }, + { + "label": "First server curl", + "slug": "products/registry-evidence/first-curl-test" + }, + { + "label": "Source testing", + "slug": "products/registry-evidence/source-testing" + }, + { + "label": "SD-JWT VC demo", + "slug": "products/registry-evidence/sd-jwt-vc-demo" + }, + { + "label": "Operator contract", + "slug": "products/registry-evidence/operator-contract" + }, + { + "label": "Performance", + "slug": "products/registry-evidence/performance" + } + ] } ] diff --git a/docs/site/src/data/openapi-sources.yaml b/docs/site/src/data/openapi-sources.yaml index 696acbbf1..912c799f8 100644 --- a/docs/site/src/data/openapi-sources.yaml +++ b/docs/site/src/data/openapi-sources.yaml @@ -12,3 +12,10 @@ artifact: openapi/registry-notary.openapi.json status: pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples reference_path: /reference/apis/notary/ +- id: registry-evidence + name: Registry Evidence API + owner: registry-evidence + source: Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job. + artifact: openapi/registry-evidence.openapi.json + status: pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required. + reference_path: /reference/apis/evidence/ diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index 969231577..ac989a614 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -528,3 +528,73 @@ repos: - docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, shacl, skos, json-schema, json-ld] last_reviewed: unreviewed + registry-evidence: + remote: https://github.com/registrystack/registry-stack + ref: HEAD + version: main source (unreleased) + local: ../.. + openapi: products/evidence/generated/registry-evidence.openapi.json + docs: + - src: products/evidence/README.md + dest: products/registry-evidence/index + label: Registry Evidence + nav_order: 0 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [openapi, json-schema, sd-jwt-vc, oid4vci] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Operator and integrator documentation for Evidence, the minimum-disclosure assertion service. + - src: products/evidence/CONCEPT.md + dest: products/registry-evidence/concept + label: Product concept + nav_order: 10 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [cccev, json-schema, openapi, sd-jwt-vc, oid4vci] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Approved Version 1 product concept covering the boundary, data model, trust and privacy invariants, native API, and acceptance set. + - src: products/evidence/FIRST-CURL-TEST.md + dest: products/registry-evidence/first-curl-test + label: First server curl + nav_order: 20 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Deterministic local curl checkpoint against the Evidence server with a mock source and an in-memory test JWKS. + - src: products/evidence/SOURCE-TESTING.md + dest: products/registry-evidence/source-testing + label: Source testing + nav_order: 30 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Source-testing contract with the deterministic mock matrix, opt-in public demo smoke tests, and credential handling rules. + - src: products/evidence/SD-JWT-VC-DEMO.md + dest: products/registry-evidence/sd-jwt-vc-demo + label: SD-JWT VC demo + nav_order: 40 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Deterministic local demo that issues one assertion in both later-verifiable formats and re-verifies the credential offline. + - src: products/evidence/OPERATOR-CONTRACT.md + dest: products/registry-evidence/operator-contract + label: Operator contract + nav_order: 50 + doc_type: reference + last_reviewed: unreviewed + standards_referenced: [openapi, sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Supported deployment shape, requester authority and purpose duties, required configuration and secrets, and readiness, audit, and key obligations. + - src: products/evidence/PERFORMANCE.md + dest: products/registry-evidence/performance + label: Performance + nav_order: 60 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Measured baseline of the audit-durability throughput trade and the deferred work that would recover most of the cost. From 0b7fa096c1da31c774a79813e6ecfe84a6e9f71b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 08:44:28 +0700 Subject: [PATCH 040/136] docs: tick B1, B3, B7, F6, F7 and record the docs-wave status The plan's status log records the archived-docset design decision, the partial B5/B8/F5 state, the pre-existing failures flagged out of scope, and the next unblocked items (B4 gate, E6, E2-E5). Signed-off-by: Jeremi Joslin --- ...tary-retirement-and-evidence-onboarding.md | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 3414a5468..da0d408db 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -62,14 +62,14 @@ gates for its area (see Verification), and committed. ### B. Evidence onboarding (docs site, evidencectl, CI) -- [ ] B1. Site plumbing: `openapi-sources.yaml` entry for the generated +- [x] B1. Site plumbing: `openapi-sources.yaml` entry for the generated Evidence OpenAPI with a Redoc reference page; contracts wired into the data-driven reference; Operate content from `OPERATOR-CONTRACT.md`; Security content from the invariant matrix and test traceability; a "Registry Evidence" Configure group. - [x] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an existing gate or add one). -- [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or +- [x] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. - [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job that runs each Evidence tutorial from a clean container with only the @@ -84,7 +84,7 @@ gates for its area (see Verification), and committed. installed via F1 (the released-binary form of this gate needs F3). - [ ] B6. Tutorial E6 (move Evidence to production signing) published, derived from `OPERATOR-CONTRACT.md`. -- [ ] B7. Mint has real docs presence: a Configure page and a reference page. +- [x] B7. Mint has real docs presence: a Configure page and a reference page. - [ ] B8. Onboarding spine: glossary disambiguates Evidence (product) from the retired Notary evidence credentials; `start/when-to-use` presents the two doors; the quickstart ends in an Evidence assertion over the @@ -170,9 +170,9 @@ channel; it does not get a parallel one. - [ ] F5. Personas named on the docs site (assertion provider, data publisher, consumer/verifier, operator) and every tutorial labeled with whose it is. -- [ ] F6. An Evidence errors and problems reference page exists for +- [x] F6. An Evidence errors and problems reference page exists for adopters: what each public problem means and what to do about it. -- [ ] F7. An evaluate-stage page exists: what Evidence costs to run +- [x] F7. An evaluate-stage page exists: what Evidence costs to run (footprint, dependencies, operational burden, support window). ### Global gates (checked at the end, not per item) @@ -251,3 +251,31 @@ is parallel; B has no upstream dependencies and is the standing priority build-platforms matrix, which now also builds evidence, evidencectl, and mint. The assemble step and the F2 inventory already accept the per-platform names generically. +- 2026-08-03: Docs wave landed. B1 done: repo-docs mirrors seven Evidence + product pages, the latest docset registers the product, the generated + OpenAPI flows through fetch-openapi into a Redoc operations section + plus a narrative API page, security content comes from the invariant + matrix and test traceability (security/evidence.mdx), Operate content + is the mirrored operator contract, and the sidebar gains an "Answer + with Evidence" flow (the plan's "Registry Evidence Configure group" + exists as that flow plus the Registry Evidence product group; rename + if the exact label matters). B3 done: RS-PR-EVIDENCE with 56 + requirements, every one citing its frozen contract file. B7 done: + Mint configure and reference pages, config fields cited to source + lines. F6 done: all nine public problem types documented from the + problem contract. F7 done: evaluate-stage page; writing it surfaced + stale PERFORMANCE.md group-commit claims, reconciled in their own + commit. B8 partial: two-door when-to-use, personas (F5 partial), and + glossary disambiguation are in; the quickstart flip still waits on A + and D. E1 (first-assertion tutorial) is published with commands + verified against the built binaries, but B5 stays open: E2-E5 and the + B4 gate are not built. Archived-docset design decision: a product + absent from an archived docset is filtered before docset pinning and + its OpenAPI rides the current shell; the Evidence product group + splices optionally (v0.15.2 verified past the Evidence throw; its + remaining sync failure is a pre-existing Notary allowlist gap on + main, flagged separately, as is the stale rust-result shard test). + Site suite 267 tests green. Full `npm run check` still pending. +- 2026-08-03: Session-limit note: three authoring subagents died mid + wave (configure page recovered by hand, E1 rewritten by hand, E6 not + written). Next unblocked items: B4 gate, E6, then E2-E5. From ff508f8fe6cd2e8fd39291e6ce4c4e3da6db3801 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 08:46:25 +0700 Subject: [PATCH 041/136] docs(evidence): use straight quotes in the operator contract lead The Vale gate on the mirrored product page rejects typographic quotes. Signed-off-by: Jeremi Joslin --- products/evidence/OPERATOR-CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 9a2dcc249..e873655fe 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -82,7 +82,7 @@ unsafe bundle safe. ## Discovery of available evidence -Evidence Version 1 answers “what may this caller request?” with authenticated +Evidence Version 1 answers "what may this caller request?" with authenticated `GET /v1/evidence-definitions`. Availability is requester-relative: the definition must exist in the exact deployed bundle and exactly one authority path must match the verified token, requirement, purpose, audience, complete From e70fe075e9bb95699206c23b0c92f89894f66337 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:13:00 +0700 Subject: [PATCH 042/136] fix(evidence): remove eight adopter frictions found writing the tutorial A tutorial writer took the Mint and Evidence scaffold end to end and recorded where the tooling misled or under-informed them. Each fix below closes one of those: - Bundle immutability errors name the failing path, not just the rule. - `evidence serve` announces a start only once both listeners are held, and a bind failure names the address and the cause. - A rejected source response shape logs which member failed which schema rule. - The bounded-integer rejection states the whole rule it enforces. - `evidencectl new --port` chooses the Evidence listener port, and refuses the port a paired `--with-mint` deployment binds. - `--with-mint` keeps the example caller's key at project root: the caller is a different party from the issuer that registers it. - `evidencectl fixtures run` reports the cases evaluated beside the artifact counts, which are not a coverage figure. - New `evidencectl keygen token` writes a printable, header-safe bearer token. `keygen secret` makes HMAC key material whose raw bytes an HTTP header rejects; its help and the scaffold's printed steps now say so. Security review notes: - `keygen token` writes 32 bytes of `getrandom` entropy as base64url without padding, owner-only (0600), with no trailing newline because the runtime reads the file whole. The value is never printed, and a test asserts it reaches neither stream. - The response-shape rejection log is built from the violation's instance and schema pointers only. The library's own message embeds the offending value and is deliberately unused; a test plants a secret in a rejected response and asserts it never appears. - The bind failure message carries the configured address and the operating system's reason, both already operator-supplied or operator-visible. No secret reaches it. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/bundle.rs | 170 +++++++++++++++--- crates/registry-evidence/src/kernel.rs | 135 +++++++++++++- crates/registry-evidence/src/main.rs | 24 +-- crates/registry-evidence/src/server.rs | 70 +++++++- .../tests/source_contracts.rs | 7 +- crates/registry-evidencectl/src/fixtures.rs | 53 +++++- crates/registry-evidencectl/src/keygen.rs | 53 ++++++ crates/registry-evidencectl/src/scaffold.rs | 57 ++++-- .../registry-evidencectl/templates/README.md | 25 ++- .../registry-evidencectl/templates/gitignore | 3 + .../templates/mint/README-section.md | 12 +- .../templates/mint/client.yaml.example | 2 +- .../templates/mint/mint.yaml | 2 +- .../templates/runtime.yaml | 2 +- crates/registry-evidencectl/tests/fixtures.rs | 99 +++++++++- crates/registry-evidencectl/tests/keygen.rs | 104 +++++++++++ crates/registry-evidencectl/tests/scaffold.rs | 140 ++++++++++++++- 17 files changed, 885 insertions(+), 73 deletions(-) diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index 8c082b2dd..04b37efb2 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -46,8 +46,8 @@ const ALLOWED_DIRECTORIES: [&str; 6] = [ pub enum BundleError { #[error("the Evidence deployment bundle is unavailable")] Unavailable, - #[error("the Evidence deployment bundle is not an immutable read-only directory")] - NotImmutable, + #[error("an Evidence deployment input is not immutable: {0}")] + NotImmutable(ArtifactFault), #[error("the Evidence deployment bundle contains an unsupported filesystem entry")] UnsupportedEntry, #[error("the Evidence deployment bundle contains a prohibited path")] @@ -74,6 +74,7 @@ impl BundleError { Self::Config(fault) | Self::InvalidArtifact(fault) | Self::InvalidScript(fault) + | Self::NotImmutable(fault) | Self::UnknownFile(fault) => Some(fault), _ => None, } @@ -88,6 +89,7 @@ impl BundleError { Self::Config(fault) => Self::Config(fault.bind(artifact)), Self::InvalidArtifact(fault) => Self::InvalidArtifact(fault.bind(artifact)), Self::InvalidScript(fault) => Self::InvalidScript(fault.bind(artifact)), + Self::NotImmutable(fault) => Self::NotImmutable(fault.bind(artifact)), other => other, } } @@ -152,6 +154,11 @@ fn invalid_artifact(cause: &'static str) -> BundleError { BundleError::InvalidArtifact(ArtifactFault::unbound(cause)) } +/// An immutability fault whose artifact the caller binds when it knows one. +fn not_immutable(cause: &'static str) -> BundleError { + BundleError::NotImmutable(ArtifactFault::unbound(cause)) +} + /// A script fault whose artifact the caller binds later. fn invalid_script(cause: &'static str) -> BundleError { BundleError::InvalidScript(ArtifactFault::unbound(cause)) @@ -266,13 +273,17 @@ impl RuntimeDocument { return Err(BundleError::InvalidPath); } let filesystem_read_only = filesystem_is_read_only(path)?; - validate_read_only(&metadata, filesystem_read_only)?; + let writable_runtime = "the runtime file is writable"; + validate_read_only(&metadata, filesystem_read_only, writable_runtime) + .map_err(|error| error.in_artifact(RUNTIME_FILE))?; let bytes = read_stable_file( path, &metadata, crate::config::MAX_CONFIG_BYTES as u64, filesystem_read_only, - )?; + writable_runtime, + ) + .map_err(|error| error.in_artifact(RUNTIME_FILE))?; let config = RuntimeConfig::parse_yaml(&bytes).map_err(|error| { BundleError::Config(ArtifactFault::new(RUNTIME_FILE, error.fault())) })?; @@ -287,12 +298,14 @@ impl RuntimeDocument { return Err(BundleError::InvalidPath); } let ca_filesystem_read_only = filesystem_is_read_only(ca_path)?; - validate_read_only(&ca_metadata, ca_filesystem_read_only)?; + let writable_ca = "the TLS CA bundle the runtime file names is writable"; + validate_read_only(&ca_metadata, ca_filesystem_read_only, writable_ca)?; let ca_bytes = read_stable_file( ca_path, &ca_metadata, MAX_CA_BUNDLE_BYTES, ca_filesystem_read_only, + writable_ca, )?; validate_ca_bundle(&ca_bytes).map_err(|error| error.in_artifact(RUNTIME_FILE))?; ca_bundles.insert(profile.to_owned(), ca_bytes); @@ -411,7 +424,11 @@ fn capture_bundle_files(root: &Path) -> Result>, Bundle return Err(BundleError::InvalidPath); } let filesystem_read_only = filesystem_is_read_only(root)?; - validate_read_only(&root_metadata, filesystem_read_only)?; + validate_read_only( + &root_metadata, + filesystem_read_only, + "the bundle directory is writable", + )?; let canonical_root = fs::canonicalize(root).map_err(|_| BundleError::Unavailable)?; let mut paths = Vec::new(); collect_paths( @@ -430,7 +447,14 @@ fn capture_bundle_files(root: &Path) -> Result>, Bundle let mut total = 0_u64; for (relative, path, scanned_metadata) in paths { let cap = file_size_cap(&relative); - let bytes = read_stable_file(&path, &scanned_metadata, cap, filesystem_read_only)?; + let bytes = read_stable_file( + &path, + &scanned_metadata, + cap, + filesystem_read_only, + "the bundle artifact is writable", + ) + .map_err(|error| error.in_artifact(&relative))?; total = total .checked_add(u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)?) .ok_or(BundleError::TooLarge)?; @@ -463,11 +487,23 @@ fn collect_paths( if metadata.file_type().is_symlink() { return Err(BundleError::InvalidPath); } - validate_read_only(&metadata, filesystem_read_only)?; let relative_path = path .strip_prefix(root) .map_err(|_| BundleError::InvalidPath)?; let relative = path_to_bundle_string(relative_path)?; + // Named after the relative path is known, so a writable artifact says + // which one it is. A path the bundle grammar refuses is refused as a + // path first; both fail closed, and neither reaches the caller unread. + validate_read_only( + &metadata, + filesystem_read_only, + if metadata.is_dir() { + "the bundle directory is writable" + } else { + "the bundle artifact is writable" + }, + ) + .map_err(|error| error.in_artifact(&relative))?; let top = relative.split('/').next().ok_or(BundleError::InvalidPath)?; if directory == root { if metadata.is_dir() { @@ -520,22 +556,36 @@ fn path_to_bundle_string(path: &Path) -> Result { Ok(value.to_owned()) } +/// Refuse a writable deployment input, naming which input it was. +/// +/// Every caller passes its own cause because the classes are not +/// interchangeable to whoever has to fix one: a writable bundle artifact is +/// re-frozen with the documented `chmod`, while a writable runtime file or CA +/// bundle sits outside the bundle entirely and re-freezing changes nothing. #[cfg(unix)] -fn validate_read_only(metadata: &Metadata, filesystem_read_only: bool) -> Result<(), BundleError> { +fn validate_read_only( + metadata: &Metadata, + filesystem_read_only: bool, + cause: &'static str, +) -> Result<(), BundleError> { use std::os::unix::fs::PermissionsExt as _; if !filesystem_read_only && metadata.permissions().mode() & 0o222 != 0 { - Err(BundleError::NotImmutable) + Err(not_immutable(cause)) } else { Ok(()) } } #[cfg(not(unix))] -fn validate_read_only(metadata: &Metadata, filesystem_read_only: bool) -> Result<(), BundleError> { +fn validate_read_only( + metadata: &Metadata, + filesystem_read_only: bool, + cause: &'static str, +) -> Result<(), BundleError> { if filesystem_read_only || metadata.permissions().readonly() { Ok(()) } else { - Err(BundleError::NotImmutable) + Err(not_immutable(cause)) } } @@ -567,15 +617,18 @@ fn read_stable_file( scanned: &Metadata, cap: u64, filesystem_read_only: bool, + writable_cause: &'static str, ) -> Result, BundleError> { if scanned.len() > cap { return Err(BundleError::TooLarge); } let mut file = open_no_follow(path)?; let opened = file.metadata().map_err(|_| BundleError::Unavailable)?; - validate_read_only(&opened, filesystem_read_only)?; + validate_read_only(&opened, filesystem_read_only, writable_cause)?; if !opened.is_file() || !same_file(scanned, &opened) || opened.len() > cap { - return Err(BundleError::NotImmutable); + return Err(not_immutable( + "the file was replaced between the directory scan and opening it", + )); } let mut bytes = Vec::new(); file.by_ref() @@ -589,7 +642,7 @@ fn read_stable_file( if !same_file(&opened, &after) || after.len() != u64::try_from(bytes.len()).map_err(|_| BundleError::TooLarge)? { - return Err(BundleError::NotImmutable); + return Err(not_immutable("the file changed while it was being read")); } Ok(bytes) } @@ -1286,7 +1339,7 @@ fn validate_schema_node(node: &JsonValue, role: SchemaRole) -> Result<(), Bundle && !constant { return Err(invalid_artifact( - "schema integers must be bounded or enumerated", + "schema integers need both a minimum and a maximum, or an enum, or a const", )); } } @@ -1781,6 +1834,10 @@ fn validate_runtime_bindings( Ok(()) } +/// The secret root is the one immutability check whose subject is outside the +/// bundle, so its cause says so. Re-freezing the bundle does not touch it, and +/// an operator told only that a deployment input is not immutable audits the +/// bundle first and finds nothing wrong with it. fn validate_secret_root(path: &Path) -> Result<(), BundleError> { let metadata = fs::symlink_metadata(path).map_err(|_| BundleError::Unavailable)?; if metadata.file_type().is_symlink() || !metadata.is_dir() { @@ -1790,12 +1847,16 @@ fn validate_secret_root(path: &Path) -> Result<(), BundleError> { { use std::os::unix::fs::PermissionsExt as _; if metadata.permissions().mode() & 0o077 != 0 { - return Err(BundleError::NotImmutable); + return Err(not_immutable( + "the secret root directory the runtime file names is reachable by group or other", + )); } } #[cfg(not(unix))] if !metadata.permissions().readonly() { - return Err(BundleError::NotImmutable); + return Err(not_immutable( + "the secret root directory the runtime file names is writable", + )); } Ok(()) } @@ -2129,15 +2190,55 @@ mod tests { ); } + /// The closed subset is learnable, but only if each rule states itself + /// whole. A lower bound alone is what JSON Schema habit supplies, and it is + /// refused, so the refusal has to say that an upper bound is the missing + /// half rather than leave the author to guess which of three admitted forms + /// was meant. + #[test] + fn an_integer_bounded_on_one_side_is_refused_by_the_whole_rule() { + let admitted = [ + "{type: integer, minimum: 0, maximum: 64}", + "{type: integer, enum: [1, 2]}", + "{type: integer, const: 1}", + ]; + for node in admitted { + let node: JsonValue = serde_norway::from_str(node).expect("admitted integer node"); + assert!( + validate_schema_node(&node, SchemaRole::Facts).is_ok(), + "the subset admits this integer: {node}" + ); + } + + for node in [ + "{type: integer, minimum: 0}", + "{type: integer, maximum: 64}", + "{type: integer}", + ] { + let node: JsonValue = serde_norway::from_str(node).expect("unbounded integer node"); + let refused = validate_schema_node(&node, SchemaRole::Facts) + .expect_err("an integer bounded on one side is refused"); + assert_eq!( + refused.artifact_fault().map(ArtifactFault::fault), + Some(&SchemaFault::because( + "schema integers need both a minimum and a maximum, or an enum, or a const" + )), + "the refusal must name the whole rule: {node}" + ); + } + } + #[cfg(unix)] #[test] fn writable_bundle_and_unknown_files_fail_closed() { let writable = tempfile::tempdir().expect("temporary bundle"); copy_acceptance_bundle("adult-status", writable.path()); - assert!(matches!( - Bundle::load(writable.path()), - Err(BundleError::NotImmutable) - )); + let writable_error = Bundle::load(writable.path()).expect_err("a writable bundle fails"); + let fault = writable_error + .artifact_fault() + .expect("the refusal names what is writable"); + assert_eq!(fault.artifact(), ""); + assert_eq!(fault.fault().cause(), "the bundle directory is writable"); let unknown = tempfile::tempdir().expect("temporary bundle"); copy_acceptance_bundle("adult-status", unknown.path()); @@ -2219,10 +2320,12 @@ mod tests { ) .expect("write runtime document"); - assert!(matches!( - RuntimeDocument::load(&runtime_path), - Err(BundleError::NotImmutable) - )); + let writable = RuntimeDocument::load(&runtime_path).expect_err("a writable runtime fails"); + let fault = writable + .artifact_fault() + .expect("the refusal names what is writable"); + assert_eq!(fault.artifact(), RUNTIME_FILE); + assert_eq!(fault.fault().cause(), "the runtime file is writable"); fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)) .expect("lock runtime document"); let runtime = RuntimeDocument::load(&runtime_path).expect("runtime loads"); @@ -2233,5 +2336,20 @@ mod tests { runtime.bytes(), fs::read(&runtime_path).expect("read runtime") ); + + // Everything the operator can re-freeze is already frozen here, so the + // refusal has to name the one input outside the bundle. Re-freezing the + // bundle in answer to it changes nothing, which is what makes an + // unnamed immutability failure cost a mode audit of the whole tree. + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o750)) + .expect("loosen secret root"); + let loose = RuntimeDocument::load(&runtime_path).expect_err("a group-readable root fails"); + let fault = loose + .artifact_fault() + .expect("the refusal names what is loose"); + assert_eq!( + fault.fault().cause(), + "the secret root directory the runtime file names is reachable by group or other" + ); } } diff --git a/crates/registry-evidence/src/kernel.rs b/crates/registry-evidence/src/kernel.rs index ec2ab0dcd..d9afaea54 100644 --- a/crates/registry-evidence/src/kernel.rs +++ b/crates/registry-evidence/src/kernel.rs @@ -364,7 +364,12 @@ impl OfflineKernel { .response_schemas .get(&requirement.source) .ok_or(KernelError::Bundle)?; - if !response_schema.is_valid(source_response) { + if let Err(errors) = response_schema.validate(source_response) { + report_response_shape_rejection( + &requirement.source, + source.response_schema.as_str(), + errors, + ); return Err(KernelError::SourceProtocol); } let parameters = serde_json::to_value(&source.request.adapter_parameters) @@ -570,6 +575,72 @@ fn map_context_error(_: RhaiRuntimeError) -> KernelError { KernelError::Bundle } +/// How many response shape violations one rejection reports. +/// +/// A rejected response is one event, and the first few violations already say +/// which member disagrees with which rule. An unbounded list would let a source +/// decide how much an operator log holds. +const REPORTED_SHAPE_VIOLATIONS: usize = 5; + +/// Record which member of a projected response failed which schema rule. +/// +/// The two pointers are the whole diagnosis and neither is a value. Without +/// them a stale response schema and a source that changed its protocol are the +/// same `dependency_unavailable`, which sends an operator to the provider for a +/// defect that lives in the bundle. +/// +/// Nothing from the response body is recorded: the paths are members the +/// bundle's own projection selected, and the schema path is bundle text. The +/// library's own error message embeds the offending value, so it is deliberately +/// not used here. +fn report_response_shape_rejection<'a>( + source_id: &str, + schema_artifact: &str, + errors: impl Iterator>, +) { + let (violations, total) = describe_response_shape_rejection(errors); + tracing::warn!( + target: "registry_evidence::source", + source = source_id, + schema = schema_artifact, + violations = violations.join("; "), + total_violations = total, + "the projected source response does not match its declared response shape" + ); +} + +/// Reduce validation errors to bounded, value-free violation descriptions. +/// +/// Separated from the logging call so the property that matters can be asserted +/// directly: what is produced here is the only thing that reaches the log. +fn describe_response_shape_rejection<'a>( + errors: impl Iterator>, +) -> (Vec, usize) { + let mut violations = Vec::new(); + let mut total = 0usize; + for error in errors { + total += 1; + if violations.len() < REPORTED_SHAPE_VIOLATIONS { + violations.push(format!( + "{} violates {}", + display_pointer(&error.instance_path), + display_pointer(&error.schema_path) + )); + } + } + (violations, total) +} + +/// Render a JSON Pointer, naming the document root rather than printing nothing. +fn display_pointer(pointer: &jsonschema::paths::JSONPointer) -> String { + let rendered = pointer.to_string(); + if rendered.is_empty() { + "the response root".to_owned() + } else { + rendered + } +} + fn compile_schema(schema: &Value) -> Result { JSONSchema::options() .with_draft(Draft::Draft202012) @@ -1257,6 +1328,68 @@ mod tests { ); } + /// A shape rejection reaches the requester as `dependency_unavailable`, + /// which names the provider. When the real defect is a bundle schema that + /// no longer describes the source, the operator log is the only place that + /// can say so, and it can only say it by naming the member and the rule. + /// It must do that without recording any part of the response. + #[test] + fn a_response_shape_rejection_names_the_member_and_the_rule_but_no_value() { + let copied = immutable_fixture("adult-status"); + let bundle = Arc::new(Bundle::load(copied.path()).expect("bundle loads")); + let requirement = &bundle.config.requirements[0]; + let source = bundle + .config + .sources + .get(&requirement.source) + .expect("the requirement names a configured source"); + let schema = bundle + .fact_schema(&source.response_schema) + .expect("the response schema is a bundle artifact"); + let compiled = compile_schema(schema).expect("the response schema compiles"); + + let secret = "0451-mrs-hunt-was-born-in-caracas"; + let response = json!({"total": 1, "date_of_birth": secret}); + let errors = compiled + .validate(&response) + .expect_err("a malformed date violates the shape"); + let (violations, total) = describe_response_shape_rejection(errors); + assert_eq!(total, 1); + assert_eq!( + violations, + vec!["/date_of_birth violates /properties/date_of_birth/format".to_owned()] + ); + + // The library's own message would carry the value here, which is the + // reason the description is built from the two pointers instead. + assert!( + !violations + .iter() + .any(|violation| violation.contains(secret)), + "no response value reaches the log: {violations:?}" + ); + + // A violation at the document root still names somewhere. + let not_an_object = json!([]); + let errors = compiled + .validate(¬_an_object) + .expect_err("an array is not the declared object"); + let (violations, _) = describe_response_shape_rejection(errors); + assert_eq!( + violations, + vec!["the response root violates /type".to_owned()] + ); + + // A source cannot decide how much the log holds. + let many = json!({"total": -1, "date_of_birth": "not-a-date", "extra": 1}); + let errors = compiled + .validate(&many) + .expect_err("several rules are violated at once"); + let (violations, total) = describe_response_shape_rejection(errors); + assert!(total >= 3, "the count is the whole number of violations"); + assert!(violations.len() <= REPORTED_SHAPE_VIOLATIONS); + } + #[test] fn extraction_failures_and_invalid_outputs_fail_closed() { let copied = immutable_fixture("adult-status"); diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index b210959f4..d88166858 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -137,10 +137,16 @@ impl std::error::Error for CliError {} /// The diagnostic names a bundle-relative artifact, a schema path, and a text /// location so an operator can find the defect. It carries no document value, /// which is what keeps a failed `check` safe to paste into a ticket. +/// +/// A service failure carries an owned message instead, because the operating +/// system decides both the address and the reason and neither is known when +/// this enum is written. Those two are the whole diagnosis of a failed start, +/// so a fixed string here would cost an operator the port and the cause. #[derive(Debug, PartialEq, Eq)] enum CommandError { Cli(CliError), Deployment(&'static str, ArtifactFault), + Service(String), } impl fmt::Display for CommandError { @@ -148,6 +154,7 @@ impl fmt::Display for CommandError { match self { Self::Cli(error) => fmt::Display::fmt(error, formatter), Self::Deployment(message, fault) => write!(formatter, "{message}: {fault}"), + Self::Service(reason) => write!(formatter, "service failed: {reason}"), } } } @@ -226,18 +233,13 @@ async fn run(cli: Cli) -> Result { .await .map_err(runtime_initialization_error)?, ); - tracing::info!( - target: "registry_evidence::startup", - bundle_revision = runtime.bundle().revision(), - runtime_revision = runtime.runtime_revision(), - bind_host = runtime.runtime_config().listener.bind_host, - port = runtime.runtime_config().listener.port, - metrics = runtime.runtime_config().metrics_listener.is_some(), - "evidence service starting" - ); + // The startup announcement belongs to the server, which makes it + // after both listeners are held. Nothing is reported here, because + // a start reported before the bind describes a service that may + // never have got its port. server::serve(runtime, shutdown_signal()) .await - .map_err(|_| CommandError::Cli(CliError("service failed")))?; + .map_err(|error| CommandError::Service(error.to_string()))?; Ok(ExitCode::SUCCESS) } Command::Verify { @@ -273,7 +275,7 @@ async fn run(cli: Cli) -> Result { fn deployment_load_error(error: BundleError) -> CommandError { let message = match &error { BundleError::Unavailable => "deployment input is unavailable", - BundleError::NotImmutable => "deployment input is not immutable", + BundleError::NotImmutable(_) => "deployment input is not immutable", BundleError::UnsupportedEntry => "deployment contains an unsupported entry", BundleError::InvalidPath => "deployment contains an invalid path binding", BundleError::UnknownFile(_) => "deployment artifact closure is invalid", diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index ff1a5b871..052489676 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -227,6 +227,8 @@ where { let listener_config = runtime.runtime_config().listener.clone(); let metrics_config = runtime.runtime_config().metrics_listener.clone(); + let bundle_revision = runtime.bundle().revision().to_owned(); + let runtime_revision = runtime.runtime_revision().to_owned(); let listener = bind(&listener_config.bind_host, listener_config.port).await?; let (app, evaluations, metrics) = build_app_with_tracker(runtime); @@ -237,6 +239,21 @@ where Some(config) => Some(bind(&config.bind_host, config.port).await?), None => None, }; + + // Announced only once both listeners are held, because whatever already + // owns a taken port answers in this service's place. A start announced + // before the bind reads as success to anyone who backgrounds the process + // and greps the log, and their verification run then tests the deployment + // that won the port. + tracing::info!( + target: "registry_evidence::startup", + bundle_revision, + runtime_revision, + bind_host = listener_config.bind_host, + port = listener_config.port, + metrics = metrics_config.is_some(), + "evidence service listening" + ); let (stop_metrics, metrics_stopped) = tokio::sync::watch::channel(()); let metrics_server = metrics_listener.map(|listener| { let mut stopped = metrics_stopped; @@ -267,11 +284,24 @@ where result } +/// Bind one listener, naming the address in any failure. +/// +/// A bind failure is reported to an operator who configured two addresses and +/// controls neither exclusively. Without the address, the two most common +/// causes read alike and neither points at its fix: a port already taken by +/// another process, and an address this host does not own. async fn bind(bind_host: &str, port: u16) -> io::Result { - let ip = bind_host - .parse::() - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - TcpListener::bind(SocketAddr::new(ip, port)).await + let ip = bind_host.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{bind_host} is not an address: {error}"), + ) + })?; + TcpListener::bind(SocketAddr::new(ip, port)) + .await + .map_err(|error| { + io::Error::new(error.kind(), format!("could not bind {ip}:{port}: {error}")) + }) } /// Serve a pre-bound listener and drain client-bound handlers on shutdown. @@ -731,6 +761,38 @@ mod tests { use super::*; use tower::ServiceExt; + /// A port this service did not get is the one failure whose symptom is + /// another service answering correctly in its place. The refusal has to + /// carry the address, because an operator reading it is deciding whether + /// the configuration is wrong or a previous instance is still running. + #[tokio::test] + async fn a_taken_port_is_refused_by_address() { + let held = bind("127.0.0.1", 0).await.expect("an ephemeral port binds"); + let taken = held.local_addr().expect("the held listener has an address"); + + let refused = bind("127.0.0.1", taken.port()) + .await + .expect_err("a held port cannot be bound twice"); + assert_eq!(refused.kind(), io::ErrorKind::AddrInUse); + assert!( + refused + .to_string() + .starts_with(&format!("could not bind 127.0.0.1:{}: ", taken.port())), + "the refusal names the address it failed on: {refused}" + ); + + let malformed = bind("localhost", 0) + .await + .expect_err("a host name is not an address"); + assert_eq!(malformed.kind(), io::ErrorKind::InvalidInput); + assert!( + malformed + .to_string() + .starts_with("localhost is not an address: "), + "the refusal names the value it could not parse: {malformed}" + ); + } + #[test] fn authorization_requires_one_unambiguous_bearer_value() { let mut headers = HeaderMap::new(); diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index 4524bcc32..b686c3738 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -2273,7 +2273,12 @@ fn runtime_ca_capture_rejects_symlink_malformed_and_mutable_files() { match case { CaCase::Symlink => assert_eq!(error, BundleError::InvalidPath), CaCase::Malformed => assert!(matches!(error, BundleError::InvalidArtifact(_))), - CaCase::Mutable => assert_eq!(error, BundleError::NotImmutable), + // The CA bundle sits outside the bundle directory, so the refusal + // has to name it. Re-freezing the bundle would not touch it. + CaCase::Mutable => assert_eq!( + error.artifact_fault().map(|fault| fault.fault().cause()), + Some("the TLS CA bundle the runtime file names is writable") + ), } } } diff --git a/crates/registry-evidencectl/src/fixtures.rs b/crates/registry-evidencectl/src/fixtures.rs index f15c6f276..4acda05e5 100644 --- a/crates/registry-evidencectl/src/fixtures.rs +++ b/crates/registry-evidencectl/src/fixtures.rs @@ -33,11 +33,13 @@ pub struct RunArgs { pub json: bool, } -/// The result of one `evidence` invocation: whether it exited zero, and, when -/// it did not, its captured stderr for the operator to read. +/// The result of one `evidence` invocation: whether it exited zero, when it did +/// not its captured stderr for the operator to read, and, for a fixture run, +/// how many cases that fixture evaluated. struct StepOutcome { passed: bool, stderr: Option, + evaluated_cases: Option, } #[derive(Debug, Serialize)] @@ -53,6 +55,9 @@ struct FixtureReport { passed: bool, #[serde(skip_serializing_if = "Option::is_none")] stderr: Option, + /// Absent when the fixture failed, or when `evidence` reported no count. + #[serde(skip_serializing_if = "Option::is_none")] + evaluated_cases: Option, } #[derive(Debug, Serialize)] @@ -60,6 +65,12 @@ struct RunReport { check: CheckReport, fixtures: Vec, passed: bool, + /// The cases every fixture in this run evaluated, summed. + /// + /// The step counts above measure artifacts, which a reader mistakes for + /// coverage: a project with four fixture files reports the same `5 passed` + /// whether those files hold four cases or forty. + evaluated_cases: usize, } pub fn run(command: FixturesCommand) -> Result { @@ -98,11 +109,16 @@ fn run_fixtures(args: RunArgs) -> Result { path: fixture_path.clone(), passed: outcome.passed, stderr: outcome.stderr, + evaluated_cases: outcome.evaluated_cases, }); } } let overall_passed = check_passed && fixtures.iter().all(|fixture| fixture.passed); + let evaluated_cases = fixtures + .iter() + .filter_map(|fixture| fixture.evaluated_cases) + .sum(); let report = RunReport { check: CheckReport { passed: check_passed, @@ -110,6 +126,7 @@ fn run_fixtures(args: RunArgs) -> Result { }, fixtures, passed: overall_passed, + evaluated_cases, }; if args.json { @@ -280,18 +297,36 @@ fn run_evidence_step(evidence_bin: &Path, runtime_path: &Path, args: &[&str]) -> Ok(output) if output.status.success() => StepOutcome { passed: true, stderr: None, + evaluated_cases: evaluated_cases(&String::from_utf8_lossy(&output.stdout)), }, Ok(output) => StepOutcome { passed: false, stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()), + evaluated_cases: None, }, Err(error) => StepOutcome { passed: false, stderr: Some(format!("failed to run {}: {error}", evidence_bin.display())), + evaluated_cases: None, }, } } +/// Read the case count out of `Evidence fixture passed (N evaluated cases)`. +/// +/// This driver makes no semantic decision, so the count is `evidence`'s own +/// figure or nothing at all. An unrecognized line leaves it absent rather than +/// guessed, which keeps a total that is short honest instead of wrong. +fn evaluated_cases(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + line.trim() + .strip_prefix("Evidence fixture passed (")? + .strip_suffix(" evaluated cases)")? + .parse() + .ok() + }) +} + /// Print one line per step and a summary line. /// /// In JSON mode this goes to stderr, keeping stdout reserved for the single @@ -303,7 +338,11 @@ fn print_diagnostics(report: &RunReport, to_stderr: bool) { lines.extend(indented(report.check.stderr.as_deref())); } for fixture in &report.fixtures { - lines.push(step_line(&fixture.path, fixture.passed)); + let mut line = step_line(&fixture.path, fixture.passed); + if let Some(cases) = fixture.evaluated_cases { + line.push_str(&format!(" ({cases} cases)")); + } + lines.push(line); if !fixture.passed { lines.extend(indented(fixture.stderr.as_deref())); } @@ -312,7 +351,13 @@ fn print_diagnostics(report: &RunReport, to_stderr: bool) { usize::from(report.check.passed) + report.fixtures.iter().filter(|f| f.passed).count(); let failed_count = usize::from(!report.check.passed) + report.fixtures.iter().filter(|f| !f.passed).count(); - lines.push(format!("{passed_count} passed, {failed_count} failed")); + // The step counts stay, because they are what the exit code is made of. + // The case total is beside them because it is the number a reader is + // actually looking for: how much of the deployment this run exercised. + lines.push(format!( + "{passed_count} passed, {failed_count} failed ({} cases evaluated)", + report.evaluated_cases + )); for line in lines { if to_stderr { diff --git a/crates/registry-evidencectl/src/keygen.rs b/crates/registry-evidencectl/src/keygen.rs index 9b544dc34..1362980aa 100644 --- a/crates/registry-evidencectl/src/keygen.rs +++ b/crates/registry-evidencectl/src/keygen.rs @@ -21,7 +21,13 @@ pub enum KeygenCommand { /// Ed25519 signing keypair as private and public JWK files. Signing(SigningArgs), /// One random raw secret file, 32 bytes (audit or subject-binding HMAC). + /// + /// This is HMAC key material, not a credential a source will accept: the + /// bytes are arbitrary and an HTTP header value rejects most of them. Use + /// `keygen token` for a bearer token. Secret(SecretArgs), + /// One random bearer token file, printable and header-safe. + Token(TokenArgs), /// Ed25519 holder keypair for SD-JWT VC confirmation binding. Holder(HolderArgs), } @@ -56,6 +62,17 @@ pub struct SecretArgs { pub force: bool, } +#[derive(Debug, Args)] +pub struct TokenArgs { + /// Output file for the bearer token (written 0600). + #[arg(long)] + pub out: PathBuf, + + /// Overwrite an existing output file. + #[arg(long)] + pub force: bool, +} + #[derive(Debug, Args)] pub struct HolderArgs { /// Directory receiving the holder private JWK file (created 0700). @@ -89,6 +106,9 @@ const PRIVATE_DIR_MODE: u32 = 0o700; /// Exactly 32 raw bytes: one HMAC secret (audit or subject-binding). const SECRET_FILE_BYTES: usize = 32; +/// How much randomness a generated bearer token carries, before encoding. +const TOKEN_ENTROPY_BYTES: usize = 32; + pub fn run(command: KeygenCommand) -> Result { match command { KeygenCommand::Signing(args) => run_keypair( @@ -100,6 +120,7 @@ pub fn run(command: KeygenCommand) -> Result { SIGNING_PUBLIC_FILENAME, ), KeygenCommand::Secret(args) => run_secret(&args), + KeygenCommand::Token(args) => run_token(&args), KeygenCommand::Holder(args) => run_keypair( &args.out_dir, args.kid.as_deref(), @@ -215,6 +236,38 @@ fn run_secret(args: &SecretArgs) -> Result { Ok(ExitCode::SUCCESS) } +/// Write one bearer token, for a source that has no token of its own to issue. +/// +/// A real source issues its own credential and this command has no business +/// inventing one. It exists for the stand-in source a project is stood up +/// against first, where the alternative is `keygen secret`: the obvious +/// neighbour, and the wrong tool, because its raw bytes reach an HTTP header +/// that rejects most of them. +fn run_token(args: &TokenArgs) -> Result { + reject_existing(&[&args.out], args.force)?; + + let mut entropy = Zeroizing::new([0_u8; TOKEN_ENTROPY_BYTES]); + getrandom::fill(entropy.as_mut_slice()).context("failed to generate random key material")?; + // base64url without padding: every character is unreserved in a header + // value and never NUL, so no draw has to be rejected. Written without a + // trailing newline, because the runtime reads the file whole and would + // carry one into the header. + let token = Zeroizing::new(URL_SAFE_NO_PAD.encode(entropy.as_slice())); + + if let Some(parent) = args + .out + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + ensure_secret_parent_dir(parent)?; + } + write_owner_file(&args.out, token.as_bytes(), PRIVATE_FILE_MODE, args.force)?; + + println!("wrote {}", args.out.display()); + + Ok(ExitCode::SUCCESS) +} + /// Draws `SECRET_FILE_BYTES` uniformly at random, rejecting any draw that /// contains a NUL byte. /// diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index b157b3a05..d06c8defc 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -64,6 +64,13 @@ const MINT_AUTHENTICATION_NOTE: &str = "\ # deployment in mint/. Every value below is mirrored in mint/mint.yaml, and a # single-sided edit produces tokens Mint issues and this deployment refuses."; +/// The listener ports the generated documents bind. +/// +/// Mint's is fixed, because a paired project runs both processes on one host +/// and the two ports have to differ for either to serve. +const DEFAULT_LISTENER_PORT: u16 = 8080; +const MINT_LISTENER_PORT: u16 = 8081; + /// Project-relative locations the scaffold owns. const BUNDLE_DIRECTORY: &str = "bundle"; const SECRET_DIRECTORY: &str = "secrets"; @@ -74,10 +81,14 @@ const MINT_DIRECTORY: &str = "mint"; const MINT_CONFIG_FILE: &str = "mint/mint.yaml"; const MINT_CLIENT_DIRECTORY: &str = "mint/clients"; const MINT_SECRET_DIRECTORY: &str = "mint/secrets"; -/// The example caller's key is not Mint's own, so it sits beside rather than in -/// the secret root Mint reads. Both are under a `secrets` path component, which -/// is what the generated `.gitignore` excludes. -const MINT_CALLER_SECRET_DIRECTORY: &str = "mint/secrets/caller"; +/// The example caller's key, at project root rather than inside `mint/`. +/// +/// A caller is a different party from the issuer that registers it, and a +/// scaffold is what adopters copy structure from. Keeping this key under +/// `mint/` would model a trust boundary that does not exist, and would put the +/// caller's private key on the Mint host for anyone who promotes `mint/` as a +/// unit. +const CALLER_SECRET_DIRECTORY: &str = "caller"; /// One rendered file: where it lands in the project, and its template bytes. struct ProjectFile { @@ -164,6 +175,15 @@ pub struct NewArgs { #[arg(long, default_value = "urn:example:scaffold:issuer")] pub issuer_id: String, + /// Port the generated runtime file binds the Evidence listener to. + /// + /// One machine often carries several scaffolded projects, and the default + /// is the same in every one of them. Editing the frozen runtime file to + /// move a port means unfreezing it, which is the state the project is + /// least safe in. + #[arg(long, default_value_t = DEFAULT_LISTENER_PORT)] + pub port: u16, + /// Also render a paired Registry Mint configuration for the project. #[arg(long)] pub with_mint: bool, @@ -174,6 +194,16 @@ pub struct NewArgs { } pub fn run(args: NewArgs) -> anyhow::Result { + // A paired project is two processes on one host. Rendering both onto one + // port produces documents that each look right and cannot both serve, and + // the loser of the race is whichever was started second. + if args.with_mint && args.port == MINT_LISTENER_PORT { + bail!( + "--port {MINT_LISTENER_PORT} is the port the paired Mint deployment binds; \ + choose another port for Evidence" + ); + } + if directory_has_entries(&args.directory)? && !args.force { bail!( "refusing to scaffold into the non-empty directory {}; pass --force to proceed", @@ -241,6 +271,7 @@ pub fn run(args: NewArgs) -> anyhow::Result { write_project_file(&path, &rendered)?; } create_secret_directory(&root.join(MINT_SECRET_DIRECTORY))?; + create_secret_directory(&root.join(CALLER_SECRET_DIRECTORY))?; } report(&root, &secret_root, args.with_mint); @@ -271,6 +302,8 @@ fn placeholders(root: &Path, args: &NewArgs) -> anyhow::Result anyhow::Result PathBuf { /// the file named by `$ARGV_LOG`; /// - exits 1 with a fixed diagnostic on stderr when its step name equals /// `$FAIL_STEP` (`check`, or `evaluate:`), and exits 0 -/// otherwise. +/// otherwise; +/// - prints the real `evidence evaluate` summary line, with `$CASES` cases, +/// when `$CASES` is set and the step is an evaluation. fn write_stub_evidence(dir: &Path) -> PathBuf { let path = dir.join("evidence"); let script = r#"#!/bin/sh @@ -81,6 +83,9 @@ if [ "$step" = "${FAIL_STEP:-}" ]; then fi printf 'stub ok for %s\n' "$step" +if [ -n "$fixture" ] && [ -n "${CASES:-}" ]; then + printf 'Evidence fixture passed (%s evaluated cases)\n' "$CASES" +fi exit 0 "#; fs::write(&path, script).expect("write stub evidence script"); @@ -274,6 +279,98 @@ fn json_output_is_one_parseable_document_on_stdout_with_expected_pass_fail_value assert!(stderr.contains("FAIL: fixtures/b.yaml"), "{stderr}"); } +/// The step counts measure artifacts, and a reader takes the summary line for +/// coverage. Two fixture files holding seven cases each is a fourteen-case run, +/// and reporting it as `3 passed` says nothing about how much was exercised. +#[test] +fn the_summary_totals_the_cases_each_fixture_evaluated() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml", "fixtures/b.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env("CASES", "7") + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!( + stdout.contains("PASS: fixtures/a.yaml (7 cases)"), + "{stdout}" + ); + assert!( + stdout.contains("3 passed, 0 failed (14 cases evaluated)"), + "{stdout}" + ); + + // A failing fixture evaluated nothing this run can count, so the total + // reports what actually ran rather than an estimate of what would have. + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .arg("--json") + .env("ARGV_LOG", &argv_log) + .env("CASES", "7") + .env("FAIL_STEP", "evaluate:fixtures/b.yaml") + .output() + .expect("run evidencectl"); + + assert!(!output.status.success()); + let report: serde_json::Value = + serde_json::from_str(stdout_of(&output).trim()).expect("parse JSON report"); + assert_eq!(report["evaluated_cases"], serde_json::json!(7)); + let fixtures = report["fixtures"].as_array().expect("fixtures array"); + assert_eq!(fixtures[0]["evaluated_cases"], serde_json::json!(7)); + assert!( + fixtures[1].get("evaluated_cases").is_none(), + "a failed fixture reports no count: {}", + fixtures[1] + ); +} + +/// An `evidence` that reports no count at all leaves the total short rather +/// than guessed. The driver makes no semantic decision of its own, and a +/// fabricated case count would be exactly that. +#[test] +fn an_unrecognized_summary_line_is_counted_as_nothing() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = write_project(dir.path(), &["fixtures/a.yaml"]); + let stub = write_stub_evidence(dir.path()); + let argv_log = dir.path().join("argv.log"); + + let output = evidencectl() + .args(["fixtures", "run", "--project"]) + .arg(&project) + .arg("--evidence-bin") + .arg(&stub) + .env("ARGV_LOG", &argv_log) + .env_remove("CASES") + .env_remove("FAIL_STEP") + .output() + .expect("run evidencectl"); + + assert!(output.status.success(), "{}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!( + stdout.contains("2 passed, 0 failed (0 cases evaluated)"), + "{stdout}" + ); + assert!( + !stdout.contains("cases)\n") || !stdout.contains("PASS: fixtures/a.yaml ("), + "an uncounted fixture must not claim a count: {stdout}" + ); +} + #[test] fn missing_runtime_yaml_errors_clearly_without_invoking_evidence() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/registry-evidencectl/tests/keygen.rs b/crates/registry-evidencectl/tests/keygen.rs index 4983d9a17..f69eff062 100644 --- a/crates/registry-evidencectl/tests/keygen.rs +++ b/crates/registry-evidencectl/tests/keygen.rs @@ -454,3 +454,107 @@ fn signing_error_names_the_offending_paths() { "error should name the offending path: {stderr}" ); } + +/// A bearer token ends up in an HTTP header, where the raw bytes `keygen +/// secret` writes are not valid. `keygen token` must therefore emit only +/// characters a header value accepts, with no trailing newline: the runtime +/// reads the file whole and would carry one into the header. +#[test] +fn token_writes_a_header_safe_value_with_owner_only_mode() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("secrets").join("source-bearer-token"); + + let output = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + assert_eq!(mode_of(out.parent().unwrap()), 0o700, "parent dir mode"); + assert_eq!(mode_of(&out), 0o600, "token file mode"); + + let token = fs::read_to_string(&out).expect("read token"); + assert_eq!(token.len(), 43, "token: {token}"); + assert!( + token + .chars() + .all(|character| character.is_ascii_alphanumeric() + || character == '-' + || character == '_'), + "token carries a character an HTTP header value rejects: {token}" + ); + + // The generated credential is as secret as any private key, and this tool + // never prints those either. + assert_output_excludes(&output, &token); +} + +#[test] +fn token_invocations_generate_independent_values() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = dir.path().join("first.token"); + let second = dir.path().join("second.token"); + + for out in [&first, &second] { + let output = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(out) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + } + + assert_ne!( + fs::read_to_string(&first).expect("read first token"), + fs::read_to_string(&second).expect("read second token") + ); +} + +#[test] +fn token_refuses_overwrite_without_force_then_succeeds_with_force() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("source-bearer-token"); + fs::write(&out, b"already here").expect("seed token"); + + let refused = evidencectl() + .args(["keygen", "token", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(!refused.status.success()); + assert_eq!( + fs::read_to_string(&out).expect("read token"), + "already here", + "a refused run must leave the existing token alone" + ); + + let forced = evidencectl() + .args(["keygen", "token", "--force", "--out"]) + .arg(&out) + .output() + .expect("run evidencectl"); + assert!(forced.status.success(), "{}", stderr_of(&forced)); + assert_ne!( + fs::read_to_string(&out).expect("read token"), + "already here" + ); +} + +/// `keygen secret` is the obvious tool for the one secret every scaffolded +/// bundle needs, and it is the wrong one. Its own help has to say so, because +/// the alternative is discovering it at the first live request. +#[test] +fn secret_help_says_it_does_not_make_bearer_tokens() { + let output = evidencectl() + .args(["keygen", "secret", "--help"]) + .output() + .expect("run evidencectl"); + assert!(output.status.success(), "{}", stderr_of(&output)); + + let help = stdout_of(&output); + assert!( + help.contains("keygen token"), + "keygen secret's help never points at the token generator:\n{help}" + ); +} diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index 277925e4e..85f003621 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -65,6 +65,33 @@ fn the_printed_next_steps_name_the_source_bearer_token() { ); } +/// Anyone standing the project up against a stand-in source has to invent that +/// token, and the neighbouring `keygen secret` lines make it the obvious tool. +/// It is the wrong one: it writes raw bytes, and a bearer token ends up in an +/// HTTP header. The step that names the token must name the generator that +/// suits it. +#[test] +fn the_printed_next_steps_offer_a_generator_for_a_stand_in_sources_token() { + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + let outcome = evidencectl(&["new", project.to_str().expect("project path")]); + assert!( + outcome.status.success(), + "evidencectl new failed: {}", + String::from_utf8_lossy(&outcome.stderr) + ); + + let printed = String::from_utf8_lossy(&outcome.stdout); + let token_step = printed + .lines() + .find(|line| line.contains("keygen token")) + .unwrap_or_else(|| panic!("no printed step generates a bearer token:\n{printed}")); + assert!( + token_step.contains("source-bearer-token"), + "the token generator step does not write the token the bundle reads: {token_step}" + ); +} + fn passes_check_and_every_fixture(project: &Path) { provision_secrets(project); let fixtures = scaffolded_fixtures(project); @@ -307,6 +334,60 @@ fn the_mint_configuration_is_rendered_only_when_it_is_asked_for() { ); } +/// A scaffold is what adopters copy structure from, so where it puts a key is +/// a claim about who owns it. The caller is not Mint, and `mint/` is the unit +/// an operator promotes to the Mint host. +#[test] +fn the_example_callers_key_lives_outside_the_mint_deployment() { + use std::os::unix::fs::PermissionsExt as _; + + let workspace = TempDir::new().expect("temporary directory"); + let project = workspace.path().join("project"); + scaffold(&[project.to_str().expect("project path"), "--with-mint"]); + + let caller = project.join("caller"); + let metadata = fs::metadata(&caller).expect("the caller key directory exists"); + assert!(metadata.is_dir()); + assert_eq!( + metadata.permissions().mode() & 0o777, + 0o700, + "the caller key directory must be owner-only" + ); + assert_eq!( + fs::read_dir(&caller).expect("caller key directory").count(), + 0, + "the scaffold must not generate the caller's key material" + ); + + // Nothing the operator is told to put under mint/ is the caller's private + // half. `mint/secrets/` is Mint's own signing key and nothing else. + let readme = fs::read_to_string(project.join("README.md")).expect("readme"); + // The rendered paths are the canonical ones the scaffold wrote, which on + // this platform resolves the temporary directory's symlinked parent. + let rendered_caller = fs::canonicalize(&caller).expect("canonical caller path"); + assert!( + readme.contains(&format!( + "keygen signing --out-dir {}", + rendered_caller.to_str().expect("caller path") + )), + "the README must generate the caller's key outside mint/" + ); + assert!( + !readme.contains("mint/secrets/caller"), + "no step may put the caller's key inside the Mint deployment" + ); + + // The generated ignore rules have to cover the new location, or the first + // commit of a scaffolded project carries a private key. + assert!( + fs::read_to_string(project.join(".gitignore")) + .expect("gitignore") + .lines() + .any(|line| line.trim() == "caller/"), + "the caller key directory must be excluded from version control" + ); +} + /// The two documents are rendered from one set of values, and this is what says /// so: every value the pairing depends on has to agree on both sides. #[test] @@ -415,6 +496,63 @@ fn the_rendered_mint_configuration_passes_mint_check() { ); } +/// The runtime file is frozen at mode 444 before the project is ever served, +/// so a port chosen after the fact costs an unfreeze. The flag exists so the +/// second project on a host never has to enter that state. +#[test] +fn the_listener_port_is_chosen_when_the_project_is_generated() { + let workspace = TempDir::new().expect("temporary directory"); + + let defaulted = workspace.path().join("defaulted"); + scaffold(&[defaulted.to_str().expect("project path")]); + assert_eq!( + yaml(&defaulted.join("runtime.yaml"))["listener"]["port"], + serde_norway::Value::from(8080), + "an unspecified port keeps the documented default" + ); + + let chosen = workspace.path().join("chosen"); + scaffold(&[chosen.to_str().expect("project path"), "--port", "9443"]); + assert_eq!( + yaml(&chosen.join("runtime.yaml"))["listener"]["port"], + serde_norway::Value::from(9443) + ); + + // A paired project runs both processes at once, so the one collision the + // scaffold can see is the one it refuses rather than renders. + let paired = workspace.path().join("paired"); + let refused = evidencectl(&[ + "new", + paired.to_str().expect("project path"), + "--with-mint", + "--port", + "8081", + ]); + assert!(!refused.status.success()); + assert!( + String::from_utf8_lossy(&refused.stderr).contains("the paired Mint deployment binds"), + "the refusal must name why 8081 is taken: {}", + String::from_utf8_lossy(&refused.stderr) + ); + + let paired = workspace.path().join("paired-ok"); + scaffold(&[ + paired.to_str().expect("project path"), + "--with-mint", + "--port", + "9443", + ]); + assert_eq!( + yaml(&paired.join("runtime.yaml"))["listener"]["port"], + serde_norway::Value::from(9443) + ); + assert_eq!( + yaml(&paired.join(MINT_CONFIG))["listener"]["port"], + serde_norway::Value::from(8081), + "Mint keeps its own port" + ); +} + /// Run `evidencectl new` and require success. fn scaffold(arguments: &[&str]) { let mut invocation = vec!["new"]; @@ -637,7 +775,7 @@ fn provision_mint_secrets(project: &Path) { // The caller's own key belongs to the caller, so it lands where the README // says it does rather than beside Mint's. write_secret( - &root.join("secrets/caller/signing-ed25519-private-jwk"), + &project.join("caller/signing-ed25519-private-jwk"), caller_jwk.as_bytes(), ); fs::write( From 3f1226062fbd07e2a9379c9b55b349924e44aedb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:15:07 +0700 Subject: [PATCH 043/136] feat(docs): add the Evidence tutorial gate check-evidence-tutorials.sh extracts the first-assertion tutorial's shell fences, drift-checks the fence count and required command and output literals, then replays the on-machine journey (scaffold, key generation, the immutability freeze, the fixtures run, cleanup) against toolset binaries built from source or supplied via EVIDENCE_BIN and EVIDENCECTL_BIN. The release-download fences wait for a release that ships the toolset (plan item F3). The dry-run drift check joins npm run check; three node tests cover the pass case and both drift failures. Verified end to end locally: the tutorial executes verbatim and reports 2 passed, 0 failed. Signed-off-by: Jeremi Joslin --- docs/site/package.json | 5 +- docs/site/scripts/check-evidence-tutorials.sh | 170 ++++++++++++++++++ .../scripts/check-evidence-tutorials.test.mjs | 66 +++++++ 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100755 docs/site/scripts/check-evidence-tutorials.sh create mode 100644 docs/site/scripts/check-evidence-tutorials.test.mjs diff --git a/docs/site/package.json b/docs/site/package.json index 4b8ec3cf9..afa140874 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -39,9 +39,12 @@ "check:tutorial:dry-run": "scripts/check-tutorial.sh --dry-run", "test:tutorial:registryctl": "node --test scripts/registryctl-tutorial.test.mjs", "check:tutorial:registryctl": "bash scripts/check-registryctl-tutorials.sh", + "test:tutorial:evidence": "node --test scripts/check-evidence-tutorials.test.mjs", + "check:tutorial:evidence": "bash scripts/check-evidence-tutorials.sh", + "check:tutorial:evidence:dry-run": "bash scripts/check-evidence-tutorials.sh --dry-run", "check:tutorial:public-source-live": "scripts/check-registryctl-public-source-live.sh", "check:links": "npm run build && npm run check:links:built", - "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", + "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", "check:archives": "npm run build && npm run check:llms:built && npm run assemble:archives -- --bootstrap && npm run check:seo:built && npm run check:links:built", "check:archive-lock": "node scripts/archive-lock.mjs check", "check:seo:current": "node scripts/check-seo.mjs --scope current", diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh new file mode 100755 index 000000000..19f726c68 --- /dev/null +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# +# Execute the current Evidence tutorials from a fresh reader directory. +# +# This gate builds the Evidence toolset from the checked-out source unless +# EVIDENCE_BIN and EVIDENCECTL_BIN select exact candidate or released bytes, +# then replays the first-assertion tutorial's own shell fences: scaffold, +# key generation, the immutability freeze, the fixtures run, and cleanup. +# The release-download fences are not executed here; the released-binary form +# of this gate arrives once a release ships the toolset (plan item F3). +# +# Usage: +# scripts/check-evidence-tutorials.sh extract, drift-check, execute +# scripts/check-evidence-tutorials.sh --dry-run extract and drift-check only +# +# Drift detection: +# - EXPECTED_SH_FENCES pins how many sh fences the tutorial holds; bump it +# when you intentionally add or remove a documented command block +# - RUNNABLE_FROM pins where the on-machine journey starts (the fences +# before it download a release and are replaced by the built binaries) +# - REQUIRED_LITERALS pins the commands and outputs the tutorial must keep +# documenting; the executed fences run verbatim, so a changed command is +# exercised as written +# +# Configuration: +# EVIDENCE_BIN / EVIDENCECTL_BIN run these exact binaries instead of +# building from source +# EVIDENCE_TUTORIAL_CARGO_PROFILE ci (default) or release +# EVIDENCE_TUTORIAL_FILE tutorial path override (tests only) + +set -euo pipefail + +SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" +TUTORIAL="${EVIDENCE_TUTORIAL_FILE:-$SITE_ROOT/src/content/docs/tutorials/first-evidence-assertion.mdx}" +BUILD_PROFILE="${EVIDENCE_TUTORIAL_CARGO_PROFILE:-ci}" +TARGET_DIR="$REPO_ROOT/target/evidence-tutorial-source" + +EXPECTED_SH_FENCES=8 +RUNNABLE_FROM=3 +# shellcheck disable=SC2016 # the first entry is literal tutorial text, not an expansion +REQUIRED_LITERALS=( + 'evidencectl-${tag}-install.sh' + 'evidencectl new hello-evidence' + 'evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1' + 'chmod -R a-w bundle && chmod 444 runtime.yaml' + 'evidencectl fixtures run --project .' + '2 passed, 0 failed' +) + +DRY_RUN=0 +case "${1:-}" in +'') ;; +--dry-run) DRY_RUN=1 ;; +*) + printf 'unknown argument: %s (expected --dry-run or nothing)\n' "$1" >&2 + exit 2 + ;; +esac + +if [[ ! -f "$TUTORIAL" ]]; then + printf 'Evidence tutorial not found: %s\n' "$TUTORIAL" >&2 + exit 1 +fi + +WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/evidence-tutorial.XXXXXX")" +cleanup() { + local exit_code=$? + set +e + chmod -R u+w "$WORK_ROOT" 2>/dev/null + rm -rf "$WORK_ROOT" + if ((exit_code == 0)); then + printf 'Evidence tutorial gate: PASS\n' + else + printf 'Evidence tutorial gate: FAIL (exit %d)\n' "$exit_code" >&2 + fi +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +# Extract every sh fence, in order, into numbered files. +FENCE_DIR="$WORK_ROOT/fences" +mkdir -p "$FENCE_DIR" +fence_count="$(awk -v outdir="$FENCE_DIR" ' + /^```sh$/ { infence = 1; count += 1; next } + infence && /^```$/ { infence = 0; next } + infence { print > (outdir "/fence-" sprintf("%02d", count) ".sh") } + END { print count + 0 } +' "$TUTORIAL")" + +if [[ "$fence_count" -ne "$EXPECTED_SH_FENCES" ]]; then + printf 'tutorial drift: %s sh fences found, expected %s\n' \ + "$fence_count" "$EXPECTED_SH_FENCES" >&2 + printf 'Update EXPECTED_SH_FENCES and RUNNABLE_FROM in %s when the change is intentional.\n' \ + "${BASH_SOURCE[0]}" >&2 + exit 1 +fi + +for literal in "${REQUIRED_LITERALS[@]}"; do + if ! grep -F -q -- "$literal" "$TUTORIAL"; then + printf 'tutorial drift: required literal missing: %s\n' "$literal" >&2 + exit 1 + fi +done + +if ((DRY_RUN)); then + printf 'Extracted %s sh fences; every required literal present.\n' "$fence_count" + exit 0 +fi + +# Resolve the toolset under test. +resolve_profile_dir() { + case "$BUILD_PROFILE" in + ci | release) printf '%s' "$BUILD_PROFILE" ;; + *) + printf 'unsupported tutorial Cargo profile: %s (expected ci or release)\n' \ + "$BUILD_PROFILE" >&2 + exit 1 + ;; + esac +} + +if [[ -z "${EVIDENCE_BIN:-}" || -z "${EVIDENCECTL_BIN:-}" ]]; then + profile_dir="$(resolve_profile_dir)" + (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ + cargo build --locked --profile "$BUILD_PROFILE" \ + -p registry-evidence -p registry-evidencectl) + EVIDENCE_BIN="$TARGET_DIR/$profile_dir/evidence" + EVIDENCECTL_BIN="$TARGET_DIR/$profile_dir/evidencectl" +fi +for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN"; do + if [[ ! -x "$bin" ]]; then + printf 'toolset binary not executable: %s\n' "$bin" >&2 + exit 1 + fi +done + +# The tutorial calls the binaries by name, so serve them from a shim dir. +SHIM_DIR="$WORK_ROOT/bin" +mkdir -p "$SHIM_DIR" +ln -s "$EVIDENCE_BIN" "$SHIM_DIR/evidence" +ln -s "$EVIDENCECTL_BIN" "$SHIM_DIR/evidencectl" + +# Replay the on-machine journey: every fence from RUNNABLE_FROM onward, in +# order, in one shell so `cd` persists exactly as a reader experiences it. +READER_DIR="$WORK_ROOT/reader" +mkdir -p "$READER_DIR" +RUN_SCRIPT="$WORK_ROOT/run.sh" +{ + printf 'set -euo pipefail\n' + for ((i = RUNNABLE_FROM; i <= EXPECTED_SH_FENCES; i++)); do + printf '\nprintf "==> tutorial fence %02d\\n"\n' "$i" + cat "$(printf '%s/fence-%02d.sh' "$FENCE_DIR" "$i")" + done +} >"$RUN_SCRIPT" + +RUN_LOG="$WORK_ROOT/run.log" +if ! (cd "$READER_DIR" && PATH="$SHIM_DIR:$PATH" bash "$RUN_SCRIPT") 2>&1 | + tee "$RUN_LOG"; then + printf 'tutorial execution failed; the transcript ends just before this line\n' >&2 + exit 1 +fi + +for expected in 'PASS: check' 'PASS: fixtures/cases.yaml' '2 passed, 0 failed'; do + if ! grep -F -q -- "$expected" "$RUN_LOG"; then + printf 'tutorial output drift: expected "%s" in the fixtures run output\n' \ + "$expected" >&2 + exit 1 + fi +done diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs new file mode 100644 index 000000000..aededd159 --- /dev/null +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const gate = resolve(scriptDir, 'check-evidence-tutorials.sh'); +const tutorial = resolve( + scriptDir, + '../src/content/docs/tutorials/first-evidence-assertion.mdx', +); + +async function runGate(env = {}) { + try { + const { stdout, stderr } = await execFileAsync('bash', [gate, '--dry-run'], { + env: { ...process.env, ...env }, + }); + return { code: 0, output: `${stdout}${stderr}` }; + } catch (error) { + return { code: error.code ?? 1, output: `${error.stdout}${error.stderr}` }; + } +} + +test('the dry-run gate passes against the published tutorial', async () => { + const { code, output } = await runGate(); + assert.equal(code, 0, output); + assert.match(output, /Extracted 8 sh fences/u); +}); + +test('removing a documented command block fails the drift check', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + try { + const source = await readFile(tutorial, 'utf8'); + const tampered = source.replace( + 'evidencectl fixtures run --project .', + 'evidencectl fixtures run', + ); + assert.notEqual(tampered, source, 'the tampering target must exist'); + const copy = join(workDir, 'tampered.mdx'); + await writeFile(copy, tampered); + const { code, output } = await runGate({ EVIDENCE_TUTORIAL_FILE: copy }); + assert.notEqual(code, 0, 'a missing required literal must fail the gate'); + assert.match(output, /required literal missing/u); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); + +test('changing the fence count fails the drift check', async () => { + const workDir = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + try { + const source = await readFile(tutorial, 'utf8'); + const copy = join(workDir, 'extra-fence.mdx'); + await writeFile(copy, `${source}\n\`\`\`sh\necho extra\n\`\`\`\n`); + const { code, output } = await runGate({ EVIDENCE_TUTORIAL_FILE: copy }); + assert.notEqual(code, 0, 'an added fence must fail the count check'); + assert.match(output, /sh fences found, expected/u); + } finally { + await rm(workDir, { recursive: true, force: true }); + } +}); From 0d9ce4a9860b7c3911a27abe4d7e52ef2c7c78cd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:15:17 +0700 Subject: [PATCH 044/136] feat(ci): run the Evidence tutorials from a clean container A path-gated evidence-tutorials job tests the gate helpers, checks command drift, builds the toolset with the ci profile, and executes the tutorial inside the repository's pinned builder-image digest with the repo mounted read-only and the prebuilt binaries injected, so the run sees only a clean Debian userland plus the toolset. The classifier gains an evidence_tutorial output firing on the gate's inputs and on registry-evidence and registry-evidencectl changes, with routing tests, and the required-results job counts the new job. actionlint clean; classifier suite 29 tests green. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 19 +++++++++ .github/scripts/test_ci_changes.py | 29 +++++++++++++ .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 1ff0b199a..2f65db7d4 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -508,6 +508,24 @@ def classify( or bool(affected & TUTORIAL_PACKAGES) ) + evidence_tutorial = ( + complete + or any( + path + in { + "Cargo.lock", + "Cargo.toml", + "docs/site/package-lock.json", + "docs/site/package.json", + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + } + for path in paths + ) + or bool(affected & EVIDENCE_PACKAGES) + ) + matrix = [] for shard_name, shard_packages in SHARDS.items(): selected = sorted(affected.intersection(shard_packages)) @@ -536,6 +554,7 @@ def classify( "docs_archives": docs_archives, "editors": editors, "registryctl_tutorial": registryctl_tutorial, + "evidence_tutorial": evidence_tutorial, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 4ef3a119f..35dac8430 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -58,6 +58,35 @@ def test_example_pr_runs_only_affected_rust_shards(self) -> None: self.assertTrue(outputs["registryctl_tutorial"]) self.assertFalse(outputs["platform"]) + def test_evidence_tutorial_routing(self) -> None: + infrastructure = ( + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + "docs/site/package.json", + ) + for path in infrastructure: + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["evidence_tutorial"] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidence/src/runtime.rs",))[ + "evidence_tutorial" + ] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidencectl/src/scaffold.rs",))[ + "evidence_tutorial" + ] + ) + self.assertFalse( + classify( + self.workspace, + ("docs/site/src/content/docs/tutorials/author-registry-project.mdx",), + )["evidence_tutorial"] + ) + def test_reverse_dependencies_are_included(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2fb2a4bc..f7b9e65de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} + evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -985,6 +986,69 @@ jobs: target/registryctl-tutorial-source \ 2>/dev/null || true + evidence-tutorials: + name: Evidence tutorials from source + needs: changes + if: needs.changes.outputs.evidence_tutorial == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + submodules: false + + - name: Assert amd64 runner + shell: bash + run: | + set -euo pipefail + [[ "$(uname -m)" == "x86_64" ]] + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: docs/site/package-lock.json + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Test the tutorial gate helpers + working-directory: docs/site + run: npm run test:tutorial:evidence + + - name: Check tutorial command drift + working-directory: docs/site + run: npm run check:tutorial:evidence:dry-run + + - name: Build the Evidence toolset under test + shell: bash + run: | + set -euo pipefail + CARGO_TARGET_DIR="target/evidence-tutorial-source" \ + cargo build --locked --profile ci \ + -p registry-evidence -p registry-evidencectl + + - name: Execute the Evidence tutorials in a clean container + shell: bash + run: | + set -euo pipefail + # The image is the repository's pinned release-builder digest, used + # here only as a clean Debian userland: the gate exercises a shell + # and coreutils, and the toolset binaries are mounted in prebuilt. + docker run --rm \ + --mount "type=bind,src=${PWD},dst=/work,readonly" \ + --env EVIDENCE_BIN=/work/target/evidence-tutorial-source/ci/evidence \ + --env EVIDENCECTL_BIN=/work/target/evidence-tutorial-source/ci/evidencectl \ + rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ + bash /work/docs/site/scripts/check-evidence-tutorials.sh + docs: name: Docs checks needs: changes @@ -1158,6 +1222,7 @@ jobs: - release-tool - release-source-proof - registryctl-tutorials + - evidence-tutorials - docs - editor-extensions runs-on: ubuntu-24.04 From c7c1d4d883e62427af061c8d2dbeb1538cdba4c8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:17:44 +0700 Subject: [PATCH 045/136] docs(site): add the move-Evidence-to-production-signing tutorial Tutorial E6: production key generation with the kid-must-match rule, JWKS assembly with the retired-key retention window, offline validation with evidence check and fixture evaluation, serve and readiness confirmation, the startup-only rotation procedure, the limits of what a signature proves, and troubleshooting. Every rule restates products/evidence/OPERATOR-CONTRACT.md; commands verified against the evidencectl CLI. Site suite 270 tests green; Vale clean. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + .../move-evidence-to-production-signing.mdx | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 9cdd6dcbb..4d21ed840 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -330,6 +330,7 @@ export default defineConfig({ { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, { label: 'Configure Evidence', slug: 'configure/evidence' }, { label: 'Configure Registry Mint', slug: 'configure/mint' }, + { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, ], }, { diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx new file mode 100644 index 000000000..41563e557 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -0,0 +1,150 @@ +--- +title: Move Evidence to production signing +description: Replace scaffold key material with production signing keys, publish the JWKS, validate the deployment offline, and rotate keys without breaking verifiers. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: tutorial +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +Take a working Evidence deployment project from scaffold key material to production signing: +generate the production key, publish the JWKS verifiers read, prove the deployment offline, and +rotate keys later without invalidating assertions that are still within their validity window. +Every rule on this page restates `products/evidence/OPERATOR-CONTRACT.md`, which is the binding +contract. + + + +Key generation here produces real private key material. +Run these commands on the host that will hold the keys, not on a shared workstation. + +## Generate the production signing key + +Generate an Ed25519 signing keypair into the deployment's secret root as owner-only files: + +```sh +evidencectl keygen signing --out-dir "" --kid "" +``` + +The private JWK lands in the secret directory (created `0700`, files `0600`) and the public JWK +beside it; private bytes are never printed. The `kid` must exactly match `signing.activeKeyId` +in `bundle/evidence.yaml`: either pass the identifier your bundle review assigned, or omit +`--kid` and copy the printed RFC 7638 thumbprint into the bundle field. A mismatch is refused +by `evidence check` before it can reach production. + +The operator contract's secret rules apply to everything under the secret root: files are owned +by the service identity with mode `0600`, are regular non-symlink files, and never appear in +YAML values, Rhai, command arguments, logs, audit records, or error messages. The audit and +subject-binding secret files must each hold at least 32 independently generated raw bytes; +`evidencectl keygen secret` produces exactly that. + +## Publish the JWKS + +Assemble the public JWKS document verifiers will read, from public JWK files only: + +```sh +evidencectl jwks --out "" "" +``` + +Pass every key that must remain resolvable, active first, then each retired public key. Two +contract rules shape this file: + +- Only the current public key and configured retired public keys appear at the JWKS endpoint. + A registration carrying private material is refused upstream; keep private JWKs out of every + argument here. +- Each retired public key stays in the published JWKS for at least the maximum assertion + validity plus allowed clock skew, so an assertion signed just before a rotation still + verifies for its whole life. + +The JWKS is discovery, not a trust anchor. Verifiers obtain the provider identity and JWKS +location through governed configuration, pin that trust, allowlist the expected algorithm, and +resolve `kid` only within the trusted key set. Nothing in a message may point them at a remote +key URL. + +## Validate before exposure + +Prove the deployment offline, exactly as startup would judge it: + +```sh +evidence check +evidence evaluate --fixture "" +``` + +Both commands accept `--runtime `, or the same path through +`REGISTRY_EVIDENCE_RUNTIME`; the reference default is `/etc/registry-evidence/runtime.yaml`. +`evidence check` compiles the complete bundle and resolves the mounted audit, subject-binding, +and signing secret material exactly as startup does, without opening the audit chain: a signing +key whose `kid` does not match `signing.activeKeyId` fails here. Source credentials are not +resolved by check; readiness owns them. Fixture evaluation covers positive, negative, boundary, +missing-data, source-failure, existence-disclosure, and anti-reconstruction behavior without a +running source. + +The runtime file, bundle directory, and every captured artifact must be non-writable to the +service process; a read-only mount is preferred. If you rehearsed this in the first-assertion +tutorial, the same freeze applies in production. + +## Serve and confirm readiness + +Start the reviewed revision: + +```sh +evidence serve +``` + +Startup confirms the immutable bundle compiled, ownership and path bindings validated, secret +and signing material parsed, and the audit chain opened and verified. Then readiness rechecks +the subject-binding key, the signing provider, the pinned audit sink, and every source +credential; OAuth client-credentials sources perform their bounded token bootstrap against the +configured token endpoint. Confirm `GET /ready` answers 200 before first use: `/health` is +liveness only and answers 200 even when a source credential is missing. Neither startup nor +readiness sends an evidence-data request or probes a source data endpoint. + +## Rotate a signing key + +Rotation is a configuration change, applied through the same startup-only path as everything +else: + +1. Generate the replacement keypair with a new `kid`, using the key generation command from + this page. +2. Reassemble the JWKS with the new public key and every retired public key that is still + within the maximum assertion validity plus allowed clock skew. +3. Update `signing.activeKeyId` in the reviewed bundle to the new `kid`. +4. Run `evidence check`, then restart the service and confirm readiness again. + +Missing or failed signing is fail-closed: the service never releases an unsigned success +response, so a rotation mistake surfaces as refused requests, not as unsigned assertions. + +## What a signature proves + +A valid signature proves that the technical provider controlling the key signed the exact +payload. It does not prove the source fact is true, confer legal notarization, create a +qualified electronic signature, or create a holder credential. Governance establishes the +provider's authority to act for the named legal issuer. + +## Troubleshooting + +| Symptom | Cause and action | +|---|---| +| `evidence check` rejects the signing key | The private JWK's `kid` does not equal `signing.activeKeyId`. Align the bundle field with the generated identifier. | +| Check refuses secret material | A secret file is group- or world-accessible, a symlink, or shorter than 32 raw bytes. Regenerate with `evidencectl keygen` and keep mode `0600`. | +| `deployment input is not immutable` | The bundle or runtime file is writable to the service process. Remove the write bits or mount read-only. | +| `/ready` stays unready while `/health` is 200 | A source credential failed its readiness check. Readiness owns source credentials; check dropped none of them. | + +## Next + +- [Evidence security model](../../security/evidence/) traces these rules to the invariant + matrix and its tests. +- [Configure Evidence](../../configure/evidence/) covers the rest of the deployment project. +- [Evaluate Evidence](../../start/evaluate-evidence/) sizes the operational burden this page + is part of. From 6c40e6fd141960dba2c6561bea68e64e4a7b53da Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:18:09 +0700 Subject: [PATCH 046/136] docs: tick B4 and B6 and record the tutorial-gate design The status log records the gate's container caveat, the F3 dependency for the release-download fences, and that extending the executable gate to further tutorials belongs to B5. Signed-off-by: Jeremi Joslin --- ...tary-retirement-and-evidence-onboarding.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index da0d408db..3c8cca32a 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -71,7 +71,7 @@ gates for its area (see Verification), and committed. existing gate or add one). - [x] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. -- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job +- [x] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job that runs each Evidence tutorial from a clean container with only the prerequisites the tutorial itself documents; Evidence tutorials must pass it to merge. @@ -82,7 +82,7 @@ gates for its area (see Verification), and committed. verify an assertion as a consumer. Adopter outcome for E1: a fresh machine completes it in 15 minutes or less using released binaries installed via F1 (the released-binary form of this gate needs F3). -- [ ] B6. Tutorial E6 (move Evidence to production signing) published, +- [x] B6. Tutorial E6 (move Evidence to production signing) published, derived from `OPERATOR-CONTRACT.md`. - [x] B7. Mint has real docs presence: a Configure page and a reference page. - [ ] B8. Onboarding spine: glossary disambiguates Evidence (product) from @@ -279,3 +279,18 @@ is parallel; B has no upstream dependencies and is the standing priority - 2026-08-03: Session-limit note: three authoring subagents died mid wave (configure page recovered by hand, E1 rewritten by hand, E6 not written). Next unblocked items: B4 gate, E6, then E2-E5. +- 2026-08-03: B4 done. check-evidence-tutorials.sh drift-checks and + replays the first-assertion tutorial's fences verbatim (executed + green locally end to end); its dry-run joins npm run check; a + path-gated evidence-tutorials CI job builds the toolset and executes + the tutorial inside the repo's pinned builder-image digest with the + repo mounted read-only, counted by the required-results job. Caveat + recorded: that image is a full builder userland reused for its pinned + digest; a slimmer pinned base can replace it later. The + release-download fences stay unexecuted until F3. B6 done: the + production-signing tutorial restates OPERATOR-CONTRACT.md (key + generation, JWKS retention window, offline validation, readiness, + rotation, signature limits). Extending the executable gate to E6 and + the future E2-E5 belongs to B5. Full npm run check passed after the + docs wave; one Vale error it surfaced (typographic quotes in the + operator contract lead) fixed at the source. From b5b2e2492623708db52551389203b3497b3915db Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:19:58 +0700 Subject: [PATCH 047/136] docs: record the A1 token-topology outcome Relay embeds no token issuance and verifies inbound callers through configured OIDC with EdDSA support; Mint issues EdDSA tokens and serves a JWKS, so IdP-less Evidence-over-Relay is Relay verifier configuration pointed at Mint's issuer, and the reserved Mint-for-Relay code branch is not triggered. registry-platform-sts turns out to have zero consumers and joins the C4 orphan candidates. The Mint claim-set question moves to A2 and D. Signed-off-by: Jeremi Joslin --- .../notary-retirement-and-evidence-onboarding.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 3c8cca32a..d02ac98c3 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -48,7 +48,7 @@ gates for its area (see Verification), and committed. ### A. Composition proof (Evidence over Relay) -- [ ] A1. Token topology verified and recorded: establish whether Relay +- [x] A1. Token topology verified and recorded: establish whether Relay embeds a client-credentials token endpoint (`registry-platform-sts`) that Evidence's `SourceAuthentication::Oauth2ClientCredentials` can target, or whether IdP-less deployments need Mint to issue for Relay @@ -294,3 +294,16 @@ is parallel; B has no upstream dependencies and is the standing priority the future E2-E5 belongs to B5. Full npm run check passed after the docs wave; one Vale error it surfaced (typographic quotes in the operator contract lead) fixed at the source. +- 2026-08-03: A1 verified and recorded. Relay embeds no token issuance: + registry-platform-sts is Notary-bound token exchange with zero + consumers in the workspace (add it to the C4 orphan candidates). + Relay authenticates inbound callers through configured OIDC (issuer, + audiences, algorithm allowlist, JWKS cache in + crates/registry-relay/src/auth/oidc/, with EdDSA-verifying tests), + and Mint issues EdDSA tokens with configured audiences and serves a + JWKS. IdP-less Evidence-over-Relay therefore points Relay's verifier + configuration at Mint's issuer: deployment configuration, no + security-sensitive Relay change, so the reserved Mint-for-Relay code + branch is not triggered. Open question for A2 and D: whether Mint's + Evidence-shaped claim set satisfies Relay's authorization model, or + whether Mint (not Relay, not Evidence) needs a claim addition. From ae45e890ce3efa5411d29ef6f83e211788d05bb0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:34:26 +0700 Subject: [PATCH 048/136] fix(evidencectl): keep zero bytes out of test-provisioned secrets The scaffold test wrote 32 raw random bytes per secret file, and the runtime rejects secret material containing a NUL byte, so each file failed ~12% of the time and a full run roughly 40%. Regenerate until no byte is zero, exactly as keygen secret does. Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/tests/scaffold.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index 277925e4e..3f44a7561 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -556,8 +556,15 @@ fn provision_secrets(project: &Path) { private_jwk.as_bytes(), ); for name in SECRET_FILES { + // Regenerate until no byte is zero: the runtime rejects secret + // material containing NUL bytes, exactly as `keygen secret` does. let mut material = [0_u8; 32]; - getrandom::fill(&mut material).expect("random secret"); + loop { + getrandom::fill(&mut material).expect("random secret"); + if !material.contains(&0) { + break; + } + } write_secret(&secrets.join(name), &material); } assert_eq!( From 6163b1a6d95753bd9f40e30a2b37310d0a5c698a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:34:26 +0700 Subject: [PATCH 049/136] feat(evidencectl): suggest source configuration from an OpenAPI document evidencectl source suggest turns one OpenAPI operation into draft Evidence source artifacts: the projection allowlist, a closed-subset response schema, a get_path extract skeleton, a facts stub, and a pasteable sources block. An interactive front-end (operation picker, leaf multi-select, bound prompts with provenance-labelled defaults) and the flag-driven front-end share one deterministic core, and every run ends by printing the equivalent fully-flagged command. The tool derives what the document can state, observes optional bounds from a local sample response, and never invents a bound: an unresolved decision is omitted plus a TODO comment, so evidence check rejects the draft until the operator supplies it. The runtime stays the only validator of the closed subset. Security review notes: the drafted acquisition posture is the weakest claim (record-transformed) with an explicit upgrade TODO, never field-projected on the operator's behalf; sample files are read once, size-capped, and only derived numeric bounds ever reach an artifact, pinned by canary tests; no network access anywhere, external $refs rejected; source ids are sanitized so generated file paths cannot escape the project bundle directory; existing files are never overwritten. Signed-off-by: Jeremi Joslin --- Cargo.lock | 171 ++- Cargo.toml | 1 + crates/registry-evidencectl/Cargo.toml | 1 + crates/registry-evidencectl/src/fixtures.rs | 2 +- crates/registry-evidencectl/src/main.rs | 5 + .../registry-evidencectl/src/suggest/emit.rs | 1317 +++++++++++++++++ .../src/suggest/flatten.rs | 211 +++ .../src/suggest/interactive.rs | 290 ++++ .../registry-evidencectl/src/suggest/mod.rs | 570 +++++++ .../src/suggest/narrow.rs | 1032 +++++++++++++ .../src/suggest/openapi.rs | 445 ++++++ .../src/suggest/sample.rs | 171 +++ .../registry-evidencectl/src/suggest/types.rs | 186 +++ .../registry-evidencectl/templates/README.md | 22 + .../tests/fixtures/openapi/escaping.yaml | 20 + .../tests/fixtures/openapi/external-ref.yaml | 14 + .../tests/fixtures/openapi/records-3.0.yaml | 70 + .../tests/fixtures/openapi/records-3.1.json | 42 + .../tests/fixtures/openapi/ref-cycle.yaml | 28 + .../openapi/unsupported-constructs.yaml | 37 + .../fixtures/openapi/unsupported-version.yaml | 10 + .../tests/fixtures/samples/canary.json | 3 + .../tests/fixtures/samples/escaping.json | 4 + .../tests/fixtures/samples/integer-range.json | 7 + .../fixtures/samples/nested-records.json | 15 + .../fixtures/samples/nulls-and-absent.json | 4 + .../tests/fixtures/samples/unicode.json | 4 + .../registry-evidencectl/tests/suggest_e2e.rs | 472 ++++++ .../tests/suggest_emit.rs | 903 +++++++++++ .../tests/suggest_narrow.rs | 955 ++++++++++++ .../tests/suggest_openapi.rs | 385 +++++ .../tests/suggest_sample.rs | 310 ++++ products/evidence/README.md | 10 + 33 files changed, 7713 insertions(+), 4 deletions(-) create mode 100644 crates/registry-evidencectl/src/suggest/emit.rs create mode 100644 crates/registry-evidencectl/src/suggest/flatten.rs create mode 100644 crates/registry-evidencectl/src/suggest/interactive.rs create mode 100644 crates/registry-evidencectl/src/suggest/mod.rs create mode 100644 crates/registry-evidencectl/src/suggest/narrow.rs create mode 100644 crates/registry-evidencectl/src/suggest/openapi.rs create mode 100644 crates/registry-evidencectl/src/suggest/sample.rs create mode 100644 crates/registry-evidencectl/src/suggest/types.rs create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/canary.json create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/escaping.json create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/integer-range.json create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/nested-records.json create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json create mode 100644 crates/registry-evidencectl/tests/fixtures/samples/unicode.json create mode 100644 crates/registry-evidencectl/tests/suggest_e2e.rs create mode 100644 crates/registry-evidencectl/tests/suggest_emit.rs create mode 100644 crates/registry-evidencectl/tests/suggest_narrow.rs create mode 100644 crates/registry-evidencectl/tests/suggest_openapi.rs create mode 100644 crates/registry-evidencectl/tests/suggest_sample.rs diff --git a/Cargo.lock b/Cargo.lock index f1be4f6d9..a7383409f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,7 +85,7 @@ checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", "memchr", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -1082,7 +1082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -1306,6 +1306,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crosswalk-cel" version = "0.2.0" @@ -2821,6 +2846,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -3607,6 +3641,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.13.0", + "crossterm", + "dyn-clone", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "insta" version = "1.48.0" @@ -4146,6 +4196,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.1" @@ -4206,6 +4268,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nix" version = "0.31.3" @@ -5404,6 +5475,7 @@ dependencies = [ "clap", "ed25519-dalek", "getrandom 0.4.3", + "inquire", "registry-platform-crypto", "serde", "serde_json", @@ -6731,6 +6803,27 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7364,7 +7457,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.1", "pin-project-lite", "signal-hook-registry", "socket2", @@ -7799,6 +7892,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -8213,6 +8312,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8240,6 +8348,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8273,6 +8396,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8285,6 +8414,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8297,6 +8432,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8321,6 +8462,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8333,6 +8480,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8345,6 +8498,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8357,6 +8516,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index c4761029b..aa245141e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ repository = "https://github.com/registrystack/registry-stack" unsafe_code = "forbid" [workspace.dependencies] +inquire = { version = "0.7", default-features = false, features = ["crossterm"] } registry-config-report = { path = "crates/registry-config-report", version = "0.16.3" } registry-evidence = { path = "crates/registry-evidence", version = "0.16.3" } registry-language-server = { path = "crates/registry-language-server", version = "0.16.3" } diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 860a67499..11ae68e96 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -21,6 +21,7 @@ base64.workspace = true clap.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +inquire.workspace = true registry-platform-crypto.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-evidencectl/src/fixtures.rs b/crates/registry-evidencectl/src/fixtures.rs index f15c6f276..ccc2762c6 100644 --- a/crates/registry-evidencectl/src/fixtures.rs +++ b/crates/registry-evidencectl/src/fixtures.rs @@ -215,7 +215,7 @@ fn discover_fixtures(bundle_config_path: &Path) -> Result> { /// Resolve the `evidence` binary: an explicit `--evidence-bin`, else /// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. -fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { +pub(crate) fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { if let Some(path) = explicit { if !path.is_file() { bail!("evidence binary not found at {}", path.display()); diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs index a1b1ada69..793d15ebe 100644 --- a/crates/registry-evidencectl/src/main.rs +++ b/crates/registry-evidencectl/src/main.rs @@ -10,6 +10,7 @@ mod fixtures; mod jwks; mod keygen; mod scaffold; +mod suggest; #[derive(Debug, Parser)] #[command( @@ -34,6 +35,9 @@ enum Command { /// Drive the evidence binary across a project's bundle fixtures. #[command(subcommand)] Fixtures(fixtures::FixturesCommand), + /// Author source configuration from external API descriptions. + #[command(subcommand)] + Source(suggest::SourceCommand), } fn main() -> ExitCode { @@ -43,6 +47,7 @@ fn main() -> ExitCode { Command::Jwks(args) => jwks::run(args), Command::New(args) => scaffold::run(args), Command::Fixtures(command) => fixtures::run(command), + Command::Source(command) => suggest::run(command), }; match result { Ok(code) => code, diff --git a/crates/registry-evidencectl/src/suggest/emit.rs b/crates/registry-evidencectl/src/suggest/emit.rs new file mode 100644 index 000000000..9d25ee647 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/emit.rs @@ -0,0 +1,1317 @@ +//! Draft Evidence source artifacts from a narrowed response schema, write +//! them into a deployment project, and verify them against the `evidence` +//! binary. +//! +//! This stage never re-derives anything the earlier pipeline stages already +//! decided: it renders `NarrowOutcome` and the confirmed selection into +//! files, a pasteable source block, and a report. A bound the pipeline could +//! not resolve is never invented here either — it stays an explicit +//! `# TODO(evidencectl):` comment, so `evidence check` keeps rejecting the +//! draft until a human resolves it. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::{bail, Context, Result}; +use serde_json::Value; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, DraftArtifacts, DraftFile, NarrowOutcome, OperationKey, + Provenance, SuggestedBound, +}; + +/// Everything the emit stage needs to draft artifacts for one source. Built +/// from the outputs of the earlier pipeline stages (`openapi`, `flatten`, +/// `sample`, `narrow`); this stage does not consult the OpenAPI document, the +/// sample, or the narrowing heuristics again. +#[derive(Debug, Clone)] +pub struct EmitInputs { + /// Source identifier the caller chose. Names every emitted file and + /// becomes the `sources.` key. + pub source_id: String, + pub operation: OperationKey, + /// Response status code the schema was read from. + pub status: String, + /// Response media type the schema was read from; also the `Accept` + /// header value in the drafted source block. + pub media_type: String, + /// An origin and path prefix derived from the OpenAPI `servers` list, when + /// one could be. `None` falls back to a placeholder origin that needs + /// review. See [`split_server_url`]. + pub base_url_suggestion: Option, + /// Selected projection pointers (extended form), in presentation order. + pub selection: Vec, + pub narrowed: NarrowOutcome, + /// Every bound the closed subset required for this selection, each + /// carrying its suggestion and provenance when the pipeline could derive + /// one. Used to annotate resolved bounds with a `# derived from ...` + /// comment; a need still unresolved is already covered by + /// `narrowed.unresolved` and gets a TODO comment instead. + pub needs: Vec, + /// The OpenAPI document path, echoed back in `equivalent_command`. + pub openapi_path: PathBuf, + /// The sample file path, if one was used, echoed back in + /// `equivalent_command`. + pub sample_path: Option, + /// The deployment project the draft was (or would be) written into, + /// echoed back in `equivalent_command`. + pub project: Option, +} + +/// The outcome of running `evidence check` against a written draft. +/// +/// Bundle-stage failures and secret/runtime-stage failures are distinguished +/// by the runtime's own fixed stderr messages: bundle rejection prints +/// "deployment ..." text, while secret/runtime initialization failure prints +/// "runtime ... initialization failed" — which means the bundle itself was +/// already accepted, only local secret material is missing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CheckClassification { + /// `evidence check` passed outright. + BundleAccepted, + /// The bundle was rejected; `stderr` is the runtime's captured message. + BundleRejected { stderr: String }, + /// The bundle was accepted, but local secret material has not been + /// provisioned yet (expected for a freshly drafted project). + SecretsUnprovisioned, +} + +/// An OpenAPI server URL split into the two places the runtime keeps its +/// parts: `baseUrl` is validated as an origin, so any path the server URL +/// carried belongs on the request path instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerSuggestion { + /// Scheme, host and optional port, with no trailing slash. + pub base_url: String, + /// The path the server URL carried, without a trailing slash, or the + /// empty string when it carried none. + pub path_prefix: String, +} + +/// Splits an OpenAPI server URL into an origin and a path prefix. +/// +/// Returns `None` for anything that does not name one fixed origin: a URL with +/// `{variables}`, a relative URL, or one carrying a query or fragment. The +/// caller falls back to the placeholder origin in that case, because a +/// `baseUrl` the runtime rejects is worse than an obvious placeholder. The +/// origin is not otherwise validated here: the runtime is the validator, and +/// the drafted value carries a review TODO either way. +pub fn split_server_url(url: &str) -> Option { + if url.contains(['{', '}', '?', '#', ' ']) { + return None; + } + let (scheme, rest) = url.split_once("://")?; + if scheme.is_empty() + || !scheme.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) + { + return None; + } + let (authority, path) = match rest.find('/') { + Some(position) => (&rest[..position], &rest[position..]), + None => (rest, ""), + }; + if authority.is_empty() || authority.contains('@') { + return None; + } + let path_prefix = path.trim_end_matches('/'); + Some(ServerSuggestion { + base_url: format!("{scheme}://{authority}"), + path_prefix: path_prefix.to_owned(), + }) +} + +/// The get_path pointer byte-length ceiling the runtime enforces. +const GET_PATH_MAX_BYTES: usize = 256; +/// The get_path pointer segment-count ceiling the runtime enforces. +const GET_PATH_MAX_SEGMENTS: usize = 16; + +/// The two methods an Evidence fixed request admits. A GET source must also +/// forbid the JSON body channel, so the preparation-limit pair below is chosen +/// from the method rather than fixed. +const ADMITTED_METHODS: [&str; 2] = ["GET", "POST"]; + +const PREPARATION_LIMITS_MAX_QUERY_PAIRS: u32 = 8; +const PREPARATION_LIMITS_MAX_QUERY_NAME_BYTES: u32 = 32; +const PREPARATION_LIMITS_MAX_QUERY_VALUE_BYTES: u32 = 256; +const PREPARATION_LIMITS_MAX_JSON_DEPTH: u32 = 8; +const PREPARATION_LIMITS_MAX_COLLECTION_ITEMS: u32 = 16; +const PREPARATION_LIMITS_MAX_STRING_BYTES: u32 = 256; +const PREPARATION_LIMITS_MAX_NORMALIZED_BYTES: u32 = 4096; +const REQUEST_TIMEOUT_MILLISECONDS: u32 = 3000; +const MAXIMUM_RESPONSE_BYTES: u32 = 65536; +const CONCURRENCY_LIMIT: u32 = 8; + +/// Draft the response schema, extract-script skeleton, facts-schema stub, +/// pasteable source block, human report, and equivalent command for one +/// source, from already-decided inputs. +pub fn draft(inputs: &EmitInputs) -> Result { + let method = request_method(&inputs.operation.method)?; + + let mut get_paths = Vec::with_capacity(inputs.selection.len()); + for pointer in &inputs.selection { + let derived = get_path_pointer(pointer).with_context(|| { + format!( + "preparing the extract script for source `{}`", + inputs.source_id + ) + })?; + get_paths.push((pointer.clone(), derived)); + } + + let files = vec![ + DraftFile { + bundle_relative_path: format!("schemas/{}-response.schema.yaml", inputs.source_id), + contents: render_response_schema(inputs), + }, + DraftFile { + bundle_relative_path: format!("adapters/{}-extract.rhai", inputs.source_id), + contents: render_extract_script(inputs, &get_paths), + }, + DraftFile { + bundle_relative_path: format!("schemas/{}-facts.schema.yaml", inputs.source_id), + contents: render_facts_schema(&inputs.source_id), + }, + ]; + + Ok(DraftArtifacts { + files, + source_block: render_source_block(inputs, method), + report: render_report(inputs), + equivalent_command: render_equivalent_command(inputs), + }) +} + +/// Checks the operation's method against the runtime's fixed-request method +/// enumeration, which holds two members. Anything else is refused by name +/// rather than drafted into a source the runtime would reject. +pub fn request_method(method: &str) -> Result<&'static str> { + let upper = method.to_ascii_uppercase(); + ADMITTED_METHODS + .into_iter() + .find(|admitted| *admitted == upper) + .ok_or_else(|| { + anyhow::anyhow!( + "an Evidence fixed request declares method GET or POST; `{method}` is outside \ + that enumeration, so no source can call this operation" + ) + }) +} + +/// The request path the source declares: the OpenAPI operation path with the +/// server URL's path prefix, if any, in front of it. +fn request_path(inputs: &EmitInputs) -> String { + let prefix = inputs + .base_url_suggestion + .as_ref() + .map(|server| server.path_prefix.as_str()) + .unwrap_or_default(); + format!("{prefix}{}", inputs.operation.path) +} + +/// Write every draft file under `/bundle/`, creating parent +/// directories as needed. Refuses to overwrite any existing file: if any +/// target already exists, nothing is written and the error lists every +/// collision so the caller can resolve them all at once. +pub fn write_into_project(project: &Path, files: &[DraftFile]) -> Result> { + let bundle_directory = project.join("bundle"); + let targets: Vec = files + .iter() + .map(|file| bundle_directory.join(&file.bundle_relative_path)) + .collect(); + + let collisions: Vec = targets + .iter() + .filter(|path| path.exists()) + .map(|path| path.display().to_string()) + .collect(); + if !collisions.is_empty() { + bail!( + "refusing to overwrite existing file(s): {}", + collisions.join(", ") + ); + } + + for (file, target) in files.iter().zip(&targets) { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating the directory {}", parent.display()))?; + } + fs::write(target, &file.contents) + .with_context(|| format!("writing {}", target.display()))?; + } + Ok(targets) +} + +/// Run `evidence --runtime /runtime.yaml check` and classify the +/// result. `evidence_bin` resolves the same way as the other `evidencectl` +/// subcommands that shell out to the runtime binary: an explicit path, else +/// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. +pub fn verify(project: &Path, evidence_bin: Option<&Path>) -> Result { + let evidence_bin = crate::fixtures::resolve_evidence_binary(evidence_bin) + .context("resolving the evidence binary")?; + let runtime_path = project.join("runtime.yaml"); + + let output = Command::new(&evidence_bin) + .arg("--runtime") + .arg(&runtime_path) + .arg("check") + .output() + .with_context(|| format!("failed to run {}", evidence_bin.display()))?; + + if output.status.success() { + return Ok(CheckClassification::BundleAccepted); + } + + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + if is_secrets_unprovisioned(&stderr) { + return Ok(CheckClassification::SecretsUnprovisioned); + } + Ok(CheckClassification::BundleRejected { stderr }) +} + +/// True when `stderr` is one of the runtime's fixed +/// "runtime initialization failed" messages, which mean the bundle +/// itself was accepted and only local secret/runtime material is missing. +fn is_secrets_unprovisioned(stderr: &str) -> bool { + let trimmed = stderr.trim(); + trimmed.starts_with("evidence: runtime ") && trimmed.ends_with("initialization failed") +} + +/// Derive a plain RFC 6901 `get_path` pointer from an extended projection +/// pointer by substituting `0` for every `*` wildcard segment, then enforce +/// the runtime's byte-length and segment-count ceilings. +fn get_path_pointer(extended_pointer: &str) -> Result { + let derived = extended_pointer + .split('/') + .map(|segment| if segment == "*" { "0" } else { segment }) + .collect::>() + .join("/"); + + if derived.len() > GET_PATH_MAX_BYTES { + bail!( + "selection pointer {extended_pointer} produces a get_path pointer of {} bytes, exceeding the {GET_PATH_MAX_BYTES}-byte ceiling", + derived.len() + ); + } + let segment_count = derived.split('/').filter(|s| !s.is_empty()).count(); + if segment_count > GET_PATH_MAX_SEGMENTS { + bail!( + "selection pointer {extended_pointer} produces a get_path pointer of {segment_count} segments, exceeding the {GET_PATH_MAX_SEGMENTS}-segment ceiling" + ); + } + Ok(derived) +} + +fn escape_pointer_segment(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} + +/// How a derived bound is described wherever it is reported: in a schema +/// comment, in the report, and in the auto-acceptance notes a flag-driven run +/// prints. One phrasing keeps those three consistent. +/// +/// [`Provenance::Operator`] has no label here, because a value the operator +/// typed was not derived from anything: it is reported separately, and calling +/// it a derivation would misattribute a human decision to the tool. +pub(super) fn provenance_label(provenance: &Provenance) -> Option<&'static str> { + match provenance { + Provenance::Spec => Some("the OpenAPI schema"), + Provenance::Format => Some("its declared format"), + Provenance::Sample => Some("the sample response (widened)"), + Provenance::PageSize => Some("a page-size parameter in the spec"), + Provenance::Operator => None, + } +} + +/// The comment written above a bound the operator chose themselves. +const OPERATOR_CHOICE_COMMENT: &str = "# chosen at the prompt"; + +/// The caution shown wherever a bound the tool guessed is announced. +/// +/// Only a sampled integer earns one. A page of results says how long that page +/// was, but an integer field is usually a counter, and the highest value one +/// response happened to carry is no statement about how high it can climb; +/// under-sizing that ceiling rejects real responses later. Every other +/// derivation reads a stated bound rather than guessing at one. +pub(super) fn review_note(kind: &BoundKind, provenance: &Provenance) -> Option<&'static str> { + match (kind, provenance) { + (BoundKind::IntegerRange, Provenance::Sample) => { + Some("a counter usually needs a more generous ceiling than one response shows") + } + _ => None, + } +} + +/// Re-attributes to the operator every bound whose accepted value differs from +/// what the pipeline suggested, so the draft's comments and the report describe +/// what actually happened. +/// +/// A suggestion adopted unchanged keeps its real provenance: the operator +/// confirming a derivation does not make the derivation theirs. A need the +/// operator answered where nothing was suggested, and one whose suggestion they +/// edited, both become [`Provenance::Operator`] carrying the accepted value. +pub fn attribute_operator_edits( + needs: &mut [BoundNeed], + resolutions: &BTreeMap<(String, BoundKind), BoundValues>, +) { + for need in needs.iter_mut() { + let key = (need.pointer.clone(), need.kind.clone()); + let Some(accepted) = resolutions.get(&key) else { + continue; + }; + if need + .suggestion + .as_ref() + .is_some_and(|suggestion| &suggestion.values == accepted) + { + continue; + } + need.suggestion = Some(SuggestedBound { + values: accepted.clone(), + provenance: Provenance::Operator, + }); + } +} + +/// Tracks which (pointer, kind) bound needs are still unresolved (get a TODO +/// comment) versus resolved with known provenance (get a "derived from" +/// comment), so the schema renderer can annotate each node as it recurses. +struct SchemaAnnotations { + unresolved: BTreeSet<(String, BoundKind)>, + provenance: BTreeMap<(String, BoundKind), Provenance>, +} + +impl SchemaAnnotations { + fn new(narrowed: &NarrowOutcome, needs: &[BoundNeed]) -> Self { + let unresolved: BTreeSet<(String, BoundKind)> = narrowed + .unresolved + .iter() + .map(|need| (need.pointer.clone(), need.kind.clone())) + .collect(); + + let mut provenance = BTreeMap::new(); + for need in needs { + let key = (need.pointer.clone(), need.kind.clone()); + if unresolved.contains(&key) { + continue; + } + if let Some(suggestion) = &need.suggestion { + provenance.insert(key, suggestion.provenance.clone()); + } + } + + Self { + unresolved, + provenance, + } + } + + fn comment_for(&self, pointer: &str, kind: BoundKind) -> Option { + let key = (pointer.to_owned(), kind.clone()); + if self.unresolved.contains(&key) { + return Some(format!( + "# TODO(evidencectl): {pointer} needs {}", + kind.label() + )); + } + self.provenance.get(&key).map(|provenance| { + provenance_label(provenance).map_or_else( + || OPERATOR_CHOICE_COMMENT.to_owned(), + |label| format!("# derived from {label}"), + ) + }) + } +} + +fn push_line(out: &mut String, indent: usize, text: &str) { + for _ in 0..indent { + out.push_str(" "); + } + out.push_str(text); + out.push('\n'); +} + +fn yaml_scalar_string(value: &str) -> String { + quote_if(value, needs_yaml_quoting(value)) +} + +/// A scalar written inside a flow sequence. There `,`, `[`, `]`, `{` and `}` +/// are significant wherever they appear, not only at the start of the scalar: +/// an unquoted `pending, review` would read as two members. +fn yaml_flow_scalar_string(value: &str) -> String { + let quoted = needs_yaml_quoting(value) || value.contains([',', '[', ']', '{', '}']); + quote_if(value, quoted) +} + +fn quote_if(value: &str, quoted: bool) -> String { + if quoted { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } else { + value.to_owned() + } +} + +fn needs_yaml_quoting(value: &str) -> bool { + if value.is_empty() { + return true; + } + if matches!( + value, + "true" | "false" | "null" | "~" | "yes" | "no" | "Yes" | "No" | "TRUE" | "FALSE" | "NULL" + ) { + return true; + } + if value.trim() != value { + return true; + } + if value.parse::().is_ok() { + return true; + } + let first = value.chars().next().expect("checked non-empty above"); + if matches!( + first, + '-' | '?' + | ':' + | ',' + | '[' + | ']' + | '{' + | '}' + | '#' + | '&' + | '*' + | '!' + | '|' + | '>' + | '\'' + | '"' + | '%' + | '@' + | '`' + ) { + return true; + } + // A bare colon or hash is fine in a plain scalar (e.g. `https://...`); it + // is only ambiguous with a mapping key or a comment when followed by + // whitespace, or when a colon ends the scalar entirely. + if value.contains(": ") || value.ends_with(':') || value.contains(" #") { + return true; + } + value.contains('\n') +} + +fn render_key(key: &str) -> String { + yaml_scalar_string(key) +} + +fn render_scalar(value: &Value) -> String { + render_scalar_with(value, yaml_scalar_string) +} + +fn render_flow_scalar(value: &Value) -> String { + render_scalar_with(value, yaml_flow_scalar_string) +} + +fn render_scalar_with(value: &Value, quote: fn(&str) -> String) -> String { + match value { + Value::String(s) => quote(s), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_owned(), + other => other.to_string(), + } +} + +fn render_flow_list(items: &[Value]) -> String { + let rendered: Vec = items.iter().map(render_flow_scalar).collect(); + format!("[{}]", rendered.join(", ")) +} + +/// A `const` value, which the closed subset admits as a scalar or as a +/// sequence (an array node may be pinned to one exact list). +fn render_const_value(value: &Value) -> String { + match value { + Value::Array(items) => render_flow_list(items), + other => render_scalar(other), + } +} + +fn render_type(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => render_flow_list(items), + other => render_scalar(other), + } +} + +fn primary_type_name(object: &serde_json::Map) -> Option<&str> { + match object.get("type") { + Some(Value::String(s)) => Some(s.as_str()), + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .find(|s| *s != "null"), + _ => None, + } +} + +fn bound_kind_of(object: &serde_json::Map) -> Option { + match primary_type_name(object) { + Some("array") => Some(BoundKind::ArrayMaxItems), + Some("integer") => Some(BoundKind::IntegerRange), + Some("string") => Some(BoundKind::StringLength), + _ => None, + } +} + +fn render_response_schema(inputs: &EmitInputs) -> String { + let annotations = SchemaAnnotations::new(&inputs.narrowed, &inputs.needs); + let mut out = String::new(); + push_line( + &mut out, + 0, + &format!( + "# Closed schema for the projected response of {}. The runtime checks it", + inputs.source_id + ), + ); + push_line( + &mut out, + 0, + "# before the extract script runs, so the script maps a response whose shape", + ); + push_line( + &mut out, + 0, + "# it can rely on and never re-checks presence or type by hand.", + ); + push_line(&mut out, 0, "#"); + push_line( + &mut out, + 0, + "# Generated by `evidencectl source suggest`. Every TODO below blocks", + ); + push_line( + &mut out, + 0, + "# `evidence check` until it is resolved by hand; every \"derived from\" comment", + ); + push_line( + &mut out, + 0, + "# states where a bound came from so it can be reviewed rather than trusted", + ); + push_line(&mut out, 0, "# blindly."); + render_schema_node(&inputs.narrowed.schema, "", 0, &annotations, &mut out); + out +} + +fn render_schema_node( + node: &Value, + pointer: &str, + indent: usize, + annotations: &SchemaAnnotations, + out: &mut String, +) { + let Some(object) = node.as_object() else { + return; + }; + if let Some(type_value) = object.get("type") { + push_line(out, indent, &format!("type: {}", render_type(type_value))); + } + match primary_type_name(object) { + Some("object") => { + if let Some(additional) = object.get("additionalProperties") { + push_line( + out, + indent, + &format!("additionalProperties: {}", render_scalar(additional)), + ); + } + if let Some(required) = object.get("required").and_then(Value::as_array) { + push_line( + out, + indent, + &format!("required: {}", render_flow_list(required)), + ); + } + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + push_line(out, indent, "properties:"); + for (key, child) in properties { + let child_pointer = format!("{pointer}/{}", escape_pointer_segment(key)); + if let Some(kind) = child.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for(&child_pointer, kind) { + push_line(out, indent + 1, &comment); + } + } + push_line(out, indent + 1, &format!("{}:", render_key(key))); + render_schema_node(child, &child_pointer, indent + 2, annotations, out); + } + } + } + Some("array") => { + if let Some(min_items) = object.get("minItems") { + push_line( + out, + indent, + &format!("minItems: {}", render_scalar(min_items)), + ); + } + if let Some(max_items) = object.get("maxItems") { + push_line( + out, + indent, + &format!("maxItems: {}", render_scalar(max_items)), + ); + } + if object.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + push_line(out, indent, "uniqueItems: true"); + } + if let Some(const_value) = object.get("const") { + push_line( + out, + indent, + &format!("const: {}", render_const_value(const_value)), + ); + } + if let Some(items) = object.get("items") { + // An array of scalars never reaches the object-properties loop + // below, so a bound demanded of the items node is annotated + // here or nowhere. + let child_pointer = format!("{pointer}/*"); + if let Some(kind) = items.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for(&child_pointer, kind) { + push_line(out, indent, &comment); + } + } + push_line(out, indent, "items:"); + render_schema_node(items, &child_pointer, indent + 1, annotations, out); + } + } + _ => { + for key in ["minimum", "maximum", "minLength", "maxLength"] { + if let Some(value) = object.get(key) { + push_line(out, indent, &format!("{key}: {}", render_scalar(value))); + } + } + if let Some(format_value) = object.get("format") { + push_line( + out, + indent, + &format!("format: {}", render_scalar(format_value)), + ); + } + if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { + push_line( + out, + indent, + &format!("enum: {}", render_flow_list(enum_values)), + ); + } + if let Some(const_value) = object.get("const") { + push_line( + out, + indent, + &format!("const: {}", render_const_value(const_value)), + ); + } + } + } +} + +fn render_facts_schema(source_id: &str) -> String { + let mut out = String::new(); + push_line( + &mut out, + 0, + "# Closed schema for the facts extraction may hand to the derivation for", + ); + push_line( + &mut out, + 0, + &format!("# {source_id}. Extraction output that does not match exactly is"), + ); + push_line(&mut out, 0, "# rejected before any derivation runs."); + push_line(&mut out, 0, "#"); + push_line( + &mut out, + 0, + "# TODO(evidencectl): replace placeholder_fact with the real fact(s) this", + ); + push_line( + &mut out, + 0, + "# source's extract script produces, and give each one real bounds.", + ); + push_line(&mut out, 0, "type: object"); + push_line(&mut out, 0, "additionalProperties: false"); + push_line(&mut out, 0, "required: [placeholder_fact]"); + push_line(&mut out, 0, "properties:"); + push_line( + &mut out, + 1, + "# TODO(evidencectl): rename and bound this placeholder fact.", + ); + push_line(&mut out, 1, "placeholder_fact:"); + push_line(&mut out, 2, "type: string"); + push_line(&mut out, 2, "minLength: 1"); + push_line(&mut out, 2, "maxLength: 256"); + out +} + +/// The commented loop sketch shown beside a selection that crosses an array, +/// as `(indentation, code)` pairs. +/// +/// Every construct in it is one the runtime's Rhai engine actually registers: +/// an array is reached by its own pointer with `get_path`, guarded with +/// `is_missing`, and iterated with `for`. The engine disables ranges, exposes +/// `len` as a property rather than a method, and defines no `string + integer` +/// operator, so a sketch built from any of those would not run if pasted. +/// +/// Each array the pointer crosses is named by its own pointer relative to the +/// element that contains it, so a nested array is reached from the outer +/// element rather than from the response root. +fn loop_sketch(extended_pointer: &str) -> Vec<(usize, String)> { + let parts: Vec<&str> = extended_pointer.split("/*").collect(); + let Some((remainder, arrays)) = parts.split_last() else { + return Vec::new(); + }; + if arrays.is_empty() { + return Vec::new(); + } + + let mut lines = Vec::new(); + let mut indent = 4; + for (depth, array_pointer) in arrays.iter().enumerate() { + let level = depth + 1; + let container = if depth == 0 { + "source_response".to_owned() + } else { + format!("element_{depth}") + }; + lines.push(( + indent, + format!("let items_{level} = get_path({container}, \"{array_pointer}\");"), + )); + lines.push((indent, format!("if !is_missing(items_{level}) {{"))); + indent += 4; + lines.push((indent, format!("for element_{level} in items_{level} {{"))); + indent += 4; + } + + let innermost = arrays.len(); + if remainder.is_empty() { + lines.push(( + indent, + format!("// element_{innermost} is the value at {extended_pointer}"), + )); + } else { + lines.push(( + indent, + format!("let value = get_path(element_{innermost}, \"{remainder}\");"), + )); + lines.push((indent, "// ...".to_owned())); + } + for _ in arrays { + indent -= 4; + lines.push((indent, "}".to_owned())); + indent -= 4; + lines.push((indent, "}".to_owned())); + } + lines +} + +fn render_extract_script(inputs: &EmitInputs, get_paths: &[(String, String)]) -> String { + let mut out = String::new(); + push_line( + &mut out, + 0, + "// Fact extraction skeleton generated by `evidencectl source suggest` for", + ); + push_line( + &mut out, + 0, + &format!("// {} {}.", inputs.operation.method, inputs.operation.path), + ); + push_line( + &mut out, + 0, + "// The response schema has already rejected anything outside its declared", + ); + push_line( + &mut out, + 0, + "// shape, so get_path below never needs a presence or type check beyond", + ); + push_line( + &mut out, + 0, + "// is_missing. What remains is deciding how the selected leaves relate, which", + ); + push_line( + &mut out, + 0, + "// cardinality outcome they mean, and which facts the derivation needs.", + ); + push_line(&mut out, 0, "//"); + push_line( + &mut out, + 0, + "// TODO(evidencectl): decide how this response distinguishes zero, one, and", + ); + push_line( + &mut out, + 0, + "// multiple matches, and return the matching outcome instead of the", + ); + push_line(&mut out, 0, "// unconditional match below."); + push_line(&mut out, 0, "fn extract(source_response, parameters) {"); + + for (index, (extended_pointer, get_path_pointer)) in get_paths.iter().enumerate() { + let variable = format!("leaf_{}", index + 1); + push_line( + &mut out, + 1, + &format!("let {variable} = get_path(source_response, \"{get_path_pointer}\");"), + ); + push_line(&mut out, 1, &format!("if is_missing({variable}) {{")); + push_line( + &mut out, + 2, + &format!("// TODO(evidencectl): decide what an absent {extended_pointer} means here."), + ); + push_line(&mut out, 1, "}"); + if extended_pointer.contains('*') { + push_line( + &mut out, + 1, + &format!("// TODO(evidencectl): {extended_pointer} is under an array; the index 0"), + ); + push_line( + &mut out, + 1, + "// pointer above only reaches the first element. Iterate every element once", + ); + push_line( + &mut out, + 1, + "// the cardinality check above is written, for example:", + ); + for (sketch_indent, code) in loop_sketch(extended_pointer) { + let padding = " ".repeat(sketch_indent); + push_line(&mut out, 1, &format!("// {padding}{code}")); + } + } + out.push('\n'); + } + + push_line(&mut out, 1, "#{outcome: \"match\", facts: #{}}"); + out.push_str("}\n"); + out +} + +fn render_source_block(inputs: &EmitInputs, method: &str) -> String { + let mut out = String::new(); + push_line( + &mut out, + 0, + "# Paste this block under `sources:` in bundle/evidence.yaml, then resolve", + ); + push_line( + &mut out, + 0, + "# every TODO(evidencectl) comment below before running `evidence check`.", + ); + push_line(&mut out, 0, "sources:"); + push_line(&mut out, 1, &format!("{}:", render_key(&inputs.source_id))); + push_line(&mut out, 2, "transport: http-json"); + + match &inputs.base_url_suggestion { + Some(server) => { + push_line( + &mut out, + 2, + "# TODO(evidencectl): confirm this base URL against the intended deployment;", + ); + push_line( + &mut out, + 2, + "# derived from the OpenAPI servers list, which states an origin only here", + ); + push_line( + &mut out, + 2, + "# and any path prefix on the request path below.", + ); + push_line( + &mut out, + 2, + &format!("baseUrl: {}", yaml_scalar_string(&server.base_url)), + ); + } + None => { + push_line( + &mut out, + 2, + "# TODO(evidencectl): replace this placeholder with the source's real origin.", + ); + push_line(&mut out, 2, "baseUrl: https://source.invalid"); + } + } + + // The posture describes the response the source puts on the wire, before + // this deployment projects anything: a local projection narrows what is + // kept, never what was disclosed, so it cannot upgrade the claim. The + // weakest posture is therefore the only honest default. + push_line( + &mut out, + 2, + "# TODO(evidencectl): upgrade to field-projected or source-derived only if the", + ); + push_line( + &mut out, + 2, + "# source's pre-projection response really carries no more than this.", + ); + push_line(&mut out, 2, "posture: record-transformed"); + push_line( + &mut out, + 2, + "# TODO(evidencectl): review authentication; static-bearer is a placeholder.", + ); + push_line( + &mut out, + 2, + "# See CONFIG.md#source-authentication for the other supported kinds. Do not", + ); + push_line(&mut out, 2, "# map OpenAPI security schemes automatically."); + push_line(&mut out, 2, "authentication:"); + push_line(&mut out, 3, "kind: static-bearer"); + push_line( + &mut out, + 3, + &format!("tokenRef: secret:file/{}-bearer-token", inputs.source_id), + ); + push_line(&mut out, 2, "request:"); + push_line(&mut out, 3, &format!("method: {method}")); + let path = request_path(inputs); + if path.contains(['{', '}']) { + // A `path:` admits no braces, so a templated operation path is a + // `pathTemplate:`. Its placeholders need `pathBindings` naming where + // each value comes from, which nothing in the document states. + push_line( + &mut out, + 3, + "# TODO(evidencectl): pathBindings — bind each placeholder in the template", + ); + push_line( + &mut out, + 3, + "# below to a selector input or adapter parameter; the runtime requires one", + ); + push_line(&mut out, 3, "# binding per placeholder and no others."); + push_line( + &mut out, + 3, + &format!("pathTemplate: {}", yaml_scalar_string(&path)), + ); + } else { + push_line(&mut out, 3, &format!("path: {}", yaml_scalar_string(&path))); + } + push_line(&mut out, 3, "fixedHeaders:"); + push_line(&mut out, 4, "- name: Accept"); + push_line( + &mut out, + 5, + &format!("value: {}", yaml_scalar_string(&inputs.media_type)), + ); + push_line( + &mut out, + 3, + "# TODO(evidencectl): selectorInputs — copy the shape from", + ); + push_line( + &mut out, + 3, + "# templates/bundle/evidence.yaml (sources.source-a.request.selectorInputs)", + ); + push_line( + &mut out, + 3, + "# and name this source's real selector profile and fields.", + ); + push_line( + &mut out, + 3, + "# TODO(evidencectl): prepareScript — author this script from", + ); + push_line( + &mut out, + 3, + "# templates/bundle/adapters/source-a-prepare.rhai.", + ); + push_line( + &mut out, + 3, + "# TODO(evidencectl): adapterParameters and adapterParametersSchema — copy the", + ); + push_line( + &mut out, + 3, + "# shape from templates/bundle/evidence.yaml and", + ); + push_line( + &mut out, + 3, + "# templates/bundle/schemas/adapter-parameters.schema.yaml.", + ); + // The two channels are chosen from the method, not fixed: the runtime + // rejects a GET source whose JSON body channel is anything but forbidden, + // and rejects any source that forbids both. Only the limits belonging to + // the usable channel are stated. + push_line(&mut out, 3, "preparationLimits:"); + if method == "GET" { + push_line(&mut out, 4, "query: required"); + push_line(&mut out, 4, "jsonBody: forbidden"); + push_line( + &mut out, + 4, + &format!("maximumQueryPairs: {PREPARATION_LIMITS_MAX_QUERY_PAIRS}"), + ); + push_line( + &mut out, + 4, + &format!("maximumQueryNameBytes: {PREPARATION_LIMITS_MAX_QUERY_NAME_BYTES}"), + ); + push_line( + &mut out, + 4, + &format!("maximumQueryValueBytes: {PREPARATION_LIMITS_MAX_QUERY_VALUE_BYTES}"), + ); + } else { + push_line(&mut out, 4, "query: forbidden"); + push_line(&mut out, 4, "jsonBody: required"); + push_line( + &mut out, + 4, + &format!("maximumJsonDepth: {PREPARATION_LIMITS_MAX_JSON_DEPTH}"), + ); + push_line( + &mut out, + 4, + &format!("maximumCollectionItems: {PREPARATION_LIMITS_MAX_COLLECTION_ITEMS}"), + ); + push_line( + &mut out, + 4, + &format!("maximumStringBytes: {PREPARATION_LIMITS_MAX_STRING_BYTES}"), + ); + } + push_line( + &mut out, + 4, + &format!("maximumNormalizedBytes: {PREPARATION_LIMITS_MAX_NORMALIZED_BYTES}"), + ); + let selection_values: Vec = inputs + .selection + .iter() + .cloned() + .map(Value::String) + .collect(); + push_line( + &mut out, + 3, + &format!("projection: {}", render_flow_list(&selection_values)), + ); + push_line(&mut out, 3, "redirects: deny"); + push_line( + &mut out, + 3, + &format!("timeoutMilliseconds: {REQUEST_TIMEOUT_MILLISECONDS}"), + ); + push_line( + &mut out, + 3, + &format!("maximumResponseBytes: {MAXIMUM_RESPONSE_BYTES}"), + ); + push_line( + &mut out, + 3, + &format!("concurrencyLimit: {CONCURRENCY_LIMIT}"), + ); + push_line( + &mut out, + 2, + &format!( + "responseSchema: schemas/{}-response.schema.yaml", + inputs.source_id + ), + ); + push_line( + &mut out, + 2, + &format!("extractScript: adapters/{}-extract.rhai", inputs.source_id), + ); + push_line( + &mut out, + 2, + &format!("factSchema: schemas/{}-facts.schema.yaml", inputs.source_id), + ); + out +} + +fn render_report(inputs: &EmitInputs) -> String { + let mut out = String::new(); + out.push_str(&format!( + "evidencectl source suggest: draft for source `{}` ({} {})\n\n", + inputs.source_id, inputs.operation.method, inputs.operation.path + )); + + let unresolved_keys: BTreeSet<(String, BoundKind)> = inputs + .narrowed + .unresolved + .iter() + .map(|need| (need.pointer.clone(), need.kind.clone())) + .collect(); + + let mut derived_lines = Vec::new(); + let mut chosen_lines = Vec::new(); + for need in &inputs.needs { + let key = (need.pointer.clone(), need.kind.clone()); + if unresolved_keys.contains(&key) { + continue; + } + let Some(suggestion) = &need.suggestion else { + continue; + }; + let note = review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")); + match provenance_label(&suggestion.provenance) { + Some(label) => derived_lines.push(format!( + " - {} ({}): derived from {label}{note}", + need.pointer, + need.kind.label() + )), + None => chosen_lines.push(format!(" - {} ({})", need.pointer, need.kind.label())), + } + } + if derived_lines.is_empty() { + out.push_str("Derived automatically: none.\n\n"); + } else { + out.push_str("Derived automatically:\n"); + for line in &derived_lines { + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + if !chosen_lines.is_empty() { + out.push_str("Chosen at the prompt:\n"); + for line in &chosen_lines { + out.push_str(line); + out.push('\n'); + } + out.push('\n'); + } + + out.push_str(&format!( + "Still needs your input ({} schema bound(s), plus the source block below):\n", + inputs.narrowed.unresolved.len() + )); + for need in &inputs.narrowed.unresolved { + out.push_str(&format!( + " - TODO(evidencectl): {} needs {}\n", + need.pointer, + need.kind.label() + )); + } + out.push_str(" - TODO(evidencectl): sources..request selectorInputs, prepareScript,\n"); + out.push_str( + " adapterParameters, and adapterParametersSchema in the pasted source block.\n", + ); + out.push_str( + " - TODO(evidencectl): review authentication and baseUrl in the pasted source block.\n\n", + ); + + out.push_str("Next steps:\n"); + out.push_str(&format!( + " 1. Resolve every TODO(evidencectl) comment in schemas/{}-response.schema.yaml,\n adapters/{}-extract.rhai, schemas/{}-facts.schema.yaml, and the pasted source block.\n", + inputs.source_id, inputs.source_id, inputs.source_id + )); + out.push_str(" 2. Paste the source block under `sources:` in bundle/evidence.yaml.\n"); + out.push_str(" 3. Run `evidence check --runtime /runtime.yaml`.\n"); + out.push_str( + "\nUntil step 2 is done `evidence check` fails, naming every drafted file, with\n\ + `deployment artifact closure is invalid`: the bundle now carries artifacts\n\ + evidence.yaml does not declare yet. That error is the remaining to-do list, not a\n\ + broken draft.\n", + ); + out +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// Quotes one argument of the reproduce command for a POSIX shell. +/// +/// The reproduce line is meant to be pasted, and a projection pointer carries +/// `*`: an interactive zsh expands it, and aborts the whole command when it +/// matches nothing. Single quotes are used because they suppress every +/// expansion; the only character they cannot carry is the single quote itself, +/// which is spliced in as `'\''`. +fn shell_quote(value: &str) -> String { + const SHELL_SPECIAL: [char; 19] = [ + '*', '?', '[', ']', '{', '}', '\n', '"', '\'', '\\', '|', '&', ';', '<', '>', '(', ')', + '~', '#', + ]; + let unsafe_value = + value.is_empty() || value.chars().any(char::is_whitespace) || value.contains(SHELL_SPECIAL); + if !unsafe_value { + return value.to_owned(); + } + format!("'{}'", value.replace('\'', r"'\''")) +} + +fn render_equivalent_command(inputs: &EmitInputs) -> String { + let mut parts: Vec = vec![ + "evidencectl".to_owned(), + "source".to_owned(), + "suggest".to_owned(), + ]; + + parts.push("--openapi".to_owned()); + parts.push(shell_quote(&path_display(&inputs.openapi_path))); + parts.push("--operation".to_owned()); + parts.push(shell_quote(&format!( + "{} {}", + inputs.operation.method, inputs.operation.path + ))); + parts.push("--status".to_owned()); + parts.push(shell_quote(&inputs.status)); + parts.push("--media-type".to_owned()); + parts.push(shell_quote(&inputs.media_type)); + if let Some(sample) = &inputs.sample_path { + parts.push("--sample".to_owned()); + parts.push(shell_quote(&path_display(sample))); + } + parts.push("--source-id".to_owned()); + parts.push(inputs.source_id.clone()); + if let Some(project) = &inputs.project { + parts.push("--project".to_owned()); + parts.push(shell_quote(&path_display(project))); + } + for pointer in &inputs.selection { + parts.push("--select".to_owned()); + parts.push(shell_quote(pointer)); + } + + parts.join(" ") +} diff --git a/crates/registry-evidencectl/src/suggest/flatten.rs b/crates/registry-evidencectl/src/suggest/flatten.rs new file mode 100644 index 000000000..130fb7e35 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/flatten.rs @@ -0,0 +1,211 @@ +//! Flattens a fully-resolved response schema into candidate projection +//! leaves. +//! +//! Every leaf pointer is in the extended projection form ADAPTER-API.md +//! defines: RFC 6901 segments (`~0`/`~1` escaped) for object members and the +//! reserved segment `*` for "every array element". Constructs the closed +//! schema subset cannot express as a selectable leaf (a real union via +//! `oneOf`/`anyOf`, an `allOf` merging more than one schema, an +//! `additionalProperties` schema, an untyped node, a pointer past the depth +//! limit) are skipped with a warning rather than failing the whole spec: one +//! exotic node should not block drafting from the rest of the operation. + +use serde_json::Value; + +use super::types::{CandidateLeaf, ResolvedSchema}; + +/// The extended-pointer form shares `get_path`'s 16-segment ceiling (see +/// `primitive-library.yaml`), so a projection pointer this stage produces +/// never needs truncating again once `*` is substituted for a numeric index. +const MAX_POINTER_SEGMENTS: usize = 16; + +/// Flattens `schema` into its selectable leaves plus warnings for any +/// skipped, unsupported node. A leaf's `pointer` selects one scalar value +/// (or, through a `*` segment, every occurrence of one scalar value inside +/// an array); containers (objects, arrays) are never themselves leaves. +pub fn candidate_leaves(schema: &ResolvedSchema) -> (Vec, Vec) { + let mut leaves = Vec::new(); + let mut warnings = Vec::new(); + walk(&schema.0, String::new(), 0, &mut leaves, &mut warnings); + (leaves, warnings) +} + +fn walk( + node: &Value, + pointer: String, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + let Some(object) = node.as_object() else { + warnings.push(format!( + "schema at `{}` is not an object node; skipped", + display_pointer(&pointer) + )); + return; + }; + + if object.contains_key("oneOf") || object.contains_key("anyOf") { + warnings.push(format!( + "unsupported oneOf/anyOf union at `{}`; skipped", + display_pointer(&pointer) + )); + return; + } + if let Some(members) = object.get("allOf").and_then(Value::as_array) { + // A single-member allOf is the common code-generation idiom for + // attaching shared metadata via $ref (already inlined by this + // point); it is equivalent to its one member. Anything wider is a + // real merge this stage does not attempt to compute. + return if members.len() == 1 { + walk(&members[0], pointer, depth, leaves, warnings) + } else { + warnings.push(format!( + "unsupported allOf with {} members at `{}`; skipped", + members.len(), + display_pointer(&pointer) + )); + }; + } + + let Some((base_type, nullable)) = resolve_type(object) else { + warnings.push(format!( + "schema at `{}` has no single supported type (missing `type`, or a multi-type union beyond `[T, \"null\"]`); skipped", + display_pointer(&pointer) + )); + return; + }; + + match base_type.as_str() { + "object" => walk_object(object, &pointer, depth, leaves, warnings), + "array" => walk_array(object, &pointer, depth, leaves, warnings), + scalar => leaves.push(CandidateLeaf { + pointer, + type_label: type_label(scalar, object), + nullable, + description: object + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + }), + } +} + +fn walk_object( + object: &serde_json::Map, + pointer: &str, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + if object + .get("additionalProperties") + .is_some_and(Value::is_object) + { + warnings.push(format!( + "unsupported additionalProperties schema at `{}`; unnamed extra members cannot be named as projection pointers", + display_pointer(pointer) + )); + } + if object.contains_key("patternProperties") { + warnings.push(format!( + "unsupported patternProperties at `{}`; skipped", + display_pointer(pointer) + )); + } + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + warnings.push(format!( + "object at `{}` has no declared properties; nothing to select", + display_pointer(pointer) + )); + return; + }; + if depth >= MAX_POINTER_SEGMENTS { + warnings.push(format!( + "pointer depth limit ({MAX_POINTER_SEGMENTS} segments) reached at `{}`; not descending further", + display_pointer(pointer) + )); + return; + } + for (member_name, member_schema) in properties { + let child_pointer = format!("{pointer}/{}", escape_pointer_segment(member_name)); + walk(member_schema, child_pointer, depth + 1, leaves, warnings); + } +} + +fn walk_array( + object: &serde_json::Map, + pointer: &str, + depth: usize, + leaves: &mut Vec, + warnings: &mut Vec, +) { + let Some(items) = object.get("items") else { + warnings.push(format!( + "array at `{}` has no `items` schema; skipped", + display_pointer(pointer) + )); + return; + }; + if depth >= MAX_POINTER_SEGMENTS { + warnings.push(format!( + "pointer depth limit ({MAX_POINTER_SEGMENTS} segments) reached at `{}`; not descending further", + display_pointer(pointer) + )); + return; + } + walk(items, format!("{pointer}/*"), depth + 1, leaves, warnings); +} + +/// Resolves a schema node's JSON Schema `type` to `(base type, nullable)`. +/// Accepts a bare string type, or the closed subset's only admitted union: +/// an array pairing exactly one non-`null` type with `"null"`. Any other +/// shape (missing `type`, or a genuine multi-type union) is not represented +/// and yields `None`. +fn resolve_type(object: &serde_json::Map) -> Option<(String, bool)> { + match object.get("type") { + Some(Value::String(type_name)) => Some((type_name.clone(), false)), + Some(Value::Array(type_names)) => { + let mut non_null = Vec::new(); + let mut has_null = false; + for entry in type_names { + match entry.as_str()? { + "null" => has_null = true, + other => non_null.push(other), + } + } + if non_null.len() == 1 { + Some((non_null[0].to_string(), has_null)) + } else { + None + } + } + _ => None, + } +} + +/// A human label for a leaf's type: `string (date)` when a string carries a +/// `format`, otherwise the bare JSON Schema type name. +fn type_label(base_type: &str, object: &serde_json::Map) -> String { + if base_type == "string" { + if let Some(format) = object.get("format").and_then(Value::as_str) { + return format!("string ({format})"); + } + } + base_type.to_string() +} + +/// Escapes an object member name into one RFC 6901 pointer segment: `~` and +/// `/` are escaped in that order (escaping `/` first would double-escape a +/// `~` produced by escaping a literal `~`). +fn escape_pointer_segment(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(root)" + } else { + pointer + } +} diff --git a/crates/registry-evidencectl/src/suggest/interactive.rs b/crates/registry-evidencectl/src/suggest/interactive.rs new file mode 100644 index 000000000..e2af43efd --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/interactive.rs @@ -0,0 +1,290 @@ +//! The interactive front-end of `evidencectl source suggest`. +//! +//! Every prompt the tool can raise lives in this module, so the pipeline in +//! `mod.rs` and the stages beneath it stay promptless and deterministic: the +//! front-end only turns the pipeline's own findings into questions and turns +//! the answers back into the same values a fully-flagged run would have +//! supplied. Nothing here derives a bound, validates a schema, or writes a +//! file. +//! +//! A prompt is only ever raised when [`is_interactive`] holds, which requires +//! both standard input and standard output to be terminals. A run that is +//! piped, redirected, or driven by CI therefore fails with a message naming +//! the flags it needs rather than blocking on a question nobody can answer. + +use std::{collections::BTreeMap, io::IsTerminal}; + +use anyhow::{anyhow, Result}; +use inquire::{ + error::InquireError, + validator::{MinLengthValidator, Validation}, + Confirm, CustomUserError, MultiSelect, Select, Text, +}; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, CandidateLeaf, DraftFile, OperationKey, OperationSummary, + Provenance, +}; + +/// The longest leaf description shown beside a candidate pointer. A schema +/// description can run to paragraphs; the list stays readable instead. +const DESCRIPTION_BUDGET: usize = 60; + +/// True when both standard input and standard output are terminals, which is +/// what an inquire prompt needs to draw itself and read an answer. +pub fn is_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +/// Ask which operation the source calls. +pub fn choose_operation(operations: &[OperationSummary]) -> Result { + let labels: Vec = operations.iter().map(operation_label).collect(); + let chosen = Select::new("Which operation does this source call?", labels) + .with_help_message("type to filter, arrows to move, enter to select") + .raw_prompt() + .map_err(prompt_error)?; + Ok(operations[chosen.index].key.clone()) +} + +/// Ask which response leaves the projection allowlist should carry. +/// +/// Nothing is preselected: the projection is the data-minimization boundary, +/// so every field is chosen deliberately. At least one leaf is required, +/// because a source with an empty projection reads nothing. +pub fn choose_leaves(leaves: &[CandidateLeaf]) -> Result> { + let labels: Vec = leaves.iter().map(leaf_label).collect(); + let chosen = MultiSelect::new("Which response fields does this source need?", labels) + .with_validator(MinLengthValidator::new(1)) + .with_help_message( + "space to toggle, enter to confirm; select the smallest set the derivation needs", + ) + .raw_prompt() + .map_err(prompt_error)?; + Ok(chosen + .into_iter() + .map(|option| leaves[option.index].pointer.clone()) + .collect()) +} + +/// Ask for every bound the closed subset demands and the specification does +/// not state. +/// +/// A derived suggestion is offered as an editable value with its provenance in +/// the help line, never adopted silently. Clearing the value skips the +/// decision, which leaves an explicit TODO in the draft that `evidence check` +/// rejects until a human resolves it. +pub fn resolve_bounds(needs: &[BoundNeed]) -> Result> { + let mut resolutions = BTreeMap::new(); + for need in needs { + if let Some(values) = ask_bound(need)? { + resolutions.insert((need.pointer.clone(), need.kind.clone()), values); + } + } + Ok(resolutions) +} + +fn ask_bound(need: &BoundNeed) -> Result> { + let message = format!("{} for {}", need.kind.label(), need.pointer); + let help = match &need.suggestion { + Some(suggestion) => format!( + "suggested from {}{}; edit it, or clear the line to leave a TODO for review", + provenance_phrase(&suggestion.provenance), + super::emit::review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")) + ), + None => format!( + "nothing in the specification or the sample implies one; enter {} or leave it empty to leave a TODO", + input_shape(&need.kind) + ), + }; + let initial = need + .suggestion + .as_ref() + .map(|suggestion| render_bound(&suggestion.values)); + + let kind = need.kind.clone(); + let mut prompt = Text::new(&message).with_help_message(&help).with_validator( + move |input: &str| -> Result { + Ok(match parse_bound(&kind, input) { + Ok(_) => Validation::Valid, + Err(message) => Validation::Invalid(message.into()), + }) + }, + ); + if let Some(initial) = &initial { + prompt = prompt.with_initial_value(initial); + } + + let answer = prompt.prompt().map_err(prompt_error)?; + parse_bound(&need.kind, &answer).map_err(|message| anyhow!("{message}")) +} + +/// Ask for the source identifier, offering `default_id` as an editable value. +pub fn choose_source_id(default_id: &str) -> Result { + let answer = Text::new("Identifier for this source?") + .with_help_message("names the generated files and the `sources.` key") + .with_initial_value(default_id) + .with_validator(|input: &str| -> Result { + Ok(match super::validate_source_id(input) { + Ok(_) => Validation::Valid, + Err(error) => Validation::Invalid(format!("{error}").into()), + }) + }) + .prompt() + .map_err(prompt_error)?; + super::validate_source_id(&answer) +} + +/// Show the files a draft would create and ask whether to write them. +pub fn confirm_write(project_display: &str, files: &[DraftFile]) -> Result { + eprintln!("These files will be written into {project_display}:"); + for file in files { + eprintln!(" bundle/{}", file.bundle_relative_path); + } + Confirm::new("Write them?") + .with_default(true) + .with_help_message("nothing existing is ever overwritten; a collision stops the write") + .prompt() + .map_err(prompt_error) +} + +/// `METHOD /path — summary`, the one-line form an operation is chosen by. +fn operation_label(operation: &OperationSummary) -> String { + let mut label = format!("{} {}", operation.key.method, operation.key.path); + if let Some(summary) = &operation.summary { + label.push_str(" — "); + label.push_str(&truncate(summary)); + } + label +} + +/// `pointer type (nullable) — description`, the one-line form a leaf is +/// chosen by. +fn leaf_label(leaf: &CandidateLeaf) -> String { + let mut label = format!("{} {}", leaf.pointer, leaf.type_label); + if leaf.nullable { + label.push_str(" (nullable)"); + } + if let Some(description) = &leaf.description { + label.push_str(" — "); + label.push_str(&truncate(description)); + } + label +} + +/// One line of `text`, cut to the description budget on a character boundary. +fn truncate(text: &str) -> String { + let single_line = text.split('\n').next().unwrap_or(text).trim(); + if single_line.chars().count() <= DESCRIPTION_BUDGET { + return single_line.to_owned(); + } + let kept: String = single_line.chars().take(DESCRIPTION_BUDGET).collect(); + format!("{kept}…") +} + +fn provenance_phrase(provenance: &Provenance) -> &'static str { + match provenance { + Provenance::Spec => "the OpenAPI schema", + Provenance::Format => "the declared format", + Provenance::Sample => "the sample response, widened", + Provenance::PageSize => "a page-size parameter in the spec", + Provenance::Operator => "your own answer", + } +} + +/// The text form of a bound value, which is also the editable initial value +/// of its prompt. +fn render_bound(values: &BoundValues) -> String { + match values { + BoundValues::MaxItems(maximum) => maximum.to_string(), + BoundValues::IntegerRange { minimum, maximum } => format!("{minimum} {maximum}"), + BoundValues::StringLength { + min_length, + max_length, + } => format!("{min_length} {max_length}"), + } +} + +/// What the operator is asked to type for a bound with no suggestion. +fn input_shape(kind: &BoundKind) -> &'static str { + match kind { + BoundKind::ArrayMaxItems => "a maximum item count", + BoundKind::IntegerRange => "a minimum and a maximum, separated by a space", + BoundKind::StringLength => "a minimum and a maximum length, separated by a space", + } +} + +/// Parses one bound answer. An empty answer is a deliberate skip, not an +/// error: it leaves the bound unresolved and the draft rejected until a human +/// supplies it. The message in `Err` is shown inline by the prompt validator, +/// so the operator can correct the answer without restarting. +fn parse_bound(kind: &BoundKind, input: &str) -> std::result::Result, String> { + let fields: Vec<&str> = input + .split([' ', ',', '\t']) + .filter(|field| !field.is_empty()) + .collect(); + if fields.is_empty() { + return Ok(None); + } + match kind { + BoundKind::ArrayMaxItems => { + let [maximum] = fields[..] else { + return Err("enter one maximum item count".to_owned()); + }; + let maximum = parse_unsigned(maximum)?; + Ok(Some(BoundValues::MaxItems(maximum))) + } + BoundKind::IntegerRange => { + let [minimum, maximum] = fields[..] else { + return Err("enter a minimum and a maximum, separated by a space".to_owned()); + }; + let minimum = parse_signed(minimum)?; + let maximum = parse_signed(maximum)?; + if minimum > maximum { + return Err("the minimum must not exceed the maximum".to_owned()); + } + Ok(Some(BoundValues::IntegerRange { minimum, maximum })) + } + BoundKind::StringLength => { + let [minimum, maximum] = fields[..] else { + return Err("enter a minimum and a maximum length, separated by a space".to_owned()); + }; + let min_length = parse_unsigned(minimum)?; + let max_length = parse_unsigned(maximum)?; + if min_length > max_length { + return Err("the minimum length must not exceed the maximum length".to_owned()); + } + Ok(Some(BoundValues::StringLength { + min_length, + max_length, + })) + } + } +} + +fn parse_unsigned(field: &str) -> std::result::Result { + field + .parse::() + .map_err(|_| format!("`{field}` is not a whole number of zero or more")) +} + +fn parse_signed(field: &str) -> std::result::Result { + field + .parse::() + .map_err(|_| format!("`{field}` is not a whole number")) +} + +/// Turns an inquire failure into an actionable error. Cancelling a prompt is +/// an ordinary outcome of an interactive session, not a defect, so it reports +/// what did not happen rather than a library error. +fn prompt_error(error: InquireError) -> anyhow::Error { + match error { + InquireError::OperationCanceled | InquireError::OperationInterrupted => { + anyhow!("cancelled at a prompt; nothing was written") + } + InquireError::NotTTY => { + anyhow!("this run has no terminal to prompt on; pass --operation and --select instead") + } + other => anyhow::Error::new(other).context("reading a prompt answer"), + } +} diff --git a/crates/registry-evidencectl/src/suggest/mod.rs b/crates/registry-evidencectl/src/suggest/mod.rs new file mode 100644 index 000000000..3f07b280a --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/mod.rs @@ -0,0 +1,570 @@ +//! `evidencectl source suggest`: turn one OpenAPI operation into draft +//! Evidence source artifacts. +//! +//! The tool derives what the specification can state, asks (or accepts flags) +//! for what it cannot, and never invents a bound: every unresolved decision +//! is emitted as an explicit TODO that `evidence check` rejects, so the +//! runtime stays the only validator of the closed schema subset. The +//! interactive mode is a thin front-end over the same deterministic pipeline +//! and ends by printing the equivalent fully-flagged command. + +pub mod emit; +pub mod flatten; +pub mod interactive; +pub mod narrow; +pub mod openapi; +pub mod sample; +pub mod types; + +use std::{collections::BTreeMap, path::Path, process::ExitCode}; + +use anyhow::{bail, Result}; +use clap::{Args, Subcommand}; + +use emit::{CheckClassification, EmitInputs}; +use openapi::Spec; +use types::{ + BoundKind, BoundNeed, BoundValues, CandidateLeaf, Decisions, DraftArtifacts, Observations, + OperationKey, OperationSummary, Provenance, SuggestedBound, +}; + +#[derive(Debug, Subcommand)] +pub enum SourceCommand { + /// Suggest source configuration from an OpenAPI document. + Suggest(SuggestArgs), +} + +#[derive(Debug, Args)] +pub struct SuggestArgs { + /// OpenAPI 3.0 or 3.1 document (YAML or JSON, local file only). + #[arg(long)] + pub openapi: std::path::PathBuf, + + /// Operation as "METHOD /path/template"; interactive selection if absent. + #[arg(long)] + pub operation: Option, + + /// Response status code to read the schema from. + #[arg(long, default_value = "200")] + pub status: String, + + /// Response media type to read the schema from. + #[arg(long, default_value = "application/json")] + pub media_type: String, + + /// Projection pointer to select; repeat once per leaf. Interactive + /// selection if absent. + #[arg(long = "select")] + pub selection: Vec, + + /// Sample response JSON file used to suggest bounds. Read only; nothing + /// from it is copied into any artifact except derived bounds. + #[arg(long)] + pub sample: Option, + + /// Source identifier for the generated artifacts. + #[arg(long)] + pub source_id: Option, + + /// Deployment project to write the draft into; print-only if absent. + #[arg(long)] + pub project: Option, + + /// Verify a written draft by running `evidence check` with this binary. + /// Verification is opt-in: a project being drafted into is normally + /// neither frozen nor provisioned yet, and `check` reports that state + /// rather than anything about the draft. + #[arg(long)] + pub evidence_bin: Option, +} + +pub fn run(command: SourceCommand) -> Result { + match command { + SourceCommand::Suggest(args) => suggest(args), + } +} + +/// The largest `maxItems` the closed schema subset admits, and therefore the +/// ceiling any page-size-derived suggestion is clamped to. +const MAX_PROJECTED_ITEMS: i64 = 256; + +/// The identifier used when an operation's path yields no usable one. +const FALLBACK_SOURCE_ID: &str = "source-a"; + +/// Run one drafting pass. +/// +/// The pipeline is the same in both front-ends and runs in one order: +/// load the document, pick the operation, resolve its response schema, +/// flatten it into candidate leaves, take a selection, observe an optional +/// sample, plan the bounds the closed subset still demands, resolve those +/// bounds, then narrow, draft, and either write or print. Only the two +/// resolution steps differ: prompts when a terminal is driving the run, +/// announced auto-acceptance of the pipeline's own suggestions when flags +/// are. Both produce the same [`Decisions`], and the printed equivalent +/// command reproduces either run exactly, because every suggestion is +/// derived deterministically from the same inputs. +fn suggest(args: SuggestArgs) -> Result { + let spec = Spec::load(&args.openapi)?; + let operations = spec.operations(); + if operations.is_empty() { + bail!( + "{} declares no operation with a JSON response schema; there is nothing to draft from", + args.openapi.display() + ); + } + + let flag_driven = args.operation.is_some() && !args.selection.is_empty(); + if !flag_driven && !interactive::is_interactive() { + bail!(missing_flags_message(&args)); + } + + let summary = match &args.operation { + Some(text) => find_operation(&parse_operation(text)?, &operations)?, + None => { + let key = interactive::choose_operation(&operations)?; + find_operation(&key, &operations)? + } + }; + if !summary + .json_responses + .iter() + .any(|(status, media_type)| *status == args.status && *media_type == args.media_type) + { + bail!( + "{} {} declares no `{}` `{}` response schema; it declares {}", + summary.key.method, + summary.key.path, + args.status, + args.media_type, + describe_responses(&summary.json_responses) + ); + } + let operation = summary.key.clone(); + + let schema = spec.response_schema(&operation, &args.status, &args.media_type)?; + let (leaves, warnings) = flatten::candidate_leaves(&schema); + for warning in &warnings { + eprintln!("evidencectl: {warning}"); + } + if leaves.is_empty() { + bail!( + "the `{}` `{}` response schema of {} {} has no selectable leaf; \ + nothing above can be projected", + args.status, + args.media_type, + operation.method, + operation.path + ); + } + + let selection = if args.selection.is_empty() { + interactive::choose_leaves(&leaves)? + } else { + check_selection(&args.selection, &leaves)?; + args.selection.clone() + }; + + let observations = match &args.sample { + Some(path) => sample::observe(&sample::load_sample(path)?, &selection)?, + None => Observations::default(), + }; + + let plan = narrow::plan_advisories(&schema, &selection, &observations)?; + for advisory in &plan.advisories { + eprintln!("evidencectl: {}", advisory.message()); + } + let mut needs = with_page_size_fallback(plan.needs, &spec, &operation)?; + + let resolutions = if flag_driven { + accept_suggestions(&needs) + } else { + interactive::resolve_bounds(&needs)? + }; + // An operator who typed over a suggestion owns that bound now, and the + // draft must not keep crediting the source the rejected number came from. + emit::attribute_operator_edits(&mut needs, &resolutions); + + let source_id = match &args.source_id { + Some(id) => validate_source_id(id)?, + None => { + let derived = default_source_id(&operation.path); + if flag_driven { + eprintln!("evidencectl: naming this source `{derived}`, from the operation path"); + derived + } else { + interactive::choose_source_id(&derived)? + } + } + }; + + let decisions = Decisions { + operation, + status: args.status.clone(), + media_type: args.media_type.clone(), + source_id, + selection, + resolutions, + }; + + let narrowed = narrow::apply(&schema, &decisions.selection, &decisions.resolutions)?; + let inputs = EmitInputs { + source_id: decisions.source_id.clone(), + operation: decisions.operation.clone(), + status: decisions.status.clone(), + media_type: decisions.media_type.clone(), + // Only the first server is a candidate: a later entry is usually a + // sandbox or a mirror, and quietly drafting against one because the + // primary carries template variables would point the source elsewhere. + base_url_suggestion: spec + .servers() + .into_iter() + .next() + .and_then(|url| emit::split_server_url(&url)), + selection: decisions.selection.clone(), + narrowed, + needs, + openapi_path: args.openapi.clone(), + sample_path: args.sample.clone(), + project: args.project.clone(), + }; + let artifacts = emit::draft(&inputs)?; + + let code = match &args.project { + Some(project) => deliver_into_project( + project, + &artifacts, + args.evidence_bin.as_deref(), + flag_driven, + )?, + None => { + print_draft(&artifacts); + ExitCode::SUCCESS + } + }; + + println!("{}", artifacts.report); + println!("Reproduce this run with:"); + println!(" {}", artifacts.equivalent_command); + Ok(code) +} + +/// Write the draft into a deployment project, then report what happened. +/// +/// The write refuses to replace anything that already exists, so a repeated +/// run never silently discards an edited draft. Verification runs only when +/// the operator supplied a runtime binary to run it with. +fn deliver_into_project( + project: &Path, + artifacts: &DraftArtifacts, + evidence_bin: Option<&Path>, + flag_driven: bool, +) -> Result { + if !project.is_dir() { + bail!( + "deployment project directory {} not found; scaffold one with `evidencectl new` first", + project.display() + ); + } + if !flag_driven + && !interactive::confirm_write(&project.display().to_string(), &artifacts.files)? + { + eprintln!("evidencectl: nothing was written; the draft is printed below instead."); + print_draft(artifacts); + return Ok(ExitCode::SUCCESS); + } + + let written = emit::write_into_project(project, &artifacts.files)?; + for path in &written { + println!("wrote {}", path.display()); + } + print_block( + "the block to paste under `sources:` in bundle/evidence.yaml", + &artifacts.source_block, + ); + + let Some(evidence_bin) = evidence_bin else { + println!( + "not verified: pass --evidence-bin to run `evidence check` once the project is \ + frozen and provisioned." + ); + return Ok(ExitCode::SUCCESS); + }; + match emit::verify(project, Some(evidence_bin))? { + CheckClassification::BundleAccepted => { + println!("evidence check: bundle accepted"); + Ok(ExitCode::SUCCESS) + } + CheckClassification::SecretsUnprovisioned => { + println!( + "evidence check: bundle accepted; deployment secrets not provisioned yet \ + (expected before keygen)" + ); + Ok(ExitCode::SUCCESS) + } + CheckClassification::BundleRejected { stderr } => { + eprintln!("evidence check: bundle rejected"); + eprint!("{stderr}"); + if !stderr.ends_with('\n') { + eprintln!(); + } + Ok(ExitCode::FAILURE) + } + } +} + +/// Print every drafted file, and the pasteable source block, to stdout. +fn print_draft(artifacts: &DraftArtifacts) { + for file in &artifacts.files { + print_block(&file.bundle_relative_path, &file.contents); + } + print_block( + "the block to paste under `sources:` in bundle/evidence.yaml", + &artifacts.source_block, + ); +} + +fn print_block(title: &str, body: &str) { + println!("--- {title} ---"); + print!("{body}"); + if !body.ends_with('\n') { + println!(); + } + println!(); +} + +/// Adopt every suggestion the pipeline derived, announcing each one with its +/// provenance so a flag-driven run is auditable from its own output. A need +/// with no suggestion stays unresolved: nothing here invents a bound. +fn accept_suggestions(needs: &[BoundNeed]) -> BTreeMap<(String, BoundKind), BoundValues> { + let mut resolutions = BTreeMap::new(); + for need in needs { + match &need.suggestion { + Some(suggestion) => { + let derivation = emit::provenance_label(&suggestion.provenance) + .map_or_else(String::new, |label| format!(", derived from {label}")); + let note = emit::review_note(&need.kind, &suggestion.provenance) + .map_or_else(String::new, |note| format!(" ({note})")); + eprintln!( + "evidencectl: adopting {} for `{}`{derivation}{note}", + describe_bound(&suggestion.values), + need.pointer, + ); + resolutions.insert( + (need.pointer.clone(), need.kind.clone()), + suggestion.values.clone(), + ); + } + None => eprintln!( + "evidencectl: nothing implies {} for `{}`; left as a TODO in the draft", + need.kind.label(), + need.pointer + ), + } + } + resolutions +} + +fn describe_bound(values: &BoundValues) -> String { + match values { + BoundValues::MaxItems(maximum) => format!("maxItems {maximum}"), + BoundValues::IntegerRange { minimum, maximum } => { + format!("minimum {minimum} and maximum {maximum}") + } + BoundValues::StringLength { + min_length, + max_length, + } => format!("minLength {min_length} and maxLength {max_length}"), + } +} + +/// Fill in the top-level collection's `maxItems` from the operation's +/// page-size parameters. +/// +/// The narrowing stage sees only the response schema, so it cannot know that a +/// query parameter caps how long a page can be. What that parameter bounds is +/// exactly one array: the collection the operation pages over. It says nothing +/// about an array nested inside a record, so only a need whose pointer crosses +/// no earlier wildcard is eligible; a nested array keeps whatever narrowing +/// derived, including nothing at all. +/// +/// For that one array the stated page size outranks a sample: a page of two +/// records is a fact about the request that was made, while the parameter's +/// maximum is the source's own statement about how long any page can be. A +/// bound taken from the response schema or from a format is stronger still and +/// is left alone. The largest declared maximum is used, because a smaller one +/// bounds only the requests that ask for it, and the result is clamped to the +/// subset ceiling. +fn with_page_size_fallback( + mut needs: Vec, + spec: &Spec, + operation: &OperationKey, +) -> Result> { + let eligible = |need: &BoundNeed| { + need.kind == BoundKind::ArrayMaxItems + && is_top_level_collection(&need.pointer) + && need.suggestion.as_ref().is_none_or(|suggestion| { + matches!( + suggestion.provenance, + Provenance::Sample | Provenance::PageSize + ) + }) + }; + if !needs.iter().any(eligible) { + return Ok(needs); + } + let Some(maximum) = spec + .page_size_maximums(operation)? + .into_iter() + .filter(|maximum| *maximum > 0) + .max() + else { + return Ok(needs); + }; + let clamped = u64::try_from(maximum.min(MAX_PROJECTED_ITEMS)).unwrap_or(1); + for need in needs.iter_mut().filter(|need| eligible(need)) { + need.suggestion = Some(SuggestedBound { + values: BoundValues::MaxItems(clamped), + provenance: Provenance::PageSize, + }); + } + Ok(needs) +} + +/// Whether `pointer` names an array the operation pages over directly, rather +/// than one reached by descending through another array's items. +fn is_top_level_collection(pointer: &str) -> bool { + !pointer.contains("/*") +} + +/// Parses `--operation`, which names one operation as `METHOD /path`. +/// +/// A method outside the runtime's fixed-request enumeration is refused here, +/// naming the two it admits, rather than later as an operation the document +/// "does not declare": the document may well declare it, and the reason it +/// cannot be drafted from is the runtime's, not the document's. +fn parse_operation(text: &str) -> Result { + let mut fields = text.split_whitespace(); + let (Some(method), Some(path), None) = (fields.next(), fields.next(), fields.next()) else { + bail!("--operation reads as \"METHOD /path\", for example \"GET /records\"; got `{text}`"); + }; + Ok(OperationKey { + method: emit::request_method(method)?.to_owned(), + path: path.to_owned(), + }) +} + +fn find_operation<'a>( + key: &OperationKey, + operations: &'a [OperationSummary], +) -> Result<&'a OperationSummary> { + operations + .iter() + .find(|operation| operation.key == *key) + .ok_or_else(|| { + let available = operations + .iter() + .map(|operation| format!(" {} {}", operation.key.method, operation.key.path)) + .collect::>() + .join("\n"); + anyhow::anyhow!( + "this document declares no `{} {}` with a JSON response schema; it declares:\n{available}", + key.method, + key.path + ) + }) +} + +fn describe_responses(responses: &[(String, String)]) -> String { + responses + .iter() + .map(|(status, media_type)| format!("`{status}` `{media_type}`")) + .collect::>() + .join(", ") +} + +/// Rejects a `--select` pointer that names nothing in this response schema, +/// before the narrowing stage reports the same pointer in schema terms. A +/// pointer may name a leaf or a container above one: selecting a container +/// projects its whole subtree. +fn check_selection(selection: &[String], leaves: &[CandidateLeaf]) -> Result<()> { + for pointer in selection { + let prefix = format!("{pointer}/"); + let known = leaves + .iter() + .any(|leaf| leaf.pointer == *pointer || leaf.pointer.starts_with(&prefix)); + if !known { + let available = leaves + .iter() + .map(|leaf| format!(" {}", leaf.pointer)) + .collect::>() + .join("\n"); + bail!("`--select {pointer}` names nothing in this response schema; it offers:\n{available}"); + } + } + Ok(()) +} + +/// Accepts an identifier that is safe as a file name, a YAML key, and a +/// bundle-relative path segment. +fn validate_source_id(candidate: &str) -> Result { + let acceptable = !candidate.is_empty() + && candidate.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + && !candidate.starts_with('-') + && !candidate.ends_with('-'); + if !acceptable { + bail!( + "`{candidate}` is not a usable source identifier: use lowercase letters, digits and \ + inner hyphens, for example `source-a`" + ); + } + Ok(candidate.to_owned()) +} + +/// Derives a source identifier from the last literal segment of the operation +/// path, so `/v1/records/{id}` suggests `records`. A path made only of +/// template parameters yields the neutral fallback. +fn default_source_id(path: &str) -> String { + let sanitized = path + .split('/') + .rfind(|segment| !segment.is_empty() && !segment.starts_with('{')) + .map(|segment| { + segment + .chars() + .map(|character| { + let lowered = character.to_ascii_lowercase(); + if lowered.is_ascii_lowercase() || lowered.is_ascii_digit() { + lowered + } else { + '-' + } + }) + .collect::() + }) + .unwrap_or_default(); + let trimmed = sanitized.trim_matches('-').to_owned(); + if trimmed.is_empty() { + FALLBACK_SOURCE_ID.to_owned() + } else { + trimmed + } +} + +/// The error a run gets when it has neither a terminal to ask on nor the +/// flags that answer the questions. +fn missing_flags_message(args: &SuggestArgs) -> String { + let mut missing: Vec<&str> = Vec::new(); + if args.operation.is_none() { + missing.push("--operation"); + } + if args.selection.is_empty() { + missing.push("--select"); + } + format!( + "this run has no terminal to prompt on, so it needs {} on the command line; \ + run it in a terminal to choose interactively, or pass the missing flags \ + (an interactive run prints the equivalent fully-flagged command)", + missing.join(" and ") + ) +} diff --git a/crates/registry-evidencectl/src/suggest/narrow.rs b/crates/registry-evidencectl/src/suggest/narrow.rs new file mode 100644 index 000000000..1a0c17318 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/narrow.rs @@ -0,0 +1,1032 @@ +//! Narrowing a resolved response schema to the Evidence closed schema subset. +//! +//! The runtime validates a projected response against a bundle-relative +//! `responseSchema` written in the closed Version 1 subset: objects are closed +//! and declare bounded properties, arrays declare `maxItems` in `1..=256`, +//! integers declare both `minimum` and `maximum` or an enumeration or a +//! constant, strings declare `maxLength` or one of the two date formats or an +//! enumeration or a constant, and the only admitted union is the response-role +//! pair `[T, "null"]`. Two relaxations belong to the response role alone: +//! `required` may be a subset of the declared properties, because projection +//! drops a selected leaf the record did not carry, and a node may be null. +//! +//! This module does two things over that subset and nothing else: +//! +//! - [`plan_advisories`] enumerates the bounds the subset demands and the OpenAPI +//! document does not already state, each with the best suggestion the +//! inputs support and the provenance of that suggestion. +//! - [`apply`] prunes the schema to the projection selection and rewrites it +//! into the subset, inserting the bounds a human confirmed and omitting the +//! ones nobody did. +//! +//! The module never invents a bound. An unresolved bound is left out of the +//! emitted schema and reported in [`NarrowOutcome::unresolved`], so the draft +//! fails `evidence check` until an operator supplies it. The runtime stays the +//! only validator of the subset; this module only generates toward it. + +use std::collections::BTreeMap; + +use anyhow::{bail, Context as _, Result}; +use serde_json::{Map as JsonMap, Value}; + +use super::types::{ + BoundKind, BoundNeed, BoundValues, NarrowOutcome, Observations, Observed, Provenance, + ResolvedSchema, SuggestedBound, +}; + +/// The largest `maxItems` the closed subset admits. +const MAX_ITEMS_CEILING: u64 = 256; +/// The largest `maxLength` the closed subset admits. +const MAX_LENGTH_CEILING: u64 = 65_536; +/// The largest number of properties one closed object may declare. +const MAX_PROPERTIES: usize = 64; +/// The largest enumeration the closed subset admits. +const MAX_ENUM_MEMBERS: usize = 256; +/// The byte length of a canonical hyphenated UUID. +const UUID_LENGTH: u64 = 36; +/// The smallest string bound derived from a sample. Below this a bound says +/// more about the one response that was read than about the source. +const SAMPLE_STRING_FLOOR: u64 = 16; +/// The bucket a sampled array length is rounded up to. +const SAMPLE_ARRAY_BUCKET: u64 = 8; +/// The smallest integer maximum derived from a sample, for the same reason as +/// the string floor. +const SAMPLE_INTEGER_FLOOR: u64 = 10; + +/// Everything the narrowing stage can say about a schema before a human +/// decides anything: the bounds the subset still demands, and the findings +/// that are not bound decisions. +#[derive(Debug, Clone, Default)] +pub struct Plan { + /// Bounds the closed subset demands and the specification does not state, + /// in schema order. + pub needs: Vec, + /// Facts an operator should see that are not bound decisions. + pub advisories: Vec, +} + +/// One thing worth telling the operator about a node that is not a missing +/// bound. Advisories never change the emitted schema. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Advisory { + /// The extended projection pointer of the node it concerns. + pub pointer: String, + pub kind: AdvisoryKind, +} + +/// What an [`Advisory`] reports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdvisoryKind { + /// The sample carried an explicit null where the specification does not + /// admit one. The emitted schema follows the specification, so the draft + /// rejects that response until the operator writes the node as the + /// response-role pair `[T, "null"]`. + NullOutsideSpec, + /// A string `format` outside the closed subset was dropped. The subset + /// admits `date` and `date-time` only; every other format survives, if at + /// all, as a length bound. + DroppedFormat(String), +} + +impl Advisory { + /// A one-line explanation, safe to print: it names the pointer and the + /// rule, never a sampled value. + pub fn message(&self) -> String { + match &self.kind { + AdvisoryKind::NullOutsideSpec => format!( + "`{}` was null in the sample but the specification does not admit null; \ + write the node as the pair [T, \"null\"] if the source really reports it", + display_pointer(&self.pointer) + ), + AdvisoryKind::DroppedFormat(format) => format!( + "`{}` declares format `{format}`, which the closed subset does not admit; \ + the subset admits `date` and `date-time` only", + display_pointer(&self.pointer) + ), + } + } +} + +/// Enumerates every bound the closed subset demands of the selected subtrees +/// and the specification does not already satisfy. +/// +/// Contract: +/// +/// - `selection` holds extended projection pointers (`~0`/`~1` escapes, `*` +/// for every array element). Duplicates, ancestor and descendant overlaps, +/// pointers absent from the schema, and numeric array indexes are errors: +/// bundle validation would reject the projection they describe, so they +/// fail here first. +/// - A bound the specification states inside the subset raises no need at all. +/// - Each need carries the best derivable suggestion, by precedence +/// `Spec` > `Format` > `Sample`. `Spec` appears when a stated bound is +/// outside the subset and can be clamped into it; `Format` only for `uuid`, +/// which is a fixed 36-byte string; `Sample` is widened by the policy +/// documented on [`widen_integer_range`], [`widen_string_length`] and +/// [`widen_array_items`]. A need with no derivable value carries none: this +/// module never invents a bound. +/// - Needs are returned in schema order. +/// +/// The returned [`Plan`] also carries the findings that are not bound +/// decisions: an explicit null the specification does not admit, and every +/// string format the closed subset made this stage drop. +pub fn plan_advisories( + schema: &ResolvedSchema, + selection: &[String], + observations: &Observations, +) -> Result { + let selected = build_selection(selection)?; + let mut narrowing = Narrowing { + observations, + resolutions: &[], + needs: Vec::new(), + advisories: Vec::new(), + }; + narrowing.narrow(&schema.0, &selected, "", true)?; + Ok(Plan { + needs: narrowing.needs, + advisories: narrowing.advisories, + }) +} + +/// Produces the closed-subset response schema for the selected subtrees. +/// +/// Contract: +/// +/// - The selection is validated exactly as in [`plan_advisories`]. +/// - The result is pruned to the selected subtrees and the containers needed +/// to reach them. Selecting a container keeps its whole subtree. +/// - Every object is closed with `additionalProperties: false` and declares +/// `required` as the members the specification marks required among the +/// members that were kept. A record reached through `*` therefore requires +/// nothing unless the specification guarantees that member of every record. +/// - Enumerations, constants, `uniqueItems`, in-subset bounds and the `date` +/// and `date-time` formats are carried through. Every other format is +/// dropped, because the subset admits no other. A `[T, "null"]` type pair is +/// preserved. +/// - A bound in `resolutions` is written into the schema after being checked +/// against the subset limits; a bound nobody resolved is omitted entirely +/// and returned in [`NarrowOutcome::unresolved`] in schema order. A +/// resolution matching no need is an error rather than a silent no-op. +/// - Arrays declare `minItems`, from the specification when it states one and +/// `0` otherwise: an empty page survives projection, and saying so +/// constrains nothing. +pub fn apply( + schema: &ResolvedSchema, + selection: &[String], + resolutions: &BTreeMap<(String, BoundKind), BoundValues>, +) -> Result { + let entries: Vec = resolutions + .iter() + .map(|(key, values)| (key.clone(), values.clone())) + .collect(); + apply_entries(schema, selection, &entries) +} + +/// [`apply`] over resolutions held as a slice rather than a map. +/// +/// [`apply`] takes the map form `Decisions` declares and delegates here; this +/// entry point takes the same pairs as a slice, for a caller holding them in +/// schema order rather than keyed. Order is not significant; duplicate keys +/// resolve to the first entry. +pub fn apply_entries( + schema: &ResolvedSchema, + selection: &[String], + resolutions: &[Resolution], +) -> Result { + let selected = build_selection(selection)?; + let observations = Observations::default(); + let mut narrowing = Narrowing { + observations: &observations, + resolutions, + needs: Vec::new(), + advisories: Vec::new(), + }; + let narrowed = narrowing.narrow(&schema.0, &selected, "", true)?; + for (pointer, kind) in resolutions.iter().map(|(key, _)| key) { + if !narrowing + .needs + .iter() + .any(|need| &need.pointer == pointer && &need.kind == kind) + { + bail!( + "the resolved {} for `{}` matches no bound this schema needs; \ + check the pointer against the selection", + kind.label(), + display_pointer(pointer) + ); + } + } + let unresolved = narrowing + .needs + .into_iter() + .filter(|need| resolution(resolutions, &need.pointer, &need.kind).is_none()) + .collect(); + Ok(NarrowOutcome { + schema: narrowed, + unresolved, + }) +} + +/// Rounds a sampled array length up to the next multiple of eight, with eight +/// as the floor, clamped to the subset ceiling of 256. +/// +/// One response is weak evidence of how long a page can be, so the bound is +/// deliberately looser than what was seen. It still has to stay inside the +/// subset, and an operator confirms it before it is written. +fn widen_array_items(observed: u64) -> u64 { + let bucketed = observed + .max(1) + .div_ceil(SAMPLE_ARRAY_BUCKET) + .saturating_mul(SAMPLE_ARRAY_BUCKET); + bucketed.clamp(SAMPLE_ARRAY_BUCKET, MAX_ITEMS_CEILING) +} + +/// Rounds a sampled string byte length up to the next power of two, with 16 as +/// the floor, clamped to the subset ceiling of 65,536. +fn widen_string_length(observed: u64) -> u64 { + let mut widened = SAMPLE_STRING_FLOOR; + while widened < observed && widened < MAX_LENGTH_CEILING { + widened = widened.saturating_mul(2); + } + widened.clamp(SAMPLE_STRING_FLOOR, MAX_LENGTH_CEILING) +} + +/// Widens a sampled integer range outward to round numbers. +/// +/// A non-negative observed minimum is kept at `0`: a count or a total that +/// never went below zero in one sample is not evidence of a floor above it. A +/// negative minimum is widened by half again of its magnitude, rounded away +/// from zero to the next number of the form `{1,2,5} x 10^k`. +/// +/// The maximum is treated far more generously, because a sampled integer is +/// usually a counter and one response says almost nothing about how high it +/// can climb: it becomes the next power of ten at or above twice what was +/// observed, with a floor of ten. Observing 12 therefore suggests 100 and +/// observing 60 suggests 1000. A ceiling that is merely snug around the sample +/// is the bound most likely to reject a legitimate response later, and the +/// operator confirms it before it is written. +fn widen_integer_range(min_observed: i64, max_observed: i64) -> (i64, i64) { + let minimum = if min_observed >= 0 { + 0 + } else { + let magnitude = min_observed.unsigned_abs(); + let widened = round_up_to_round_number(magnitude.saturating_add(magnitude / 2)); + i64::try_from(widened).map_or(i64::MIN, |widened| -widened) + }; + let target = if max_observed <= 0 { + 0 + } else { + max_observed.unsigned_abs().saturating_mul(2) + }; + let maximum = next_power_of_ten(target.max(SAMPLE_INTEGER_FLOOR)); + (minimum, i64::try_from(maximum).unwrap_or(i64::MAX)) +} + +/// The smallest power of ten that is at least `value`. +fn next_power_of_ten(value: u64) -> u64 { + let mut power: u64 = 1; + while power < value { + let Some(next) = power.checked_mul(10) else { + return u64::MAX; + }; + power = next; + } + power +} + +/// The smallest number of the form `{1,2,5} x 10^k` that is at least `value`. +fn round_up_to_round_number(value: u64) -> u64 { + if value <= 1 { + return 1; + } + let mut scale: u64 = 1; + loop { + for step in [1, 2, 5] { + let Some(candidate) = scale.checked_mul(step) else { + return u64::MAX; + }; + if candidate >= value { + return candidate; + } + } + let Some(next) = scale.checked_mul(10) else { + return u64::MAX; + }; + scale = next; + } +} + +/// One segment of an extended projection pointer. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + /// An ordinary object member, already unescaped. + Key(String), + /// The reserved segment `*`, visiting every element of an array. + Wildcard, +} + +/// The selection as a tree. A node either terminates a projection entry, in +/// which case its whole subtree is projected, or names the children selected +/// beneath it. +#[derive(Debug, Default)] +struct Selected { + /// The projection entry terminating here, kept for error messages. + terminal: Option, + children: Vec<(Segment, Selected)>, +} + +impl Selected { + fn child(&self, segment: &Segment) -> Option<&Selected> { + self.children + .iter() + .find_map(|(candidate, node)| (candidate == segment).then_some(node)) + } + + /// Any projection entry terminating at or beneath this node. + fn first_terminal(&self) -> Option<&str> { + if let Some(terminal) = &self.terminal { + return Some(terminal); + } + self.children + .iter() + .find_map(|(_, node)| node.first_terminal()) + } + + /// The entry to name in a message about this node. + fn blamed(&self) -> &str { + self.first_terminal().unwrap_or("(unknown entry)") + } +} + +/// Parses and validates the selection into a tree. +/// +/// Duplicate entries and ancestor/descendant overlaps are rejected here, +/// naming both entries, because bundle validation would reject the projection +/// they describe and a failure at authoring time is cheaper to read. +fn build_selection(selection: &[String]) -> Result { + if selection.is_empty() { + bail!("the projection selection is empty: select at least one response leaf"); + } + let mut root = Selected::default(); + for pointer in selection { + let segments = parse_pointer(pointer)?; + insert_selection(&mut root, &segments, pointer)?; + } + Ok(root) +} + +fn insert_selection(node: &mut Selected, segments: &[Segment], pointer: &str) -> Result<()> { + let Some((head, tail)) = segments.split_first() else { + if let Some(owner) = &node.terminal { + bail!("projection entry `{pointer}` duplicates `{owner}`"); + } + if let Some(descendant) = node.first_terminal() { + bail!( + "projection entries `{pointer}` and `{descendant}` overlap: \ + `{pointer}` already selects every leaf beneath it" + ); + } + node.terminal = Some(pointer.to_owned()); + return Ok(()); + }; + if let Some(owner) = &node.terminal { + bail!( + "projection entries `{owner}` and `{pointer}` overlap: \ + `{owner}` already selects every leaf beneath it" + ); + } + let index = match node + .children + .iter() + .position(|(segment, _)| segment == head) + { + Some(index) => index, + None => { + node.children.push((head.clone(), Selected::default())); + node.children.len() - 1 + } + }; + insert_selection(&mut node.children[index].1, tail, pointer) +} + +/// Splits an extended projection pointer into segments. +fn parse_pointer(pointer: &str) -> Result> { + if pointer.is_empty() { + bail!("a projection entry is empty: an entry names at least one segment, e.g. `/total`"); + } + let Some(body) = pointer.strip_prefix('/') else { + bail!("projection entry `{pointer}` must start with `/`"); + }; + body.split('/') + .map(|raw| parse_segment(raw, pointer)) + .collect() +} + +fn parse_segment(raw: &str, pointer: &str) -> Result { + if raw == "*" { + return Ok(Segment::Wildcard); + } + if raw.is_empty() { + bail!("projection entry `{pointer}` has an empty segment"); + } + let mut decoded = String::with_capacity(raw.len()); + let mut characters = raw.chars(); + while let Some(character) = characters.next() { + if character != '~' { + decoded.push(character); + continue; + } + match characters.next() { + Some('0') => decoded.push('~'), + Some('1') => decoded.push('/'), + _ => bail!( + "projection entry `{pointer}` has an invalid escape: \ + RFC 6901 defines `~0` and `~1` only" + ), + } + } + Ok(Segment::Key(decoded)) +} + +/// Appends one already-decoded member name to a pointer, re-escaping it. +fn child_pointer(parent: &str, key: &str) -> String { + format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1")) +} + +/// Renders a pointer for a message, naming the root rather than printing an +/// empty string. +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(response root)" + } else { + pointer + } +} + +/// One confirmed bound, keyed the way [`Decisions::resolutions`] keys it. +/// +/// [`Decisions::resolutions`]: super::types::Decisions::resolutions +pub type Resolution = ((String, BoundKind), BoundValues); + +/// Looks a resolution up. [`BoundKind`] carries no ordering, so resolutions +/// are scanned rather than searched; a selection is a handful of entries. +fn resolution<'a>( + resolutions: &'a [Resolution], + pointer: &str, + kind: &BoundKind, +) -> Option<&'a BoundValues> { + resolutions + .iter() + .find_map(|((candidate, candidate_kind), values)| { + (candidate == pointer && candidate_kind == kind).then_some(values) + }) +} + +/// One narrowing pass. It emits the pruned schema and collects, in schema +/// order, every bound the subset demands and every advisory. +struct Narrowing<'a> { + observations: &'a Observations, + resolutions: &'a [Resolution], + needs: Vec, + advisories: Vec, +} + +impl Narrowing<'_> { + fn observed(&self, pointer: &str) -> Option<&Observed> { + self.observations.by_pointer.get(pointer) + } + + /// Records a demanded bound and returns the resolution for it, when a + /// human supplied one. + fn demand( + &mut self, + pointer: &str, + kind: BoundKind, + suggestion: Option, + ) -> Option { + let resolved = resolution(self.resolutions, pointer, &kind).cloned(); + self.needs.push(BoundNeed { + pointer: pointer.to_owned(), + kind, + suggestion, + }); + resolved + } + + fn advise(&mut self, pointer: &str, kind: AdvisoryKind) { + self.advisories.push(Advisory { + pointer: pointer.to_owned(), + kind, + }); + } + + fn narrow( + &mut self, + schema: &Value, + selected: &Selected, + pointer: &str, + at_root: bool, + ) -> Result { + let node = schema.as_object().with_context(|| { + format!( + "the schema node at `{}` is not an object; every node in the closed subset is one", + display_pointer(pointer) + ) + })?; + let Some((base, nullable)) = node_type(node, pointer)? else { + // A node with no type but a bounded const is admitted as it is. + reject_descent(selected, pointer)?; + let mut narrowed = JsonMap::new(); + carry(node, "const", &mut narrowed); + return Ok(Value::Object(narrowed)); + }; + if nullable && at_root { + bail!( + "the response schema root is a plain object in the closed subset; \ + the `[T, \"null\"]` pair is not admitted there" + ); + } + if !nullable && self.observed(pointer).is_some_and(|seen| seen.saw_null) { + self.advise(pointer, AdvisoryKind::NullOutsideSpec); + } + let mut narrowed = JsonMap::new(); + narrowed.insert( + "type".to_owned(), + if nullable { + Value::Array(vec![Value::from(base), Value::from("null")]) + } else { + Value::from(base) + }, + ); + match base { + "object" => self.narrow_object(node, selected, pointer, &mut narrowed)?, + "array" => self.narrow_array(node, selected, pointer, &mut narrowed)?, + "string" => { + reject_descent(selected, pointer)?; + self.narrow_string(node, pointer, &mut narrowed)?; + } + "integer" => { + reject_descent(selected, pointer)?; + self.narrow_integer(node, pointer, &mut narrowed)?; + } + "boolean" => { + reject_descent(selected, pointer)?; + carry(node, "enum", &mut narrowed); + carry(node, "const", &mut narrowed); + } + other => bail!( + "the node at `{}` has type `{other}`, which is outside the closed Version 1 \ + subset; that subset admits object, array, string, integer and boolean", + display_pointer(pointer) + ), + } + Ok(Value::Object(narrowed)) + } + + fn narrow_object( + &mut self, + node: &JsonMap, + selected: &Selected, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let properties = node + .get("properties") + .and_then(Value::as_object) + .with_context(|| { + format!( + "the object at `{}` declares no properties; the closed subset needs them", + display_pointer(pointer) + ) + })?; + let whole = selected.terminal.is_some(); + if !whole { + for (segment, child) in &selected.children { + let Segment::Key(key) = segment else { + bail!( + "projection entry `{}` uses `*` at `{}`, which is an object, not an array", + child.blamed(), + display_pointer(pointer) + ); + }; + if !properties.contains_key(key) { + bail!( + "projection entry `{}` names `{key}`, which the response schema does \ + not declare at `{}`", + child.blamed(), + display_pointer(pointer) + ); + } + } + } + let spec_required: Vec<&str> = node + .get("required") + .and_then(Value::as_array) + .map(|values| values.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let mut kept = JsonMap::new(); + let mut required = Vec::new(); + for (key, child_schema) in properties { + let child_selected = if whole { + selected + } else { + match selected.child(&Segment::Key(key.clone())) { + Some(child) => child, + None => continue, + } + }; + let child = child_pointer(pointer, key); + let narrowed_child = self.narrow(child_schema, child_selected, &child, false)?; + if spec_required.contains(&key.as_str()) { + required.push(Value::from(key.clone())); + } + kept.insert(key.clone(), narrowed_child); + } + if kept.is_empty() { + bail!( + "no member of the object at `{}` is selected; a closed object declares at \ + least one property", + display_pointer(pointer) + ); + } + if kept.len() > MAX_PROPERTIES { + bail!( + "the object at `{}` keeps {} members; the closed subset admits at most \ + {MAX_PROPERTIES}", + display_pointer(pointer), + kept.len() + ); + } + narrowed.insert("additionalProperties".to_owned(), Value::Bool(false)); + narrowed.insert("required".to_owned(), Value::Array(required)); + narrowed.insert("properties".to_owned(), Value::Object(kept)); + Ok(()) + } + + fn narrow_array( + &mut self, + node: &JsonMap, + selected: &Selected, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let items = node.get("items").with_context(|| { + format!( + "the array at `{}` does not close its item type; the closed subset needs `items`", + display_pointer(pointer) + ) + })?; + let child_selected = if selected.terminal.is_some() { + selected + } else { + match selected.child(&Segment::Wildcard) { + Some(child) => child, + None => bail!( + "projection entry `{}` addresses the array at `{}` by member name; \ + an array is visited with the reserved segment `*`, and numeric indexes \ + are not projection syntax", + selected.blamed(), + display_pointer(pointer) + ), + } + }; + let spec_minimum = node.get("minItems").and_then(Value::as_u64); + let spec_maximum = node.get("maxItems").and_then(Value::as_u64); + let stated_in_subset = spec_maximum.is_some_and(|maximum| { + (1..=MAX_ITEMS_CEILING).contains(&maximum) && maximum >= spec_minimum.unwrap_or(0) + }); + let maximum = if stated_in_subset { + spec_maximum + } else { + let suggestion = self.array_suggestion(pointer, spec_maximum, spec_minimum); + match self.demand(pointer, BoundKind::ArrayMaxItems, suggestion) { + Some(values) => Some(accept_max_items(&values, pointer)?), + None => None, + } + }; + let minimum = match (spec_minimum, maximum) { + (Some(minimum), Some(maximum)) => minimum.min(maximum), + (Some(minimum), None) => minimum, + (None, _) => 0, + }; + narrowed.insert("minItems".to_owned(), Value::from(minimum)); + if let Some(maximum) = maximum { + narrowed.insert("maxItems".to_owned(), Value::from(maximum)); + } + if node.get("uniqueItems").and_then(Value::as_bool) == Some(true) { + narrowed.insert("uniqueItems".to_owned(), Value::Bool(true)); + } + carry(node, "const", narrowed); + let child = format!("{pointer}/*"); + let narrowed_items = self.narrow(items, child_selected, &child, false)?; + narrowed.insert("items".to_owned(), narrowed_items); + Ok(()) + } + + /// The best `maxItems` the inputs support: a stated bound clamped into the + /// subset, else a widened sample observation, else nothing. + fn array_suggestion( + &self, + pointer: &str, + spec_maximum: Option, + spec_minimum: Option, + ) -> Option { + let floor = spec_minimum.unwrap_or(0).clamp(1, MAX_ITEMS_CEILING); + if let Some(stated) = spec_maximum { + return Some(SuggestedBound { + values: BoundValues::MaxItems(stated.clamp(floor, MAX_ITEMS_CEILING)), + provenance: Provenance::Spec, + }); + } + let observed = self.observed(pointer)?.max_array_items?; + Some(SuggestedBound { + values: BoundValues::MaxItems(widen_array_items(observed).max(floor)), + provenance: Provenance::Sample, + }) + } + + fn narrow_string( + &mut self, + node: &JsonMap, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let format = node.get("format").and_then(Value::as_str); + let dated = matches!(format, Some("date" | "date-time")); + if let Some(dropped) = format.filter(|_| !dated) { + self.advise(pointer, AdvisoryKind::DroppedFormat(dropped.to_owned())); + } + let spec_minimum = node.get("minLength").and_then(Value::as_u64); + let spec_maximum = node.get("maxLength").and_then(Value::as_u64); + let bounded = + spec_maximum.is_some_and(|maximum| (1..=MAX_LENGTH_CEILING).contains(&maximum)); + let enumerated = is_bounded_enum(node, Value::is_string); + let constant = node + .get("const") + .and_then(Value::as_str) + .is_some_and(|value| value.len() as u64 <= MAX_LENGTH_CEILING); + if dated { + if let Some(format) = format { + narrowed.insert("format".to_owned(), Value::from(format)); + } + } + if enumerated { + carry(node, "enum", narrowed); + } + if constant { + carry(node, "const", narrowed); + } + if bounded || dated || enumerated || constant { + if let Some((minimum, maximum)) = spec_minimum.zip(spec_maximum) { + if minimum > 0 && minimum <= maximum { + narrowed.insert("minLength".to_owned(), Value::from(minimum)); + } + } + if let Some(maximum) = spec_maximum.filter(|_| bounded) { + narrowed.insert("maxLength".to_owned(), Value::from(maximum)); + } + return Ok(()); + } + let suggestion = self.string_suggestion(pointer, format, spec_minimum, spec_maximum); + let Some(values) = self.demand(pointer, BoundKind::StringLength, suggestion) else { + return Ok(()); + }; + let (minimum, maximum) = accept_string_length(&values, pointer)?; + if minimum > 0 { + narrowed.insert("minLength".to_owned(), Value::from(minimum)); + } + narrowed.insert("maxLength".to_owned(), Value::from(maximum)); + Ok(()) + } + + /// The best string length the inputs support: a stated bound clamped into + /// the subset, else the fixed length a `uuid` format implies, else a + /// widened sample observation, else nothing. + fn string_suggestion( + &self, + pointer: &str, + format: Option<&str>, + spec_minimum: Option, + spec_maximum: Option, + ) -> Option { + let minimum = spec_minimum.unwrap_or(0); + if spec_maximum.is_some_and(|maximum| maximum > MAX_LENGTH_CEILING) { + return Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: minimum.min(MAX_LENGTH_CEILING), + max_length: MAX_LENGTH_CEILING, + }, + provenance: Provenance::Spec, + }); + } + if format == Some("uuid") { + return Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: UUID_LENGTH, + max_length: UUID_LENGTH, + }, + provenance: Provenance::Format, + }); + } + let observed = self.observed(pointer)?.max_string_bytes?; + let maximum = widen_string_length(observed); + Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: minimum.min(maximum), + max_length: maximum, + }, + provenance: Provenance::Sample, + }) + } + + fn narrow_integer( + &mut self, + node: &JsonMap, + pointer: &str, + narrowed: &mut JsonMap, + ) -> Result<()> { + let spec_minimum = node.get("minimum").and_then(Value::as_i64); + let spec_maximum = node.get("maximum").and_then(Value::as_i64); + let stated = spec_minimum + .zip(spec_maximum) + .filter(|(minimum, maximum)| minimum <= maximum); + let enumerated = is_bounded_enum(node, |value| value.as_i64().is_some()); + let constant = node.get("const").and_then(Value::as_i64).is_some(); + if enumerated { + carry(node, "enum", narrowed); + } + if constant { + carry(node, "const", narrowed); + } + if let Some((minimum, maximum)) = stated { + narrowed.insert("minimum".to_owned(), Value::from(minimum)); + narrowed.insert("maximum".to_owned(), Value::from(maximum)); + return Ok(()); + } + if enumerated || constant { + return Ok(()); + } + let suggestion = self.integer_suggestion(pointer, spec_minimum, spec_maximum); + let Some(values) = self.demand(pointer, BoundKind::IntegerRange, suggestion) else { + return Ok(()); + }; + let (minimum, maximum) = accept_integer_range(&values, pointer)?; + narrowed.insert("minimum".to_owned(), Value::from(minimum)); + narrowed.insert("maximum".to_owned(), Value::from(maximum)); + Ok(()) + } + + /// The best integer range the inputs support. A stated end is kept as it + /// stands and only the missing end is derived from the sample; with no + /// sample and only one stated end there is no suggestion, because the + /// other end would have to be invented. + fn integer_suggestion( + &self, + pointer: &str, + spec_minimum: Option, + spec_maximum: Option, + ) -> Option { + let observed = self.observed(pointer); + let seen_minimum = observed.and_then(|seen| seen.min_integer); + let seen_maximum = observed.and_then(|seen| seen.max_integer); + let widened = seen_minimum + .or(seen_maximum) + .zip(seen_maximum.or(seen_minimum)) + .map(|(minimum, maximum)| widen_integer_range(minimum, maximum)); + let minimum = spec_minimum.or(widened.map(|(minimum, _)| minimum))?; + let maximum = spec_maximum.or(widened.map(|(_, maximum)| maximum))?; + if minimum > maximum { + return None; + } + Some(SuggestedBound { + values: BoundValues::IntegerRange { minimum, maximum }, + provenance: if spec_minimum.is_some() && spec_maximum.is_some() { + Provenance::Spec + } else { + Provenance::Sample + }, + }) + } +} + +/// Reads the one type a node declares, admitting the response-role pair +/// `[T, "null"]`. Returns `None` for a node that declares a bounded const +/// instead of a type. +fn node_type<'node>( + node: &'node JsonMap, + pointer: &str, +) -> Result> { + match node.get("type") { + None => { + if node.get("const").is_some() { + return Ok(None); + } + bail!( + "the node at `{}` declares no type; the closed subset needs one type \ + or one bounded const", + display_pointer(pointer) + ) + } + Some(Value::String(name)) => Ok(Some((name.as_str(), false))), + Some(Value::Array(members)) => match members.as_slice() { + [Value::String(name), Value::String(null)] if null == "null" && name != "null" => { + Ok(Some((name.as_str(), true))) + } + _ => bail!( + "the node at `{}` declares a type union the closed subset does not admit; \ + a response node may write `[T, \"null\"]` and nothing else", + display_pointer(pointer) + ), + }, + Some(_) => bail!( + "the node at `{}` declares a type that is neither a name nor a `[T, \"null\"]` pair", + display_pointer(pointer) + ), + } +} + +/// Fails when a projection entry descends past a leaf. +fn reject_descent(selected: &Selected, pointer: &str) -> Result<()> { + if selected.terminal.is_some() { + return Ok(()); + } + bail!( + "projection entry `{}` descends past the leaf at `{}`", + selected.blamed(), + display_pointer(pointer) + ) +} + +/// Copies one keyword through unchanged when the node declares it. +fn carry(node: &JsonMap, keyword: &str, narrowed: &mut JsonMap) { + if let Some(value) = node.get(keyword) { + narrowed.insert(keyword.to_owned(), value.clone()); + } +} + +/// True when the node declares an enumeration the closed subset admits. +fn is_bounded_enum(node: &JsonMap, member: fn(&Value) -> bool) -> bool { + node.get("enum") + .and_then(Value::as_array) + .is_some_and(|values| { + !values.is_empty() && values.len() <= MAX_ENUM_MEMBERS && values.iter().all(member) + }) +} + +/// Checks a resolved `maxItems` against the subset before it is written. +fn accept_max_items(values: &BoundValues, pointer: &str) -> Result { + let BoundValues::MaxItems(maximum) = values else { + bail!( + "the resolution for `{}` is not a maxItems value, but the array there needs one", + display_pointer(pointer) + ); + }; + if !(1..=MAX_ITEMS_CEILING).contains(maximum) { + bail!( + "the resolved maxItems {maximum} for `{}` is outside the closed subset range \ + 1..={MAX_ITEMS_CEILING}", + display_pointer(pointer) + ); + } + Ok(*maximum) +} + +/// Checks a resolved string length against the subset before it is written. +fn accept_string_length(values: &BoundValues, pointer: &str) -> Result<(u64, u64)> { + let BoundValues::StringLength { + min_length, + max_length, + } = values + else { + bail!( + "the resolution for `{}` is not a string length, but the string there needs one", + display_pointer(pointer) + ); + }; + if *max_length == 0 || *max_length > MAX_LENGTH_CEILING || min_length > max_length { + bail!( + "the resolved string length {min_length}..={max_length} for `{}` is outside the \ + closed subset range 1..={MAX_LENGTH_CEILING}", + display_pointer(pointer) + ); + } + Ok((*min_length, *max_length)) +} + +/// Checks a resolved integer range against the subset before it is written. +fn accept_integer_range(values: &BoundValues, pointer: &str) -> Result<(i64, i64)> { + let BoundValues::IntegerRange { minimum, maximum } = values else { + bail!( + "the resolution for `{}` is not an integer range, but the integer there needs one", + display_pointer(pointer) + ); + }; + if minimum > maximum { + bail!( + "the resolved integer range {minimum}..={maximum} for `{}` is empty", + display_pointer(pointer) + ); + } + Ok((*minimum, *maximum)) +} diff --git a/crates/registry-evidencectl/src/suggest/openapi.rs b/crates/registry-evidencectl/src/suggest/openapi.rs new file mode 100644 index 000000000..8133d35d6 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/openapi.rs @@ -0,0 +1,445 @@ +//! Loads a local OpenAPI 3.0.x or 3.1.x document and resolves the pieces the +//! `source suggest` pipeline needs: operation listings and one operation's +//! response schema with every local `$ref` inlined. +//! +//! Only local files are read and only local `#/components/...` refs are +//! followed. An external or remote `$ref` (anything not starting with `#/`) +//! and a `$ref` cycle are both rejected with a clear error rather than +//! silently truncated or ignored, because a partially-resolved schema would +//! let the closed-subset narrowing stage draft against data the runtime +//! cannot actually see. + +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::Value; + +use super::types::{OperationKey, OperationSummary, ResolvedSchema}; + +/// Path Item Object keys this pipeline can draft a source from. +/// +/// OpenAPI allows eight methods, but an Evidence fixed request declares one of +/// two: the runtime's method enumeration is `GET` and `POST`. Offering any +/// other method would only produce a source the runtime rejects, so the +/// listing is filtered here rather than at the far end of the pipeline. +const OPERATION_METHODS: [&str; 2] = ["get", "post"]; + +/// A loaded, dialect-checked OpenAPI document. +/// +/// `load` accepts OpenAPI 3.0.x and 3.1.x, in YAML or JSON, from a local +/// file. It does not otherwise validate the document against the OpenAPI +/// meta-schema; malformed structure surfaces as an error from whichever +/// accessor first needs the missing or mistyped piece. +#[derive(Debug, Clone)] +pub struct Spec { + document: Value, +} + +impl Spec { + /// Reads and parses the OpenAPI document at `path`. Accepts YAML or + /// JSON (YAML is a superset for this purpose, so both are parsed the + /// same way) and requires a top-level `openapi: 3.0.x` or `3.1.x` + /// version string. No network access is performed; `path` must name a + /// local file. + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading OpenAPI document at {}", path.display()))?; + let document: Value = serde_norway::from_str(&text) + .with_context(|| format!("parsing {} as YAML or JSON", path.display()))?; + let version = document + .get("openapi") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!( + "{} has no top-level `openapi` version string", + path.display() + ) + })?; + if !(version.starts_with("3.0.") || version.starts_with("3.1.")) { + bail!( + "{} declares `openapi: {version}`; only OpenAPI 3.0.x and 3.1.x are supported", + path.display() + ); + } + Ok(Spec { document }) + } + + /// Every path-and-method operation that carries at least one JSON + /// response schema (a response whose media type contains `json`, e.g. + /// `application/json` or `application/problem+json`, and that declares + /// a `schema`). Operations with no JSON response are omitted because + /// they have nothing this pipeline can draft from. + pub fn operations(&self) -> Vec { + let mut out = Vec::new(); + let Some(paths) = self.document.get("paths").and_then(Value::as_object) else { + return out; + }; + for (path, path_item_raw) in paths { + let Ok(path_item) = self.resolve_top_ref(path_item_raw, &mut Vec::new()) else { + continue; + }; + let Some(path_item) = path_item.as_object() else { + continue; + }; + for method in OPERATION_METHODS { + let Some(operation) = path_item.get(method) else { + continue; + }; + let json_responses = self.json_responses(operation); + if json_responses.is_empty() { + continue; + } + out.push(OperationSummary { + key: OperationKey { + method: method.to_ascii_uppercase(), + path: path.clone(), + }, + summary: operation + .get("summary") + .and_then(Value::as_str) + .map(str::to_string), + json_responses, + }); + } + } + out + } + + /// The `(status, media type)` pairs on `operation` whose media type + /// looks like JSON and which declare a response `schema`. + fn json_responses(&self, operation: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + let Some(responses) = operation.get("responses").and_then(Value::as_object) else { + return out; + }; + for (status, response_raw) in responses { + let Ok(response) = self.resolve_top_ref(response_raw, &mut Vec::new()) else { + continue; + }; + let Some(content) = response.get("content").and_then(Value::as_object) else { + continue; + }; + for (media_type, media_object) in content { + if media_type.to_ascii_lowercase().contains("json") + && media_object.get("schema").is_some() + { + out.push((status.clone(), media_type.clone())); + } + } + } + out + } + + /// The response schema for `key`'s `status`/`media_type` response, with + /// every local `$ref` inlined and OpenAPI 3.0 `nullable: true` rewritten + /// to the 3.1 type pair `[T, "null"]`. + pub fn response_schema( + &self, + key: &OperationKey, + status: &str, + media_type: &str, + ) -> Result { + let operation = self.find_operation(key)?; + let responses = operation + .get("responses") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("{} {} declares no `responses`", key.method, key.path))?; + let response_raw = responses + .get(status) + .ok_or_else(|| anyhow!("{} {} has no `{status}` response", key.method, key.path))?; + let response = self + .resolve_top_ref(response_raw, &mut Vec::new()) + .with_context(|| { + format!( + "resolving the `{status}` response of {} {}", + key.method, key.path + ) + })?; + let content = response + .get("content") + .and_then(Value::as_object) + .ok_or_else(|| { + anyhow!( + "{} {} `{status}` response declares no `content`", + key.method, + key.path + ) + })?; + let media_object = content.get(media_type).ok_or_else(|| { + anyhow!( + "{} {} `{status}` response has no `{media_type}` content", + key.method, + key.path + ) + })?; + let schema = media_object.get("schema").ok_or_else(|| { + anyhow!( + "{} {} `{status}` `{media_type}` response declares no `schema`", + key.method, + key.path + ) + })?; + let resolved = self + .inline_schema(schema, &mut Vec::new()) + .with_context(|| { + format!( + "resolving the `{status}` `{media_type}` response schema of {} {}", + key.method, key.path + ) + })?; + Ok(ResolvedSchema(resolved)) + } + + /// Base URLs from the document's top-level `servers` array, in document + /// order. Empty when the document declares none. + pub fn servers(&self) -> Vec { + self.document + .get("servers") + .and_then(Value::as_array) + .map(|servers| { + servers + .iter() + .filter_map(|server| server.get("url").and_then(Value::as_str)) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() + } + + /// Integer `maximum` values found on `key`'s query parameters (path-item + /// level and operation level) whose name contains `page`, `size`, or + /// `limit`, case-insensitively. + /// + /// This is a naming heuristic, not a semantic one: it does not attempt + /// to determine whether a matching parameter actually bounds page size, + /// and it looks only at the parameter's own `schema.maximum`. Later + /// pipeline stages decide whether and how to use the values as + /// `Provenance::PageSize`. + pub fn page_size_maximums(&self, key: &OperationKey) -> Result> { + let paths = self + .document + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("document has no `paths`"))?; + let path_item_raw = paths + .get(&key.path) + .ok_or_else(|| anyhow!("no path `{}` in the document", key.path))?; + let path_item = self.resolve_top_ref(path_item_raw, &mut Vec::new())?; + + let mut maximums = Vec::new(); + if let Some(parameters) = path_item.get("parameters").and_then(Value::as_array) { + self.collect_page_size_maximums(parameters, &mut maximums)?; + } + let operation = self.find_operation(key)?; + if let Some(parameters) = operation.get("parameters").and_then(Value::as_array) { + self.collect_page_size_maximums(parameters, &mut maximums)?; + } + Ok(maximums) + } + + fn collect_page_size_maximums(&self, parameters: &[Value], out: &mut Vec) -> Result<()> { + for parameter_raw in parameters { + let parameter = self.resolve_top_ref(parameter_raw, &mut Vec::new())?; + let Some(name) = parameter.get("name").and_then(Value::as_str) else { + continue; + }; + if parameter.get("in").and_then(Value::as_str) != Some("query") { + continue; + } + let lower = name.to_ascii_lowercase(); + if !(lower.contains("page") || lower.contains("size") || lower.contains("limit")) { + continue; + } + let Some(schema) = parameter.get("schema") else { + continue; + }; + let resolved = self + .inline_schema(schema, &mut Vec::new()) + .with_context(|| format!("resolving the schema of query parameter `{name}`"))?; + if let Some(maximum) = resolved.get("maximum").and_then(Value::as_i64) { + out.push(maximum); + } + } + Ok(()) + } + + /// Finds `key`'s Operation Object, resolving a path-item-level `$ref` if + /// present. + fn find_operation(&self, key: &OperationKey) -> Result<&Value> { + let paths = self + .document + .get("paths") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("document has no `paths`"))?; + let path_item_raw = paths + .get(&key.path) + .ok_or_else(|| anyhow!("no path `{}` in the document", key.path))?; + let path_item = self.resolve_top_ref(path_item_raw, &mut Vec::new())?; + let method = key.method.to_ascii_lowercase(); + path_item + .get(&method) + .ok_or_else(|| anyhow!("path `{}` has no `{}` operation", key.path, key.method)) + } + + /// Follows a chain of `$ref` at the top level of `node` (a Response, + /// Parameter, or Path Item Object, none of which nest further schema + /// keywords the way a Schema Object does) until a non-`$ref` object is + /// reached. Returns `node` unchanged when it carries no `$ref`. + fn resolve_top_ref<'a>( + &'a self, + node: &'a Value, + stack: &mut Vec, + ) -> Result<&'a Value> { + let Some(object) = node.as_object() else { + return Ok(node); + }; + let Some(reference) = object.get("$ref").and_then(Value::as_str) else { + return Ok(node); + }; + let pointer = local_ref_pointer(reference)?; + if stack.iter().any(|seen| seen == reference) { + bail!("$ref cycle detected at `{reference}`"); + } + let target = resolve_pointer(&self.document, pointer) + .with_context(|| format!("resolving $ref `{reference}`"))?; + stack.push(reference.to_string()); + let resolved = self.resolve_top_ref(target, stack)?; + stack.pop(); + Ok(resolved) + } + + /// Recursively inlines every local `$ref` inside a Schema Object and + /// normalizes OpenAPI 3.0 `nullable: true` to the 3.1 type pair. Per + /// OpenAPI 3.0 semantics, a schema node carrying `$ref` has any sibling + /// keywords ignored; this function does the same, uniformly, for + /// simplicity. + fn inline_schema(&self, node: &Value, stack: &mut Vec) -> Result { + let Value::Object(object) = node else { + return Ok(node.clone()); + }; + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + let pointer = local_ref_pointer(reference)?; + if stack.iter().any(|seen| seen == reference) { + bail!("$ref cycle detected at `{reference}`"); + } + let target = resolve_pointer(&self.document, pointer) + .with_context(|| format!("resolving $ref `{reference}`"))? + .clone(); + stack.push(reference.to_string()); + let inlined = self.inline_schema(&target, stack); + stack.pop(); + return inlined; + } + + let mut result = serde_json::Map::with_capacity(object.len()); + for (key, value) in object { + let inlined_value = match key.as_str() { + "properties" => match value.as_object() { + Some(members) => { + let mut properties = serde_json::Map::with_capacity(members.len()); + for (member_name, member_schema) in members { + properties.insert( + member_name.clone(), + self.inline_schema(member_schema, stack)?, + ); + } + Value::Object(properties) + } + None => value.clone(), + }, + "items" | "not" | "additionalProperties" if value.is_object() => { + self.inline_schema(value, stack)? + } + "allOf" | "oneOf" | "anyOf" => { + if let Some(members) = value.as_array() { + let mut inlined_members = Vec::with_capacity(members.len()); + for member in members { + inlined_members.push(self.inline_schema(member, stack)?); + } + Value::Array(inlined_members) + } else { + value.clone() + } + } + _ => value.clone(), + }; + result.insert(key.clone(), inlined_value); + } + normalize_nullable(&mut result); + Ok(Value::Object(result)) + } +} + +/// Rewrites OpenAPI 3.0's `nullable: true` in place to the 3.1-style type +/// pair `[T, "null"]`, removing the `nullable` keyword. A `nullable: true` +/// with no `type` keyword to pair against is left unrepresented (the +/// `nullable` key is still removed): the closed subset this pipeline drafts +/// toward always requires an explicit type, so a later stage rejects the +/// node as untyped rather than this function guessing one. +fn normalize_nullable(object: &mut serde_json::Map) { + let Some(Value::Bool(true)) = object.remove("nullable") else { + return; + }; + match object.get_mut("type") { + Some(Value::String(type_name)) => { + let pair = Value::Array(vec![ + Value::String(type_name.clone()), + Value::String("null".to_string()), + ]); + object.insert("type".to_string(), pair); + } + Some(Value::Array(type_names)) + if !type_names + .iter() + .any(|entry| entry.as_str() == Some("null")) => + { + type_names.push(Value::String("null".to_string())); + } + _ => {} + } +} + +/// Validates that `reference` is a local same-document ref (`#/...` or the +/// whole-document `#`) and returns its JSON Pointer (without the leading +/// `#`). Rejects anything else — a relative or absolute document reference, +/// a URL, or a bare non-pointer fragment — as external or remote. +fn local_ref_pointer(reference: &str) -> Result<&str> { + if reference == "#" { + return Ok(""); + } + if reference.starts_with("#/") { + // Strip only the leading `#`, keeping the `/` that `resolve_pointer` expects. + Ok(&reference[1..]) + } else { + Err(anyhow!( + "external or remote $ref `{reference}` is not supported; only local `#/...` refs are" + )) + } +} + +/// Resolves an RFC 6901 JSON Pointer (without its leading `#`, e.g. +/// `/components/schemas/Record`) against `document`. +fn resolve_pointer<'a>(document: &'a Value, pointer: &str) -> Result<&'a Value> { + let mut current = document; + if pointer.is_empty() { + return Ok(current); + } + for raw_segment in pointer.split('/').skip(1) { + let segment = raw_segment.replace("~1", "/").replace("~0", "~"); + current = match current { + Value::Object(map) => map + .get(&segment) + .ok_or_else(|| anyhow!("no member `{segment}` at this point in the document"))?, + Value::Array(items) => { + let index: usize = segment + .parse() + .map_err(|_| anyhow!("`{segment}` is not a valid array index"))?; + items + .get(index) + .ok_or_else(|| anyhow!("index {index} is out of bounds"))? + } + _ => bail!("cannot descend into a scalar value at `{segment}`"), + }; + } + Ok(current) +} diff --git a/crates/registry-evidencectl/src/suggest/sample.rs b/crates/registry-evidencectl/src/suggest/sample.rs new file mode 100644 index 000000000..05bc7fe01 --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/sample.rs @@ -0,0 +1,171 @@ +//! Raw shape observation from a sample response. +//! +//! `observe` walks a sample JSON document against a selection of extended +//! projection pointers (the same syntax `flatten` produces and the runtime's +//! `request.projection` accepts: RFC 6901 segments with `~0`/`~1` escapes and +//! the reserved segment `*` visiting every array element) and records what +//! shape each selected leaf had: integer extremes, maximum string byte +//! length, maximum array length, and whether an explicit null was seen. This +//! is bookkeeping only; no widening policy and no subset validation happen +//! here. That is the narrow stage's job, using these observations as one +//! input among several (spec bounds and formats outrank a sample). +//! +//! Privacy invariant: no function in this module stores, logs, or returns a +//! sample string *value*. Only its byte length crosses into an `Observed`. +//! The same holds for every other JSON value: only lengths, integer +//! extremes, array lengths, and a null flag are retained. + +use std::{collections::BTreeMap, fs, path::Path}; + +use anyhow::{bail, Context, Result}; +use serde_json::Value; + +use super::types::{Observations, Observed}; + +/// Sample files larger than this are rejected outright: a sample is read +/// only to suggest bounds, never to carry bulk data through this tool. +const MAX_SAMPLE_BYTES: u64 = 4 * 1024 * 1024; + +/// Read and parse a sample response document from `path`. +/// +/// Rejects files over [`MAX_SAMPLE_BYTES`] with a clear message before +/// reading their contents, and rejects a file that is not valid JSON. The +/// parsed value is returned to the caller; this function does not log or +/// otherwise surface any of its contents. +pub fn load_sample(path: &Path) -> Result { + let metadata = fs::metadata(path) + .with_context(|| format!("failed to read sample file metadata at {}", path.display()))?; + if metadata.len() > MAX_SAMPLE_BYTES { + bail!( + "sample file at {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_SAMPLE_BYTES + ); + } + let bytes = fs::read(path) + .with_context(|| format!("failed to read sample file at {}", path.display()))?; + serde_json::from_slice(&bytes) + .with_context(|| format!("sample file at {} is not valid JSON", path.display())) +} + +/// Walk `sample` following each pointer in `selection`, recording raw shape +/// observations keyed by the pointer text exactly as given. +/// +/// A pointer whose path is absent from the sample (a missing key, or a +/// wildcard segment landing on a non-array) is simply left unobserved: that +/// is not an error, since a sample need not exercise every selected leaf. +/// A pointer that is not a well-formed extended JSON Pointer (empty, or not +/// starting with `/`) is a caller error and returns `Err`. +/// +/// Every array visited through a `*` segment is also recorded, keyed by the +/// array's own pointer (the selection prefix up to that segment), holding +/// the largest length seen across every occurrence of that array shape in +/// the sample. A selection pointer that itself resolves to an array (no +/// trailing `*`) is recorded the same way, keyed by the selection pointer. +pub fn observe(sample: &Value, selection: &[String]) -> Result { + let mut by_pointer: BTreeMap = BTreeMap::new(); + for pointer in selection { + observe_one(sample, pointer, &mut by_pointer)?; + } + Ok(Observations { by_pointer }) +} + +/// Parse one extended pointer and walk `root` with it, folding results into +/// `by_pointer`. +fn observe_one( + root: &Value, + pointer: &str, + by_pointer: &mut BTreeMap, +) -> Result<()> { + if pointer.is_empty() || !pointer.starts_with('/') { + bail!( + "projection pointer {pointer:?} must be a non-empty extended JSON Pointer starting with \"/\"" + ); + } + let tokens: Vec<&str> = pointer.split('/').skip(1).collect(); + walk(root, &tokens, String::new(), by_pointer); + Ok(()) +} + +/// Follow `tokens` from `value`, tracking the pointer text already consumed +/// in `prefix` so recorded map keys are built from the same escaped text the +/// caller supplied rather than a re-derived (and possibly different) +/// rendering. +fn walk( + value: &Value, + tokens: &[&str], + prefix: String, + by_pointer: &mut BTreeMap, +) { + match tokens.split_first() { + None => record(value, &prefix, by_pointer), + Some((&"*", rest)) => { + if let Value::Array(items) = value { + // `prefix` here is the pointer to the array itself: the + // selection text consumed so far, before this `*` segment. + record(value, &prefix, by_pointer); + let element_prefix = format!("{prefix}/*"); + for item in items { + walk(item, rest, element_prefix.clone(), by_pointer); + } + } + // A non-array at a `*` segment is an unobserved mismatch, not + // an error: schema/sample disagreement is validated elsewhere. + } + Some((token, rest)) => { + if let Value::Object(members) = value { + let key = unescape_segment(token); + if let Some(child) = members.get(&key) { + let child_prefix = format!("{prefix}/{token}"); + walk(child, rest, child_prefix, by_pointer); + } + } + // A missing key or non-object here is likewise left unobserved. + } + } +} + +/// Fold one visited JSON value into the observation entry at `pointer`, +/// widening extremes but never retaining the value itself. +fn record(value: &Value, pointer: &str, by_pointer: &mut BTreeMap) { + let entry = by_pointer.entry(pointer.to_owned()).or_default(); + match value { + Value::Null => entry.saw_null = true, + Value::Bool(_) | Value::Object(_) => {} + Value::Number(number) => { + if let Some(seen) = number.as_i64() { + entry.min_integer = + Some(entry.min_integer.map_or(seen, |current| current.min(seen))); + entry.max_integer = + Some(entry.max_integer.map_or(seen, |current| current.max(seen))); + } + } + Value::String(text) => { + let bytes = text.len() as u64; + entry.max_string_bytes = Some( + entry + .max_string_bytes + .map_or(bytes, |current| current.max(bytes)), + ); + } + Value::Array(items) => { + let count = items.len() as u64; + entry.max_array_items = Some( + entry + .max_array_items + .map_or(count, |current| current.max(count)), + ); + } + } +} + +/// Decode one RFC 6901 pointer segment: `~1` to `/`, then `~0` to `~`, in +/// that order, so a literal `~1` in a key (encoded `~01`) round-trips. +fn unescape_segment(raw: &str) -> String { + if raw.contains('~') { + raw.replace("~1", "/").replace("~0", "~") + } else { + raw.to_owned() + } +} diff --git a/crates/registry-evidencectl/src/suggest/types.rs b/crates/registry-evidencectl/src/suggest/types.rs new file mode 100644 index 000000000..6fb9e625b --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/types.rs @@ -0,0 +1,186 @@ +//! Interchange types for the source-suggest pipeline. +//! +//! The pipeline is: load and resolve an OpenAPI document, flatten one +//! operation's response schema into candidate leaves, collect a selection and +//! bound decisions (from flags or the interactive prompts), narrow to the +//! closed schema subset, then emit draft artifacts. Every stage communicates +//! through the types here so the interactive front-end and the flag-driven +//! front-end share one deterministic core. + +use std::collections::BTreeMap; + +/// One operation in the OpenAPI document: an uppercase HTTP method and the +/// literal path template, e.g. `GET` and `/records/{id}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationKey { + pub method: String, + pub path: String, +} + +/// One selectable operation, as listed to the user. +#[derive(Debug, Clone)] +pub struct OperationSummary { + pub key: OperationKey, + pub summary: Option, + /// (status code, media type) pairs carrying a JSON response schema. + pub json_responses: Vec<(String, String)>, +} + +/// A response schema with every local `$ref` inlined and the dialect +/// normalized: OpenAPI 3.0 `nullable: true` is rewritten to the 3.1 type +/// pair `[T, "null"]`, so downstream stages handle one form. +#[derive(Debug, Clone)] +pub struct ResolvedSchema(pub serde_json::Value); + +/// One selectable leaf of the resolved schema, presented to the user and +/// mapped one-to-one onto a projection allowlist entry. +/// +/// Pointers are in the extended projection form defined by ADAPTER-API.md: +/// RFC 6901 segments with `~0`/`~1` escapes, and the reserved segment `*` +/// visiting every element of an array (`/results/*/trackingId`). The emit +/// stage derives `get_path` pointers from these by substituting a numeric +/// index for `*`, because `get_path` is plain RFC 6901 and does not accept +/// `*`. +#[derive(Debug, Clone)] +pub struct CandidateLeaf { + /// Extended projection pointer into the projected tree. + pub pointer: String, + /// Human label for the leaf's type, e.g. `string (date)`, `integer`. + pub type_label: String, + /// True when the spec admits an explicit null for this leaf. + pub nullable: bool, + pub description: Option, +} + +/// What kind of bound the closed subset demands and the spec did not supply. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum BoundKind { + /// Arrays must declare `maxItems` between 1 and 256. + ArrayMaxItems, + /// Integers must carry both `minimum` and `maximum` (or enum/const). + IntegerRange, + /// Strings need `minLength`/`maxLength` (or format/enum/const). + StringLength, +} + +/// Where a suggested bound value came from. Shown to the user so a default +/// is confirmed with its provenance, never adopted blind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Provenance { + /// Stated in the OpenAPI document itself. + Spec, + /// Derived mechanically from a `format` the subset does not admit, + /// e.g. `uuid` becoming fixed length bounds. + Format, + /// Observed in the sample response and widened. + Sample, + /// Derived from a page-size parameter in the spec. + PageSize, + /// Chosen by the operator at the prompt, either where nothing could be + /// derived or in place of a suggestion they edited. Nothing the tool + /// derived carries this: it is the one provenance that is not a + /// derivation. + Operator, +} + +/// One concrete bound value with its provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SuggestedBound { + pub values: BoundValues, + pub provenance: Provenance, +} + +/// The value shape per bound kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BoundValues { + MaxItems(u64), + IntegerRange { minimum: i64, maximum: i64 }, + StringLength { min_length: u64, max_length: u64 }, +} + +/// One decision the closed subset requires at `pointer`, with the best +/// suggestion the pipeline could derive, if any. +#[derive(Debug, Clone)] +pub struct BoundNeed { + pub pointer: String, + pub kind: BoundKind, + pub suggestion: Option, +} + +/// Raw observations from a sample response, keyed by pointer. Widening +/// policy belongs to the narrowing stage, not here. +#[derive(Debug, Clone, Default)] +pub struct Observations { + pub by_pointer: BTreeMap, +} + +/// What one leaf (or array) looked like in the sample. +#[derive(Debug, Clone, Default)] +pub struct Observed { + pub min_integer: Option, + pub max_integer: Option, + pub max_string_bytes: Option, + pub max_array_items: Option, + pub saw_null: bool, +} + +/// The complete set of decisions the deterministic core consumes. The +/// interactive front-end and the flag parser both produce exactly this. +#[derive(Debug, Clone)] +pub struct Decisions { + pub operation: OperationKey, + pub status: String, + pub media_type: String, + pub source_id: String, + /// Selected projection allowlist entries (extended-pointer form), in + /// presentation order. The pipeline must reject or normalize a + /// selection containing both an ancestor and its descendant, because + /// bundle validation fails overlapping projection paths. + pub selection: Vec, + /// Confirmed bound values, keyed by (pointer, kind). A need absent here + /// is emitted as an explicit TODO and the draft fails `evidence check` + /// until the operator supplies it: the tool never invents a bound. + pub resolutions: BTreeMap<(String, BoundKind), BoundValues>, +} + +impl BoundKind { + /// Stable key form for maps and reports. + pub fn label(&self) -> &'static str { + match self { + BoundKind::ArrayMaxItems => "maxItems", + BoundKind::IntegerRange => "integer bounds", + BoundKind::StringLength => "string length bounds", + } + } +} + +/// The narrowed response schema plus everything still owed by a human. +#[derive(Debug, Clone)] +pub struct NarrowOutcome { + /// The closed-subset schema as a YAML-ready value. Unresolved bounds are + /// omitted (never invented), so `evidence check` rejects the draft until + /// the operator fills them. + pub schema: serde_json::Value, + /// Bounds still unresolved, in schema order. + pub unresolved: Vec, +} + +/// One draft file to write, bundle-relative. +#[derive(Debug, Clone)] +pub struct DraftFile { + pub bundle_relative_path: String, + pub contents: String, +} + +/// Everything the emit stage produces for one source. +#[derive(Debug, Clone)] +pub struct DraftArtifacts { + pub files: Vec, + /// A `sources.` YAML block the operator pastes into evidence.yaml, + /// with review markers on every value the spec could not decide. + pub source_block: String, + /// Human report: what was derived, from where, and what remains. + pub report: String, + /// The fully-flagged non-interactive invocation reproducing this run. + pub equivalent_command: String, +} diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md index c3879e458..6e385f8c0 100644 --- a/crates/registry-evidencectl/templates/README.md +++ b/crates/registry-evidencectl/templates/README.md @@ -112,3 +112,25 @@ Work through `bundle/evidence.yaml` in this order: The full configuration contract, including the authoring and promotion workflow, is documented in `products/evidence/reference/request-adapter/deployment-projects/CONFIG.md` in the Registry Stack repository. + +### Suggesting source configuration from an API description + +When the system you are pointing step 3 at publishes an OpenAPI description, +`evidencectl` can draft that source for you: a closed response schema, an +extraction script, and the facts schema the extraction fills. Unfreeze the +project first, since the draft is written into `bundle/`. + +```bash +evidencectl source suggest \ + --openapi ./api.yaml \ + --sample ./sample-response.json \ + --project {{project_root}} +``` + +Run in a terminal with no `--operation` and no `--select`, it asks which +operation the source calls and which response fields the projection carries, +then prints the equivalent fully flagged command so the same draft can be +reproduced in review. The sample is optional and never printed; it only widens +bounds the description leaves open. Nothing existing is overwritten, and every +bound that neither the description nor the sample implies is left as an explicit +`TODO` that `evidence check` rejects until you resolve it. diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml new file mode 100644 index 000000000..53db2c96b --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/escaping.yaml @@ -0,0 +1,20 @@ +openapi: 3.0.3 +info: + title: Escaping + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + "tags/primary": + type: string + "tilde~name": + type: string diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml new file mode 100644 index 000000000..9d8844670 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/external-ref.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: External Ref + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: 'external.yaml#/components/schemas/Record' diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml new file mode 100644 index 000000000..7685c7870 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.0.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: + title: Example Records API + version: "1.0.0" +servers: + - url: https://records.example.test/api +paths: + /records: + get: + summary: Search records + parameters: + - name: pageSize + in: query + schema: + type: integer + maximum: 100 + responses: + "200": + description: A page of records + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + post: + summary: Create a record + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Record' + delete: + summary: Delete every record + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Record' +components: + schemas: + RecordSearchResponse: + type: object + additionalProperties: false + required: [total, results] + properties: + total: + type: integer + recordedOn: + type: string + format: date-time + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/Record' + Record: + type: object + additionalProperties: false + properties: + trackingId: + type: string + status: + type: string + enum: [open, closed] + notes: + type: string + nullable: true diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json new file mode 100644 index 000000000..8eb16b074 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/records-3.1.json @@ -0,0 +1,42 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Example Records API", "version": "1.0.0" }, + "paths": { + "/records/{id}": { + "get": { + "summary": "Get one record", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "maximum": 50 } } + ], + "responses": { + "200": { + "description": "One record", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["trackingId", "status"], + "properties": { + "trackingId": { "type": "string" }, + "status": { "type": ["string", "null"] }, + "total": { "type": ["integer", "null"] } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { "type": "object", "additionalProperties": false, "properties": {} } + } + } + } + } + } + } + } +} diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml new file mode 100644 index 000000000..7c314d754 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/ref-cycle.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.3 +info: + title: Ref Cycle + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/A' +components: + schemas: + A: + type: object + additionalProperties: false + properties: + child: + $ref: '#/components/schemas/B' + B: + type: object + additionalProperties: false + properties: + parent: + $ref: '#/components/schemas/A' diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml new file mode 100644 index 000000000..1f66819a2 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-constructs.yaml @@ -0,0 +1,37 @@ +openapi: 3.0.3 +info: + title: Unsupported Constructs + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + trackingId: + type: string + choice: + oneOf: + - type: string + - type: integer + wildcard: + type: object + properties: {} + additionalProperties: + type: string + simpleAllOf: + allOf: + - type: string + format: date + missingItems: + type: array + freeform: + type: object + multiType: + type: [string, integer] diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml new file mode 100644 index 000000000..daac38ccb --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/unsupported-version.yaml @@ -0,0 +1,10 @@ +openapi: "2.0" +info: + title: Swagger, Not OpenAPI 3 + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok diff --git a/crates/registry-evidencectl/tests/fixtures/samples/canary.json b/crates/registry-evidencectl/tests/fixtures/samples/canary.json new file mode 100644 index 000000000..98fc13833 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/canary.json @@ -0,0 +1,3 @@ +{ + "status": "CANARY-DO-NOT-LEAK-9f31" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/escaping.json b/crates/registry-evidencectl/tests/fixtures/samples/escaping.json new file mode 100644 index 000000000..fb1bee406 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/escaping.json @@ -0,0 +1,4 @@ +{ + "a/b": "slash-key", + "a~b": "tilde-key" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json b/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json new file mode 100644 index 000000000..e21018e69 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/integer-range.json @@ -0,0 +1,7 @@ +{ + "records": [ + { "priority": 3 }, + { "priority": -7 }, + { "priority": 12 } + ] +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json b/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json new file mode 100644 index 000000000..7d51b22d2 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/nested-records.json @@ -0,0 +1,15 @@ +{ + "total": 42, + "results": [ + { + "trackingId": "abc-123", + "status": "open", + "tags": ["a", "b", "c"] + }, + { + "trackingId": "def-456-longer", + "status": "closed", + "tags": ["x"] + } + ] +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json b/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json new file mode 100644 index 000000000..20fcd3862 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/nulls-and-absent.json @@ -0,0 +1,4 @@ +{ + "recordedOn": null, + "status": "active" +} diff --git a/crates/registry-evidencectl/tests/fixtures/samples/unicode.json b/crates/registry-evidencectl/tests/fixtures/samples/unicode.json new file mode 100644 index 000000000..ea6ac7dc6 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/samples/unicode.json @@ -0,0 +1,4 @@ +{ + "status": "héllo", + "note": "日本語" +} diff --git a/crates/registry-evidencectl/tests/suggest_e2e.rs b/crates/registry-evidencectl/tests/suggest_e2e.rs new file mode 100644 index 000000000..27282c94f --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_e2e.rs @@ -0,0 +1,472 @@ +//! `evidencectl source suggest` end to end, through the real binary. +//! +//! Every case here drives the installed `evidencectl` executable +//! non-interactively, the way an operator reproduces a reviewed interactive +//! run: a synthetic OpenAPI document and a synthetic sample response are +//! written into a temporary directory, and the command is asked to draft one +//! source from them. Nothing here reaches the network, and no sample value is +//! expected in any assertion: only bounds derived from the sample are. + +use std::{ + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use serde_json::Value; + +/// A two-operation document with one paginated collection response. The +/// vocabulary is deliberately generic: records, trackingId, status, total, +/// recordedOn. +const OPENAPI_DOCUMENT: &str = r#"openapi: 3.0.3 +info: + title: Example record service + version: "1.0.0" +servers: + - url: https://records.example.invalid/v1 +paths: + /records: + get: + summary: List records + parameters: + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + responses: + "200": + description: matching records + content: + application/json: + schema: + type: object + required: [total, records] + properties: + total: + type: integer + records: + type: array + items: + $ref: '#/components/schemas/Record' +components: + schemas: + Record: + type: object + required: [trackingId] + properties: + trackingId: + type: string + status: + type: string + enum: [active, closed] + recordedOn: + type: string + format: date + tags: + type: array + items: + type: string + maxLength: 32 +"#; + +/// Two records, so the array observation is a real length rather than one. +const SAMPLE_RESPONSE: &str = r#"{ + "total": 3, + "records": [ + {"trackingId": "TR-000001", "status": "active", "recordedOn": "2024-05-01"}, + {"trackingId": "TR-000002", "status": "closed", "recordedOn": "2024-05-02"} + ] +} +"#; + +#[test] +fn drafts_into_a_project_and_then_refuses_to_overwrite_the_draft() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + let sample = write(workspace.path(), "records.sample.json", SAMPLE_RESPONSE); + let project = scaffold(workspace.path()); + + let arguments = vec![ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/total".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--sample".to_owned(), + path_argument(&sample), + "--source-id".to_owned(), + "source-b".to_owned(), + "--project".to_owned(), + path_argument(&project), + ]; + let output = evidencectl(&arguments); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let bundle = project.join("bundle"); + let schema_path = bundle.join("schemas/source-b-response.schema.yaml"); + let script_path = bundle.join("adapters/source-b-extract.rhai"); + let facts_path = bundle.join("schemas/source-b-facts.schema.yaml"); + for path in [&schema_path, &script_path, &facts_path] { + assert!(path.is_file(), "expected {} to be written", path.display()); + } + + // The response schema parses as YAML and carries the sample-derived + // bounds, widened by the narrowing policy rather than copied. + let schema: Value = + serde_norway::from_str(&std::fs::read_to_string(&schema_path).expect("read schema")) + .expect("the drafted response schema parses as YAML"); + assert_eq!(schema["type"], "object"); + assert_eq!(schema["additionalProperties"], Value::Bool(false)); + assert_eq!(schema["properties"]["total"]["minimum"], 0); + assert_eq!(schema["properties"]["total"]["maximum"], 10); + // Two records is weak evidence of how long a page can be; the spec's own + // page-size maximum is the stronger statement and wins for the top-level + // collection. + assert_eq!(schema["properties"]["records"]["maxItems"], 50); + assert_eq!( + schema["properties"]["records"]["items"]["properties"]["trackingId"]["maxLength"], + 16 + ); + + let script = std::fs::read_to_string(&script_path).expect("read extract script"); + assert!( + script.contains(r#"get_path(source_response, "/total")"#), + "extract skeleton must read the selected scalar leaf: {script}" + ); + assert!( + script.contains(r#"get_path(source_response, "/records/0/trackingId")"#), + "extract skeleton must substitute a numeric index for the `*` segment: {script}" + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("evidencectl source suggest: draft for source `source-b`"), + "the report belongs on stdout: {stdout}" + ); + assert!( + stdout.contains("evidencectl source suggest --openapi"), + "the equivalent command belongs on stdout: {stdout}" + ); + // The reproduce line is pasted into a shell, so a pointer carrying `*` is + // quoted rather than left for the shell to expand. + assert!( + stdout.contains("--select '/records/*/trackingId'"), + "the equivalent command must reproduce the selection, quoted: {stdout}" + ); + + // Pasting the source block is a step the operator still owes, and until it + // happens `evidence check` names the drafted files back at them. The report + // says so, so that error reads as the to-do list it is. + assert!( + stdout.contains("deployment artifact closure is invalid"), + "the report must predict the closure error: {stdout}" + ); + + // Every adopted default is announced with its provenance, so a flag-driven + // run is auditable without re-reading the generated files. + let stderr = stderr_of(&output); + assert!( + stderr.contains("the sample response (widened)"), + "adopted bounds must be announced with their provenance: {stderr}" + ); + assert!( + stderr.contains("a counter usually needs a more generous ceiling"), + "a sampled integer ceiling must be flagged for review: {stderr}" + ); + + // A second identical run must not silently replace a draft an operator may + // already have edited. + let repeat = evidencectl(&arguments); + assert!( + !repeat.status.success(), + "a second run must not overwrite the first draft" + ); + assert!( + stderr_of(&repeat).contains("refusing to overwrite"), + "unexpected refusal message: {}", + stderr_of(&repeat) + ); +} + +#[test] +fn prints_the_draft_without_a_project_and_writes_nothing() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + let project = scaffold(workspace.path()); + let bundle_before = bundle_entries(&project); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--source-id".to_owned(), + "source-c".to_owned(), + ]); + assert!( + output.status.success(), + "print-only source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + for block in [ + "--- schemas/source-c-response.schema.yaml ---", + "--- adapters/source-c-extract.rhai ---", + "--- schemas/source-c-facts.schema.yaml ---", + ] { + assert!( + stdout.contains(block), + "missing draft block {block}:\n{stdout}" + ); + } + assert!( + stdout.contains("sources:") && stdout.contains("source-c:"), + "the pasteable source block belongs on stdout: {stdout}" + ); + // With no sample, the page-size parameter is the only evidence of how long + // the array can be, and the string leaf has none at all. + assert!( + stdout.contains("maxItems: 50"), + "the page-size parameter must bound the array: {stdout}" + ); + assert!( + stdout.contains("TODO(evidencectl): /records/*/trackingId needs string length bounds"), + "an underivable bound must stay an explicit TODO: {stdout}" + ); + assert!( + stderr_of(&output).contains("a page-size parameter in the spec"), + "adopted page-size bound must be announced: {}", + stderr_of(&output) + ); + + assert_eq!( + bundle_entries(&project), + bundle_before, + "a print-only run must not write into any project" + ); +} + +/// A page-size parameter bounds one page of the top-level collection. It says +/// nothing about how long an array *inside* a record can be, so a nested array +/// stays an explicit TODO instead of inheriting the page size. +#[test] +fn a_page_size_bounds_the_collection_but_not_an_array_inside_a_record() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/tags/*".to_owned(), + "--source-id".to_owned(), + "source-e".to_owned(), + ]); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("maxItems: 50"), + "the page-size parameter must bound the collection: {stdout}" + ); + assert!( + stdout.contains("TODO(evidencectl): /records/*/tags needs maxItems"), + "a nested array must not inherit the page size: {stdout}" + ); + assert_eq!( + stdout.matches("maxItems: 50").count(), + 1, + "only the collection carries the page-size bound: {stdout}" + ); +} + +/// The runtime's fixed-request method is an enumeration of two. An operation +/// outside it is refused by name, before any file is drafted. +#[test] +fn an_operation_outside_the_runtime_method_enum_is_refused_by_name() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "PATCH /records".to_owned(), + "--select".to_owned(), + "/total".to_owned(), + ]); + assert!( + !output.status.success(), + "PATCH must not draft a source: {}", + stdout_of(&output) + ); + let stderr = stderr_of(&output); + assert!( + stderr.contains("PATCH") && stderr.contains("GET") && stderr.contains("POST"), + "the refusal must name the method and the two admitted ones: {stderr}" + ); +} + +#[test] +fn a_non_interactive_run_names_the_flags_it_needs() { + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + + // `output()` gives the child a null stdin and piped stdout, so neither is a + // terminal and the interactive selection cannot run. + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + ]); + assert!( + !output.status.success(), + "a non-interactive run without the selection flags must fail" + ); + let stderr = stderr_of(&output); + assert!( + stderr.contains("--operation") && stderr.contains("--select"), + "the error must name every missing flag: {stderr}" + ); +} + +/// The classification of `evidence check` is reported in plain words. The +/// runtime is represented here by a stub printing one of its fixed messages, +/// so the assertion is about `evidencectl`'s reporting and not about a +/// deployment project that is not frozen or provisioned yet. +#[cfg(unix)] +#[test] +fn reports_the_check_classification_when_a_runtime_binary_is_supplied() { + use std::os::unix::fs::PermissionsExt as _; + + let workspace = tempfile::tempdir().expect("tempdir"); + let openapi = write(workspace.path(), "records.openapi.yaml", OPENAPI_DOCUMENT); + let project = scaffold(workspace.path()); + + let stub = workspace.path().join("evidence"); + std::fs::write( + &stub, + "#!/bin/sh\nprintf 'evidence: runtime signing initialization failed\\n' >&2\nexit 1\n", + ) + .expect("write stub runtime"); + let mut permissions = std::fs::metadata(&stub).expect("stat stub").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&stub, permissions).expect("chmod stub"); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/status".to_owned(), + "--source-id".to_owned(), + "source-d".to_owned(), + "--project".to_owned(), + path_argument(&project), + "--evidence-bin".to_owned(), + path_argument(&stub), + ]); + assert!( + output.status.success(), + "an accepted bundle must not fail the draft: {}", + stderr_of(&output) + ); + let stdout = stdout_of(&output); + assert!( + stdout.contains("bundle accepted; deployment secrets not provisioned yet"), + "unexpected check report: {stdout}" + ); +} + +fn evidencectl(arguments: &[String]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +/// Scaffold a deployment project to draft into, using the tool's own `new`. +fn scaffold(root: &Path) -> PathBuf { + let project = root.join("project"); + let output = evidencectl(&[ + "new".to_owned(), + path_argument(&project), + "--force".to_owned(), + ]); + assert!( + output.status.success(), + "evidencectl new failed: {}", + stderr_of(&output) + ); + project +} + +fn write(root: &Path, name: &str, contents: &str) -> PathBuf { + let path = root.join(name); + std::fs::write(&path, contents).expect("writing a test input"); + path +} + +/// Every path beneath the project's bundle directory, sorted, so a test can +/// prove a run wrote nothing. +fn bundle_entries(project: &Path) -> Vec { + let mut entries = Vec::new(); + let mut pending = vec![project.join("bundle")]; + while let Some(directory) = pending.pop() { + let Ok(children) = std::fs::read_dir(&directory) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if path.is_dir() { + pending.push(path.clone()); + } + entries.push(path); + } + } + entries.sort(); + entries +} + +fn path_argument(path: &Path) -> String { + path.to_str() + .expect("test paths are valid UTF-8") + .to_owned() +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} diff --git a/crates/registry-evidencectl/tests/suggest_emit.rs b/crates/registry-evidencectl/tests/suggest_emit.rs new file mode 100644 index 000000000..b95f58a81 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_emit.rs @@ -0,0 +1,903 @@ +//! Emit stage: draft artifacts from a narrowed response schema, write them +//! into a deployment project, and classify `evidence check` output. +//! +//! Inputs are built here as `NarrowOutcome`/`EmitInputs` literals rather than +//! produced by the other pipeline stages, so a failure names an emit-stage +//! rule and never a bug in the OpenAPI loader, the sampler, or the narrowing +//! heuristics (all still placeholders as this file is written). + +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +#[allow(dead_code)] +#[path = "../src/fixtures.rs"] +mod fixtures; + +#[allow(dead_code)] +#[path = "../src/suggest/emit.rs"] +mod emit; + +use std::path::{Path, PathBuf}; + +use serde_json::json; + +use emit::{CheckClassification, EmitInputs}; +use types::{ + BoundKind, BoundNeed, BoundValues, NarrowOutcome, OperationKey, Provenance, SuggestedBound, +}; + +/// A schema exercising every case the response-schema renderer must handle: +/// a resolved integer bound (derived from the spec), an unresolved array +/// missing `maxItems`, a nested unresolved string missing length bounds +/// (despite carrying a sample-derived suggestion nobody confirmed yet), and a +/// nullable string that needs no bound because `format` alone satisfies the +/// subset. +fn narrow_outcome_fixture() -> NarrowOutcome { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "required": ["total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "event_date": {"type": ["string", "null"], "format": "date"}, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "status": {"type": "string"} + } + } + } + } + }); + + let unresolved = vec![ + BoundNeed { + pointer: "/results".to_owned(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }, + BoundNeed { + pointer: "/results/*/status".to_owned(), + kind: BoundKind::StringLength, + suggestion: Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64, + }, + provenance: Provenance::Sample, + }), + }, + ]; + + NarrowOutcome { schema, unresolved } +} + +fn needs_fixture() -> Vec { + vec![ + BoundNeed { + pointer: "/total".to_owned(), + kind: BoundKind::IntegerRange, + suggestion: Some(SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + provenance: Provenance::Spec, + }), + }, + BoundNeed { + pointer: "/results".to_owned(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }, + BoundNeed { + pointer: "/results/*/status".to_owned(), + kind: BoundKind::StringLength, + suggestion: Some(SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64, + }, + provenance: Provenance::Sample, + }), + }, + ] +} + +fn base_inputs() -> EmitInputs { + EmitInputs { + source_id: "search-a".to_owned(), + operation: OperationKey { + method: "GET".to_owned(), + path: "/v1/records".to_owned(), + }, + status: "200".to_owned(), + media_type: "application/json".to_owned(), + base_url_suggestion: emit::split_server_url("https://api.example.invalid"), + selection: vec![ + "/total".to_owned(), + "/event_date".to_owned(), + "/results/*/status".to_owned(), + ], + narrowed: narrow_outcome_fixture(), + needs: needs_fixture(), + openapi_path: PathBuf::from("tests/fixtures/openapi/example.yaml"), + sample_path: None, + project: None, + } +} + +fn file_contents<'a>(artifacts: &'a types::DraftArtifacts, bundle_relative_path: &str) -> &'a str { + artifacts + .files + .iter() + .find(|file| file.bundle_relative_path == bundle_relative_path) + .unwrap_or_else(|| panic!("expected a draft file at {bundle_relative_path}")) + .contents + .as_str() +} + +fn assert_adjacent(text: &str, comment: &str, next_line_contains: &str) { + let lines: Vec<&str> = text.lines().collect(); + let comment_index = lines + .iter() + .position(|line| line.contains(comment)) + .unwrap_or_else(|| panic!("expected a line containing {comment:?} in:\n{text}")); + let next = lines + .get(comment_index + 1) + .unwrap_or_else(|| panic!("expected a line after {comment:?} in:\n{text}")); + assert!( + next.contains(next_line_contains), + "expected the line after {comment:?} to contain {next_line_contains:?}, got {next:?}" + ); +} + +#[test] +fn response_schema_parses_and_carries_adjacent_annotations() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + assert!(parsed.is_mapping()); + + assert_adjacent( + response_schema, + "# TODO(evidencectl): /results needs maxItems", + "results:", + ); + assert_adjacent( + response_schema, + "# TODO(evidencectl): /results/*/status needs string length bounds", + "status:", + ); + assert_adjacent( + response_schema, + "# derived from the OpenAPI schema", + "total:", + ); + + // event_date needs no bound comment: `format: date` alone satisfies the + // subset, so no BoundNeed exists for it in the fixture. + let event_date_index = response_schema + .lines() + .position(|line| line.trim() == "event_date:") + .expect("event_date property present"); + let preceding = response_schema.lines().nth(event_date_index - 1).unwrap(); + assert!( + !preceding.contains("TODO(evidencectl)") && !preceding.contains("derived from"), + "event_date should carry no bound annotation, got preceding line {preceding:?}" + ); +} + +/// A flow sequence makes `,`, `[`, `]`, `{` and `}` significant anywhere in a +/// member, not only at its start: an unquoted `pending, review` would parse as +/// two enumeration members. +#[test] +fn a_flow_list_member_containing_a_comma_stays_one_member() { + let mut inputs = base_inputs(); + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "status": {"type": "string", "enum": ["pending, review", "closed"]} + } + }), + unresolved: Vec::new(), + }; + inputs.needs = Vec::new(); + inputs.selection = vec!["/status".to_owned(), "/a,b".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + let enumeration = parsed["properties"]["status"]["enum"] + .as_sequence() + .expect("enum is a sequence"); + assert_eq!(enumeration.len(), 2, "got {enumeration:?}"); + assert_eq!(enumeration[0].as_str(), Some("pending, review")); + + let block: serde_norway::Value = + serde_norway::from_str(&artifacts.source_block).expect("source block parses as YAML"); + let projection = block["sources"]["search-a"]["request"]["projection"] + .as_sequence() + .expect("projection is a sequence"); + assert_eq!(projection.len(), 2, "got {projection:?}"); + assert_eq!(projection[1].as_str(), Some("/a,b")); +} + +/// A bound demanded of an array's items node belongs above `items:`; without +/// it the only annotation-carrying place is the object-properties loop, which +/// an array of scalars never reaches. +#[test] +fn a_bound_on_an_array_items_node_is_annotated_above_items() { + let mut inputs = base_inputs(); + let unresolved = vec![BoundNeed { + pointer: "/tags/*".to_owned(), + kind: BoundKind::StringLength, + suggestion: None, + }]; + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "tags": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": {"type": "string"} + } + } + }), + unresolved: unresolved.clone(), + }; + inputs.needs = unresolved; + inputs.selection = vec!["/tags/*".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + assert_adjacent( + response_schema, + "# TODO(evidencectl): /tags/* needs string length bounds", + "items:", + ); +} + +/// `uniqueItems` and an array `const` narrow the accepted response, and the +/// narrowing stage deliberately carries both through; dropping them here would +/// widen the drafted schema past what the specification stated. +#[test] +fn an_array_keeps_unique_items_and_a_constant() { + let mut inputs = base_inputs(); + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "tags": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "uniqueItems": true, + "const": ["alpha", "beta"], + "items": {"type": "string", "maxLength": 16} + } + } + }), + unresolved: Vec::new(), + }; + inputs.needs = Vec::new(); + inputs.selection = vec!["/tags/*".to_owned()]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + let parsed: serde_norway::Value = + serde_norway::from_str(response_schema).expect("response schema parses as YAML"); + assert_eq!( + parsed["properties"]["tags"]["uniqueItems"].as_bool(), + Some(true) + ); + assert_eq!( + parsed["properties"]["tags"]["const"] + .as_sequence() + .map(Vec::len), + Some(2) + ); +} + +#[test] +fn facts_schema_stub_is_minimal_and_parses() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let facts_schema = file_contents(&artifacts, "schemas/search-a-facts.schema.yaml"); + + let parsed: serde_norway::Value = + serde_norway::from_str(facts_schema).expect("facts schema parses as YAML"); + let mapping = parsed.as_mapping().expect("facts schema is a mapping"); + assert_eq!( + mapping + .get(serde_norway::Value::String("type".to_owned())) + .and_then(|v| v.as_str()), + Some("object") + ); + assert_eq!( + mapping + .get(serde_norway::Value::String( + "additionalProperties".to_owned() + )) + .and_then(|v| v.as_bool()), + Some(false) + ); + let required = mapping + .get(serde_norway::Value::String("required".to_owned())) + .and_then(|v| v.as_sequence()) + .expect("required is a sequence"); + assert_eq!(required.len(), 1); + assert_eq!(required[0].as_str(), Some("placeholder_fact")); + + let properties = mapping + .get(serde_norway::Value::String("properties".to_owned())) + .and_then(|v| v.as_mapping()) + .expect("properties is a mapping"); + assert_eq!(properties.len(), 1); + assert!(properties.contains_key(serde_norway::Value::String("placeholder_fact".to_owned()))); + + assert!(facts_schema.contains("TODO(evidencectl)")); +} + +#[test] +fn extract_script_uses_get_path_for_every_selected_leaf_with_wildcard_substitution() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!(script.contains(r#"get_path(source_response, "/total")"#)); + assert!(script.contains(r#"get_path(source_response, "/event_date")"#)); + // The extended pointer's `*` becomes `0` in the plain get_path pointer. + assert!(script.contains(r#"get_path(source_response, "/results/0/status")"#)); + assert_eq!(script.matches("is_missing(leaf_").count(), 3); + + // A selection under an array gets a commented loop sketch. + assert!(script.contains("for element_1 in items_1 {")); + assert!(!script.contains(r#"source_response["total"]"#)); + + assert!(script.contains("fn extract(source_response, parameters) {")); +} + +/// The commented loop sketch is read by an operator and then pasted, so it may +/// only use constructs the runtime's Rhai engine actually registers: ranges +/// (`..`) are disabled, `len` is a property getter rather than a method, and +/// no operator concatenates a string with an integer. +#[test] +fn the_array_loop_sketch_uses_only_constructs_the_runtime_registers() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!(!script.contains("0.."), "ranges are disabled: {script}"); + assert!(!script.contains(".len()"), "len is a getter: {script}"); + assert!( + !script.contains("+ index"), + "string + integer has no operator: {script}" + ); + // The array is reached by its own pointer and iterated directly. + assert!( + script.contains(r#"// let items_1 = get_path(source_response, "/results");"#), + "the sketch must read the array by its own pointer: {script}" + ); + assert!( + script.contains(r#"// if !is_missing(items_1) {"#), + "the sketch must guard an absent array: {script}" + ); + assert!( + script.contains(r#"// let value = get_path(element_1, "/status");"#), + "the sketch must read the leaf from each element: {script}" + ); +} + +/// A pointer crossing two arrays names each array by its own pointer: the +/// segment before the first `*` says nothing about the inner one. +#[test] +fn a_nested_array_loop_sketch_names_every_array_it_crosses() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/results/*/tags/*".to_owned()]; + let artifacts = emit::draft(&inputs).expect("draft"); + let script = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!( + script.contains(r#"let items_1 = get_path(source_response, "/results");"#), + "the outer array is /results: {script}" + ); + assert!( + script.contains(r#"let items_2 = get_path(element_1, "/tags");"#), + "the inner array is /tags, reached from an outer element: {script}" + ); + assert!( + script.contains("element_2 is the value at /results/*/tags/*"), + "the innermost element is the selected value: {script}" + ); +} + +#[test] +fn get_path_byte_ceiling_is_enforced_and_names_the_pointer() { + let mut inputs = base_inputs(); + let long_segment = "x".repeat(300); + let long_pointer = format!("/{long_segment}"); + inputs.selection = vec![long_pointer.clone()]; + + let error = emit::draft(&inputs).expect_err("oversized pointer must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains(&long_pointer) || message.contains("byte")); + assert!(message.contains("256")); +} + +#[test] +fn get_path_segment_ceiling_is_enforced_and_names_the_pointer() { + let mut inputs = base_inputs(); + let segments: Vec = (0..17).map(|n| format!("s{n}")).collect(); + let deep_pointer = format!("/{}", segments.join("/")); + inputs.selection = vec![deep_pointer.clone()]; + + let error = emit::draft(&inputs).expect_err("pointer with too many segments must be rejected"); + let message = format!("{error:#}"); + assert!(message.contains(&deep_pointer)); + assert!(message.contains("16")); +} + +#[test] +fn source_block_parses_and_carries_request_and_review_markers() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let parsed: serde_norway::Value = + serde_norway::from_str(&artifacts.source_block).expect("source block parses as YAML"); + assert!(parsed.is_mapping()); + + assert!(artifacts.source_block.contains("search-a:")); + assert!(artifacts.source_block.contains("method: GET")); + assert!(artifacts.source_block.contains("path: /v1/records")); + assert!(artifacts.source_block.contains("value: application/json")); + assert!(artifacts + .source_block + .contains("projection: [/total, /event_date, /results/*/status]")); + assert!(artifacts + .source_block + .contains("baseUrl: https://api.example.invalid")); + assert!(artifacts + .source_block + .contains("# TODO(evidencectl): review authentication")); + assert!(artifacts + .source_block + .contains("responseSchema: schemas/search-a-response.schema.yaml")); + assert!(artifacts + .source_block + .contains("extractScript: adapters/search-a-extract.rhai")); + assert!(artifacts + .source_block + .contains("factSchema: schemas/search-a-facts.schema.yaml")); + + // selectorInputs / prepareScript / adapterParameters are never + // fabricated: they show up only as TODO comments, not real keys. + assert!(!artifacts.source_block.contains("selectorInputs:")); + assert!(!artifacts.source_block.contains("prepareScript: adapters")); + assert!(!artifacts.source_block.contains("adapterParameters:")); +} + +/// The runtime rejects a GET source that does not forbid the JSON body +/// channel, and rejects any source that forbids both channels, so the pair is +/// chosen from the method rather than fixed. +#[test] +fn a_get_source_carries_its_request_in_the_query_channel() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let block = &artifacts.source_block; + + assert!(block.contains("query: required"), "{block}"); + assert!(block.contains("jsonBody: forbidden"), "{block}"); + assert!(!block.contains("jsonBody: required"), "{block}"); + assert!(block.contains("maximumQueryPairs:"), "{block}"); + assert!( + !block.contains("maximumJsonDepth:"), + "a forbidden JSON body needs no JSON limits: {block}" + ); +} + +#[test] +fn a_post_source_carries_its_request_in_the_json_body() { + let mut inputs = base_inputs(); + inputs.operation.method = "POST".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + assert!(block.contains("query: forbidden"), "{block}"); + assert!(block.contains("jsonBody: required"), "{block}"); + assert!(block.contains("maximumJsonDepth:"), "{block}"); + assert!(!block.contains("maximumQueryPairs:"), "{block}"); +} + +/// The runtime's fixed-request method admits GET and POST only. +#[test] +fn a_method_outside_the_runtime_enum_is_refused_by_name() { + let mut inputs = base_inputs(); + inputs.operation.method = "PATCH".to_owned(); + + let error = emit::draft(&inputs).expect_err("PATCH is not an admitted method"); + let message = format!("{error:#}"); + assert!(message.contains("PATCH"), "{message}"); + assert!( + message.contains("GET") && message.contains("POST"), + "{message}" + ); +} + +/// A templated OpenAPI path cannot be a `path:`, which the runtime rejects on +/// `{` and `}`. It becomes a `pathTemplate:`, whose placeholders still need +/// `pathBindings` the tool never invents. +#[test] +fn a_templated_path_becomes_a_path_template_with_a_bindings_todo() { + let mut inputs = base_inputs(); + inputs.operation.path = "/v1/records/{id}".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + let parsed: serde_norway::Value = + serde_norway::from_str(block).expect("source block parses as YAML"); + assert!(parsed.is_mapping()); + + assert!(block.contains("pathTemplate: /v1/records/{id}"), "{block}"); + assert!(!block.contains("path: /v1/records/{id}"), "{block}"); + assert!( + block.contains("# TODO(evidencectl): pathBindings — bind each placeholder"), + "{block}" + ); +} + +/// `baseUrl` is validated as an origin: any path the OpenAPI server URL +/// carries has to move onto the request path instead of staying in the origin. +#[test] +fn a_server_path_prefix_moves_onto_the_request_path() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = emit::split_server_url("https://api.example.invalid:8443/v1/"); + inputs.operation.path = "/records".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let block = &artifacts.source_block; + + assert!( + block.contains("baseUrl: https://api.example.invalid:8443\n"), + "{block}" + ); + assert!(block.contains("path: /v1/records"), "{block}"); +} + +#[test] +fn a_server_path_prefix_moves_onto_a_path_template_too() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = emit::split_server_url("https://api.example.invalid/v1"); + inputs.operation.path = "/records/{id}".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!( + artifacts + .source_block + .contains("pathTemplate: /v1/records/{id}"), + "{}", + artifacts.source_block + ); +} + +/// A server URL with template variables names no single origin, so the draft +/// falls back to the placeholder rather than emitting an unusable baseUrl. +#[test] +fn a_server_url_with_variables_yields_no_base_url_suggestion() { + assert!(emit::split_server_url("https://{tenant}.example.invalid/v1").is_none()); + assert!(emit::split_server_url("/relative/only").is_none()); + + let split = emit::split_server_url("https://api.example.invalid").expect("plain origin splits"); + assert_eq!(split.base_url, "https://api.example.invalid"); + assert_eq!(split.path_prefix, ""); +} + +/// The acquisition posture describes the pre-projection wire response, and +/// local projection never upgrades the claim, so the draft states the weakest +/// one and asks for review. +#[test] +fn the_drafted_posture_is_the_weakest_claim_with_an_upgrade_todo() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let block = &artifacts.source_block; + + assert!(block.contains("posture: record-transformed"), "{block}"); + assert!(!block.contains("posture: field-projected"), "{block}"); + assert!( + block.contains("# TODO(evidencectl): upgrade to field-projected or source-derived only if"), + "{block}" + ); +} + +#[test] +fn source_block_falls_back_to_placeholder_base_url_without_a_suggestion() { + let mut inputs = base_inputs(); + inputs.base_url_suggestion = None; + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!(artifacts + .source_block + .contains("baseUrl: https://source.invalid")); + assert!(artifacts + .source_block + .contains("# TODO(evidencectl): replace this placeholder")); +} + +#[test] +fn write_into_project_creates_directories_and_writes_every_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let written = emit::write_into_project(&project, &artifacts.files).expect("write"); + assert_eq!(written.len(), 3); + + for file in &artifacts.files { + let path = project.join("bundle").join(&file.bundle_relative_path); + assert!(path.exists(), "expected {path:?} to exist"); + let on_disk = std::fs::read_to_string(&path).expect("read written file"); + assert_eq!(on_disk, file.contents); + } +} + +#[test] +fn write_into_project_refuses_all_collisions_and_writes_nothing() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + let artifacts = emit::draft(&base_inputs()).expect("draft"); + + let response_path = project + .join("bundle") + .join("schemas/search-a-response.schema.yaml"); + let facts_path = project + .join("bundle") + .join("schemas/search-a-facts.schema.yaml"); + std::fs::create_dir_all(response_path.parent().unwrap()).expect("mkdir"); + std::fs::write(&response_path, b"pre-existing\n").expect("seed collision file 1"); + std::fs::write(&facts_path, b"pre-existing\n").expect("seed collision file 2"); + + let error = + emit::write_into_project(&project, &artifacts.files).expect_err("must refuse to overwrite"); + let message = error.to_string(); + assert!(message.contains(&response_path.display().to_string())); + assert!(message.contains(&facts_path.display().to_string())); + + let extract_path = project + .join("bundle") + .join("adapters/search-a-extract.rhai"); + assert!( + !extract_path.exists(), + "a non-colliding file must not be written when any file collides" + ); + // The pre-existing collision files must be untouched. + assert_eq!( + std::fs::read_to_string(&response_path).unwrap(), + "pre-existing\n" + ); +} + +/// A value the operator typed is not a value the pipeline derived, and must +/// not be reported as one. +#[test] +fn an_operator_chosen_bound_is_reported_as_chosen_not_derived() { + let mut inputs = base_inputs(); + inputs.narrowed.unresolved = Vec::new(); + inputs.needs = vec![BoundNeed { + pointer: "/total".to_owned(), + kind: BoundKind::IntegerRange, + suggestion: Some(SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + provenance: Provenance::Operator, + }), + }]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + assert_adjacent(response_schema, "# chosen at the prompt", "total:"); + assert!( + !response_schema.contains("# derived from"), + "{response_schema}" + ); + + assert!( + artifacts.report.contains("Chosen at the prompt:"), + "{}", + artifacts.report + ); + assert!( + artifacts.report.contains("Derived automatically: none."), + "an operator's own answer is not a derivation: {}", + artifacts.report + ); +} + +/// Only a need whose accepted value differs from the suggestion is +/// reattributed: adopting a suggestion unchanged keeps its real provenance. +#[test] +fn only_an_edited_or_invented_bound_is_reattributed_to_the_operator() { + let mut needs = needs_fixture(); + let mut resolutions: std::collections::BTreeMap<(String, BoundKind), BoundValues> = + std::collections::BTreeMap::new(); + // Adopted unchanged. + resolutions.insert( + ("/total".to_owned(), BoundKind::IntegerRange), + BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + ); + // Answered where nothing was suggested. + resolutions.insert( + ("/results".to_owned(), BoundKind::ArrayMaxItems), + BoundValues::MaxItems(32), + ); + // Edited away from the suggestion. + resolutions.insert( + ("/results/*/status".to_owned(), BoundKind::StringLength), + BoundValues::StringLength { + min_length: 1, + max_length: 128, + }, + ); + + emit::attribute_operator_edits(&mut needs, &resolutions); + + assert_eq!( + needs[0].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Spec) + ); + assert_eq!( + needs[1].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Operator) + ); + assert_eq!( + needs[1].suggestion.as_ref().map(|s| &s.values), + Some(&BoundValues::MaxItems(32)) + ); + assert_eq!( + needs[2].suggestion.as_ref().map(|s| &s.provenance), + Some(&Provenance::Operator) + ); +} + +/// Writing the files leaves the bundle referencing artifacts evidence.yaml +/// does not declare yet, which `evidence check` reports as an invalid artifact +/// closure. That message is the remaining to-do list, so the report says so +/// rather than leaving an operator to read it as breakage. +#[test] +fn the_report_predicts_the_closure_error_that_precedes_pasting_the_block() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + assert!( + artifacts + .report + .contains("deployment artifact closure is invalid"), + "{}", + artifacts.report + ); +} + +#[test] +fn equivalent_command_is_deterministic_with_the_documented_flag_order() { + let mut inputs = base_inputs(); + inputs.sample_path = Some(PathBuf::from("tests/fixtures/samples/example.json")); + inputs.project = Some(PathBuf::from("/tmp/example-project")); + + let first = emit::draft(&inputs).expect("draft").equivalent_command; + let second = emit::draft(&inputs).expect("draft").equivalent_command; + assert_eq!(first, second, "equivalent_command must be deterministic"); + + let expected = "evidencectl source suggest \ +--openapi tests/fixtures/openapi/example.yaml \ +--operation 'GET /v1/records' \ +--status 200 \ +--media-type application/json \ +--sample tests/fixtures/samples/example.json \ +--source-id search-a \ +--project /tmp/example-project \ +--select /total \ +--select /event_date \ +--select '/results/*/status'"; + assert_eq!(first, expected); +} + +/// The reproduce line is meant to be pasted into a shell. A projection pointer +/// carries `*`, which an interactive zsh expands (and aborts on when nothing +/// matches), so every value a shell would rewrite is quoted. +#[test] +fn the_reproduce_command_quotes_every_value_a_shell_would_rewrite() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/results/*/status".to_owned(), "/a?b".to_owned()]; + inputs.openapi_path = PathBuf::from("/tmp/spec (copy).yaml"); + + let command = emit::draft(&inputs).expect("draft").equivalent_command; + assert!( + command.contains("--select '/results/*/status'"), + "{command}" + ); + assert!(command.contains("--select '/a?b'"), "{command}"); + assert!( + command.contains("--openapi '/tmp/spec (copy).yaml'"), + "{command}" + ); + assert!( + !command.contains("--select /results/*/status"), + "a bare glob must not survive into the reproduce line: {command}" + ); +} + +#[test] +fn equivalent_command_omits_optional_flags_when_absent() { + let inputs = base_inputs(); + let command = emit::draft(&inputs).expect("draft").equivalent_command; + assert!(!command.contains("--sample")); + assert!(!command.contains("--project")); +} + +#[cfg(unix)] +fn write_stub_evidence(dir: &Path, script: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + + let path = dir.join("evidence"); + std::fs::write(&path, script).expect("write stub evidence script"); + let mut permissions = std::fs::metadata(&path).expect("stat stub").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("chmod stub"); + path +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_successful_check_as_bundle_accepted() { + let temp = tempfile::tempdir().expect("tempdir"); + let stub = write_stub_evidence(temp.path(), "#!/bin/sh\nexit 0\n"); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::BundleAccepted); +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_deployment_message_as_bundle_rejected() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = "#!/bin/sh\nprintf 'evidence: deployment configuration is invalid: artifact evidence.yaml: unknown field\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + match classification { + CheckClassification::BundleRejected { stderr } => { + assert!(stderr.contains("deployment configuration is invalid")); + } + other => panic!("expected BundleRejected, got {other:?}"), + } +} + +#[cfg(unix)] +#[test] +fn verify_classifies_a_runtime_initialization_message_as_secrets_unprovisioned() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = + "#!/bin/sh\nprintf 'evidence: runtime signing initialization failed\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::SecretsUnprovisioned); +} diff --git a/crates/registry-evidencectl/tests/suggest_narrow.rs b/crates/registry-evidencectl/tests/suggest_narrow.rs new file mode 100644 index 000000000..b86a58909 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_narrow.rs @@ -0,0 +1,955 @@ +//! Narrowing stage: an OpenAPI response schema restricted to a projection +//! selection and rewritten into the closed Version 1 response subset. +//! +//! The inputs are built here as JSON literals rather than loaded through the +//! OpenAPI stage, so a failure names a narrowing rule and never a loader bug. +//! Every expectation is checked against the shipped shapes under +//! `products/evidence/fixtures/source-shapes/*/schemas/response.schema.yaml`: +//! closed objects, `required` as the spec-guaranteed subset of kept members, +//! bounded arrays and strings, and the `[T, "null"]` response pair. + +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +#[allow(dead_code)] +#[path = "../src/suggest/narrow.rs"] +mod narrow; + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use narrow::{AdvisoryKind, Plan, Resolution}; +use types::{ + BoundKind, BoundValues, NarrowOutcome, Observations, Observed, Provenance, ResolvedSchema, + SuggestedBound, +}; + +/// Builds a resolved schema from a JSON literal. +fn schema(value: Value) -> ResolvedSchema { + ResolvedSchema(value) +} + +/// Builds a selection from string literals. +fn selection(entries: &[&str]) -> Vec { + entries.iter().map(|entry| (*entry).to_owned()).collect() +} + +/// Builds observations for one pointer. +fn observed(pointer: &str, observation: Observed) -> Observations { + let mut observations = Observations::default(); + observations + .by_pointer + .insert(pointer.to_owned(), observation); + observations +} + +/// Builds a one-entry resolution list for the slice entry point, which takes +/// the same pairs as the `BTreeMap` form declared in `types.rs`. +fn resolved(pointer: &str, kind: BoundKind, values: BoundValues) -> Vec { + vec![((pointer.to_owned(), kind), values)] +} + +fn plan( + schema: &ResolvedSchema, + entries: &[&str], + observations: &Observations, +) -> Vec { + plan_advisories(schema, entries, observations).needs +} + +fn plan_advisories(schema: &ResolvedSchema, entries: &[&str], observations: &Observations) -> Plan { + narrow::plan_advisories(schema, &selection(entries), observations).expect("plan") +} + +fn apply(schema: &ResolvedSchema, entries: &[&str], resolutions: &[Resolution]) -> NarrowOutcome { + narrow::apply_entries(schema, &selection(entries), resolutions).expect("apply") +} + +/// A root object wrapping one property, the smallest schema the subset admits. +fn root(properties: Value, required: Value) -> ResolvedSchema { + schema(json!({ + "type": "object", + "required": required, + "properties": properties, + })) +} + +#[test] +fn an_integer_with_only_a_minimum_raises_a_bound_need() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0}}), + json!(["total"]), + ); + + let needs = plan(&input, &["/total"], &Observations::default()); + + assert_eq!(needs.len(), 1, "one unmet bound: {needs:?}"); + assert_eq!(needs[0].pointer, "/total"); + assert_eq!(needs[0].kind, BoundKind::IntegerRange); + assert!( + needs[0].suggestion.is_none(), + "nothing to derive a maximum from: {:?}", + needs[0].suggestion + ); +} + +#[test] +fn an_integer_with_an_enum_needs_no_bound_and_keeps_the_enum() { + let input = root( + json!({"total": {"type": "integer", "enum": [0, 1, 2]}}), + json!(["total"]), + ); + + assert!(plan(&input, &["/total"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/total"], &[]); + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "enum": [0, 1, 2]}) + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn an_integer_with_a_const_needs_no_bound() { + let input = root( + json!({"page": {"type": "integer", "const": 1}}), + json!(["page"]), + ); + + assert!(plan(&input, &["/page"], &Observations::default()).is_empty()); + let outcome = apply(&input, &["/page"], &[]); + assert_eq!( + outcome.schema["properties"]["page"], + json!({"type": "integer", "const": 1}) + ); +} + +#[test] +fn a_sample_widens_an_integer_range_outward_to_round_numbers() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(0), + max_integer: Some(2), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + let suggestion = needs[0].suggestion.clone().expect("a sample suggestion"); + assert_eq!( + suggestion, + SuggestedBound { + values: BoundValues::IntegerRange { + minimum: 0, + maximum: 10 + }, + provenance: Provenance::Sample, + } + ); +} + +/// A sampled integer is usually a counter, and a counter's ceiling is the one +/// bound a single response says least about: the widening is deliberately +/// generous rather than snug around what was seen. +#[test] +fn a_sample_integer_maximum_widens_to_a_generous_power_of_ten() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(4), + max_integer: Some(1_000), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 0, + maximum: 10_000 + }, + "the observed floor of 4 is kept at 0 and 1000 widens past 2000 to 10000" + ); +} + +#[test] +fn a_small_sampled_counter_still_gets_a_generous_ceiling() { + for (observed_maximum, expected) in [(12_i64, 100_i64), (60, 1_000), (2, 10)] { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + let observations = observed( + "/total", + Observed { + min_integer: Some(0), + max_integer: Some(observed_maximum), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 0, + maximum: expected + }, + "observing {observed_maximum} should suggest a ceiling of {expected}" + ); + } +} + +#[test] +fn a_negative_sample_integer_widens_below_zero() { + let input = root(json!({"offset": {"type": "integer"}}), json!(["offset"])); + let observations = observed( + "/offset", + Observed { + min_integer: Some(-30), + max_integer: Some(5), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/offset"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: -50, + maximum: 10 + } + ); +} + +#[test] +fn a_spec_minimum_survives_into_a_sample_suggestion() { + let input = root( + json!({"total": {"type": "integer", "minimum": 1}}), + json!(["total"]), + ); + let observations = observed( + "/total", + Observed { + min_integer: Some(3), + max_integer: Some(3), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/total"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::IntegerRange { + minimum: 1, + maximum: 10 + }, + "the stated minimum is authoritative; only the missing maximum is derived" + ); +} + +#[test] +fn a_string_with_the_date_format_is_kept_and_needs_no_bound() { + let input = root( + json!({"recordedOn": {"type": "string", "format": "date"}}), + json!([]), + ); + + assert!(plan(&input, &["/recordedOn"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/recordedOn"], &[]); + assert_eq!( + outcome.schema["properties"]["recordedOn"], + json!({"type": "string", "format": "date"}) + ); +} + +#[test] +fn a_string_with_the_uuid_format_is_suggested_fixed_length_and_loses_the_format() { + let input = root( + json!({"trackingId": {"type": "string", "format": "uuid"}}), + json!(["trackingId"]), + ); + + let plan = plan_advisories(&input, &["/trackingId"], &Observations::default()); + + assert_eq!(plan.needs.len(), 1); + assert_eq!(plan.needs[0].kind, BoundKind::StringLength); + assert_eq!( + plan.needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::StringLength { + min_length: 36, + max_length: 36 + }, + provenance: Provenance::Format, + } + ); + assert!( + plan.advisories + .iter() + .any(|advisory| advisory.pointer == "/trackingId" + && advisory.kind == AdvisoryKind::DroppedFormat("uuid".to_owned())), + "the dropped format is reported: {:?}", + plan.advisories + ); + + let outcome = apply( + &input, + &["/trackingId"], + &resolved( + "/trackingId", + BoundKind::StringLength, + BoundValues::StringLength { + min_length: 36, + max_length: 36, + }, + ), + ); + assert_eq!( + outcome.schema["properties"]["trackingId"], + json!({"type": "string", "minLength": 36, "maxLength": 36}), + "the closed subset admits only the date formats, so `uuid` is dropped" + ); +} + +#[test] +fn a_string_with_an_unsupported_format_suggests_nothing_and_reports_the_drop() { + let input = root( + json!({"contact": {"type": "string", "format": "email"}}), + json!(["contact"]), + ); + + let plan = plan_advisories(&input, &["/contact"], &Observations::default()); + + assert_eq!(plan.needs.len(), 1); + assert!( + plan.needs[0].suggestion.is_none(), + "nothing mechanical follows from `email`" + ); + assert_eq!( + plan.advisories[0].kind, + AdvisoryKind::DroppedFormat("email".to_owned()) + ); +} + +#[test] +fn a_sample_string_length_rounds_up_to_the_next_power_of_two() { + let input = root(json!({"status": {"type": "string"}}), json!(["status"])); + let observations = observed( + "/status", + Observed { + max_string_bytes: Some(36), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/status"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::StringLength { + min_length: 0, + max_length: 64 + }, + provenance: Provenance::Sample, + } + ); +} + +#[test] +fn a_short_sample_string_still_gets_the_sixteen_byte_floor() { + let input = root(json!({"status": {"type": "string"}}), json!(["status"])); + let observations = observed( + "/status", + Observed { + max_string_bytes: Some(3), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/status"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::StringLength { + min_length: 0, + max_length: 16 + } + ); +} + +#[test] +fn a_string_enum_needs_no_bound_and_is_carried_through() { + let input = root( + json!({"status": {"type": "string", "enum": ["open", "closed"]}}), + json!(["status"]), + ); + + assert!(plan(&input, &["/status"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/status"], &[]); + assert_eq!( + outcome.schema["properties"]["status"], + json!({"type": "string", "enum": ["open", "closed"]}) + ); +} + +#[test] +fn an_array_without_max_items_takes_a_widened_clamped_sample_suggestion() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 32}}}), + json!(["records"]), + ); + let observations = observed( + "/records", + Observed { + max_array_items: Some(2), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/records/*"], &observations); + + assert_eq!(needs.len(), 1); + assert_eq!(needs[0].pointer, "/records"); + assert_eq!(needs[0].kind, BoundKind::ArrayMaxItems); + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::MaxItems(8), + provenance: Provenance::Sample, + }, + "two observed elements round up to the next multiple of eight" + ); +} + +#[test] +fn a_large_sample_array_is_clamped_to_the_subset_ceiling() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 32}}}), + json!(["records"]), + ); + let observations = observed( + "/records", + Observed { + max_array_items: Some(500), + ..Observed::default() + }, + ); + + let needs = plan(&input, &["/records/*"], &observations); + + assert_eq!( + needs[0].suggestion.clone().expect("suggestion").values, + BoundValues::MaxItems(256), + "the closed subset admits maxItems 1..=256" + ); +} + +#[test] +fn a_spec_max_items_outside_the_subset_is_clamped_and_still_reported() { + let input = root( + json!({ + "records": { + "type": "array", + "maxItems": 5_000, + "items": {"type": "string", "maxLength": 32} + } + }), + json!(["records"]), + ); + + let needs = plan(&input, &["/records/*"], &Observations::default()); + + assert_eq!(needs.len(), 1); + assert_eq!( + needs[0].suggestion.clone().expect("suggestion"), + SuggestedBound { + values: BoundValues::MaxItems(256), + provenance: Provenance::Spec, + } + ); + + let outcome = apply(&input, &["/records/*"], &[]); + assert!( + outcome.schema["properties"]["records"] + .get("maxItems") + .is_none(), + "an out-of-subset spec bound is never emitted unresolved" + ); +} + +#[test] +fn a_spec_max_items_inside_the_subset_raises_no_need() { + let input = root( + json!({ + "records": { + "type": "array", + "maxItems": 2, + "items": {"type": "string", "maxLength": 32} + } + }), + json!(["records"]), + ); + + assert!(plan(&input, &["/records/*"], &Observations::default()).is_empty()); + + let outcome = apply(&input, &["/records/*"], &[]); + assert_eq!( + outcome.schema["properties"]["records"], + json!({ + "type": "array", + "minItems": 0, + "maxItems": 2, + "items": {"type": "string", "maxLength": 32} + }) + ); +} + +#[test] +fn a_record_under_a_wildcard_requires_only_the_kept_members_the_spec_guarantees() { + let input = root( + json!({ + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "required": ["id", "status"], + "properties": { + "id": {"type": "string", "maxLength": 64}, + "status": {"type": "string", "enum": ["open", "closed"]}, + "note": {"type": "string", "maxLength": 8} + } + } + } + }), + json!(["total", "results"]), + ); + + let outcome = apply(&input, &["/total", "/results/*/id"], &[]); + + assert_eq!( + outcome.schema, + json!({ + "type": "object", + "additionalProperties": false, + "required": ["results", "total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 1000000}, + "results": { + "type": "array", + "minItems": 0, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": {"type": "string", "maxLength": 64} + } + } + } + } + }), + "unselected members leave, and `status` leaves `required` with them" + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn a_record_the_spec_does_not_guarantee_gets_an_empty_required_list() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "properties": {"recordedOn": {"type": "string", "format": "date"}} + } + } + }), + json!(["results"]), + ); + + let outcome = apply(&input, &["/results/*/recordedOn"], &[]); + + assert_eq!( + outcome.schema["properties"]["results"]["items"]["required"], + json!([]), + "projection drops a leaf the record did not carry, so nothing is required" + ); +} + +#[test] +fn a_nullable_type_pair_is_preserved_through_narrowing() { + let input = root( + json!({ + "result": { + "type": ["object", "null"], + "properties": { + "code": {"type": ["string", "null"], "maxLength": 32} + } + } + }), + json!([]), + ); + + let outcome = apply(&input, &["/result/code"], &[]); + + assert_eq!( + outcome.schema["properties"]["result"], + json!({ + "type": ["object", "null"], + "additionalProperties": false, + "required": [], + "properties": {"code": {"type": ["string", "null"], "maxLength": 32}} + }) + ); +} + +#[test] +fn an_explicit_null_the_spec_does_not_admit_is_reported_as_an_advisory() { + let input = root( + json!({"status": {"type": "string", "maxLength": 32}}), + json!(["status"]), + ); + let observations = observed( + "/status", + Observed { + saw_null: true, + max_string_bytes: Some(4), + ..Observed::default() + }, + ); + + let plan = plan_advisories(&input, &["/status"], &observations); + + assert!(plan.needs.is_empty(), "the spec already bounds the string"); + assert_eq!(plan.advisories.len(), 1); + assert_eq!(plan.advisories[0].pointer, "/status"); + assert_eq!(plan.advisories[0].kind, AdvisoryKind::NullOutsideSpec); + assert!( + plan.advisories[0].message().contains("null"), + "the advisory explains itself: {}", + plan.advisories[0].message() + ); +} + +#[test] +fn a_nullable_leaf_that_saw_null_raises_no_advisory() { + let input = root( + json!({"status": {"type": ["string", "null"], "maxLength": 32}}), + json!(["status"]), + ); + let observations = observed( + "/status", + Observed { + saw_null: true, + ..Observed::default() + }, + ); + + assert!(plan_advisories(&input, &["/status"], &observations) + .advisories + .is_empty()); +} + +#[test] +fn an_ancestor_and_its_descendant_cannot_both_be_selected() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": { + "type": "object", + "properties": {"id": {"type": "string", "maxLength": 8}} + } + } + }), + json!(["results"]), + ); + + let error = narrow::apply_entries(&input, &selection(&["/results", "/results/*/id"]), &[]) + .expect_err("overlapping projection entries are rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("/results"), "{message}"); + assert!(message.contains("/results/*/id"), "{message}"); + assert!(message.contains("overlap"), "{message}"); +} + +#[test] +fn a_duplicate_selection_entry_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + let error = narrow::plan_advisories( + &input, + &selection(&["/total", "/total"]), + &Observations::default(), + ) + .expect_err("duplicate projection entries are rejected"); + let message = format!("{error:#}"); + + assert!(message.contains("/total"), "{message}"); + assert!(message.contains("duplicat"), "{message}"); +} + +#[test] +fn a_numeric_index_into_an_array_is_rejected_in_favour_of_the_wildcard() { + let input = root( + json!({ + "results": { + "type": "array", + "maxItems": 2, + "items": {"type": "string", "maxLength": 8} + } + }), + json!([]), + ); + + let error = narrow::apply_entries(&input, &selection(&["/results/0"]), &[]) + .expect_err("numeric indexes are not projection syntax"); + let message = format!("{error:#}"); + + assert!(message.contains('*'), "{message}"); +} + +#[test] +fn a_selection_that_is_not_in_the_schema_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + let error = narrow::apply_entries(&input, &selection(&["/missing"]), &[]) + .expect_err("an unknown pointer is a visible failure"); + + assert!(format!("{error:#}").contains("/missing")); +} + +#[test] +fn an_unresolved_bound_is_omitted_from_the_schema_and_reported() { + let input = root( + json!({ + "total": {"type": "integer"}, + "records": {"type": "array", "items": {"type": "string"}} + }), + json!(["total"]), + ); + + let outcome = apply(&input, &["/total", "/records/*"], &[]); + + let total = &outcome.schema["properties"]["total"]; + assert_eq!(total, &json!({"type": "integer"})); + assert!(total.get("minimum").is_none() && total.get("maximum").is_none()); + let records = &outcome.schema["properties"]["records"]; + assert!(records.get("maxItems").is_none()); + assert!(records["items"].get("maxLength").is_none()); + + let reported: Vec<(&str, &BoundKind)> = outcome + .unresolved + .iter() + .map(|need| (need.pointer.as_str(), &need.kind)) + .collect(); + assert_eq!( + reported, + vec![ + ("/records", &BoundKind::ArrayMaxItems), + ("/records/*", &BoundKind::StringLength), + ("/total", &BoundKind::IntegerRange), + ], + "unresolved bounds are reported in schema order" + ); +} + +#[test] +fn a_resolved_bound_is_written_into_the_schema() { + let input = root(json!({"total": {"type": "integer"}}), json!(["total"])); + + let outcome = apply( + &input, + &["/total"], + &resolved( + "/total", + BoundKind::IntegerRange, + BoundValues::IntegerRange { + minimum: 0, + maximum: 1_000_000, + }, + ), + ); + + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "minimum": 0, "maximum": 1000000}) + ); + assert!(outcome.unresolved.is_empty()); +} + +#[test] +fn unselected_siblings_are_pruned_from_every_level() { + let input = root( + json!({ + "total": {"type": "integer", "minimum": 0, "maximum": 100}, + "secret": {"type": "string", "maxLength": 32}, + "meta": { + "type": "object", + "required": ["page"], + "properties": { + "page": {"type": "integer", "const": 1}, + "cursor": {"type": "string", "maxLength": 64} + } + } + }), + json!(["total", "secret", "meta"]), + ); + + let outcome = apply(&input, &["/total", "/meta/page"], &[]); + + assert_eq!( + outcome.schema, + json!({ + "type": "object", + "additionalProperties": false, + "required": ["meta", "total"], + "properties": { + "total": {"type": "integer", "minimum": 0, "maximum": 100}, + "meta": { + "type": "object", + "additionalProperties": false, + "required": ["page"], + "properties": {"page": {"type": "integer", "const": 1}} + } + } + }) + ); +} + +#[test] +fn selecting_a_container_keeps_its_whole_subtree() { + let input = root( + json!({ + "meta": { + "type": "object", + "required": ["page"], + "properties": { + "page": {"type": "integer", "const": 1}, + "cursor": {"type": "string", "maxLength": 64} + } + }, + "other": {"type": "string", "maxLength": 4} + }), + json!(["meta", "other"]), + ); + + let outcome = apply(&input, &["/meta"], &[]); + + assert_eq!( + outcome.schema["properties"]["meta"]["properties"]["cursor"], + json!({"type": "string", "maxLength": 64}) + ); + assert!(outcome.schema["properties"].get("other").is_none()); +} + +#[test] +fn an_escaped_segment_addresses_the_literal_key() { + let input = root( + json!({"a/b": {"type": "string", "maxLength": 8}, "c~d": {"type": "string", "maxLength": 8}}), + json!([]), + ); + + let outcome = apply(&input, &["/a~1b", "/c~0d"], &[]); + + assert!(outcome.schema["properties"].get("a/b").is_some()); + assert!(outcome.schema["properties"].get("c~d").is_some()); +} + +#[test] +fn a_type_outside_the_closed_subset_is_rejected() { + let input = root(json!({"score": {"type": "number"}}), json!(["score"])); + + let error = narrow::apply_entries(&input, &selection(&["/score"]), &[]) + .expect_err("`number` is outside the closed Version 1 subset"); + + assert!(format!("{error:#}").contains("number")); +} + +#[test] +fn a_resolution_outside_the_subset_range_is_rejected() { + let input = root( + json!({"records": {"type": "array", "items": {"type": "string", "maxLength": 8}}}), + json!([]), + ); + + let error = narrow::apply_entries( + &input, + &selection(&["/records/*"]), + &resolved( + "/records", + BoundKind::ArrayMaxItems, + BoundValues::MaxItems(1_000), + ), + ) + .expect_err("a resolved maxItems above 256 is outside the subset"); + + assert!(format!("{error:#}").contains("256")); +} + +#[test] +fn a_resolution_that_matches_no_need_is_rejected() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0, "maximum": 10}}), + json!(["total"]), + ); + + let error = narrow::apply_entries( + &input, + &selection(&["/total"]), + &resolved( + "/totl", + BoundKind::IntegerRange, + BoundValues::IntegerRange { + minimum: 0, + maximum: 10, + }, + ), + ) + .expect_err("a stray resolution is a visible failure"); + + assert!(format!("{error:#}").contains("/totl")); +} + +#[test] +fn an_empty_selection_is_rejected() { + let input = root(json!({"total": {"type": "integer", "const": 1}}), json!([])); + + assert!(narrow::apply_entries(&input, &[], &[]).is_err()); +} + +#[test] +fn the_map_entry_point_narrows_the_same_schema() { + let input = root( + json!({"total": {"type": "integer", "minimum": 0, "maximum": 10}}), + json!(["total"]), + ); + + let outcome = narrow::apply(&input, &selection(&["/total"]), &BTreeMap::new()) + .expect("the map entry point delegates to the slice one"); + + assert_eq!( + outcome.schema["properties"]["total"], + json!({"type": "integer", "minimum": 0, "maximum": 10}) + ); +} diff --git a/crates/registry-evidencectl/tests/suggest_openapi.rs b/crates/registry-evidencectl/tests/suggest_openapi.rs new file mode 100644 index 000000000..1e88c9a5d --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_openapi.rs @@ -0,0 +1,385 @@ +//! Tests for the OpenAPI loading (`openapi.rs`) and schema flattening +//! (`flatten.rs`) stages of `evidencectl source suggest`. +//! +//! `registry-evidencectl` ships only a binary target, so this integration +//! test pulls the modules it needs in directly by path rather than through a +//! library crate. `types` and `openapi`/`flatten` are declared as siblings +//! here, mirroring their nesting under `src/suggest/`, so the `super::types` +//! imports inside `openapi.rs` and `flatten.rs` resolve unchanged. + +#[path = "../src/suggest/flatten.rs"] +mod flatten; +#[path = "../src/suggest/openapi.rs"] +mod openapi; +// `types.rs` is shared across every pipeline stage; this test binary only +// exercises the openapi/flatten slice of it, so the rest looks unused to +// this crate's own dead-code analysis even though the real `evidencectl` +// binary uses all of it once every stage is wired together. +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::path::{Path, PathBuf}; + +use types::{OperationKey, ResolvedSchema}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/openapi") + .join(name) +} + +fn operation(method: &str, path: &str) -> OperationKey { + OperationKey { + method: method.to_string(), + path: path.to_string(), + } +} + +// --- Spec::load ------------------------------------------------------------ + +#[test] +fn load_accepts_openapi_3_0_yaml() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + assert!(!spec.operations().is_empty()); +} + +#[test] +fn load_accepts_openapi_3_1_json() { + let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + assert!(!spec.operations().is_empty()); +} + +#[test] +fn load_rejects_unsupported_openapi_version() { + let error = openapi::Spec::load(&fixture("unsupported-version.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("3.0") || message.contains("3.1"), + "message was: {message}" + ); +} + +#[test] +fn load_rejects_missing_file() { + let error = openapi::Spec::load(&fixture("does-not-exist.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("does-not-exist.yaml"), + "message was: {message}" + ); +} + +// --- Spec::operations -------------------------------------------------------- + +/// The runtime's fixed-request method is an enumeration of GET and POST, so an +/// operation on any other method is not offerable: the fixture's `DELETE +/// /records` carries a JSON response and is still absent from the listing. +#[test] +fn operations_lists_only_the_methods_the_runtime_admits() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let mut keys: Vec = spec + .operations() + .into_iter() + .map(|summary| summary.key) + .collect(); + keys.sort_by(|a, b| (&a.path, &a.method).cmp(&(&b.path, &b.method))); + assert_eq!( + keys, + vec![operation("GET", "/records"), operation("POST", "/records")] + ); +} + +#[test] +fn operations_reports_summary_and_json_responses() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let get_records = spec + .operations() + .into_iter() + .find(|summary| summary.key == operation("GET", "/records")) + .expect("GET /records present"); + assert_eq!(get_records.summary.as_deref(), Some("Search records")); + assert_eq!( + get_records.json_responses, + vec![("200".to_string(), "application/json".to_string())] + ); +} + +#[test] +fn operations_collects_every_json_response_status() { + let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let get_record = spec + .operations() + .into_iter() + .find(|summary| summary.key == operation("GET", "/records/{id}")) + .expect("GET /records/{id} present"); + let mut responses = get_record.json_responses; + responses.sort(); + assert_eq!( + responses, + vec![ + ("200".to_string(), "application/json".to_string()), + ("404".to_string(), "application/json".to_string()), + ] + ); +} + +// --- Spec::response_schema --------------------------------------------------- + +#[test] +fn response_schema_inlines_local_refs_and_normalizes_nullable() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + + let rendered = resolved.0.to_string(); + assert!( + !rendered.contains("$ref"), + "refs should be fully inlined: {rendered}" + ); + + // Top-level `nullable: true` string becomes the 3.1 type pair. + assert_eq!( + resolved.0["properties"]["recordedOn"]["type"], + serde_json::json!(["string", "null"]) + ); + assert!(resolved.0["properties"]["recordedOn"] + .get("nullable") + .is_none()); + + // The $ref'd array item schema (Record) is inlined in place, and its own + // nested `nullable: true` (notes) is normalized too. + let record_item = &resolved.0["properties"]["results"]["items"]; + assert_eq!( + record_item["properties"]["trackingId"]["type"], + serde_json::json!("string") + ); + assert_eq!( + record_item["properties"]["notes"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn response_schema_passes_through_3_1_type_arrays_unchanged() { + let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let resolved = spec + .response_schema( + &operation("GET", "/records/{id}"), + "200", + "application/json", + ) + .expect("resolves"); + assert_eq!( + resolved.0["properties"]["status"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn response_schema_rejects_external_ref() { + let spec = openapi::Spec::load(&fixture("external-ref.yaml")).expect("loads"); + let error = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("external") || message.contains("remote"), + "message was: {message}" + ); +} + +#[test] +fn response_schema_rejects_ref_cycle() { + let spec = openapi::Spec::load(&fixture("ref-cycle.yaml")).expect("loads"); + let error = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("cycle"), "message was: {message}"); +} + +#[test] +fn response_schema_rejects_unknown_status() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let error = spec + .response_schema(&operation("GET", "/records"), "500", "application/json") + .unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("500"), "message was: {message}"); +} + +// --- Spec::servers / Spec::page_size_maximums -------------------------------- + +#[test] +fn servers_lists_declared_base_urls_in_order() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + assert_eq!( + spec.servers(), + vec!["https://records.example.test/api".to_string()] + ); +} + +#[test] +fn servers_is_empty_when_undeclared() { + let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + assert!(spec.servers().is_empty()); +} + +#[test] +fn page_size_maximums_reads_matching_query_parameters() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records")) + .expect("no ref errors"); + assert_eq!(maximums, vec![100]); +} + +#[test] +fn page_size_maximums_is_empty_without_matching_parameters() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("POST", "/records")) + .expect("no ref errors"); + assert!(maximums.is_empty()); +} + +#[test] +fn page_size_maximums_matches_limit_named_parameters() { + let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records/{id}")) + .expect("no ref errors"); + assert_eq!(maximums, vec![50]); +} + +// --- flatten::candidate_leaves ------------------------------------------------ + +#[test] +fn candidate_leaves_flattens_arrays_and_nullable_records() { + let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!( + pointers, + vec![ + "/recordedOn", + "/results/*/notes", + "/results/*/status", + "/results/*/trackingId", + "/total", + ] + ); + + let recorded_on = leaves + .iter() + .find(|leaf| leaf.pointer == "/recordedOn") + .expect("present"); + assert_eq!(recorded_on.type_label, "string (date-time)"); + assert!(recorded_on.nullable); + + let notes = leaves + .iter() + .find(|leaf| leaf.pointer == "/results/*/notes") + .expect("present"); + assert!(notes.nullable); + + let total = leaves + .iter() + .find(|leaf| leaf.pointer == "/total") + .expect("present"); + assert_eq!(total.type_label, "integer"); + assert!(!total.nullable); +} + +#[test] +fn candidate_leaves_escapes_member_names_per_rfc_6901() { + let spec = openapi::Spec::load(&fixture("escaping.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/tags~1primary", "/tilde~0name"]); +} + +#[test] +fn candidate_leaves_skips_and_warns_on_unsupported_constructs() { + let spec = openapi::Spec::load(&fixture("unsupported-constructs.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let (leaves, warnings) = flatten::candidate_leaves(&resolved); + + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/simpleAllOf", "/trackingId"]); + + let simple_all_of = leaves + .iter() + .find(|leaf| leaf.pointer == "/simpleAllOf") + .expect("present"); + assert_eq!(simple_all_of.type_label, "string (date)"); + + assert_eq!(warnings.len(), 5, "warnings: {warnings:#?}"); + assert!(warnings + .iter() + .any(|warning| warning.contains("/choice") && warning.contains("oneOf"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/wildcard") && warning.contains("additionalProperties"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/missingItems") && warning.contains("items"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/freeform") && warning.contains("properties"))); + assert!(warnings + .iter() + .any(|warning| warning.contains("/multiType"))); +} + +#[test] +fn candidate_leaves_truncates_at_depth_limit_and_warns() { + fn nested_object(remaining: usize) -> serde_json::Value { + if remaining == 0 { + serde_json::json!({"type": "string"}) + } else { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { "level": nested_object(remaining - 1) }, + }) + } + } + + let schema = serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "shallow": {"type": "string"}, + "deep": nested_object(20), + }, + }); + let resolved = ResolvedSchema(schema); + let (leaves, warnings) = flatten::candidate_leaves(&resolved); + + let pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/shallow"]); + assert!( + warnings + .iter() + .any(|warning| warning.contains("16") && warning.contains("/deep")), + "warnings: {warnings:#?}" + ); +} diff --git a/crates/registry-evidencectl/tests/suggest_sample.rs b/crates/registry-evidencectl/tests/suggest_sample.rs new file mode 100644 index 000000000..da36ef5d8 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_sample.rs @@ -0,0 +1,310 @@ +//! Tests for the sample-observation stage of `evidencectl source suggest`. +//! +//! `registry-evidencectl` is a binary-only crate (no library target), so the +//! module under test is pulled in directly by path rather than through +//! normal `use registry_evidencectl::...` linkage. `types` is included the +//! same way and declared as a sibling of `sample`, mirroring their real +//! relationship as siblings under `suggest`: `sample.rs` reaches it through +//! `super::types`, and `super` from a crate-root `mod sample;` here is this +//! same crate root, where `mod types;` also lives. + +#[path = "../src/suggest/sample.rs"] +mod sample; +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::{fs, path::PathBuf}; + +use sample::{load_sample, observe}; +use serde_json::json; +use types::Observations; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/samples") + .join(name) +} + +fn load_fixture(name: &str) -> serde_json::Value { + load_sample(&fixture(name)).unwrap_or_else(|error| panic!("failed to load {name}: {error:#}")) +} + +// --- load_sample ----------------------------------------------------------- + +#[test] +fn load_sample_reads_a_valid_json_file() { + let value = load_fixture("nested-records.json"); + assert_eq!(value["total"], json!(42)); +} + +#[test] +fn load_sample_rejects_a_file_that_is_not_valid_json() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("not-json.json"); + fs::write(&path, b"{ this is not json").expect("write fixture"); + + let error = load_sample(&path).expect_err("invalid JSON must be rejected"); + assert!( + error.to_string().contains("not valid JSON"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn load_sample_rejects_a_missing_file() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("missing.json"); + + let error = load_sample(&path).expect_err("a missing file must be rejected"); + assert!( + error + .to_string() + .contains("failed to read sample file metadata"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn load_sample_rejects_a_file_over_the_size_ceiling() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().join("oversized.json"); + + // A single 4 MiB+1 array element keeps this a well-formed (if useless) + // JSON document while tripping the size ceiling before any parsing. + let mut contents = Vec::with_capacity(4 * 1024 * 1024 + 16); + contents.extend_from_slice(b"\""); + contents.resize(contents.len() + 4 * 1024 * 1024 + 1, b'Z'); + contents.extend_from_slice(b"\""); + fs::write(&path, &contents).expect("write oversized fixture"); + + let error = load_sample(&path).expect_err("an oversized file must be rejected"); + let message = error.to_string(); + assert!(message.contains("exceeding"), "unexpected error: {message}"); + // The ceiling is enforced from file metadata alone, before any read, so + // the fill content can never reach the error message; "ZZZZ" is a + // pattern a temp-directory path could not plausibly contain. + assert!( + !message.contains("ZZZZ"), + "error message must not echo sample content: {message}" + ); +} + +// --- observe: integers ------------------------------------------------------- + +#[test] +fn observe_tracks_integer_min_and_max_across_every_array_element() { + let sample = load_fixture("integer-range.json"); + let selection = vec!["/records/*/priority".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let observed = observations + .by_pointer + .get("/records/*/priority") + .expect("priority observed"); + assert_eq!(observed.min_integer, Some(-7)); + assert_eq!(observed.max_integer, Some(12)); + assert!(!observed.saw_null); + assert_eq!(observed.max_string_bytes, None); +} + +// --- observe: strings, unicode ---------------------------------------------- + +#[test] +fn observe_measures_string_length_in_bytes_not_characters() { + let sample = load_fixture("unicode.json"); + let selection = vec!["/status".to_owned(), "/note".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + // "héllo" is 5 characters but 6 bytes: 'é' is a 2-byte UTF-8 sequence. + let status = observations + .by_pointer + .get("/status") + .expect("status observed"); + assert_eq!("héllo".chars().count(), 5); + assert_eq!(status.max_string_bytes, Some(6)); + + // "日本語" is 3 characters but 9 bytes: each character is 3 UTF-8 bytes. + let note = observations.by_pointer.get("/note").expect("note observed"); + assert_eq!("日本語".chars().count(), 3); + assert_eq!(note.max_string_bytes, Some(9)); +} + +// --- observe: arrays --------------------------------------------------------- + +#[test] +fn observe_records_max_array_items_for_the_wildcard_array_itself() { + let sample = load_fixture("nested-records.json"); + let selection = vec!["/results/*/trackingId".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let results = observations + .by_pointer + .get("/results") + .expect("results array observed"); + assert_eq!(results.max_array_items, Some(2)); + + let tracking_id = observations + .by_pointer + .get("/results/*/trackingId") + .expect("trackingId observed"); + assert_eq!( + tracking_id.max_string_bytes, + Some("def-456-longer".len() as u64) + ); +} + +#[test] +fn observe_records_max_array_items_for_an_array_selected_wholesale_under_a_wildcard() { + let sample = load_fixture("nested-records.json"); + // `tags` is itself an array within each record; it is selected without a + // further `*`, exercising the "array selected wholesale" case nested + // under an outer wildcard. + let selection = vec!["/results/*/tags".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let tags = observations + .by_pointer + .get("/results/*/tags") + .expect("tags observed"); + // Record one carries 3 tags, record two carries 1: the largest is kept. + assert_eq!(tags.max_array_items, Some(3)); +} + +#[test] +fn observe_records_a_top_level_array_selected_wholesale() { + let sample = load_fixture("nested-records.json"); + let selection = vec!["/results".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let results = observations + .by_pointer + .get("/results") + .expect("results observed"); + assert_eq!(results.max_array_items, Some(2)); +} + +// --- observe: nulls and absence --------------------------------------------- + +#[test] +fn observe_flags_an_explicit_null_without_recording_a_length() { + let sample = load_fixture("nulls-and-absent.json"); + let selection = vec!["/recordedOn".to_owned(), "/status".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let recorded_on = observations + .by_pointer + .get("/recordedOn") + .expect("recordedOn observed"); + assert!(recorded_on.saw_null); + assert_eq!(recorded_on.max_string_bytes, None); + + let status = observations + .by_pointer + .get("/status") + .expect("status observed"); + assert!(!status.saw_null); + assert_eq!(status.max_string_bytes, Some("active".len() as u64)); +} + +#[test] +fn observe_leaves_an_absent_pointer_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + let selection = vec!["/doesNotExist".to_owned()]; + + let observations = observe(&sample, &selection).expect("an absent pointer is not an error"); + + assert!(!observations.by_pointer.contains_key("/doesNotExist")); + assert!(observations.by_pointer.is_empty()); +} + +#[test] +fn observe_leaves_a_wildcard_on_a_non_array_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + // `status` is a string, not an array: the `*` segment cannot land. + let selection = vec!["/status/*/child".to_owned()]; + + let observations = observe(&sample, &selection).expect("a type mismatch is not an error"); + + assert!(observations.by_pointer.is_empty()); +} + +#[test] +fn observe_leaves_a_key_lookup_on_a_non_object_unobserved_without_error() { + let sample = load_fixture("nulls-and-absent.json"); + // `status` is a string, not an object: it has no `child` member. + let selection = vec!["/status/child".to_owned()]; + + let observations = observe(&sample, &selection).expect("a type mismatch is not an error"); + + assert!(observations.by_pointer.is_empty()); +} + +// --- observe: pointer escaping ----------------------------------------------- + +#[test] +fn observe_unescapes_tilde_one_and_tilde_zero_segments() { + let sample = load_fixture("escaping.json"); + // "~1" decodes to "/", so "/a~1b" reaches the key "a/b". + // "~0" decodes to "~", so "/a~0b" reaches the key "a~b". + let selection = vec!["/a~1b".to_owned(), "/a~0b".to_owned()]; + + let observations = observe(&sample, &selection).expect("observe"); + + let slash_key = observations.by_pointer.get("/a~1b").expect("a/b observed"); + assert_eq!(slash_key.max_string_bytes, Some("slash-key".len() as u64)); + + let tilde_key = observations.by_pointer.get("/a~0b").expect("a~b observed"); + assert_eq!(tilde_key.max_string_bytes, Some("tilde-key".len() as u64)); +} + +// --- observe: malformed pointer syntax -------------------------------------- + +#[test] +fn observe_rejects_a_pointer_missing_its_leading_slash() { + let sample = json!({ "status": "open" }); + let selection = vec!["status".to_owned()]; + + let error = + observe(&sample, &selection).expect_err("a pointer without a leading slash is malformed"); + assert!(error + .to_string() + .contains("must be a non-empty extended JSON Pointer")); +} + +#[test] +fn observe_rejects_an_empty_pointer() { + let sample = json!({ "status": "open" }); + let selection = vec![String::new()]; + + let error = observe(&sample, &selection).expect_err("an empty pointer is malformed"); + assert!(error + .to_string() + .contains("must be a non-empty extended JSON Pointer")); +} + +// --- privacy: no sample string value survives into Observations ------------ + +#[test] +fn observe_never_carries_a_sample_string_value_into_its_debug_rendering() { + let sample = load_fixture("canary.json"); + let canary = sample["status"].as_str().expect("canary value").to_owned(); + let selection = vec!["/status".to_owned()]; + + let observations: Observations = observe(&sample, &selection).expect("observe"); + + let rendered = format!("{observations:?}"); + assert!( + !rendered.contains(&canary), + "Debug rendering of Observations must never contain a sample string value: {rendered}" + ); + // The pointer and the derived length are expected to appear; only the + // value itself must be absent. + assert!(rendered.contains("/status")); +} diff --git a/products/evidence/README.md b/products/evidence/README.md index fc1e4b43f..ac41973f4 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -86,6 +86,16 @@ re-implements evaluation, signing, or verification. It must not depend on `registry-notary*`, and its source and scaffold templates are covered by the same source-product and domain neutrality checks as the runtime. +`evidencectl source suggest` drafts one source from an OpenAPI description: +it derives a closed response schema, an extraction script, and the facts schema +from the chosen operation, the projection the operator selects, and an optional +sample response, leaving an explicit `TODO` wherever a bound cannot be derived +so `evidence check` rejects the draft until a human resolves it. + +```bash +evidencectl source suggest --openapi ./api.yaml --project ./deployment-project +``` + ## Discovering available evidence An authenticated caller lists the complete Evidence request shapes it can From 329a526066b44e43c7bca8f98628aef95fd16826 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:38:09 +0700 Subject: [PATCH 050/136] test(evidence): prove a signed assertion over a Relay-shaped source A wiremock mirrors the Relay OpenAPI's protected household-record read by hand (path, bearer, Data-Purpose, entity response shape) so Evidence proves the blessed Evidence-over-Relay composition without depending on Relay code. The test drives OAuth client credentials through the frozen runtime's public API to a signed flattened JWS, verifies it against the deployment JWKS under the full relying policy, and pins minimum disclosure with canaries: the record id, the untouched field, and the raw region code never reach the assertion payload. Zero production-code changes were needed; every composition component already exists in the frozen V1 runtime. Ticks A2 in plans/notary-retirement-and-evidence-onboarding.md. Signed-off-by: Jeremi Joslin --- .../tests/relay_shaped_source.rs | 581 ++++++++++++++++++ ...tary-retirement-and-evidence-onboarding.md | 16 +- 2 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 crates/registry-evidence/tests/relay_shaped_source.rs diff --git a/crates/registry-evidence/tests/relay_shaped_source.rs b/crates/registry-evidence/tests/relay_shaped_source.rs new file mode 100644 index 000000000..2df7e8ae5 --- /dev/null +++ b/crates/registry-evidence/tests/relay_shaped_source.rs @@ -0,0 +1,581 @@ +//! Evidence-over-Relay composition: one full signed Evidence assertion is +//! evaluated over a mock HTTP source whose wire shape mirrors a Registry +//! Relay protected read API, authenticated with OAuth client credentials. +//! +//! The mock mirrors the Relay wire shape by hand: the templated protected +//! read path and the minimal single-record JSON response body of +//! `GET /v1/datasets/{dataset_id}/entities/{entity}/records/{id}` in +//! `crates/registry-relay/openapi/registry-relay.openapi.json`. Evidence +//! proves the composition without importing or depending on any Relay code, +//! per the Evidence product boundary rules, so no Relay crate, type, or +//! fixture appears here and the record content stays synthetic and +//! domain-neutral. + +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use ed25519_dalek::SigningKey; +use rand_core::OsRng; +use registry_evidence::bundle::Bundle; +use registry_evidence::config::{PreparationChannelPolicy, PreparationLimits, SourceConfig}; +use registry_evidence::kernel::{EvidenceConstruction, OfflineKernel, ValueProjection}; +use registry_evidence::model::{LookupResult, PublicValue, SelectorValue, SubjectBinding}; +use registry_evidence::rhai_runtime::{ + RequestPartRequirement, RequestPartsBounds, RequestPartsLimits, RhaiRuntime, + MAXIMUM_ARRAY_ITEMS, MAXIMUM_JSON_BODY_DEPTH, MAXIMUM_QUERY_NAME_BYTES, MAXIMUM_QUERY_PAIRS, + MAXIMUM_QUERY_VALUE_BYTES, MAXIMUM_REQUEST_PARTS_BYTES, MAXIMUM_STRING_BYTES, +}; +use registry_evidence::secrets::{SecretProvider, SecretResolver}; +use registry_evidence::signing::{jwks_document, EvidenceSigner}; +use registry_evidence::source::{ResolvedSourceSelector, SourceExecutor}; +use registry_evidence::verifier::{verify_flattened_jws, EvidenceVerificationPolicy}; +use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; +use serde_json::json; +use tempfile::TempDir; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use zeroize::Zeroizing; + +/// The Relay-shaped protected read for one synthetic record: the templated +/// `/v1/datasets/{dataset_id}/entities/{entity}/records/{id}` path with +/// domain-neutral dataset and entity segments and the subject's record key. +const RECORD_PATH: &str = "/v1/datasets/synthetic-units/entities/unit-record/records/REC-0001"; +/// Relay requires a `Data-Purpose` header on entity record reads; the +/// deployment pins it as a reviewed fixed header. +const DATA_PURPOSE: &str = "https://relying.invalid/purpose/fixture-routing"; +/// Raw record material from the mirrored Relay response body. None of it may +/// reach the signed assertion payload. +const RAW_FIELD_NAME_CANARY: &str = "area_geometry"; +const RAW_FIELD_VALUE_CANARY: &str = "SYNTHETIC-AREA-GEOMETRY-CANARY"; +const RECORD_KEY: &str = "REC-0001"; +const RAW_REGION_CODE: &str = "R-101"; + +const AUDIENCE: &str = "https://relying.invalid/residence-procedure"; +const BINDING_KEY: &[u8] = b"relay-composition-binding-key-32-bytes-minimum"; +const REQUIREMENT: &str = "urn:example:fixture:requirement:residence-region:v1"; + +/// The reviewed bounded request preparation: the Relay read is a completely +/// fixed request, so both dynamic channels stay empty. +const PREPARE_SCRIPT: &str = "fn prepare(selectors, parameters) { #{query: [], body: ()} }"; + +/// The reviewed extraction over the Rust-projected response: only the +/// projected `region` field is visible here, and it becomes the one declared +/// fact for the residence-region acceptance derivation. +const EXTRACT_SCRIPT: &str = r#" +fn extract(source_response, parameters) { + let region_code = get_path(source_response, "/region"); + if is_missing(region_code) { return #{outcome: "no_match"}; } + #{outcome: "match", facts: #{official_residence_code: region_code}} +} +"#; + +/// An Evidence source declared the way a deployment bundle would declare it: +/// oauth2-client-credentials against the mock token endpoint and a fixed GET +/// against the Relay-shaped record path. +fn relay_shaped_source(base_url: &str, token_endpoint: &str) -> SourceConfig { + serde_json::from_value(json!({ + "transport": "http-json", + "baseUrl": base_url, + "posture": "field-projected", + "authentication": { + "kind": "oauth2-client-credentials", + "tokenEndpoint": token_endpoint, + "clientIdRef": "secret:file/relay-client-id", + "clientSecretRef": "secret:file/relay-client-secret", + "scope": "registry.read", + "credentialPlacement": "form-body", + "maximumCacheSeconds": 60 + }, + "request": { + "method": "GET", + "pathTemplate": "/v1/datasets/synthetic-units/entities/unit-record/records/{record}", + "pathBindings": { + "record": { + "role": "subject", + "profile": "residence-record-v1", + "field": "record_reference" + } + }, + "fixedHeaders": [ + {"name": "Accept", "value": "application/json"}, + {"name": "Data-Purpose", "value": DATA_PURPOSE} + ], + "selectorInputs": [{ + "role": "subject", + "alternatives": [ + {"profile": "residence-record-v1", "fields": ["record_reference"]} + ] + }], + "prepareScript": "adapters/prepare.rhai", + "adapterParameters": {}, + "adapterParametersSchema": "schemas/parameters.schema.yaml", + "preparationLimits": {"query": "forbidden", "jsonBody": "forbidden"}, + "projection": ["/region"], + "redirects": "deny", + "timeoutMilliseconds": 1000, + "maximumResponseBytes": 65536, + "concurrencyLimit": 4 + }, + "responseSchema": "schemas/response.schema.yaml", + "extractScript": "adapters/extract.rhai", + "factSchema": "schemas/facts.schema.yaml" + })) + .expect("Relay-shaped source config deserializes") +} + +fn resolver(entries: &[(&str, &str)]) -> (TempDir, Arc) { + let root = tempfile::tempdir().expect("temporary secret root"); + for (name, value) in entries { + let path = root.path().join(name); + fs::write(&path, value).expect("write synthetic secret"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("protect secret"); + } + let resolver = SecretResolver::new([SecretProvider::File], root.path()) + .map(Arc::new) + .expect("resolver builds"); + (root, resolver) +} + +fn request_limits(config: &PreparationLimits) -> RequestPartsLimits { + fn channel(policy: PreparationChannelPolicy) -> RequestPartRequirement { + match policy { + PreparationChannelPolicy::Required => RequestPartRequirement::Required, + PreparationChannelPolicy::Allowed => RequestPartRequirement::Optional, + PreparationChannelPolicy::Forbidden => RequestPartRequirement::Forbidden, + } + } + + fn configured(value: Option, fallback: usize) -> usize { + value + .map(|value| usize::try_from(value).expect("configured limit fits usize")) + .unwrap_or(fallback) + } + + RequestPartsLimits::new( + channel(config.query), + channel(config.json_body), + RequestPartsBounds { + maximum_query_pairs: configured(config.maximum_query_pairs, MAXIMUM_QUERY_PAIRS), + maximum_query_name_bytes: configured( + config.maximum_query_name_bytes, + MAXIMUM_QUERY_NAME_BYTES, + ), + maximum_query_value_bytes: configured( + config.maximum_query_value_bytes, + MAXIMUM_QUERY_VALUE_BYTES, + ), + maximum_json_depth: configured(config.maximum_json_depth, MAXIMUM_JSON_BODY_DEPTH), + maximum_collection_items: configured( + config.maximum_collection_items, + MAXIMUM_ARRAY_ITEMS, + ), + maximum_string_bytes: configured(config.maximum_string_bytes, MAXIMUM_STRING_BYTES), + maximum_normalized_bytes: configured( + config.maximum_normalized_bytes, + MAXIMUM_REQUEST_PARTS_BYTES, + ), + }, + ) + .expect("fixture preparation limits satisfy the production ABI") +} + +fn encoded_parameters(bytes: &[u8]) -> Vec<(String, String)> { + url::form_urlencoded::parse(bytes) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +fn contains_parameter(parameters: &[(String, String)], name: &str, value: &str) -> bool { + parameters + .iter() + .any(|(actual_name, actual_value)| actual_name == name && actual_value == value) +} + +fn copy_fixture_tree(source: &Path, target: &Path) { + fs::create_dir(target).expect("fixture directory is copied"); + for entry in fs::read_dir(source).expect("fixture directory is readable") { + let entry = entry.expect("fixture entry is readable"); + let destination = target.join(entry.file_name()); + if entry + .file_type() + .expect("fixture entry type is readable") + .is_dir() + { + copy_fixture_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("fixture file is copied"); + } + } +} + +fn make_fixture_bundle_read_only(path: &Path) { + for entry in fs::read_dir(path).expect("fixture bundle is readable") { + let entry = entry.expect("fixture bundle entry is readable"); + let child = entry.path(); + if entry + .file_type() + .expect("fixture bundle entry type is readable") + .is_dir() + { + make_fixture_bundle_read_only(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o444)) + .expect("fixture bundle file becomes read-only"); + } + } + fs::set_permissions(path, fs::Permissions::from_mode(0o555)) + .expect("fixture bundle directory becomes read-only"); +} + +async fn fixture_signer() -> EvidenceSigner { + const KEY_ID: &str = "relay-composition-evidence-key"; + let signing_key = SigningKey::generate(&mut OsRng); + let private_bytes = Zeroizing::new(signing_key.to_bytes()); + let public_bytes = signing_key.verifying_key().to_bytes(); + let private = PrivateJwk { + kty: "OKP".to_owned(), + kid: Some(KEY_ID.to_owned()), + alg: Some("EdDSA".to_owned()), + crv: Some("Ed25519".to_owned()), + d: Some(URL_SAFE_NO_PAD.encode(private_bytes.as_slice())), + x: Some(URL_SAFE_NO_PAD.encode(public_bytes)), + y: None, + n: None, + e: None, + p: None, + q: None, + dp: None, + dq: None, + qi: None, + }; + let provider = Arc::new(LocalJwkSigner::new(private).expect("fixture signer builds")); + EvidenceSigner::initialize(provider, KEY_ID) + .await + .expect("fixture signer initializes") +} + +#[tokio::test] +async fn a_relay_shaped_protected_read_backs_a_full_signed_minimum_disclosure_assertion() { + // A mock OAuth token endpoint stands in for the Relay deployment's + // authorization server: it only answers a client-credentials grant and + // issues one fresh bearer token. + let token_server = MockServer::start().await; + let records_server = MockServer::start().await; + let client_id = format!("client-id-{}", ulid::Ulid::new()); + let client_secret = format!("client-secret-{}", ulid::Ulid::new()); + let access_token = format!("access-token-{}", ulid::Ulid::new()); + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=client_credentials")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "token_type": "Bearer", + "expires_in": 120, + "scope": "registry.read" + }))) + .expect(1) + .mount(&token_server) + .await; + + // The record endpoint mirrors the Relay wire shape by hand (hardcoded + // JSON, no Relay code): the single-record body follows the OpenAPI entity + // example shape of `id`, one codelist field, and one extra raw field. + // Only a request carrying the exact issued bearer, the pinned Accept + // header, and Relay's required Data-Purpose header is answered. + Mock::given(method("GET")) + .and(path(RECORD_PATH)) + .and(header("authorization", format!("Bearer {access_token}"))) + .and(header("accept", "application/json")) + .and(header("data-purpose", DATA_PURPOSE)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": RECORD_KEY, + "region": RAW_REGION_CODE, + "area_geometry": RAW_FIELD_VALUE_CANARY + }))) + .with_priority(1) + .expect(1) + .mount(&records_server) + .await; + // Any request that misses the exact bearer is rejected the way a Relay + // deployment rejects it, and must never happen. + Mock::given(method("GET")) + .and(path(RECORD_PATH)) + .respond_with(ResponseTemplate::new(401).set_body_raw( + r#"{"type":"about:blank","title":"Unauthorized","status":401}"#, + "application/problem+json", + )) + .with_priority(10) + .expect(0) + .mount(&records_server) + .await; + + // Deployment-shaped inputs: file-provider secrets and the declared source. + let (_secret_root, secrets) = resolver(&[ + ("relay-client-id", client_id.as_str()), + ("relay-client-secret", client_secret.as_str()), + ]); + let source = relay_shaped_source( + &records_server.uri(), + &format!("{}/oauth/token", token_server.uri()), + ); + + // Bounded request preparation through the production Rhai runtime. + let runtime = RhaiRuntime::new(); + let preparation = runtime + .compile_preparation(PREPARE_SCRIPT) + .expect("preparation script compiles"); + let extraction = runtime + .compile_extraction(EXTRACT_SCRIPT) + .expect("extraction script compiles"); + let parameters = serde_json::to_value(&source.request.adapter_parameters) + .expect("adapter parameters serialize"); + let script_selectors = json!({ + "subject": { + "profile": "residence-record-v1", + "values": {"record_reference": RECORD_KEY} + } + }); + let prepared = runtime + .prepare( + &preparation, + &script_selectors, + ¶meters, + &request_limits(&source.request.preparation_limits), + ) + .expect("fixed Relay read preparation succeeds"); + + // Production transport materialization pins the exact Relay-shaped read. + let transport_selectors = vec![ResolvedSourceSelector { + role: "subject".into(), + profile: "residence-record-v1".into(), + values: BTreeMap::from([( + "record_reference".into(), + SelectorValue::String(RECORD_KEY.into()), + )]), + }]; + let executor = SourceExecutor::new(&source, secrets).expect("Relay-shaped source compiles"); + let materialized = executor + .materialize_request(&transport_selectors, &prepared) + .expect("Relay-shaped request materializes"); + assert_eq!(materialized.path(), RECORD_PATH); + assert_eq!(materialized.query(), None); + assert_eq!(materialized.body(), None); + + // One end-to-end source execution: token acquisition, the authenticated + // record read, and the Rust projection boundary. + let projected = executor + .execute(&transport_selectors, &prepared) + .await + .expect("Relay-shaped source read succeeds"); + assert_eq!(projected, json!({"region": RAW_REGION_CODE})); + let projected_text = serde_json::to_string(&projected).expect("projected response serializes"); + for stripped in [RAW_FIELD_NAME_CANARY, RAW_FIELD_VALUE_CANARY, RECORD_KEY] { + assert!( + !projected_text.contains(stripped), + "projection let raw record material past the source boundary" + ); + } + let response_schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["region"], + "properties": {"region": {"type": "string", "minLength": 1, "maxLength": 32}} + })) + .expect("response schema compiles"); + assert!( + response_schema.is_valid(&projected), + "projected Relay-shaped response is outside the declared response schema" + ); + + // Reviewed extraction produces exactly the one declared fact. + let fact_schema = jsonschema::JSONSchema::compile(&json!({ + "type": "object", + "additionalProperties": false, + "required": ["official_residence_code"], + "properties": { + "official_residence_code": {"type": "string", "minLength": 1, "maxLength": 32} + } + })) + .expect("fact schema compiles"); + let facts = match runtime + .extract(&extraction, &projected, ¶meters, &fact_schema) + .expect("Relay-shaped response extracts") + { + LookupResult::Match(facts) => facts, + _ => panic!("Relay-shaped match returned a non-match outcome"), + }; + assert_eq!( + serde_json::to_value(&facts).expect("facts serialize"), + json!({"official_residence_code": RAW_REGION_CODE}) + ); + + // The immutable residence-region acceptance bundle finishes the full + // path: real derivation, output gate, Evidence construction, signing, + // and verification against the deployment's public JWKS. + let acceptance_copy = tempfile::tempdir().expect("temporary acceptance bundle root"); + let acceptance_root = acceptance_copy.path().join("residence-region"); + copy_fixture_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/residence-region"), + &acceptance_root, + ); + make_fixture_bundle_read_only(&acceptance_root); + let kernel = OfflineKernel::compile(Arc::new( + Bundle::load(&acceptance_root).expect("immutable residence acceptance bundle loads"), + )) + .expect("residence acceptance kernel compiles"); + let observed_at = "2026-08-02T00:00:00Z" + .parse() + .expect("fixed observation time parses"); + let values = kernel + .derive_and_validate( + REQUIREMENT, + &facts, + observed_at, + ValueProjection { + audience: AUDIENCE, + binding_key: BINDING_KEY, + binding_key_version: 1, + }, + ) + .expect("residence derivation and immutable output gate succeed"); + assert_eq!(values.as_slice().len(), 1); + assert_eq!( + values.as_slice()[0].provides_value_for, + "urn:example:fixture:concept:residence-region" + ); + assert_eq!( + values.as_slice()[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + let evidence = kernel + .construct_evidence( + REQUIREMENT, + values, + EvidenceConstruction { + evidence_id: "urn:ulid:01J4BRXQ0ZZZZZZZZZZZZZZZZZ", + request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + purpose: "fixture-routing", + audience: AUDIENCE, + issued_at: observed_at, + observed_at, + subjects: vec![SubjectBinding { + role: "subject".to_owned(), + binding: format!("urn:evidence:subject:v1_{}", "A".repeat(43)), + }], + }, + ) + .expect("residence Evidence constructs"); + let signer = fixture_signer().await; + let jws = signer + .sign_json(&evidence) + .await + .expect("residence Evidence signs"); + + // (d) The assertion payload carries only the derived answer: no raw + // record field name or value from the mirrored Relay response body. + let payload = String::from_utf8( + URL_SAFE_NO_PAD + .decode(&jws.payload) + .expect("flattened JWS payload decodes"), + ) + .expect("assertion payload is UTF-8 JSON"); + assert!( + payload.contains("REGION-NORTH"), + "the derived controlled code is disclosed" + ); + for canary in [ + RAW_FIELD_NAME_CANARY, + RAW_FIELD_VALUE_CANARY, + RECORD_KEY, + RAW_REGION_CODE, + ] { + assert!( + !payload.contains(canary), + "raw Relay record material reached the assertion payload: {canary}" + ); + } + + // (c) The response is a signed flattened JWS that verifies against the + // deployment's public JWKS under the exact relying policy. + let jwks = jwks_document(signer.public_jwk(), []).expect("deployment JWKS publishes"); + let serialized = serde_json::to_vec(&jws).expect("flattened JWS serializes"); + let mut policy = EvidenceVerificationPolicy::from_accepted_transaction( + &evidence, + registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE, + Duration::from_secs(31_536_000), + observed_at, + Duration::from_secs(0), + ); + policy.issued_by = "urn:example:fixture:issuer:authority".to_owned(); + policy.provided_by = "urn:example:fixture:provider:evidence".to_owned(); + policy.requirement = REQUIREMENT.to_owned(); + policy.evidence_type = "urn:example:fixture:evidence-type:residence-region:v1".to_owned(); + policy.purpose = "fixture-routing".to_owned(); + policy.audience = AUDIENCE.to_owned(); + policy.configuration_revision = kernel.bundle().revision().to_owned(); + let verified = verify_flattened_jws(&serialized, &jwks, &policy) + .expect("signed Evidence verifies against the deployment JWKS"); + assert_eq!(verified.supported_values.len(), 1); + assert_eq!( + verified.supported_values[0].value, + PublicValue::String("REGION-NORTH".to_owned()) + ); + + // (a) The token endpoint was called exactly once, with the closed + // client-credentials form and no credential in the URL. + let token_requests = token_server + .received_requests() + .await + .expect("token request journal"); + assert_eq!(token_requests.len(), 1, "exact OAuth bootstrap count"); + assert!( + token_requests[0].url.query().is_none(), + "token URL carries no query" + ); + let form = encoded_parameters(&token_requests[0].body); + assert!( + form.len() == 4 + && contains_parameter(&form, "grant_type", "client_credentials") + && contains_parameter(&form, "scope", "registry.read") + && contains_parameter(&form, "client_id", &client_id) + && contains_parameter(&form, "client_secret", &client_secret), + "token request body is the exact reviewed client-credentials shape" + ); + + // (b) The records endpoint saw exactly one read carrying the issued + // bearer and the pinned reviewed headers. + let record_requests = records_server + .received_requests() + .await + .expect("record request journal"); + assert_eq!(record_requests.len(), 1, "exact evidence-data count"); + let record_request = &record_requests[0]; + assert_eq!(record_request.method.as_str(), "GET"); + assert_eq!(record_request.url.path(), RECORD_PATH); + assert!(record_request.url.query().is_none()); + assert!(record_request.body.is_empty()); + assert_eq!( + record_request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some(format!("Bearer {access_token}").as_str()), + "the record read carried the issued bearer" + ); + assert_eq!( + record_request + .headers + .get("data-purpose") + .and_then(|value| value.to_str().ok()), + Some(DATA_PURPOSE) + ); +} diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index d02ac98c3..8af592e7a 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -54,7 +54,7 @@ gates for its area (see Verification), and committed. target, or whether IdP-less deployments need Mint to issue for Relay (a security-sensitive Relay change requiring review). Record the outcome in the status log below. -- [ ] A2. An ordinary sanitized Relay-shaped mock test in +- [x] A2. An ordinary sanitized Relay-shaped mock test in `crates/registry-evidence` proves a full signed assertion over an OAuth client-credentials source, with zero production-code changes. - [ ] A3. A reference deployment-project example for the Relay-backed @@ -307,3 +307,17 @@ is parallel; B has no upstream dependencies and is the standing priority branch is not triggered. Open question for A2 and D: whether Mint's Evidence-shaped claim set satisfies Relay's authorization model, or whether Mint (not Relay, not Evidence) needs a claim addition. +- 2026-08-03: A2 done, zero production-code changes needed. The new + crates/registry-evidence/tests/relay_shaped_source.rs mirrors the + Relay OpenAPI's household-record read (path, bearer, Data-Purpose, + entity response shape) by hand in a wiremock, drives OAuth client + credentials through the frozen runtime's public API to a signed + flattened JWS, verifies it against the deployment JWKS under the full + relying policy, and proves minimum disclosure with canaries: the raw + record id, the untouched field, and even the raw region code never + appear in the assertion payload. Composition components all existed + in the frozen V1 runtime. Next in A: A3, the Relay-backed reference + deployment project. E2's open design question for Jeremi: what tool + tutorial readers use to build the RFC 7523 client assertion for Mint + (the demo uses a Python walkthrough; an evidencectl helper would be + new CLI surface outside the frozen runtime contract). From 1f2390fb901b4593a9445951303cf3d63a0d8c11 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:48:40 +0700 Subject: [PATCH 051/136] wip(evidence): commit in-flight work set aside for the worktree merge Committed on request so the evidencectl/source-suggest worktree branch can merge into a clean main. Contents are the main-worktree WIP that the merge automation stashed as source-suggest-merge-setaside: the evidencectl suggest module (OpenAPI ingestion, flattening, narrowing, emit, interactive mode, with fixtures and five test files), evidence crate follow-ups to the source response-schema work (bundle, runtime, main, cli and source-contract tests), boundary-day fixture cases for both reference deployment projects, scaffold template and test updates, and wording refinements to the two Evidence tutorials. Not reviewed or verified as a unit; committed as WIP for review and squash. Restored verbatim from the setaside stash tree. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 19 --- .github/scripts/test_ci_changes.py | 29 ---- .github/workflows/ci.yml | 65 --------- .github/workflows/release-candidate.yml | 12 +- crates/registry-evidence/src/main.rs | 4 + crates/registry-evidence/src/runtime.rs | 14 +- crates/registry-evidence/tests/cli.rs | 3 +- crates/registry-evidencectl/src/scaffold.rs | 11 +- .../registry-evidencectl/templates/README.md | 22 ---- crates/registry-evidencectl/tests/scaffold.rs | 23 ++-- docs/site/astro.config.mjs | 41 ------ docs/site/package.json | 7 +- docs/site/redocly.yaml | 2 - docs/site/scripts/fetch-openapi.mjs | 7 - docs/site/scripts/generate-sidebar.test.mjs | 5 +- .../scripts/information-architecture.test.mjs | 1 - docs/site/scripts/sync-repo-docs.mjs | 6 +- .../src/content/docs/reference/apis/index.mdx | 19 +-- .../src/content/docs/reference/glossary.mdx | 18 +-- .../src/content/docs/start/when-to-use.mdx | 16 +-- docs/site/src/data/docsets.yaml | 3 - docs/site/src/data/generated/docsets.json | 4 - .../src/data/generated/openapi-sources.json | 9 -- docs/site/src/data/generated/sidebar.json | 34 ----- docs/site/src/data/openapi-sources.yaml | 7 - docs/site/src/data/repo-docs.yaml | 70 ---------- ...tary-retirement-and-evidence-onboarding.md | 124 ++---------------- products/evidence/OPERATOR-CONTRACT.md | 4 +- products/evidence/PERFORMANCE.md | 64 +++++---- products/evidence/README.md | 42 ------ .../evidence/contracts/source-contract.yaml | 1 - .../bundle/adapters/adult-status-extract.rhai | 9 -- .../professional-licence-extract.rhai | 9 -- .../bundle/fixtures/adult-status-cases.yaml | 8 +- .../fixtures/professional-licence-cases.yaml | 8 +- .../schemas/adult-status-response.schema.yaml | 9 +- .../professional-licence-response.schema.yaml | 11 +- .../adapters/birth-parents-extract.rhai | 8 +- .../registered-parent-references-cases.yaml | 22 +--- .../registered-parent-relationship-cases.yaml | 22 +--- .../schemas/birth-adult-response.schema.yaml | 8 +- .../birth-parents-response.schema.yaml | 9 +- .../dhis2-tracker/response.schema.yaml | 9 +- .../opencrvs-event-search/extract.rhai | 8 +- .../response.schema.yaml | 9 +- release/scripts/build-release-binaries.sh | 14 -- 46 files changed, 120 insertions(+), 729 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 2f65db7d4..1ff0b199a 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -508,24 +508,6 @@ def classify( or bool(affected & TUTORIAL_PACKAGES) ) - evidence_tutorial = ( - complete - or any( - path - in { - "Cargo.lock", - "Cargo.toml", - "docs/site/package-lock.json", - "docs/site/package.json", - "docs/site/scripts/check-evidence-tutorials.sh", - "docs/site/scripts/check-evidence-tutorials.test.mjs", - "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", - } - for path in paths - ) - or bool(affected & EVIDENCE_PACKAGES) - ) - matrix = [] for shard_name, shard_packages in SHARDS.items(): selected = sorted(affected.intersection(shard_packages)) @@ -554,7 +536,6 @@ def classify( "docs_archives": docs_archives, "editors": editors, "registryctl_tutorial": registryctl_tutorial, - "evidence_tutorial": evidence_tutorial, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 35dac8430..4ef3a119f 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -58,35 +58,6 @@ def test_example_pr_runs_only_affected_rust_shards(self) -> None: self.assertTrue(outputs["registryctl_tutorial"]) self.assertFalse(outputs["platform"]) - def test_evidence_tutorial_routing(self) -> None: - infrastructure = ( - "docs/site/scripts/check-evidence-tutorials.sh", - "docs/site/scripts/check-evidence-tutorials.test.mjs", - "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", - "docs/site/package.json", - ) - for path in infrastructure: - with self.subTest(path=path): - self.assertTrue( - classify(self.workspace, (path,))["evidence_tutorial"] - ) - self.assertTrue( - classify(self.workspace, ("crates/registry-evidence/src/runtime.rs",))[ - "evidence_tutorial" - ] - ) - self.assertTrue( - classify(self.workspace, ("crates/registry-evidencectl/src/scaffold.rs",))[ - "evidence_tutorial" - ] - ) - self.assertFalse( - classify( - self.workspace, - ("docs/site/src/content/docs/tutorials/author-registry-project.mdx",), - )["evidence_tutorial"] - ) - def test_reverse_dependencies_are_included(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b9e65de..b2fb2a4bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,6 @@ jobs: docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} - evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -986,69 +985,6 @@ jobs: target/registryctl-tutorial-source \ 2>/dev/null || true - evidence-tutorials: - name: Evidence tutorials from source - needs: changes - if: needs.changes.outputs.evidence_tutorial == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 40 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - with: - fetch-depth: 0 - submodules: false - - - name: Assert amd64 runner - shell: bash - run: | - set -euo pipefail - [[ "$(uname -m)" == "x86_64" ]] - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: 22.12.0 - cache: npm - cache-dependency-path: docs/site/package-lock.json - - - name: Cache Cargo registry - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - shared-key: workspace-registry - cache-targets: false - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Test the tutorial gate helpers - working-directory: docs/site - run: npm run test:tutorial:evidence - - - name: Check tutorial command drift - working-directory: docs/site - run: npm run check:tutorial:evidence:dry-run - - - name: Build the Evidence toolset under test - shell: bash - run: | - set -euo pipefail - CARGO_TARGET_DIR="target/evidence-tutorial-source" \ - cargo build --locked --profile ci \ - -p registry-evidence -p registry-evidencectl - - - name: Execute the Evidence tutorials in a clean container - shell: bash - run: | - set -euo pipefail - # The image is the repository's pinned release-builder digest, used - # here only as a clean Debian userland: the gate exercises a shell - # and coreutils, and the toolset binaries are mounted in prebuilt. - docker run --rm \ - --mount "type=bind,src=${PWD},dst=/work,readonly" \ - --env EVIDENCE_BIN=/work/target/evidence-tutorial-source/ci/evidence \ - --env EVIDENCECTL_BIN=/work/target/evidence-tutorial-source/ci/evidencectl \ - rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ - bash /work/docs/site/scripts/check-evidence-tutorials.sh - docs: name: Docs checks needs: changes @@ -1222,7 +1158,6 @@ jobs: - release-tool - release-source-proof - registryctl-tutorials - - evidence-tutorials - docs - editor-extensions runs-on: ubuntu-24.04 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 6cb26898e..cee2b431e 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -372,8 +372,7 @@ jobs: rustup toolchain install 1.95.0 \ --profile minimal --target "${{ matrix.target }}" cargo build --release --locked \ - -p registryctl -p registry-evidence -p registry-evidencectl \ - -p registry-mint --target "${{ matrix.target }}" + -p registryctl --target "${{ matrix.target }}" mkdir -p platform asset="registryctl-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/registryctl" "platform/${asset}" @@ -381,12 +380,6 @@ jobs: release/scripts/registry-release verify-registryctl-binary-version \ "platform/${asset}" \ --version "${{ needs.validate.outputs.version }}" - for evidence_binary in evidence evidencectl mint; do - asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" - cp "target/${{ matrix.target }}/release/${evidence_binary}" \ - "platform/${asset}" - chmod 0755 "platform/${asset}" - done mkdir -p candidate-platform mv platform candidate-platform/platform @@ -687,9 +680,6 @@ jobs: installer="registryctl-${{ needs.validate.outputs.tag }}-install.sh" cp crates/registryctl/install.sh "candidate/bundle-root/${installer}" chmod 0755 "candidate/bundle-root/${installer}" - evidencectl_installer="evidencectl-${{ needs.validate.outputs.tag }}-install.sh" - cp crates/registry-evidencectl/install.sh "candidate/bundle-root/${evidencectl_installer}" - chmod 0755 "candidate/bundle-root/${evidencectl_installer}" for name in registry-notary registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index b210959f4..7959eb770 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -294,6 +294,10 @@ fn runtime_initialization_error(error: RuntimeInitializationError) -> CliError { RuntimeInitializationError::Secrets => CliError("runtime secret initialization failed"), RuntimeInitializationError::Audit => CliError("runtime audit initialization failed"), RuntimeInitializationError::Signing => CliError("runtime signing initialization failed"), + RuntimeInitializationError::SigningActiveKeyId => CliError( + "runtime signing initialization failed: the signing key identifier does not match \ + signing.activeKeyId", + ), RuntimeInitializationError::Source => CliError("runtime source initialization failed"), RuntimeInitializationError::RateLimit => { CliError("runtime rate-limit initialization failed") diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 807e4dcfd..0be87f2af 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -44,7 +44,7 @@ use crate::{ validate_subject_binding_key, AuthorizationError, MatchedEntitlement, ResolvedAuthorization, ResolvedSelectorValue, }, - signing::{jwks_document, EvidenceSigner}, + signing::{jwks_document, EvidenceSigner, EvidenceSigningError}, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, @@ -62,6 +62,13 @@ pub enum RuntimeInitializationError { Audit, #[error("the Evidence signing boundary could not initialize")] Signing, + /// The signing key material is well formed but names a different key than + /// the bundle's `signing.activeKeyId`. It is separated from `Signing` + /// because it is the one signing failure an operator fixes by editing a + /// reviewed field rather than by replacing key material, and the generic + /// message sends them looking at the key file instead. + #[error("the signing key identifier does not match the configured active key")] + SigningActiveKeyId, #[error("an Evidence source plan could not initialize")] Source, #[error("the Evidence rate limiter could not initialize")] @@ -116,7 +123,10 @@ pub async fn validate_secret_material( ); let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) .await - .map_err(|_| RuntimeInitializationError::Signing)?; + .map_err(|error| match error { + EvidenceSigningError::ActiveKeyId => RuntimeInitializationError::SigningActiveKeyId, + _ => RuntimeInitializationError::Signing, + })?; let retired = bundle .retired_public_jwks .values() diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 620aa8a83..acbf19146 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -313,7 +313,8 @@ fn check_rejects_secret_material_the_server_would_refuse_at_startup() { SecretFailureCase { label: "signing key kid differs from the bundle's activeKeyId", break_secrets: |deployment| deployment.write_mismatched_signing_key(), - expected: "evidence: runtime signing initialization failed\n", + expected: "evidence: runtime signing initialization failed: the signing key \ + identifier does not match signing.activeKeyId\n", }, SecretFailureCase { label: "audit hash key below the minimum length", diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index b157b3a05..b0ed27235 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -423,8 +423,8 @@ fn report(root: &Path, secret_root: &Path, with_mint: bool) { " # obtain the source system's own bearer token and write it to {},", secret_root.join("source-bearer-token").display() ); - println!(" # mode 0600. check, the fixtures and startup all pass without it; the"); - println!(" # first live request is where a missing token is discovered."); + println!(" # mode 0600. check and the fixtures pass without it by design: readiness"); + println!(" # owns source credentials, so bundle review comes before source onboarding."); println!( " chmod -R a-w {} && chmod 444 {}", root.join(BUNDLE_DIRECTORY).display(), @@ -438,6 +438,13 @@ fn report(root: &Path, secret_root: &Path, with_mint: bool) { " evidence evaluate --runtime {} --fixture fixtures/cases.yaml", root.join(RUNTIME_FILE).display() ); + println!( + " evidence serve --runtime {}", + root.join(RUNTIME_FILE).display() + ); + println!(" # then confirm GET /ready answers 200 before first use. /health is liveness"); + println!(" # only and answers 200 even when a source credential is missing; /ready is"); + println!(" # the fail-closed gate and answers 503 until every credential resolves."); if with_mint { println!(); println!("Next steps for the paired Registry Mint deployment:"); diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md index 6e385f8c0..c3879e458 100644 --- a/crates/registry-evidencectl/templates/README.md +++ b/crates/registry-evidencectl/templates/README.md @@ -112,25 +112,3 @@ Work through `bundle/evidence.yaml` in this order: The full configuration contract, including the authoring and promotion workflow, is documented in `products/evidence/reference/request-adapter/deployment-projects/CONFIG.md` in the Registry Stack repository. - -### Suggesting source configuration from an API description - -When the system you are pointing step 3 at publishes an OpenAPI description, -`evidencectl` can draft that source for you: a closed response schema, an -extraction script, and the facts schema the extraction fills. Unfreeze the -project first, since the draft is written into `bundle/`. - -```bash -evidencectl source suggest \ - --openapi ./api.yaml \ - --sample ./sample-response.json \ - --project {{project_root}} -``` - -Run in a terminal with no `--operation` and no `--select`, it asks which -operation the source calls and which response fields the projection carries, -then prints the equivalent fully flagged command so the same draft can be -reproduced in review. The sample is optional and never printed; it only widens -bounds the description leaves open. Nothing existing is overwritten, and every -bound that neither the description nor the sample implies is left as an explicit -`TODO` that `evidence check` rejects until you resolve it. diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index 3f44a7561..74a874dfa 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -44,11 +44,13 @@ fn a_mint_paired_project_passes_check_and_every_fixture() { /// The scaffolded source authenticates with a bearer token the source system /// issues and nothing here generates. `check` and every fixture pass without -/// it, and the service starts without it, so a reader who follows only the -/// printed steps first discovers it missing at the first live request. The -/// printed steps must name it, as the generated README already does. +/// it by design, because readiness owns source credentials, so the printed +/// steps have to name both halves of that contract: the token the operator +/// must supply, and `/ready` as the gate that reports it missing. `/health` +/// answers 200 either way, so a reader told only to check health concludes a +/// deployment that can answer nothing is healthy. #[test] -fn the_printed_next_steps_name_the_source_bearer_token() { +fn the_printed_next_steps_name_the_source_bearer_token_and_the_readiness_gate() { let workspace = TempDir::new().expect("temporary directory"); let project = workspace.path().join("project"); let outcome = evidencectl(&["new", project.to_str().expect("project path")]); @@ -63,6 +65,10 @@ fn the_printed_next_steps_name_the_source_bearer_token() { printed.contains("source-bearer-token"), "the printed next steps never mention the source bearer token:\n{printed}" ); + assert!( + printed.contains("/ready"), + "the printed next steps never mention the readiness gate:\n{printed}" + ); } fn passes_check_and_every_fixture(project: &Path) { @@ -556,15 +562,8 @@ fn provision_secrets(project: &Path) { private_jwk.as_bytes(), ); for name in SECRET_FILES { - // Regenerate until no byte is zero: the runtime rejects secret - // material containing NUL bytes, exactly as `keygen secret` does. let mut material = [0_u8; 32]; - loop { - getrandom::fill(&mut material).expect("random secret"); - if !material.contains(&0) { - break; - } - } + getrandom::fill(&mut material).expect("random secret"); write_secret(&secrets.join(name), &material); } assert_eq!( diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 4d21ed840..33397bf44 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -101,13 +101,6 @@ function generatedProduct(label) { if (!group) throw new Error(`generated sidebar group "${label}" not found`); return group; } - -// A product absent from this docset's generated sidebar (a product newer than -// an archived docset) yields no group instead of failing the build. -/** @param {string} label */ -function optionalGeneratedProduct(label) { - return productSidebar.find((/** @type {{ label: string }} */ entry) => entry.label === label) ?? null; -} const disabledSitemap = { name: '@astrojs/sitemap', hooks: {}, @@ -262,11 +255,6 @@ export default defineConfig({ schema: './openapi/registry-notary.openapi.json', sidebar: { label: 'Notary API operations', collapsed: true }, }, - { - base: 'reference/apis/evidence', - schema: './openapi/registry-evidence.openapi.json', - sidebar: { label: 'Evidence API operations', collapsed: true }, - }, ]), ], defaultLocale: 'root', @@ -308,7 +296,6 @@ export default defineConfig({ { label: 'Use your own spreadsheet', slug: 'tutorials/use-your-spreadsheet' }, { label: 'Expose spreadsheet evidence', slug: 'tutorials/verify-claim-registry-api' }, { label: 'When Registry Stack fits', slug: 'start/when-to-use' }, - { label: 'Evaluate Evidence', slug: 'start/evaluate-evidence' }, { label: 'Pre-1.0 cutover', slug: 'start/pre-1.0-cutover' }, ], }, @@ -324,15 +311,6 @@ export default defineConfig({ { label: 'Configuration fields', slug: 'reference/project-configuration' }, ], }, - { - label: 'Answer with Evidence', - items: [ - { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, - { label: 'Configure Evidence', slug: 'configure/evidence' }, - { label: 'Configure Registry Mint', slug: 'configure/mint' }, - { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, - ], - }, { label: 'Operate', collapsed: true, @@ -354,7 +332,6 @@ export default defineConfig({ collapsed: true, items: [ { label: 'Overview', slug: 'security' }, - { label: 'Evidence security model', slug: 'security/evidence' }, { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' }, { label: 'Security support window', slug: 'security/support-window' }, { label: 'Release trust', slug: 'security/openssf-evidence' }, @@ -376,14 +353,11 @@ export default defineConfig({ { label: 'Overview', slug: 'reference/apis' }, { label: 'Relay (narrative)', slug: 'reference/apis/registry-relay' }, { label: 'Notary (narrative)', slug: 'reference/apis/registry-notary' }, - { label: 'Evidence (narrative)', slug: 'reference/apis/registry-evidence' }, // Generated operation pages for each schema (theme-aware, searchable). ...openAPISidebarGroups, ], }, { label: 'Errors and status codes', slug: 'reference/errors' }, - { label: 'Evidence problems', slug: 'reference/evidence-problems' }, - { label: 'Registry Mint', slug: 'reference/mint' }, { label: 'Diagnostic catalogs', collapsed: true, @@ -413,20 +387,6 @@ export default defineConfig({ collapsed: true, items: generatedProduct('Manifest').items, }, - // Evidence entered the product docset after every archived - // docset was sealed, so its group is optional: absent when an - // archived docset's generated sidebar has no Evidence product. - // generate-sidebar.test.mjs pins its presence for the current - // docset, keeping the loud-failure property there. - ...(optionalGeneratedProduct('Evidence') - ? [ - { - label: 'Registry Evidence', - collapsed: true, - items: generatedProduct('Evidence').items, - }, - ] - : []), ], }, { label: 'Contracts', slug: 'reference/contracts' }, @@ -459,7 +419,6 @@ export default defineConfig({ { label: 'RS-DOC · Documentation framework', slug: 'spec/rs-doc' }, { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, - { label: 'RS-PR-EVIDENCE · Evidence protocol', slug: 'spec/rs-pr-evidence' }, { label: 'RS-PR-NOTARY · Notary protocol', slug: 'spec/rs-pr-notary' }, { label: 'RS-PR-REGISTRYCTL · registryctl contract', slug: 'spec/rs-pr-registryctl' }, { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, diff --git a/docs/site/package.json b/docs/site/package.json index afa140874..ad10a921c 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -33,18 +33,15 @@ "check:markdown": "markdownlint-cli2", "check:style": "node scripts/run-vale.mjs src/content/docs README.md", "check:style:fixtures": "node scripts/check-vale-fixtures.mjs", - "check:openapi": "redocly lint registry-relay registry-notary registry-evidence", + "check:openapi": "redocly lint registry-relay registry-notary", "check:config-vocabulary": "scripts/check-stale-config-vocabulary.sh", "check:tutorial": "scripts/check-tutorial.sh", "check:tutorial:dry-run": "scripts/check-tutorial.sh --dry-run", "test:tutorial:registryctl": "node --test scripts/registryctl-tutorial.test.mjs", "check:tutorial:registryctl": "bash scripts/check-registryctl-tutorials.sh", - "test:tutorial:evidence": "node --test scripts/check-evidence-tutorials.test.mjs", - "check:tutorial:evidence": "bash scripts/check-evidence-tutorials.sh", - "check:tutorial:evidence:dry-run": "bash scripts/check-evidence-tutorials.sh --dry-run", "check:tutorial:public-source-live": "scripts/check-registryctl-public-source-live.sh", "check:links": "npm run build && npm run check:links:built", - "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", + "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", "check:archives": "npm run build && npm run check:llms:built && npm run assemble:archives -- --bootstrap && npm run check:seo:built && npm run check:links:built", "check:archive-lock": "node scripts/archive-lock.mjs check", "check:seo:current": "node scripts/check-seo.mjs --scope current", diff --git a/docs/site/redocly.yaml b/docs/site/redocly.yaml index a160fd10e..247dd01e5 100644 --- a/docs/site/redocly.yaml +++ b/docs/site/redocly.yaml @@ -5,8 +5,6 @@ apis: root: openapi/registry-relay.openapi.json registry-notary: root: openapi/registry-notary.openapi.json - registry-evidence: - root: openapi/registry-evidence.openapi.json rules: no-empty-servers: off no-unused-components: warn diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index afa0e0acd..39ebdabfe 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -23,7 +23,6 @@ import { promisify } from 'node:util'; import YAML from 'yaml'; import { applyDocsetRefs, - filterRepoDocsForDocset, getDocset, loadDocsets, selectedDocsetId, @@ -42,7 +41,6 @@ const cacheRoot = resolve(root, '.repo-docs-cache'); const SPEC_SOURCES = { 'registry-relay': 'openapi/registry-relay.openapi.json', 'registry-notary': 'openapi/registry-notary.openapi.json', - 'registry-evidence': 'products/evidence/generated/registry-evidence.openapi.json', }; function fail(message) { @@ -104,11 +102,6 @@ async function main() { } const docsets = await loadDocsets({ dataDir }); const docset = getDocset(docsets, selectedDocsetId(docsets)); - // Filter before applying docset refs, as sync-repo-docs.mjs does: a repo - // whose docs are all excluded from this docset must not count as an active - // repo the docset is required to pin. Such a repo keeps its repo-docs ref, - // so its spec rides the current shell the way hand-authored pages do. - filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for OpenAPI refs.`); diff --git a/docs/site/scripts/generate-sidebar.test.mjs b/docs/site/scripts/generate-sidebar.test.mjs index 2d73e47bb..6c8f0db47 100644 --- a/docs/site/scripts/generate-sidebar.test.mjs +++ b/docs/site/scripts/generate-sidebar.test.mjs @@ -155,10 +155,7 @@ test('product group labels drop the shared "Registry" prefix', () => { labels.every((l) => !/^Registry\b/.test(l)), `no group label should start with "Registry": ${labels.join(', ')}`, ); - assert.ok( - labels.includes('Relay') && labels.includes('Notary') && labels.includes('Evidence'), - labels.join(', '), - ); + assert.ok(labels.includes('Relay') && labels.includes('Notary'), labels.join(', ')); }); test('the real manifest yields one group per product with every doc present exactly once', async () => { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 184b43429..d35141b57 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -57,7 +57,6 @@ test('uses the adopter-first top-level flow in its published order', () => { assert.deepEqual(topLevelLabels(sidebarSource), [ 'Start', 'Connect an existing registry', - 'Answer with Evidence', 'Operate', 'Security', 'Reference', diff --git a/docs/site/scripts/sync-repo-docs.mjs b/docs/site/scripts/sync-repo-docs.mjs index e69617e35..ca07167ca 100644 --- a/docs/site/scripts/sync-repo-docs.mjs +++ b/docs/site/scripts/sync-repo-docs.mjs @@ -529,16 +529,12 @@ async function main() { const docsets = await loadDocsets({ dataDir }); validateRepoDocsMetadata(manifest, knownStandards, docsets); const docset = getDocset(docsets, selectedDocsetId(docsets)); - // Filter before applying docset refs: a repo whose docs are all excluded - // from this docset (a product newer than the docset, like registry-evidence - // in pre-Evidence archives) must not count as an active repo the docset is - // required to pin. - filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for product docs.`); } applyDocsetMetadataOverrides(manifest, docset); + filterRepoDocsForDocset(manifest, docset); // Clean and recreate the output dir so removed allowlist entries don't linger. await rm(outputRoot, { recursive: true, force: true }); diff --git a/docs/site/src/content/docs/reference/apis/index.mdx b/docs/site/src/content/docs/reference/apis/index.mdx index dfd504a95..e1ec57b29 100644 --- a/docs/site/src/content/docs/reference/apis/index.mdx +++ b/docs/site/src/content/docs/reference/apis/index.mdx @@ -1,13 +1,12 @@ --- title: API references -description: Generated API reference pages for Registry Relay, Registry Notary, and Evidence, built from pinned OpenAPI artifacts. +description: Generated API reference pages for Registry Relay and Registry Notary, built from pinned OpenAPI artifacts. wide: true status: current owner: registry-docs source_repos: - registry-relay - registry-notary - - registry-evidence last_reviewed: "2026-06-20" doc_type: reference locale: en @@ -17,7 +16,7 @@ standards_referenced: import OpenApiSourcesTable from '../../../../components/OpenApiSourcesTable.astro'; -Use this section to browse the HTTP API for Registry Relay, Registry Notary, or Evidence. Each page is built from a pinned OpenAPI artifact owned by the project it describes. +Use this section to browse the HTTP API for Registry Relay or Registry Notary. Each page is built from a pinned OpenAPI artifact owned by the project it describes. {/* Do not duplicate endpoint reference content in narrative pages. */} @@ -34,8 +33,6 @@ The table is generated from `src/data/openapi-sources.yaml`. evidence offering discovery, health and readiness endpoints, and optional standards adapters. - [Registry Notary API](./registry-notary/) documents the claim discovery, evaluation, batch evaluation, rendering, JWKS, service discovery, and credential issuance endpoints. -- [Registry Evidence API](./registry-evidence/) documents the assertion, requester-scoped - definition discovery, health, readiness, served-contract, and key discovery endpoints. ## Provenance and freshness @@ -54,15 +51,3 @@ cargo run -p registry-notary -- openapi > openapi/registry-notary.openapi.json ``` Regenerate and re-pin the artifact when the Notary API changes. - -[Evidence](../../products/registry-evidence/) generates its OpenAPI document with the other -Evidence contract artifacts: - -```sh -cargo run -p registry-evidence --example evidence-contracts -- --output "" -``` - -The committed copy lives in `products/evidence/generated/`, and root CI's `evidence-contracts` -job fails on any byte difference between the committed artifacts and a fresh generation. A running -Evidence service publishes the same document at `GET /openapi.json` with no authentication -required. diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index aeb681952..7655a0616 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -105,15 +105,6 @@ Product names are always in English, including on future translated pages.
environment
Private bindings and operational settings for one Registry Stack project deployment target. An environment does not change the project's stable intent.
-
Evidence (product)
-
The minimum-disclosure assertion service in this monorepo. Crate: `crates/registry-evidence`; product material: `products/evidence/`. Given authenticated authority, an authorized purpose, and a predefined requirement, Evidence serves a signed assertion that answers the requirement, not the source record, plus an SD-JWT VC serialization of that same stateless assertion under a frozen Version 1 profile. The SD-JWT VC format is never a credential lifecycle: no issuance session, holder-binding ceremony, status list, or revocation. Evidence is not a Registry Notary mode, rewrite, or reduced configuration and does not inherit the Notary product model.
- -
evidence credential (Registry Notary)
-
Registry Notary usage of "evidence": in the Evidence Gateway runtime, a Notary claim evaluation can result in credential issuance, governed by a credential profile and delivered through OID4VCI and SD-JWT VC. This Notary-scoped meaning is distinct from Evidence (product): Registry Notary evidence names a credential with an issuance lifecycle, while Evidence names a stateless signed assertion with none.
- -
Evidence toolset
-
The three released binaries `evidence`, `evidencectl`, and `mint`. Releases that include the toolset publish reproducible binaries alongside a cosign-signed `SHA256SUMS` file, and the installer installs all three together or not at all after verifying every asset. `evidencectl` shells out to `evidence` for every Evidence semantic decision and never re-implements evaluation, signing, or verification. `mint`, built from the `registry-mint` crate, issues the access tokens `evidence` verifies.
-
Evidence Gateway
Governed runtime path for evidence responses. A Relay read or consultation and a Notary claim evaluation pass trusted request and evidence context through configured authorization and disclosure policy before returning or denying a response. Registry-backed Notary claims consume compiler-pinned Relay results.
@@ -217,7 +208,7 @@ Product names are always in English, including on future translated pages.
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
registry stack
-
The formal stack products: Registry Platform, Registry Relay, Registry Manifest, Registry Notary, and Evidence, with Registry Mint as supporting token issuance. Use lowercase when referring to the concept.
+
The four formal stack products: Registry Platform, Registry Relay, Registry Manifest, and Registry Notary. Use lowercase when referring to the concept.
purpose-bound request
Registry Stack product term for a request that carries or is evaluated against purpose limitation, policy-based access control, or context-aware authorization. Relay records the `Data-Purpose` header in audit records where present.
@@ -234,9 +225,6 @@ Product names are always in English, including on future translated pages.
Registry Manifest
Rust workspace for modeling, validating, and rendering standards-facing service, registry, form, and policy metadata without running Registry Relay. Provides a library (`registry-manifest-core`) and a CLI (`registry-manifest-cli`). Repo slug: `registry-manifest`.
-
Registry Mint
-
Small supporting service, not a fourth registry stack pattern, that issues short-lived, audience-bound access tokens to registered machine clients using the `client_credentials` grant with `private_key_jwt` client authentication, so a resource server such as Evidence can require signed tokens without standing up a general-purpose identity provider. The client registry binds each client id to its own keys and to the authority Registry Mint asserts for it. Registry Mint's tests drive Evidence's authenticator; the dependency runs one way only, and Evidence does not depend on Registry Mint. Crate: `crates/registry-mint`; binary: `mint`.
-
Registry Platform
Shared Rust workspace for registry security and operational primitives, including auth helpers, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC helpers, and test fixtures. Repo slug: `registry-platform`.
@@ -327,8 +315,8 @@ Product names are always in English, including on future translated pages. ## Style notes -- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary, Registry Mint) and the adopter demo name (Solmara Lab) are always title case. The assertion product's name is Evidence, capitalized as a proper noun. -- Repo slugs and crate names (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `registry-evidence`, `registry-mint`, `solmara-lab`) are always lowercase and monospace. +- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary) and the adopter demo name (Solmara Lab) are always title case. +- Repo slugs (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `solmara-lab`) are always lowercase and monospace. - Legacy underscore forms (`registry_relay`) and old repo names (`decentralized-evidence-demo`) appear only in historical pages or `rename_status` fields. - The glossary provides a reference for standards acronyms but does not replace per-page first-use expansion. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index d6ef34702..18a4b5e8e 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -32,22 +32,10 @@ system that owns the data. | Caller needs | Use | Result | | --- | --- | --- | | Selected records or fields | Registry Relay | A protected, read-only API response | -| A signed minimum-disclosure answer about one subject | Evidence | A signed assertion carrying the answer, not the source record | | A bounded answer or status | Registry Notary | A claim result without the source record | -Registry Relay and Registry Notary can work together: Registry Relay obtains a -limited source result, and Registry Notary evaluates a reviewed claim over -that result. - -### Who does what - -- The assertion provider is an institution that answers requests with signed - facts through Evidence. -- The data publisher is an institution that exposes records through Registry - Relay. -- The consumer or verifier is a relying service that calls either door and - verifies the answers it receives. -- The operator is whoever runs the deployment. +The two products can work together. Registry Relay obtains a limited source +result, and Registry Notary evaluates a reviewed claim over that result. ## Registry Stack is not the right tool when diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index b43f55b77..0417b6e5d 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -22,9 +22,6 @@ docsets: registry-manifest: version: main source (unreleased) ref: HEAD - registry-evidence: - version: main source (unreleased) - ref: HEAD - id: v0.16.3 label: v0.16.3 path: /v/0.16.3/ diff --git a/docs/site/src/data/generated/docsets.json b/docs/site/src/data/generated/docsets.json index ebbe597cd..7a002d6a3 100644 --- a/docs/site/src/data/generated/docsets.json +++ b/docs/site/src/data/generated/docsets.json @@ -27,10 +27,6 @@ "registry-manifest": { "version": "main source (unreleased)", "ref": "HEAD" - }, - "registry-evidence": { - "version": "main source (unreleased)", - "ref": "HEAD" } } }, diff --git a/docs/site/src/data/generated/openapi-sources.json b/docs/site/src/data/generated/openapi-sources.json index 97c90c760..e0d36c381 100644 --- a/docs/site/src/data/generated/openapi-sources.json +++ b/docs/site/src/data/generated/openapi-sources.json @@ -16,14 +16,5 @@ "artifact": "openapi/registry-notary.openapi.json", "status": "pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples", "reference_path": "/reference/apis/notary/" - }, - { - "id": "registry-evidence", - "name": "Registry Evidence API", - "owner": "registry-evidence", - "source": "Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job.", - "artifact": "openapi/registry-evidence.openapi.json", - "status": "pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required.", - "reference_path": "/reference/apis/evidence/" } ] diff --git a/docs/site/src/data/generated/sidebar.json b/docs/site/src/data/generated/sidebar.json index c8775618c..0093e73d4 100644 --- a/docs/site/src/data/generated/sidebar.json +++ b/docs/site/src/data/generated/sidebar.json @@ -194,39 +194,5 @@ "slug": "products/registry-manifest/reference" } ] - }, - { - "label": "Evidence", - "collapsed": true, - "items": [ - { - "label": "Overview", - "slug": "products/registry-evidence" - }, - { - "label": "Product concept", - "slug": "products/registry-evidence/concept" - }, - { - "label": "First server curl", - "slug": "products/registry-evidence/first-curl-test" - }, - { - "label": "Source testing", - "slug": "products/registry-evidence/source-testing" - }, - { - "label": "SD-JWT VC demo", - "slug": "products/registry-evidence/sd-jwt-vc-demo" - }, - { - "label": "Operator contract", - "slug": "products/registry-evidence/operator-contract" - }, - { - "label": "Performance", - "slug": "products/registry-evidence/performance" - } - ] } ] diff --git a/docs/site/src/data/openapi-sources.yaml b/docs/site/src/data/openapi-sources.yaml index 912c799f8..696acbbf1 100644 --- a/docs/site/src/data/openapi-sources.yaml +++ b/docs/site/src/data/openapi-sources.yaml @@ -12,10 +12,3 @@ artifact: openapi/registry-notary.openapi.json status: pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples reference_path: /reference/apis/notary/ -- id: registry-evidence - name: Registry Evidence API - owner: registry-evidence - source: Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job. - artifact: openapi/registry-evidence.openapi.json - status: pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required. - reference_path: /reference/apis/evidence/ diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index ac989a614..969231577 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -528,73 +528,3 @@ repos: - docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, shacl, skos, json-schema, json-ld] last_reviewed: unreviewed - registry-evidence: - remote: https://github.com/registrystack/registry-stack - ref: HEAD - version: main source (unreleased) - local: ../.. - openapi: products/evidence/generated/registry-evidence.openapi.json - docs: - - src: products/evidence/README.md - dest: products/registry-evidence/index - label: Registry Evidence - nav_order: 0 - doc_type: explanation - last_reviewed: unreviewed - standards_referenced: [openapi, json-schema, sd-jwt-vc, oid4vci] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Operator and integrator documentation for Evidence, the minimum-disclosure assertion service. - - src: products/evidence/CONCEPT.md - dest: products/registry-evidence/concept - label: Product concept - nav_order: 10 - doc_type: explanation - last_reviewed: unreviewed - standards_referenced: [cccev, json-schema, openapi, sd-jwt-vc, oid4vci] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Approved Version 1 product concept covering the boundary, data model, trust and privacy invariants, native API, and acceptance set. - - src: products/evidence/FIRST-CURL-TEST.md - dest: products/registry-evidence/first-curl-test - label: First server curl - nav_order: 20 - doc_type: how-to - last_reviewed: unreviewed - standards_referenced: [] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Deterministic local curl checkpoint against the Evidence server with a mock source and an in-memory test JWKS. - - src: products/evidence/SOURCE-TESTING.md - dest: products/registry-evidence/source-testing - label: Source testing - nav_order: 30 - doc_type: how-to - last_reviewed: unreviewed - standards_referenced: [sd-jwt-vc] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Source-testing contract with the deterministic mock matrix, opt-in public demo smoke tests, and credential handling rules. - - src: products/evidence/SD-JWT-VC-DEMO.md - dest: products/registry-evidence/sd-jwt-vc-demo - label: SD-JWT VC demo - nav_order: 40 - doc_type: how-to - last_reviewed: unreviewed - standards_referenced: [sd-jwt-vc] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Deterministic local demo that issues one assertion in both later-verifiable formats and re-verifies the credential offline. - - src: products/evidence/OPERATOR-CONTRACT.md - dest: products/registry-evidence/operator-contract - label: Operator contract - nav_order: 50 - doc_type: reference - last_reviewed: unreviewed - standards_referenced: [openapi, sd-jwt-vc] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Supported deployment shape, requester authority and purpose duties, required configuration and secrets, and readiness, audit, and key obligations. - - src: products/evidence/PERFORMANCE.md - dest: products/registry-evidence/performance - label: Performance - nav_order: 60 - doc_type: explanation - last_reviewed: unreviewed - standards_referenced: [] - exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] - description: Measured baseline of the audit-durability throughput trade and the deferred work that would recover most of the cost. diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 8af592e7a..71ef80de4 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -48,13 +48,13 @@ gates for its area (see Verification), and committed. ### A. Composition proof (Evidence over Relay) -- [x] A1. Token topology verified and recorded: establish whether Relay +- [ ] A1. Token topology verified and recorded: establish whether Relay embeds a client-credentials token endpoint (`registry-platform-sts`) that Evidence's `SourceAuthentication::Oauth2ClientCredentials` can target, or whether IdP-less deployments need Mint to issue for Relay (a security-sensitive Relay change requiring review). Record the outcome in the status log below. -- [x] A2. An ordinary sanitized Relay-shaped mock test in +- [ ] A2. An ordinary sanitized Relay-shaped mock test in `crates/registry-evidence` proves a full signed assertion over an OAuth client-credentials source, with zero production-code changes. - [ ] A3. A reference deployment-project example for the Relay-backed @@ -62,16 +62,16 @@ gates for its area (see Verification), and committed. ### B. Evidence onboarding (docs site, evidencectl, CI) -- [x] B1. Site plumbing: `openapi-sources.yaml` entry for the generated +- [ ] B1. Site plumbing: `openapi-sources.yaml` entry for the generated Evidence OpenAPI with a Redoc reference page; contracts wired into the data-driven reference; Operate content from `OPERATOR-CONTRACT.md`; Security content from the invariant matrix and test traceability; a "Registry Evidence" Configure group. -- [x] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an +- [ ] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an existing gate or add one). -- [x] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or +- [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. -- [x] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job +- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job that runs each Evidence tutorial from a clean container with only the prerequisites the tutorial itself documents; Evidence tutorials must pass it to merge. @@ -82,9 +82,9 @@ gates for its area (see Verification), and committed. verify an assertion as a consumer. Adopter outcome for E1: a fresh machine completes it in 15 minutes or less using released binaries installed via F1 (the released-binary form of this gate needs F3). -- [x] B6. Tutorial E6 (move Evidence to production signing) published, +- [ ] B6. Tutorial E6 (move Evidence to production signing) published, derived from `OPERATOR-CONTRACT.md`. -- [x] B7. Mint has real docs presence: a Configure page and a reference page. +- [ ] B7. Mint has real docs presence: a Configure page and a reference page. - [ ] B8. Onboarding spine: glossary disambiguates Evidence (product) from the retired Notary evidence credentials; `start/when-to-use` presents the two doors; the quickstart ends in an Evidence assertion over the @@ -148,12 +148,12 @@ builder image, and `crates/registryctl/install.sh` is already published as the `registryctl--install.sh` release asset. Evidence rides that channel; it does not get a parallel one. -- [x] F1. `crates/registry-evidencectl/install.sh` exists, mirroring the +- [ ] F1. `crates/registry-evidencectl/install.sh` exists, mirroring the registryctl installer conventions: installs `evidence`, `evidencectl`, and `mint` from a release, verifies every artifact against SHA256SUMS, refuses unverified installs, has offline tests, and passes shellcheck and shfmt. -- [x] F2. The three binaries and the installer asset enter the release +- [ ] F2. The three binaries and the installer asset enter the release channel: `build-release-binaries.sh` builds and checksums them, the candidate workflow stages `evidencectl--install.sh`, and the artifact inventory in `release/scripts/registry-release` accepts them @@ -163,16 +163,16 @@ channel; it does not get a parallel one. the published assets installs working binaries. Then flip the inventory entries from optional to required at a minimum version, the way `registryctl-installer` did at v0.14.0. -- [x] F4. Platform coverage decision recorded: linux-amd64 is the +- [ ] F4. Platform coverage decision recorded: linux-amd64 is the reproducible baseline; registryctl already publishes optional macos-arm64 and linux-arm64 assets; decide and record the same optional set for the Evidence binaries. - [ ] F5. Personas named on the docs site (assertion provider, data publisher, consumer/verifier, operator) and every tutorial labeled with whose it is. -- [x] F6. An Evidence errors and problems reference page exists for +- [ ] F6. An Evidence errors and problems reference page exists for adopters: what each public problem means and what to do about it. -- [x] F7. An evaluate-stage page exists: what Evidence costs to run +- [ ] F7. An evaluate-stage page exists: what Evidence costs to run (footprint, dependencies, operational burden, support window). ### Global gates (checked at the end, not per item) @@ -223,101 +223,3 @@ is parallel; B has no upstream dependencies and is the standing priority and registryctl already ships an installer asset. F was rewritten to extend that channel (evidence, evidencectl, mint were simply missing from it) instead of inventing a parallel one. F1 and F2 in progress. -- 2026-08-03: F1 done. The evidencectl installer mirrors the registryctl - conventions (toolset all-or-nothing install, SHA256SUMS verification, - asset-dir mode, staged install with rollback) with 11 offline tests; - shellcheck and shfmt clean. Found in passing: the rust-result shard - test in release/scripts/test_registry_release.py is stale on main - (ci.yml gained evidence-contracts); flagged separately, not fixed here. -- 2026-08-03: F2 done. build-release-binaries.sh builds and checksums - evidence, evidencectl, and mint in the pinned builder; the candidate - workflow stages evidencectl--install.sh beside the registryctl - installer; the registry-release inventory accepts the four artifacts - as optional (flip to required at a minimum version is F3), with two - new unit tests. Historical manifests still validate (beta-26 checked). - The product README documents installation. Next in F: F3 needs a real - release; F4-F7 are open. -- 2026-08-03: B2 confirmed done with no new code. Root CI's - evidence-contracts job (.github/workflows/ci.yml) runs - products/evidence/scripts/check-contracts.sh, which regenerates every - Evidence contract including registry-evidence.openapi.json and - byte-diffs against products/evidence/generated/. The classifier - (.github/scripts/ci_changes.py) fires it for registry-evidence, - registry-evidencectl, and products/evidence/ changes. -- 2026-08-03: F4 decided and implemented. Platform coverage matches - registryctl exactly: linux-amd64 is the required reproducible - baseline from the pinned builder; linux-arm64 and macos-arm64 ship - as optional native-runner assets from the candidate workflow's - build-platforms matrix, which now also builds evidence, evidencectl, - and mint. The assemble step and the F2 inventory already accept the - per-platform names generically. -- 2026-08-03: Docs wave landed. B1 done: repo-docs mirrors seven Evidence - product pages, the latest docset registers the product, the generated - OpenAPI flows through fetch-openapi into a Redoc operations section - plus a narrative API page, security content comes from the invariant - matrix and test traceability (security/evidence.mdx), Operate content - is the mirrored operator contract, and the sidebar gains an "Answer - with Evidence" flow (the plan's "Registry Evidence Configure group" - exists as that flow plus the Registry Evidence product group; rename - if the exact label matters). B3 done: RS-PR-EVIDENCE with 56 - requirements, every one citing its frozen contract file. B7 done: - Mint configure and reference pages, config fields cited to source - lines. F6 done: all nine public problem types documented from the - problem contract. F7 done: evaluate-stage page; writing it surfaced - stale PERFORMANCE.md group-commit claims, reconciled in their own - commit. B8 partial: two-door when-to-use, personas (F5 partial), and - glossary disambiguation are in; the quickstart flip still waits on A - and D. E1 (first-assertion tutorial) is published with commands - verified against the built binaries, but B5 stays open: E2-E5 and the - B4 gate are not built. Archived-docset design decision: a product - absent from an archived docset is filtered before docset pinning and - its OpenAPI rides the current shell; the Evidence product group - splices optionally (v0.15.2 verified past the Evidence throw; its - remaining sync failure is a pre-existing Notary allowlist gap on - main, flagged separately, as is the stale rust-result shard test). - Site suite 267 tests green. Full `npm run check` still pending. -- 2026-08-03: Session-limit note: three authoring subagents died mid - wave (configure page recovered by hand, E1 rewritten by hand, E6 not - written). Next unblocked items: B4 gate, E6, then E2-E5. -- 2026-08-03: B4 done. check-evidence-tutorials.sh drift-checks and - replays the first-assertion tutorial's fences verbatim (executed - green locally end to end); its dry-run joins npm run check; a - path-gated evidence-tutorials CI job builds the toolset and executes - the tutorial inside the repo's pinned builder-image digest with the - repo mounted read-only, counted by the required-results job. Caveat - recorded: that image is a full builder userland reused for its pinned - digest; a slimmer pinned base can replace it later. The - release-download fences stay unexecuted until F3. B6 done: the - production-signing tutorial restates OPERATOR-CONTRACT.md (key - generation, JWKS retention window, offline validation, readiness, - rotation, signature limits). Extending the executable gate to E6 and - the future E2-E5 belongs to B5. Full npm run check passed after the - docs wave; one Vale error it surfaced (typographic quotes in the - operator contract lead) fixed at the source. -- 2026-08-03: A1 verified and recorded. Relay embeds no token issuance: - registry-platform-sts is Notary-bound token exchange with zero - consumers in the workspace (add it to the C4 orphan candidates). - Relay authenticates inbound callers through configured OIDC (issuer, - audiences, algorithm allowlist, JWKS cache in - crates/registry-relay/src/auth/oidc/, with EdDSA-verifying tests), - and Mint issues EdDSA tokens with configured audiences and serves a - JWKS. IdP-less Evidence-over-Relay therefore points Relay's verifier - configuration at Mint's issuer: deployment configuration, no - security-sensitive Relay change, so the reserved Mint-for-Relay code - branch is not triggered. Open question for A2 and D: whether Mint's - Evidence-shaped claim set satisfies Relay's authorization model, or - whether Mint (not Relay, not Evidence) needs a claim addition. -- 2026-08-03: A2 done, zero production-code changes needed. The new - crates/registry-evidence/tests/relay_shaped_source.rs mirrors the - Relay OpenAPI's household-record read (path, bearer, Data-Purpose, - entity response shape) by hand in a wiremock, drives OAuth client - credentials through the frozen runtime's public API to a signed - flattened JWS, verifies it against the deployment JWKS under the full - relying policy, and proves minimum disclosure with canaries: the raw - record id, the untouched field, and even the raw region code never - appear in the assertion payload. Composition components all existed - in the frozen V1 runtime. Next in A: A3, the Relay-backed reference - deployment project. E2's open design question for Jeremi: what tool - tutorial readers use to build the RFC 7523 client assertion for Mint - (the demo uses a Python walkthrough; an evidencectl helper would be - new CLI surface outside the frozen runtime contract). diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index e873655fe..b2dcd2eb6 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -82,7 +82,7 @@ unsafe bundle safe. ## Discovery of available evidence -Evidence Version 1 answers "what may this caller request?" with authenticated +Evidence Version 1 answers “what may this caller request?” with authenticated `GET /v1/evidence-definitions`. Availability is requester-relative: the definition must exist in the exact deployed bundle and exactly one authority path must match the verified token, requirement, purpose, audience, complete @@ -682,7 +682,7 @@ Before production exposure, the operator runs: ```sh evidence check -evidence evaluate --fixture "" +evidence evaluate --fixture ``` All commands accept `--runtime `. The same path may be supplied diff --git a/products/evidence/PERFORMANCE.md b/products/evidence/PERFORMANCE.md index 3f5a3eaa0..ee05e13d5 100644 --- a/products/evidence/PERFORMANCE.md +++ b/products/evidence/PERFORMANCE.md @@ -1,17 +1,15 @@ # Evidence Performance -Status: Measured, with group commit implemented; not a Version 1 contract -Date: 2026-08-03 +Status: Measured baseline and deferred work, not a Version 1 contract +Date: 2026-08-02 ## Purpose Evidence trades request throughput for audit durability. This file records what -that trade costs, how it was measured, and the change that recovered most of -the cost without weakening the guarantee: group commit in the audit sink, -implemented in `crates/registry-evidence/src/audit.rs`. The current end-to-end -measurement lives in `OPERATOR-CONTRACT.md` under "Measured throughput". -Nothing here is a Version 1 commitment. Throughput is not a Definition of Done -row and is not a `CONCEPT.md` non-goal. +that trade costs, how it was measured, and the one change that would recover +most of the cost without weakening the guarantee. Nothing here is a Version 1 +commitment. Throughput is not a Definition of Done row and is not a `CONCEPT.md` +non-goal; it is ordinary engineering work that has been deliberately deferred. ## The guarantee that sets the ceiling @@ -29,14 +27,11 @@ call `sync_all`, so their records sit in the page cache and are lost on power failure. Evidence is the only one of the three that survives that failure, and the ceiling below is the price of it. -## Measured baseline before group commit +## Measured baseline -This section records the measurement that motivated group commit and is kept -as the before-figure. Measured with -`soak_reports_request_throughput_against_the_audit_ceiling` in +Measured with `soak_reports_request_throughput_against_the_audit_ceiling` in `crates/registry-evidence/src/runtime_tests.rs`. Two release-profile runs, 512 -requests at 32 concurrent, against a local mock source, with each append -taking its own barrier: +requests at 32 concurrent, against a local mock source: | | run 1 | run 2 | |---|---|---| @@ -71,37 +66,38 @@ audit path. Nothing else is shared between requests. N processes with N distinct audit paths therefore give N times the throughput with no code change. Only vertical throughput is capped. -## Group commit +## Deferred work: group commit The lever for vertical throughput is batching the barrier, not removing it. -The audit sink in `crates/registry-evidence/src/audit.rs` implements this: -appends that arrive while a durable write is in flight form the next batch, -and one `fsync` covers the whole batch. There is no timer and no configured -window; a batch is exactly what queued behind the in-flight barrier, so the -sink degrades to one barrier per append when requests do not overlap. -Properties that survived the change, each held by tests in `audit.rs`: +Today each append takes the chain mutex, writes, and fsyncs alone. Under +concurrency the appends already queue, so the records that queue behind an +in-flight barrier could be written and covered by a single subsequent barrier. +One fsync would then serve many records instead of one. + +Properties that must survive the change: - durability before release: an append resolves only after the barrier that covers its own bytes has completed, so no caller receives evidence ahead of its durable record; - chain ordering: records are hash-linked in the order they were chained, and - the on-disk order matches; batching must not drop or duplicate a record; + the on-disk order matches; - fail-closed: a failed barrier fails every append it covers, and none of them may report success; - fork detection: the pinned-path, fingerprint, and tail checks in - `DurableJsonlSink::write` still bracket the batched write, and a batch that - crosses the segment bound is split so each segment stays self-consistent. - -The gain scales with concurrent arrivals rather than helping a single idle -request. With group commit in place, the end-to-end measurement in -`OPERATOR-CONTRACT.md` under "Measured throughput" sustained 7057 -requests/second at 128 concurrent on the same host class that measured the -baseline rows in this file, with both durable audit appends per request kept. - -The macOS caveat still applies to every figure in this file and in -`OPERATOR-CONTRACT.md`: re-measure on the target Linux host before quoting -production numbers. + `DurableJsonlSink::write` still bracket the batched write. + +Expected gain is roughly the batch size, bounded by concurrent arrivals, so it +scales with load rather than helping a single idle request. + +### Preconditions + +1. Re-measure on the target Linux host. If the Linux ceiling already clears the + deployment's required rate, do not do this work. +2. Treat it as a security-sensitive change to audit integrity. It needs explicit + review notes and focused negative tests for each property above, per the + root `AGENTS.md` rules and the phase-3 invariant discipline in + `products/evidence/AGENTS.md`. ## Regression baseline diff --git a/products/evidence/README.md b/products/evidence/README.md index e7603d79d..fc1e4b43f 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -86,48 +86,6 @@ re-implements evaluation, signing, or verification. It must not depend on `registry-notary*`, and its source and scaffold templates are covered by the same source-product and domain neutrality checks as the runtime. -`evidencectl source suggest` drafts one source from an OpenAPI description: -it derives a closed response schema, an extraction script, and the facts schema -from the chosen operation, the projection the operator selects, and an optional -sample response, leaving an explicit `TODO` wherever a bound cannot be derived -so `evidence check` rejects the draft until a human resolves it. - -```bash -evidencectl source suggest --openapi ./api.yaml --project ./deployment-project -``` - -## Installing the toolset - -Releases that include the Evidence toolset publish reproducible bare binaries -named `---` (for example `evidence-v1.2.0-linux-amd64`) -plus a `SHA256SUMS` file that is cosign-signed at promotion. Older releases do -not carry these assets. For a release that does, install the pinned installer -asset directly: - -```sh -curl -fsSL https://github.com/registrystack/registry-stack/releases/download//evidencectl--install.sh | bash -``` - -The installer installs the three-binary Evidence toolset, the `evidence` -runtime, `evidencectl` adopter tooling, and the `mint` token issuer, together -or not at all, verifying every asset against `SHA256SUMS` before anything -reaches the install directory. It supports Linux amd64, Linux arm64, and -macOS arm64. It checks integrity, not authenticity: for a higher-assurance -install, follow [`release/VERIFY.md`](../../release/VERIFY.md) for the pinned -tag, then rerun the installer with `EVIDENCECTL_ASSET_DIR` pointed at that -verified directory. - -Three environment variables configure the installer: `EVIDENCECTL_VERSION` -pins a `vMAJOR.MINOR.PATCH` tag, `EVIDENCECTL_INSTALL_DIR` sets the install -directory (default `~/.local/bin`), and `EVIDENCECTL_ASSET_DIR` installs from -a locally verified asset directory instead of downloading. - -To build the toolset from source instead: - -```sh -cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint -``` - ## Discovering available evidence An authenticated caller lists the complete Evidence request shapes it can diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 4d7fba62c..2de0ff6a4 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -109,7 +109,6 @@ response_shape: stage: After projection and before conversion to Rhai, so no response outside its declared shape reaches a script. artifact: One required bundle-relative responseSchema per source, in the same closed JSON Schema subset as adapterParametersSchema and factSchema and validated by the same startup checks. role_relaxation: A response schema may list fewer required members than it declares properties, because projection legitimately drops a selected leaf the record did not carry. The adapter-parameter and fact roles keep the exact required-equals-properties rule. - required_rule: Projection already decides which members a schema may require. A selected leaf can be absent, so requiring one turns an ordinary incomplete record into a source-protocol failure and must instead be checked by the script on the records that have to carry it. An intermediate container cannot be absent, because projection rejects the response before the shape is read, so requiring one only repeats the projection. nullable_form: A response schema node may write its type as the pair [T, "null"]. A source reports an explicit null where it holds no value and projection carries that null through verbatim, so the shape has to be able to say so. This is the only union the subset admits and only in the response role; null reaches the script as the same unit marker is_missing already reads. division_of_labour: schema: Member presence where the shape can guarantee it, member types, array bounds and uniqueness, string bounds and formats, and enumerated or constant values. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai index 978ec7825..cb99dc4c8 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai @@ -22,15 +22,6 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - // The shape cannot require this of every record, because the ambiguous - // page is decided from the pager before any record is read. A single - // matched record with no reference of its own is a source this adapter - // does not understand. attributes needs no check: it carries projected - // children, so a record without it never survives projection. - if is_missing(record["trackedEntity"]) { - throw("source_protocol_error"); - } - // The attribute is named by an adapter parameter, and a register that // reports it twice has no single value to read, so this loop stays. let date_of_birth = (); diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai index dd8c42d01..539c717b5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai @@ -73,15 +73,6 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; - // The shape cannot require this of every record, because the ambiguous - // page is decided from the pager before any record is read. A single - // matched record with no reference of its own is a source this adapter - // does not understand. attributes needs no check: it carries projected - // children, so a record without it never survives projection. - if is_missing(record["trackedEntity"]) { - throw("source_protocol_error"); - } - // The validity attributes are named by adapter parameters, and a register // that reports one of them twice has no single value to read, so this loop // stays. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml index 57a1dac78..78849303d 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml @@ -139,12 +139,8 @@ cases: trackedEntities: - trackedEntity: Tei00000001 attributes: [] - # The second record carries no reference of its own, only the - # attribute list the projection insists on. The pager decides this page - # before any record is read, so a response shape that demanded a - # complete record here would turn an ordinary ambiguous lookup into a - # source-protocol failure. - - attributes: [] + - trackedEntity: Tei00000002 + attributes: [] expected: lookup: ambiguous publicProblem: evidence_not_available diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml index b02f4072b..ff61fcb07 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml @@ -285,12 +285,8 @@ cases: - trackedEntity: Tei00000001 attributes: [] enrollments: [] - # The second record carries no reference of its own, only the lists - # the projection insists on. The pager decides this page before any - # record is read, so a response shape that demanded a complete record - # here would turn an ordinary ambiguous lookup into a source-protocol - # failure. - - attributes: [] + - trackedEntity: Tei00000002 + attributes: [] enrollments: [] expected: lookup: ambiguous diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml index 184d03f48..0ccbdc0dc 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml @@ -33,14 +33,7 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided from - # the pager before any record is read, so a record on that page need not - # be complete. What a single matched record must carry stays with the - # script. attributes is not listed either: projection rejects a record - # missing an intermediate its children hang from, so saying so here would - # only repeat the projection. - required: [] + required: [trackedEntity, attributes] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml index b17a3debd..bc3ae91fb 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml @@ -33,14 +33,9 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided from - # the pager before any record is read, so a record on that page need not - # be complete. attributes and enrollments are not listed either, for a - # different reason: projection rejects a record missing an intermediate - # its children hang from, so saying so here would only repeat the - # projection. - required: [] + # enrollments is optional because a tracked entity may hold none, which + # the script reads as a record carrying no licence state. + required: [trackedEntity, attributes] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai index c810fa14e..916daf387 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai @@ -18,14 +18,8 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; - // The shape cannot require these of every record, because the ambiguous - // page is decided before any record is read. A single matched record that - // omits one is a source this adapter does not understand. declaration is - // not checked here: it carries projected children, so a record without it - // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] || - is_missing(result["trackingId"]) { + result["status"] != parameters["registeredStatus"] { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml index f0aa2bc45..61fcd5362 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml @@ -84,10 +84,6 @@ cases: derivationRuns: false signed: false - id: ambiguous-child - # The second record carries no leaf at all, only the declaration the - # projection insists on. An ambiguous page is refused before any record is - # read, so a response shape that demanded a complete record here would turn - # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -95,23 +91,13 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - declaration: {} - expected: - lookup: ambiguous - publicProblem: evidence_not_available - derivationRuns: false - signed: false - - id: negative-matched-record-without-tracking-id - # The other side of the same rule: what a page need not carry, a single - # matched record must, and the script is what says so. - response: - total: 1 - results: - type: birth status: REGISTERED - declaration: {mother.personReference: PERSON-SYNTHETIC-A} + trackingId: TRACKING-SYNTHETIC-002 + declaration: {father.personReference: PERSON-SYNTHETIC-B} expected: - error: source_protocol_error + lookup: ambiguous + publicProblem: evidence_not_available derivationRuns: false signed: false - id: negative-duplicate-parent-reference diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml index ef5a70bc5..8dfcaa054 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -100,10 +100,6 @@ cases: derivationRuns: false signed: false - id: ambiguous-child - # The second record carries no leaf at all, only the declaration the - # projection insists on. An ambiguous page is refused before any record is - # read, so a response shape that demanded a complete record here would turn - # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -111,23 +107,13 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - declaration: {} - expected: - lookup: ambiguous - publicProblem: evidence_not_available - derivationRuns: false - signed: false - - id: matched-record-without-tracking-id - # The other side of the same rule: what a page need not carry, a single - # matched record must, and the script is what says so. - response: - total: 1 - results: - type: birth status: REGISTERED - declaration: {mother.personReference: PERSON-SYNTHETIC-A} + trackingId: TRACKING-SYNTHETIC-002 + declaration: {father.personReference: PERSON-SYNTHETIC-B} expected: - error: source_protocol_error + lookup: ambiguous + publicProblem: evidence_not_available derivationRuns: false signed: false - id: missing-parent-set diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml index c5b42eba8..53cc64688 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml @@ -16,10 +16,10 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided before - # any record is read, so a record on that page need not be complete. - # Which leaves a single matched record must carry stays with the script. + # Nothing can be required of every record here. Projection drops a leaf + # the record did not carry, and an ambiguous page is decided before any + # record is read, so a record on that page need not be complete. Which + # leaves a single matched record must carry stays with the script. required: [] properties: type: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml index f1808d22c..4203ebc18 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml @@ -16,14 +16,7 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided before - # any record is read, so a record on that page need not be complete. - # Which leaves a single matched record must carry stays with the script. - # declaration is the one member a record cannot omit, because projection - # rejects a record missing an intermediate its children hang from, so - # saying so here would only repeat the projection. - required: [] + required: [type, status, trackingId, declaration] properties: type: type: string diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml index fba4b7291..38daa53c4 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml @@ -21,14 +21,7 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided from - # the pager before any record is read, so a record on that page need not - # be complete. What a single matched record must carry stays with the - # script. attributes is not listed either: projection rejects a record - # missing an intermediate its children hang from, so saying so here would - # only repeat the projection. - required: [] + required: [trackedEntity, attributes] properties: trackedEntity: {type: string, minLength: 1, maxLength: 64} attributes: diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai index e1a42f85e..49c6f894f 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai @@ -23,14 +23,8 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; - // The shape cannot require these of every record, because the ambiguous - // page is decided before any record is read. A single matched record that - // omits one is a source this adapter does not understand. declaration is - // not checked here: it carries projected children, so a record without it - // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] || - is_missing(result["trackingId"]) { + result["status"] != parameters["registeredStatus"] { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml index b9a2d6b67..75d8853b5 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml @@ -14,14 +14,7 @@ properties: items: type: object additionalProperties: false - # No projected leaf can be required of every record. Projection drops a - # leaf the record did not carry, and an ambiguous page is decided before - # any record is read, so a record on that page need not be complete. - # Which leaves a single matched record must carry stays with the script. - # declaration is the one member a record cannot omit, because projection - # rejects a record missing an intermediate its children hang from, so - # saying so here would only repeat the projection. - required: [] + required: [type, status, trackingId, declaration] properties: type: {type: string, minLength: 1, maxLength: 64} status: {type: string, minLength: 1, maxLength: 64} diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 34f028767..86b6c18fa 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -95,14 +95,6 @@ docker run --rm \ --features registry-notary-server/registry-notary-cel cp target/release/registry-notary-cel-worker "dist/bin/registry-notary-cel-worker-${RELEASE_TAG}-linux-amd64" cp target/release/registry-notary-cel-worker dist/image-bin/registry-notary-cel-worker - - cargo build --release --locked \ - -p registry-evidence \ - -p registry-evidencectl \ - -p registry-mint - cp target/release/evidence "dist/bin/evidence-${RELEASE_TAG}-linux-amd64" - cp target/release/evidencectl "dist/bin/evidencectl-${RELEASE_TAG}-linux-amd64" - cp target/release/mint "dist/bin/mint-${RELEASE_TAG}-linux-amd64" ' printf '%s\n' "${release_builder_image}" > "${repo_root}/dist/image-bin/RELEASE_BUILDER_IMAGE" @@ -113,9 +105,6 @@ chmod 0755 \ "${repo_root}/dist/bin/registry-relay-rhai-worker-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-cel-worker-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/evidence-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/evidencectl-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/mint-${tag}-linux-amd64" \ "${repo_root}/dist/image-bin/registry-notary" \ "${repo_root}/dist/image-bin/registry-notary-cel-worker" \ "${repo_root}/dist/image-bin/registry-relay" \ @@ -124,9 +113,6 @@ chmod 0755 \ ( cd -- "${repo_root}/dist/bin" sha256sum -- \ - "evidence-${tag}-linux-amd64" \ - "evidencectl-${tag}-linux-amd64" \ - "mint-${tag}-linux-amd64" \ "registry-manifest-${tag}-linux-amd64" \ "registry-notary-${tag}-linux-amd64" \ "registry-notary-cel-worker-${tag}-linux-amd64" \ From b2ef9ad4e6089c9d0c2d2481356cd331df52547c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:50:08 +0700 Subject: [PATCH 052/136] Revert "wip(evidence): commit in-flight work set aside for the worktree merge" This reverts commit 1f2390fb901b4593a9445951303cf3d63a0d8c11. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 19 +++ .github/scripts/test_ci_changes.py | 29 ++++ .github/workflows/ci.yml | 65 +++++++++ .github/workflows/release-candidate.yml | 12 +- crates/registry-evidence/src/main.rs | 4 - crates/registry-evidence/src/runtime.rs | 14 +- crates/registry-evidence/tests/cli.rs | 3 +- crates/registry-evidencectl/src/scaffold.rs | 11 +- .../registry-evidencectl/templates/README.md | 22 ++++ crates/registry-evidencectl/tests/scaffold.rs | 23 ++-- docs/site/astro.config.mjs | 41 ++++++ docs/site/package.json | 7 +- docs/site/redocly.yaml | 2 + docs/site/scripts/fetch-openapi.mjs | 7 + docs/site/scripts/generate-sidebar.test.mjs | 5 +- .../scripts/information-architecture.test.mjs | 1 + docs/site/scripts/sync-repo-docs.mjs | 6 +- .../src/content/docs/reference/apis/index.mdx | 19 ++- .../src/content/docs/reference/glossary.mdx | 18 ++- .../src/content/docs/start/when-to-use.mdx | 16 ++- docs/site/src/data/docsets.yaml | 3 + docs/site/src/data/generated/docsets.json | 4 + .../src/data/generated/openapi-sources.json | 9 ++ docs/site/src/data/generated/sidebar.json | 34 +++++ docs/site/src/data/openapi-sources.yaml | 7 + docs/site/src/data/repo-docs.yaml | 70 ++++++++++ ...tary-retirement-and-evidence-onboarding.md | 124 ++++++++++++++++-- products/evidence/OPERATOR-CONTRACT.md | 4 +- products/evidence/PERFORMANCE.md | 64 ++++----- products/evidence/README.md | 42 ++++++ .../evidence/contracts/source-contract.yaml | 1 + .../bundle/adapters/adult-status-extract.rhai | 9 ++ .../professional-licence-extract.rhai | 9 ++ .../bundle/fixtures/adult-status-cases.yaml | 8 +- .../fixtures/professional-licence-cases.yaml | 8 +- .../schemas/adult-status-response.schema.yaml | 9 +- .../professional-licence-response.schema.yaml | 11 +- .../adapters/birth-parents-extract.rhai | 8 +- .../registered-parent-references-cases.yaml | 22 +++- .../registered-parent-relationship-cases.yaml | 22 +++- .../schemas/birth-adult-response.schema.yaml | 8 +- .../birth-parents-response.schema.yaml | 9 +- .../dhis2-tracker/response.schema.yaml | 9 +- .../opencrvs-event-search/extract.rhai | 8 +- .../response.schema.yaml | 9 +- release/scripts/build-release-binaries.sh | 14 ++ 46 files changed, 729 insertions(+), 120 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 1ff0b199a..2f65db7d4 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -508,6 +508,24 @@ def classify( or bool(affected & TUTORIAL_PACKAGES) ) + evidence_tutorial = ( + complete + or any( + path + in { + "Cargo.lock", + "Cargo.toml", + "docs/site/package-lock.json", + "docs/site/package.json", + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + } + for path in paths + ) + or bool(affected & EVIDENCE_PACKAGES) + ) + matrix = [] for shard_name, shard_packages in SHARDS.items(): selected = sorted(affected.intersection(shard_packages)) @@ -536,6 +554,7 @@ def classify( "docs_archives": docs_archives, "editors": editors, "registryctl_tutorial": registryctl_tutorial, + "evidence_tutorial": evidence_tutorial, } diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 4ef3a119f..35dac8430 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -58,6 +58,35 @@ def test_example_pr_runs_only_affected_rust_shards(self) -> None: self.assertTrue(outputs["registryctl_tutorial"]) self.assertFalse(outputs["platform"]) + def test_evidence_tutorial_routing(self) -> None: + infrastructure = ( + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + "docs/site/package.json", + ) + for path in infrastructure: + with self.subTest(path=path): + self.assertTrue( + classify(self.workspace, (path,))["evidence_tutorial"] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidence/src/runtime.rs",))[ + "evidence_tutorial" + ] + ) + self.assertTrue( + classify(self.workspace, ("crates/registry-evidencectl/src/scaffold.rs",))[ + "evidence_tutorial" + ] + ) + self.assertFalse( + classify( + self.workspace, + ("docs/site/src/content/docs/tutorials/author-registry-project.mdx",), + )["evidence_tutorial"] + ) + def test_reverse_dependencies_are_included(self) -> None: outputs = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2fb2a4bc..f7b9e65de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: docs_archives: ${{ steps.filter.outputs.docs_archives }} editors: ${{ steps.filter.outputs.editors }} registryctl_tutorial: ${{ steps.filter.outputs.registryctl_tutorial }} + evidence_tutorial: ${{ steps.filter.outputs.evidence_tutorial }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -985,6 +986,69 @@ jobs: target/registryctl-tutorial-source \ 2>/dev/null || true + evidence-tutorials: + name: Evidence tutorials from source + needs: changes + if: needs.changes.outputs.evidence_tutorial == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + fetch-depth: 0 + submodules: false + + - name: Assert amd64 runner + shell: bash + run: | + set -euo pipefail + [[ "$(uname -m)" == "x86_64" ]] + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: docs/site/package-lock.json + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Test the tutorial gate helpers + working-directory: docs/site + run: npm run test:tutorial:evidence + + - name: Check tutorial command drift + working-directory: docs/site + run: npm run check:tutorial:evidence:dry-run + + - name: Build the Evidence toolset under test + shell: bash + run: | + set -euo pipefail + CARGO_TARGET_DIR="target/evidence-tutorial-source" \ + cargo build --locked --profile ci \ + -p registry-evidence -p registry-evidencectl + + - name: Execute the Evidence tutorials in a clean container + shell: bash + run: | + set -euo pipefail + # The image is the repository's pinned release-builder digest, used + # here only as a clean Debian userland: the gate exercises a shell + # and coreutils, and the toolset binaries are mounted in prebuilt. + docker run --rm \ + --mount "type=bind,src=${PWD},dst=/work,readonly" \ + --env EVIDENCE_BIN=/work/target/evidence-tutorial-source/ci/evidence \ + --env EVIDENCECTL_BIN=/work/target/evidence-tutorial-source/ci/evidencectl \ + rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ + bash /work/docs/site/scripts/check-evidence-tutorials.sh + docs: name: Docs checks needs: changes @@ -1158,6 +1222,7 @@ jobs: - release-tool - release-source-proof - registryctl-tutorials + - evidence-tutorials - docs - editor-extensions runs-on: ubuntu-24.04 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index cee2b431e..6cb26898e 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -372,7 +372,8 @@ jobs: rustup toolchain install 1.95.0 \ --profile minimal --target "${{ matrix.target }}" cargo build --release --locked \ - -p registryctl --target "${{ matrix.target }}" + -p registryctl -p registry-evidence -p registry-evidencectl \ + -p registry-mint --target "${{ matrix.target }}" mkdir -p platform asset="registryctl-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/registryctl" "platform/${asset}" @@ -380,6 +381,12 @@ jobs: release/scripts/registry-release verify-registryctl-binary-version \ "platform/${asset}" \ --version "${{ needs.validate.outputs.version }}" + for evidence_binary in evidence evidencectl mint; do + asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" + cp "target/${{ matrix.target }}/release/${evidence_binary}" \ + "platform/${asset}" + chmod 0755 "platform/${asset}" + done mkdir -p candidate-platform mv platform candidate-platform/platform @@ -680,6 +687,9 @@ jobs: installer="registryctl-${{ needs.validate.outputs.tag }}-install.sh" cp crates/registryctl/install.sh "candidate/bundle-root/${installer}" chmod 0755 "candidate/bundle-root/${installer}" + evidencectl_installer="evidencectl-${{ needs.validate.outputs.tag }}-install.sh" + cp crates/registry-evidencectl/install.sh "candidate/bundle-root/${evidencectl_installer}" + chmod 0755 "candidate/bundle-root/${evidencectl_installer}" for name in registry-notary registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 7959eb770..b210959f4 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -294,10 +294,6 @@ fn runtime_initialization_error(error: RuntimeInitializationError) -> CliError { RuntimeInitializationError::Secrets => CliError("runtime secret initialization failed"), RuntimeInitializationError::Audit => CliError("runtime audit initialization failed"), RuntimeInitializationError::Signing => CliError("runtime signing initialization failed"), - RuntimeInitializationError::SigningActiveKeyId => CliError( - "runtime signing initialization failed: the signing key identifier does not match \ - signing.activeKeyId", - ), RuntimeInitializationError::Source => CliError("runtime source initialization failed"), RuntimeInitializationError::RateLimit => { CliError("runtime rate-limit initialization failed") diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 0be87f2af..807e4dcfd 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -44,7 +44,7 @@ use crate::{ validate_subject_binding_key, AuthorizationError, MatchedEntitlement, ResolvedAuthorization, ResolvedSelectorValue, }, - signing::{jwks_document, EvidenceSigner, EvidenceSigningError}, + signing::{jwks_document, EvidenceSigner}, source::{ResolvedSourceSelector, SourceError, SourceExecutor}, EVIDENCE_DEFINITIONS_SCHEMA_V1, EVIDENCE_JWS_MEDIA_TYPE, EVIDENCE_SD_JWT_VC_MEDIA_TYPE, EVIDENCE_UNSIGNED_ENVELOPE_SCHEMA_V1, EVIDENCE_UNSIGNED_MEDIA_TYPE, @@ -62,13 +62,6 @@ pub enum RuntimeInitializationError { Audit, #[error("the Evidence signing boundary could not initialize")] Signing, - /// The signing key material is well formed but names a different key than - /// the bundle's `signing.activeKeyId`. It is separated from `Signing` - /// because it is the one signing failure an operator fixes by editing a - /// reviewed field rather than by replacing key material, and the generic - /// message sends them looking at the key file instead. - #[error("the signing key identifier does not match the configured active key")] - SigningActiveKeyId, #[error("an Evidence source plan could not initialize")] Source, #[error("the Evidence rate limiter could not initialize")] @@ -123,10 +116,7 @@ pub async fn validate_secret_material( ); let signer = EvidenceSigner::initialize(provider, &bundle.config.signing.active_key_id) .await - .map_err(|error| match error { - EvidenceSigningError::ActiveKeyId => RuntimeInitializationError::SigningActiveKeyId, - _ => RuntimeInitializationError::Signing, - })?; + .map_err(|_| RuntimeInitializationError::Signing)?; let retired = bundle .retired_public_jwks .values() diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index acbf19146..620aa8a83 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -313,8 +313,7 @@ fn check_rejects_secret_material_the_server_would_refuse_at_startup() { SecretFailureCase { label: "signing key kid differs from the bundle's activeKeyId", break_secrets: |deployment| deployment.write_mismatched_signing_key(), - expected: "evidence: runtime signing initialization failed: the signing key \ - identifier does not match signing.activeKeyId\n", + expected: "evidence: runtime signing initialization failed\n", }, SecretFailureCase { label: "audit hash key below the minimum length", diff --git a/crates/registry-evidencectl/src/scaffold.rs b/crates/registry-evidencectl/src/scaffold.rs index b0ed27235..b157b3a05 100644 --- a/crates/registry-evidencectl/src/scaffold.rs +++ b/crates/registry-evidencectl/src/scaffold.rs @@ -423,8 +423,8 @@ fn report(root: &Path, secret_root: &Path, with_mint: bool) { " # obtain the source system's own bearer token and write it to {},", secret_root.join("source-bearer-token").display() ); - println!(" # mode 0600. check and the fixtures pass without it by design: readiness"); - println!(" # owns source credentials, so bundle review comes before source onboarding."); + println!(" # mode 0600. check, the fixtures and startup all pass without it; the"); + println!(" # first live request is where a missing token is discovered."); println!( " chmod -R a-w {} && chmod 444 {}", root.join(BUNDLE_DIRECTORY).display(), @@ -438,13 +438,6 @@ fn report(root: &Path, secret_root: &Path, with_mint: bool) { " evidence evaluate --runtime {} --fixture fixtures/cases.yaml", root.join(RUNTIME_FILE).display() ); - println!( - " evidence serve --runtime {}", - root.join(RUNTIME_FILE).display() - ); - println!(" # then confirm GET /ready answers 200 before first use. /health is liveness"); - println!(" # only and answers 200 even when a source credential is missing; /ready is"); - println!(" # the fail-closed gate and answers 503 until every credential resolves."); if with_mint { println!(); println!("Next steps for the paired Registry Mint deployment:"); diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md index c3879e458..6e385f8c0 100644 --- a/crates/registry-evidencectl/templates/README.md +++ b/crates/registry-evidencectl/templates/README.md @@ -112,3 +112,25 @@ Work through `bundle/evidence.yaml` in this order: The full configuration contract, including the authoring and promotion workflow, is documented in `products/evidence/reference/request-adapter/deployment-projects/CONFIG.md` in the Registry Stack repository. + +### Suggesting source configuration from an API description + +When the system you are pointing step 3 at publishes an OpenAPI description, +`evidencectl` can draft that source for you: a closed response schema, an +extraction script, and the facts schema the extraction fills. Unfreeze the +project first, since the draft is written into `bundle/`. + +```bash +evidencectl source suggest \ + --openapi ./api.yaml \ + --sample ./sample-response.json \ + --project {{project_root}} +``` + +Run in a terminal with no `--operation` and no `--select`, it asks which +operation the source calls and which response fields the projection carries, +then prints the equivalent fully flagged command so the same draft can be +reproduced in review. The sample is optional and never printed; it only widens +bounds the description leaves open. Nothing existing is overwritten, and every +bound that neither the description nor the sample implies is left as an explicit +`TODO` that `evidence check` rejects until you resolve it. diff --git a/crates/registry-evidencectl/tests/scaffold.rs b/crates/registry-evidencectl/tests/scaffold.rs index 74a874dfa..3f44a7561 100644 --- a/crates/registry-evidencectl/tests/scaffold.rs +++ b/crates/registry-evidencectl/tests/scaffold.rs @@ -44,13 +44,11 @@ fn a_mint_paired_project_passes_check_and_every_fixture() { /// The scaffolded source authenticates with a bearer token the source system /// issues and nothing here generates. `check` and every fixture pass without -/// it by design, because readiness owns source credentials, so the printed -/// steps have to name both halves of that contract: the token the operator -/// must supply, and `/ready` as the gate that reports it missing. `/health` -/// answers 200 either way, so a reader told only to check health concludes a -/// deployment that can answer nothing is healthy. +/// it, and the service starts without it, so a reader who follows only the +/// printed steps first discovers it missing at the first live request. The +/// printed steps must name it, as the generated README already does. #[test] -fn the_printed_next_steps_name_the_source_bearer_token_and_the_readiness_gate() { +fn the_printed_next_steps_name_the_source_bearer_token() { let workspace = TempDir::new().expect("temporary directory"); let project = workspace.path().join("project"); let outcome = evidencectl(&["new", project.to_str().expect("project path")]); @@ -65,10 +63,6 @@ fn the_printed_next_steps_name_the_source_bearer_token_and_the_readiness_gate() printed.contains("source-bearer-token"), "the printed next steps never mention the source bearer token:\n{printed}" ); - assert!( - printed.contains("/ready"), - "the printed next steps never mention the readiness gate:\n{printed}" - ); } fn passes_check_and_every_fixture(project: &Path) { @@ -562,8 +556,15 @@ fn provision_secrets(project: &Path) { private_jwk.as_bytes(), ); for name in SECRET_FILES { + // Regenerate until no byte is zero: the runtime rejects secret + // material containing NUL bytes, exactly as `keygen secret` does. let mut material = [0_u8; 32]; - getrandom::fill(&mut material).expect("random secret"); + loop { + getrandom::fill(&mut material).expect("random secret"); + if !material.contains(&0) { + break; + } + } write_secret(&secrets.join(name), &material); } assert_eq!( diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 33397bf44..4d21ed840 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -101,6 +101,13 @@ function generatedProduct(label) { if (!group) throw new Error(`generated sidebar group "${label}" not found`); return group; } + +// A product absent from this docset's generated sidebar (a product newer than +// an archived docset) yields no group instead of failing the build. +/** @param {string} label */ +function optionalGeneratedProduct(label) { + return productSidebar.find((/** @type {{ label: string }} */ entry) => entry.label === label) ?? null; +} const disabledSitemap = { name: '@astrojs/sitemap', hooks: {}, @@ -255,6 +262,11 @@ export default defineConfig({ schema: './openapi/registry-notary.openapi.json', sidebar: { label: 'Notary API operations', collapsed: true }, }, + { + base: 'reference/apis/evidence', + schema: './openapi/registry-evidence.openapi.json', + sidebar: { label: 'Evidence API operations', collapsed: true }, + }, ]), ], defaultLocale: 'root', @@ -296,6 +308,7 @@ export default defineConfig({ { label: 'Use your own spreadsheet', slug: 'tutorials/use-your-spreadsheet' }, { label: 'Expose spreadsheet evidence', slug: 'tutorials/verify-claim-registry-api' }, { label: 'When Registry Stack fits', slug: 'start/when-to-use' }, + { label: 'Evaluate Evidence', slug: 'start/evaluate-evidence' }, { label: 'Pre-1.0 cutover', slug: 'start/pre-1.0-cutover' }, ], }, @@ -311,6 +324,15 @@ export default defineConfig({ { label: 'Configuration fields', slug: 'reference/project-configuration' }, ], }, + { + label: 'Answer with Evidence', + items: [ + { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, + { label: 'Configure Evidence', slug: 'configure/evidence' }, + { label: 'Configure Registry Mint', slug: 'configure/mint' }, + { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, + ], + }, { label: 'Operate', collapsed: true, @@ -332,6 +354,7 @@ export default defineConfig({ collapsed: true, items: [ { label: 'Overview', slug: 'security' }, + { label: 'Evidence security model', slug: 'security/evidence' }, { label: 'Report a vulnerability', slug: 'security/report-a-vulnerability' }, { label: 'Security support window', slug: 'security/support-window' }, { label: 'Release trust', slug: 'security/openssf-evidence' }, @@ -353,11 +376,14 @@ export default defineConfig({ { label: 'Overview', slug: 'reference/apis' }, { label: 'Relay (narrative)', slug: 'reference/apis/registry-relay' }, { label: 'Notary (narrative)', slug: 'reference/apis/registry-notary' }, + { label: 'Evidence (narrative)', slug: 'reference/apis/registry-evidence' }, // Generated operation pages for each schema (theme-aware, searchable). ...openAPISidebarGroups, ], }, { label: 'Errors and status codes', slug: 'reference/errors' }, + { label: 'Evidence problems', slug: 'reference/evidence-problems' }, + { label: 'Registry Mint', slug: 'reference/mint' }, { label: 'Diagnostic catalogs', collapsed: true, @@ -387,6 +413,20 @@ export default defineConfig({ collapsed: true, items: generatedProduct('Manifest').items, }, + // Evidence entered the product docset after every archived + // docset was sealed, so its group is optional: absent when an + // archived docset's generated sidebar has no Evidence product. + // generate-sidebar.test.mjs pins its presence for the current + // docset, keeping the loud-failure property there. + ...(optionalGeneratedProduct('Evidence') + ? [ + { + label: 'Registry Evidence', + collapsed: true, + items: generatedProduct('Evidence').items, + }, + ] + : []), ], }, { label: 'Contracts', slug: 'reference/contracts' }, @@ -419,6 +459,7 @@ export default defineConfig({ { label: 'RS-DOC · Documentation framework', slug: 'spec/rs-doc' }, { label: 'RS-TERMS · Terms', slug: 'spec/rs-terms' }, { label: 'RS-ARC-G · Architecture', slug: 'spec/rs-arc-g' }, + { label: 'RS-PR-EVIDENCE · Evidence protocol', slug: 'spec/rs-pr-evidence' }, { label: 'RS-PR-NOTARY · Notary protocol', slug: 'spec/rs-pr-notary' }, { label: 'RS-PR-REGISTRYCTL · registryctl contract', slug: 'spec/rs-pr-registryctl' }, { label: 'RS-PR-RELAY · Relay protocol', slug: 'spec/rs-pr-relay' }, diff --git a/docs/site/package.json b/docs/site/package.json index ad10a921c..afa140874 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -33,15 +33,18 @@ "check:markdown": "markdownlint-cli2", "check:style": "node scripts/run-vale.mjs src/content/docs README.md", "check:style:fixtures": "node scripts/check-vale-fixtures.mjs", - "check:openapi": "redocly lint registry-relay registry-notary", + "check:openapi": "redocly lint registry-relay registry-notary registry-evidence", "check:config-vocabulary": "scripts/check-stale-config-vocabulary.sh", "check:tutorial": "scripts/check-tutorial.sh", "check:tutorial:dry-run": "scripts/check-tutorial.sh --dry-run", "test:tutorial:registryctl": "node --test scripts/registryctl-tutorial.test.mjs", "check:tutorial:registryctl": "bash scripts/check-registryctl-tutorials.sh", + "test:tutorial:evidence": "node --test scripts/check-evidence-tutorials.test.mjs", + "check:tutorial:evidence": "bash scripts/check-evidence-tutorials.sh", + "check:tutorial:evidence:dry-run": "bash scripts/check-evidence-tutorials.sh --dry-run", "check:tutorial:public-source-live": "scripts/check-registryctl-public-source-live.sh", "check:links": "npm run build && npm run check:links:built", - "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", + "check": "npm run generate && npm run check:evidence-links && npm run check:research-banners && npm run check:docset && npm run check:release-manifests && npm run check:archive-lock && npm run check:content && npm run check:cutover && npm run check:markdown && npm run check:style && npm run check:style:fixtures && npm run check:openapi && npm run check:config-vocabulary && npm run check:tutorial:dry-run && npm run check:tutorial:evidence:dry-run && npm run check:svg && npm run build && npm run check:accessibility:built && npm run check:llms:built && npm run check:seo:current && npm run check:links:current", "check:archives": "npm run build && npm run check:llms:built && npm run assemble:archives -- --bootstrap && npm run check:seo:built && npm run check:links:built", "check:archive-lock": "node scripts/archive-lock.mjs check", "check:seo:current": "node scripts/check-seo.mjs --scope current", diff --git a/docs/site/redocly.yaml b/docs/site/redocly.yaml index 247dd01e5..a160fd10e 100644 --- a/docs/site/redocly.yaml +++ b/docs/site/redocly.yaml @@ -5,6 +5,8 @@ apis: root: openapi/registry-relay.openapi.json registry-notary: root: openapi/registry-notary.openapi.json + registry-evidence: + root: openapi/registry-evidence.openapi.json rules: no-empty-servers: off no-unused-components: warn diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 39ebdabfe..afa0e0acd 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -23,6 +23,7 @@ import { promisify } from 'node:util'; import YAML from 'yaml'; import { applyDocsetRefs, + filterRepoDocsForDocset, getDocset, loadDocsets, selectedDocsetId, @@ -41,6 +42,7 @@ const cacheRoot = resolve(root, '.repo-docs-cache'); const SPEC_SOURCES = { 'registry-relay': 'openapi/registry-relay.openapi.json', 'registry-notary': 'openapi/registry-notary.openapi.json', + 'registry-evidence': 'products/evidence/generated/registry-evidence.openapi.json', }; function fail(message) { @@ -102,6 +104,11 @@ async function main() { } const docsets = await loadDocsets({ dataDir }); const docset = getDocset(docsets, selectedDocsetId(docsets)); + // Filter before applying docset refs, as sync-repo-docs.mjs does: a repo + // whose docs are all excluded from this docset must not count as an active + // repo the docset is required to pin. Such a repo keeps its repo-docs ref, + // so its spec rides the current shell the way hand-authored pages do. + filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for OpenAPI refs.`); diff --git a/docs/site/scripts/generate-sidebar.test.mjs b/docs/site/scripts/generate-sidebar.test.mjs index 6c8f0db47..2d73e47bb 100644 --- a/docs/site/scripts/generate-sidebar.test.mjs +++ b/docs/site/scripts/generate-sidebar.test.mjs @@ -155,7 +155,10 @@ test('product group labels drop the shared "Registry" prefix', () => { labels.every((l) => !/^Registry\b/.test(l)), `no group label should start with "Registry": ${labels.join(', ')}`, ); - assert.ok(labels.includes('Relay') && labels.includes('Notary'), labels.join(', ')); + assert.ok( + labels.includes('Relay') && labels.includes('Notary') && labels.includes('Evidence'), + labels.join(', '), + ); }); test('the real manifest yields one group per product with every doc present exactly once', async () => { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index d35141b57..184b43429 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -57,6 +57,7 @@ test('uses the adopter-first top-level flow in its published order', () => { assert.deepEqual(topLevelLabels(sidebarSource), [ 'Start', 'Connect an existing registry', + 'Answer with Evidence', 'Operate', 'Security', 'Reference', diff --git a/docs/site/scripts/sync-repo-docs.mjs b/docs/site/scripts/sync-repo-docs.mjs index ca07167ca..e69617e35 100644 --- a/docs/site/scripts/sync-repo-docs.mjs +++ b/docs/site/scripts/sync-repo-docs.mjs @@ -529,12 +529,16 @@ async function main() { const docsets = await loadDocsets({ dataDir }); validateRepoDocsMetadata(manifest, knownStandards, docsets); const docset = getDocset(docsets, selectedDocsetId(docsets)); + // Filter before applying docset refs: a repo whose docs are all excluded + // from this docset (a product newer than the docset, like registry-evidence + // in pre-Evidence archives) must not count as an active repo the docset is + // required to pin. + filterRepoDocsForDocset(manifest, docset); if (docset.id !== docsets.current) { applyDocsetRefs(manifest, docset); console.log(`Using archived docset ${docset.id} for product docs.`); } applyDocsetMetadataOverrides(manifest, docset); - filterRepoDocsForDocset(manifest, docset); // Clean and recreate the output dir so removed allowlist entries don't linger. await rm(outputRoot, { recursive: true, force: true }); diff --git a/docs/site/src/content/docs/reference/apis/index.mdx b/docs/site/src/content/docs/reference/apis/index.mdx index e1ec57b29..dfd504a95 100644 --- a/docs/site/src/content/docs/reference/apis/index.mdx +++ b/docs/site/src/content/docs/reference/apis/index.mdx @@ -1,12 +1,13 @@ --- title: API references -description: Generated API reference pages for Registry Relay and Registry Notary, built from pinned OpenAPI artifacts. +description: Generated API reference pages for Registry Relay, Registry Notary, and Evidence, built from pinned OpenAPI artifacts. wide: true status: current owner: registry-docs source_repos: - registry-relay - registry-notary + - registry-evidence last_reviewed: "2026-06-20" doc_type: reference locale: en @@ -16,7 +17,7 @@ standards_referenced: import OpenApiSourcesTable from '../../../../components/OpenApiSourcesTable.astro'; -Use this section to browse the HTTP API for Registry Relay or Registry Notary. Each page is built from a pinned OpenAPI artifact owned by the project it describes. +Use this section to browse the HTTP API for Registry Relay, Registry Notary, or Evidence. Each page is built from a pinned OpenAPI artifact owned by the project it describes. {/* Do not duplicate endpoint reference content in narrative pages. */} @@ -33,6 +34,8 @@ The table is generated from `src/data/openapi-sources.yaml`. evidence offering discovery, health and readiness endpoints, and optional standards adapters. - [Registry Notary API](./registry-notary/) documents the claim discovery, evaluation, batch evaluation, rendering, JWKS, service discovery, and credential issuance endpoints. +- [Registry Evidence API](./registry-evidence/) documents the assertion, requester-scoped + definition discovery, health, readiness, served-contract, and key discovery endpoints. ## Provenance and freshness @@ -51,3 +54,15 @@ cargo run -p registry-notary -- openapi > openapi/registry-notary.openapi.json ``` Regenerate and re-pin the artifact when the Notary API changes. + +[Evidence](../../products/registry-evidence/) generates its OpenAPI document with the other +Evidence contract artifacts: + +```sh +cargo run -p registry-evidence --example evidence-contracts -- --output "" +``` + +The committed copy lives in `products/evidence/generated/`, and root CI's `evidence-contracts` +job fails on any byte difference between the committed artifacts and a fresh generation. A running +Evidence service publishes the same document at `GET /openapi.json` with no authentication +required. diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index 7655a0616..aeb681952 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -105,6 +105,15 @@ Product names are always in English, including on future translated pages.
environment
Private bindings and operational settings for one Registry Stack project deployment target. An environment does not change the project's stable intent.
+
Evidence (product)
+
The minimum-disclosure assertion service in this monorepo. Crate: `crates/registry-evidence`; product material: `products/evidence/`. Given authenticated authority, an authorized purpose, and a predefined requirement, Evidence serves a signed assertion that answers the requirement, not the source record, plus an SD-JWT VC serialization of that same stateless assertion under a frozen Version 1 profile. The SD-JWT VC format is never a credential lifecycle: no issuance session, holder-binding ceremony, status list, or revocation. Evidence is not a Registry Notary mode, rewrite, or reduced configuration and does not inherit the Notary product model.
+ +
evidence credential (Registry Notary)
+
Registry Notary usage of "evidence": in the Evidence Gateway runtime, a Notary claim evaluation can result in credential issuance, governed by a credential profile and delivered through OID4VCI and SD-JWT VC. This Notary-scoped meaning is distinct from Evidence (product): Registry Notary evidence names a credential with an issuance lifecycle, while Evidence names a stateless signed assertion with none.
+ +
Evidence toolset
+
The three released binaries `evidence`, `evidencectl`, and `mint`. Releases that include the toolset publish reproducible binaries alongside a cosign-signed `SHA256SUMS` file, and the installer installs all three together or not at all after verifying every asset. `evidencectl` shells out to `evidence` for every Evidence semantic decision and never re-implements evaluation, signing, or verification. `mint`, built from the `registry-mint` crate, issues the access tokens `evidence` verifies.
+
Evidence Gateway
Governed runtime path for evidence responses. A Relay read or consultation and a Notary claim evaluation pass trusted request and evidence context through configured authorization and disclosure policy before returning or denying a response. Registry-backed Notary claims consume compiler-pinned Relay results.
@@ -208,7 +217,7 @@ Product names are always in English, including on future translated pages.
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
registry stack
-
The four formal stack products: Registry Platform, Registry Relay, Registry Manifest, and Registry Notary. Use lowercase when referring to the concept.
+
The formal stack products: Registry Platform, Registry Relay, Registry Manifest, Registry Notary, and Evidence, with Registry Mint as supporting token issuance. Use lowercase when referring to the concept.
purpose-bound request
Registry Stack product term for a request that carries or is evaluated against purpose limitation, policy-based access control, or context-aware authorization. Relay records the `Data-Purpose` header in audit records where present.
@@ -225,6 +234,9 @@ Product names are always in English, including on future translated pages.
Registry Manifest
Rust workspace for modeling, validating, and rendering standards-facing service, registry, form, and policy metadata without running Registry Relay. Provides a library (`registry-manifest-core`) and a CLI (`registry-manifest-cli`). Repo slug: `registry-manifest`.
+
Registry Mint
+
Small supporting service, not a fourth registry stack pattern, that issues short-lived, audience-bound access tokens to registered machine clients using the `client_credentials` grant with `private_key_jwt` client authentication, so a resource server such as Evidence can require signed tokens without standing up a general-purpose identity provider. The client registry binds each client id to its own keys and to the authority Registry Mint asserts for it. Registry Mint's tests drive Evidence's authenticator; the dependency runs one way only, and Evidence does not depend on Registry Mint. Crate: `crates/registry-mint`; binary: `mint`.
+
Registry Platform
Shared Rust workspace for registry security and operational primitives, including auth helpers, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC helpers, and test fixtures. Repo slug: `registry-platform`.
@@ -315,8 +327,8 @@ Product names are always in English, including on future translated pages. ## Style notes -- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary) and the adopter demo name (Solmara Lab) are always title case. -- Repo slugs (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `solmara-lab`) are always lowercase and monospace. +- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Notary, Registry Mint) and the adopter demo name (Solmara Lab) are always title case. The assertion product's name is Evidence, capitalized as a proper noun. +- Repo slugs and crate names (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-notary`, `registry-evidence`, `registry-mint`, `solmara-lab`) are always lowercase and monospace. - Legacy underscore forms (`registry_relay`) and old repo names (`decentralized-evidence-demo`) appear only in historical pages or `rename_status` fields. - The glossary provides a reference for standards acronyms but does not replace per-page first-use expansion. diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index 18a4b5e8e..d6ef34702 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -32,10 +32,22 @@ system that owns the data. | Caller needs | Use | Result | | --- | --- | --- | | Selected records or fields | Registry Relay | A protected, read-only API response | +| A signed minimum-disclosure answer about one subject | Evidence | A signed assertion carrying the answer, not the source record | | A bounded answer or status | Registry Notary | A claim result without the source record | -The two products can work together. Registry Relay obtains a limited source -result, and Registry Notary evaluates a reviewed claim over that result. +Registry Relay and Registry Notary can work together: Registry Relay obtains a +limited source result, and Registry Notary evaluates a reviewed claim over +that result. + +### Who does what + +- The assertion provider is an institution that answers requests with signed + facts through Evidence. +- The data publisher is an institution that exposes records through Registry + Relay. +- The consumer or verifier is a relying service that calls either door and + verifies the answers it receives. +- The operator is whoever runs the deployment. ## Registry Stack is not the right tool when diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index 0417b6e5d..b43f55b77 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -22,6 +22,9 @@ docsets: registry-manifest: version: main source (unreleased) ref: HEAD + registry-evidence: + version: main source (unreleased) + ref: HEAD - id: v0.16.3 label: v0.16.3 path: /v/0.16.3/ diff --git a/docs/site/src/data/generated/docsets.json b/docs/site/src/data/generated/docsets.json index 7a002d6a3..ebbe597cd 100644 --- a/docs/site/src/data/generated/docsets.json +++ b/docs/site/src/data/generated/docsets.json @@ -27,6 +27,10 @@ "registry-manifest": { "version": "main source (unreleased)", "ref": "HEAD" + }, + "registry-evidence": { + "version": "main source (unreleased)", + "ref": "HEAD" } } }, diff --git a/docs/site/src/data/generated/openapi-sources.json b/docs/site/src/data/generated/openapi-sources.json index e0d36c381..97c90c760 100644 --- a/docs/site/src/data/generated/openapi-sources.json +++ b/docs/site/src/data/generated/openapi-sources.json @@ -16,5 +16,14 @@ "artifact": "openapi/registry-notary.openapi.json", "status": "pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples", "reference_path": "/reference/apis/notary/" + }, + { + "id": "registry-evidence", + "name": "Registry Evidence API", + "owner": "registry-evidence", + "source": "Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job.", + "artifact": "openapi/registry-evidence.openapi.json", + "status": "pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required.", + "reference_path": "/reference/apis/evidence/" } ] diff --git a/docs/site/src/data/generated/sidebar.json b/docs/site/src/data/generated/sidebar.json index 0093e73d4..c8775618c 100644 --- a/docs/site/src/data/generated/sidebar.json +++ b/docs/site/src/data/generated/sidebar.json @@ -194,5 +194,39 @@ "slug": "products/registry-manifest/reference" } ] + }, + { + "label": "Evidence", + "collapsed": true, + "items": [ + { + "label": "Overview", + "slug": "products/registry-evidence" + }, + { + "label": "Product concept", + "slug": "products/registry-evidence/concept" + }, + { + "label": "First server curl", + "slug": "products/registry-evidence/first-curl-test" + }, + { + "label": "Source testing", + "slug": "products/registry-evidence/source-testing" + }, + { + "label": "SD-JWT VC demo", + "slug": "products/registry-evidence/sd-jwt-vc-demo" + }, + { + "label": "Operator contract", + "slug": "products/registry-evidence/operator-contract" + }, + { + "label": "Performance", + "slug": "products/registry-evidence/performance" + } + ] } ] diff --git a/docs/site/src/data/openapi-sources.yaml b/docs/site/src/data/openapi-sources.yaml index 696acbbf1..912c799f8 100644 --- a/docs/site/src/data/openapi-sources.yaml +++ b/docs/site/src/data/openapi-sources.yaml @@ -12,3 +12,10 @@ artifact: openapi/registry-notary.openapi.json status: pulled at the pinned ref (build artifact, regenerated each build) with federation, OID4VCI, and response examples reference_path: /reference/apis/notary/ +- id: registry-evidence + name: Registry Evidence API + owner: registry-evidence + source: Pulled from `products/evidence/generated/registry-evidence.openapi.json` at the pinned ref in `src/data/repo-docs.yaml` by `scripts/fetch-openapi.mjs`. The document is generated by `cargo run -p registry-evidence --example evidence-contracts` and byte-drift-checked in root CI by the `evidence-contracts` job. + artifact: openapi/registry-evidence.openapi.json + status: pulled at the pinned ref (build artifact, regenerated each build). A running Evidence service publishes the same generated document at `GET /openapi.json` with no authentication required. + reference_path: /reference/apis/evidence/ diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index 969231577..ac989a614 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -528,3 +528,73 @@ repos: - docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1] standards_referenced: [dcat, bregdcat-ap, shacl, skos, json-schema, json-ld] last_reviewed: unreviewed + registry-evidence: + remote: https://github.com/registrystack/registry-stack + ref: HEAD + version: main source (unreleased) + local: ../.. + openapi: products/evidence/generated/registry-evidence.openapi.json + docs: + - src: products/evidence/README.md + dest: products/registry-evidence/index + label: Registry Evidence + nav_order: 0 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [openapi, json-schema, sd-jwt-vc, oid4vci] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Operator and integrator documentation for Evidence, the minimum-disclosure assertion service. + - src: products/evidence/CONCEPT.md + dest: products/registry-evidence/concept + label: Product concept + nav_order: 10 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [cccev, json-schema, openapi, sd-jwt-vc, oid4vci] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Approved Version 1 product concept covering the boundary, data model, trust and privacy invariants, native API, and acceptance set. + - src: products/evidence/FIRST-CURL-TEST.md + dest: products/registry-evidence/first-curl-test + label: First server curl + nav_order: 20 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Deterministic local curl checkpoint against the Evidence server with a mock source and an in-memory test JWKS. + - src: products/evidence/SOURCE-TESTING.md + dest: products/registry-evidence/source-testing + label: Source testing + nav_order: 30 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Source-testing contract with the deterministic mock matrix, opt-in public demo smoke tests, and credential handling rules. + - src: products/evidence/SD-JWT-VC-DEMO.md + dest: products/registry-evidence/sd-jwt-vc-demo + label: SD-JWT VC demo + nav_order: 40 + doc_type: how-to + last_reviewed: unreviewed + standards_referenced: [sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Deterministic local demo that issues one assertion in both later-verifiable formats and re-verifies the credential offline. + - src: products/evidence/OPERATOR-CONTRACT.md + dest: products/registry-evidence/operator-contract + label: Operator contract + nav_order: 50 + doc_type: reference + last_reviewed: unreviewed + standards_referenced: [openapi, sd-jwt-vc] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Supported deployment shape, requester authority and purpose duties, required configuration and secrets, and readiness, audit, and key obligations. + - src: products/evidence/PERFORMANCE.md + dest: products/registry-evidence/performance + label: Performance + nav_order: 60 + doc_type: explanation + last_reviewed: unreviewed + standards_referenced: [] + exclude_docsets: [v0.16.3, v0.16.2, v0.16.1, v0.16.0, v0.15.2, v0.15.1, v0.15.0, v0.13.0, v0.12.2, v0.12.1, v0.12.0, v0.11.0, v0.10.0, v0.9.0, v0.8.4, v0.8.3, v0.8.2, v0.8.1, beta-5, beta-4, beta-3, beta-2026-06-12] + description: Measured baseline of the audit-durability throughput trade and the deferred work that would recover most of the cost. diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 71ef80de4..8af592e7a 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -48,13 +48,13 @@ gates for its area (see Verification), and committed. ### A. Composition proof (Evidence over Relay) -- [ ] A1. Token topology verified and recorded: establish whether Relay +- [x] A1. Token topology verified and recorded: establish whether Relay embeds a client-credentials token endpoint (`registry-platform-sts`) that Evidence's `SourceAuthentication::Oauth2ClientCredentials` can target, or whether IdP-less deployments need Mint to issue for Relay (a security-sensitive Relay change requiring review). Record the outcome in the status log below. -- [ ] A2. An ordinary sanitized Relay-shaped mock test in +- [x] A2. An ordinary sanitized Relay-shaped mock test in `crates/registry-evidence` proves a full signed assertion over an OAuth client-credentials source, with zero production-code changes. - [ ] A3. A reference deployment-project example for the Relay-backed @@ -62,16 +62,16 @@ gates for its area (see Verification), and committed. ### B. Evidence onboarding (docs site, evidencectl, CI) -- [ ] B1. Site plumbing: `openapi-sources.yaml` entry for the generated +- [x] B1. Site plumbing: `openapi-sources.yaml` entry for the generated Evidence OpenAPI with a Redoc reference page; contracts wired into the data-driven reference; Operate content from `OPERATOR-CONTRACT.md`; Security content from the invariant matrix and test traceability; a "Registry Evidence" Configure group. -- [ ] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an +- [x] B2. The Evidence OpenAPI is drift-checked in root CI (confirm an existing gate or add one). -- [ ] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or +- [x] B3. `spec/rs-pr-evidence` exists in the spec series, generated from or tightly linked to the frozen contracts so it cannot drift. -- [ ] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job +- [x] B4. Tutorial gate: an evidencectl-fixtures-driven check with a CI job that runs each Evidence tutorial from a clean container with only the prerequisites the tutorial itself documents; Evidence tutorials must pass it to merge. @@ -82,9 +82,9 @@ gates for its area (see Verification), and committed. verify an assertion as a consumer. Adopter outcome for E1: a fresh machine completes it in 15 minutes or less using released binaries installed via F1 (the released-binary form of this gate needs F3). -- [ ] B6. Tutorial E6 (move Evidence to production signing) published, +- [x] B6. Tutorial E6 (move Evidence to production signing) published, derived from `OPERATOR-CONTRACT.md`. -- [ ] B7. Mint has real docs presence: a Configure page and a reference page. +- [x] B7. Mint has real docs presence: a Configure page and a reference page. - [ ] B8. Onboarding spine: glossary disambiguates Evidence (product) from the retired Notary evidence credentials; `start/when-to-use` presents the two doors; the quickstart ends in an Evidence assertion over the @@ -148,12 +148,12 @@ builder image, and `crates/registryctl/install.sh` is already published as the `registryctl--install.sh` release asset. Evidence rides that channel; it does not get a parallel one. -- [ ] F1. `crates/registry-evidencectl/install.sh` exists, mirroring the +- [x] F1. `crates/registry-evidencectl/install.sh` exists, mirroring the registryctl installer conventions: installs `evidence`, `evidencectl`, and `mint` from a release, verifies every artifact against SHA256SUMS, refuses unverified installs, has offline tests, and passes shellcheck and shfmt. -- [ ] F2. The three binaries and the installer asset enter the release +- [x] F2. The three binaries and the installer asset enter the release channel: `build-release-binaries.sh` builds and checksums them, the candidate workflow stages `evidencectl--install.sh`, and the artifact inventory in `release/scripts/registry-release` accepts them @@ -163,16 +163,16 @@ channel; it does not get a parallel one. the published assets installs working binaries. Then flip the inventory entries from optional to required at a minimum version, the way `registryctl-installer` did at v0.14.0. -- [ ] F4. Platform coverage decision recorded: linux-amd64 is the +- [x] F4. Platform coverage decision recorded: linux-amd64 is the reproducible baseline; registryctl already publishes optional macos-arm64 and linux-arm64 assets; decide and record the same optional set for the Evidence binaries. - [ ] F5. Personas named on the docs site (assertion provider, data publisher, consumer/verifier, operator) and every tutorial labeled with whose it is. -- [ ] F6. An Evidence errors and problems reference page exists for +- [x] F6. An Evidence errors and problems reference page exists for adopters: what each public problem means and what to do about it. -- [ ] F7. An evaluate-stage page exists: what Evidence costs to run +- [x] F7. An evaluate-stage page exists: what Evidence costs to run (footprint, dependencies, operational burden, support window). ### Global gates (checked at the end, not per item) @@ -223,3 +223,101 @@ is parallel; B has no upstream dependencies and is the standing priority and registryctl already ships an installer asset. F was rewritten to extend that channel (evidence, evidencectl, mint were simply missing from it) instead of inventing a parallel one. F1 and F2 in progress. +- 2026-08-03: F1 done. The evidencectl installer mirrors the registryctl + conventions (toolset all-or-nothing install, SHA256SUMS verification, + asset-dir mode, staged install with rollback) with 11 offline tests; + shellcheck and shfmt clean. Found in passing: the rust-result shard + test in release/scripts/test_registry_release.py is stale on main + (ci.yml gained evidence-contracts); flagged separately, not fixed here. +- 2026-08-03: F2 done. build-release-binaries.sh builds and checksums + evidence, evidencectl, and mint in the pinned builder; the candidate + workflow stages evidencectl--install.sh beside the registryctl + installer; the registry-release inventory accepts the four artifacts + as optional (flip to required at a minimum version is F3), with two + new unit tests. Historical manifests still validate (beta-26 checked). + The product README documents installation. Next in F: F3 needs a real + release; F4-F7 are open. +- 2026-08-03: B2 confirmed done with no new code. Root CI's + evidence-contracts job (.github/workflows/ci.yml) runs + products/evidence/scripts/check-contracts.sh, which regenerates every + Evidence contract including registry-evidence.openapi.json and + byte-diffs against products/evidence/generated/. The classifier + (.github/scripts/ci_changes.py) fires it for registry-evidence, + registry-evidencectl, and products/evidence/ changes. +- 2026-08-03: F4 decided and implemented. Platform coverage matches + registryctl exactly: linux-amd64 is the required reproducible + baseline from the pinned builder; linux-arm64 and macos-arm64 ship + as optional native-runner assets from the candidate workflow's + build-platforms matrix, which now also builds evidence, evidencectl, + and mint. The assemble step and the F2 inventory already accept the + per-platform names generically. +- 2026-08-03: Docs wave landed. B1 done: repo-docs mirrors seven Evidence + product pages, the latest docset registers the product, the generated + OpenAPI flows through fetch-openapi into a Redoc operations section + plus a narrative API page, security content comes from the invariant + matrix and test traceability (security/evidence.mdx), Operate content + is the mirrored operator contract, and the sidebar gains an "Answer + with Evidence" flow (the plan's "Registry Evidence Configure group" + exists as that flow plus the Registry Evidence product group; rename + if the exact label matters). B3 done: RS-PR-EVIDENCE with 56 + requirements, every one citing its frozen contract file. B7 done: + Mint configure and reference pages, config fields cited to source + lines. F6 done: all nine public problem types documented from the + problem contract. F7 done: evaluate-stage page; writing it surfaced + stale PERFORMANCE.md group-commit claims, reconciled in their own + commit. B8 partial: two-door when-to-use, personas (F5 partial), and + glossary disambiguation are in; the quickstart flip still waits on A + and D. E1 (first-assertion tutorial) is published with commands + verified against the built binaries, but B5 stays open: E2-E5 and the + B4 gate are not built. Archived-docset design decision: a product + absent from an archived docset is filtered before docset pinning and + its OpenAPI rides the current shell; the Evidence product group + splices optionally (v0.15.2 verified past the Evidence throw; its + remaining sync failure is a pre-existing Notary allowlist gap on + main, flagged separately, as is the stale rust-result shard test). + Site suite 267 tests green. Full `npm run check` still pending. +- 2026-08-03: Session-limit note: three authoring subagents died mid + wave (configure page recovered by hand, E1 rewritten by hand, E6 not + written). Next unblocked items: B4 gate, E6, then E2-E5. +- 2026-08-03: B4 done. check-evidence-tutorials.sh drift-checks and + replays the first-assertion tutorial's fences verbatim (executed + green locally end to end); its dry-run joins npm run check; a + path-gated evidence-tutorials CI job builds the toolset and executes + the tutorial inside the repo's pinned builder-image digest with the + repo mounted read-only, counted by the required-results job. Caveat + recorded: that image is a full builder userland reused for its pinned + digest; a slimmer pinned base can replace it later. The + release-download fences stay unexecuted until F3. B6 done: the + production-signing tutorial restates OPERATOR-CONTRACT.md (key + generation, JWKS retention window, offline validation, readiness, + rotation, signature limits). Extending the executable gate to E6 and + the future E2-E5 belongs to B5. Full npm run check passed after the + docs wave; one Vale error it surfaced (typographic quotes in the + operator contract lead) fixed at the source. +- 2026-08-03: A1 verified and recorded. Relay embeds no token issuance: + registry-platform-sts is Notary-bound token exchange with zero + consumers in the workspace (add it to the C4 orphan candidates). + Relay authenticates inbound callers through configured OIDC (issuer, + audiences, algorithm allowlist, JWKS cache in + crates/registry-relay/src/auth/oidc/, with EdDSA-verifying tests), + and Mint issues EdDSA tokens with configured audiences and serves a + JWKS. IdP-less Evidence-over-Relay therefore points Relay's verifier + configuration at Mint's issuer: deployment configuration, no + security-sensitive Relay change, so the reserved Mint-for-Relay code + branch is not triggered. Open question for A2 and D: whether Mint's + Evidence-shaped claim set satisfies Relay's authorization model, or + whether Mint (not Relay, not Evidence) needs a claim addition. +- 2026-08-03: A2 done, zero production-code changes needed. The new + crates/registry-evidence/tests/relay_shaped_source.rs mirrors the + Relay OpenAPI's household-record read (path, bearer, Data-Purpose, + entity response shape) by hand in a wiremock, drives OAuth client + credentials through the frozen runtime's public API to a signed + flattened JWS, verifies it against the deployment JWKS under the full + relying policy, and proves minimum disclosure with canaries: the raw + record id, the untouched field, and even the raw region code never + appear in the assertion payload. Composition components all existed + in the frozen V1 runtime. Next in A: A3, the Relay-backed reference + deployment project. E2's open design question for Jeremi: what tool + tutorial readers use to build the RFC 7523 client assertion for Mint + (the demo uses a Python walkthrough; an evidencectl helper would be + new CLI surface outside the frozen runtime contract). diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index b2dcd2eb6..e873655fe 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -82,7 +82,7 @@ unsafe bundle safe. ## Discovery of available evidence -Evidence Version 1 answers “what may this caller request?” with authenticated +Evidence Version 1 answers "what may this caller request?" with authenticated `GET /v1/evidence-definitions`. Availability is requester-relative: the definition must exist in the exact deployed bundle and exactly one authority path must match the verified token, requirement, purpose, audience, complete @@ -682,7 +682,7 @@ Before production exposure, the operator runs: ```sh evidence check -evidence evaluate --fixture +evidence evaluate --fixture "" ``` All commands accept `--runtime `. The same path may be supplied diff --git a/products/evidence/PERFORMANCE.md b/products/evidence/PERFORMANCE.md index ee05e13d5..3f5a3eaa0 100644 --- a/products/evidence/PERFORMANCE.md +++ b/products/evidence/PERFORMANCE.md @@ -1,15 +1,17 @@ # Evidence Performance -Status: Measured baseline and deferred work, not a Version 1 contract -Date: 2026-08-02 +Status: Measured, with group commit implemented; not a Version 1 contract +Date: 2026-08-03 ## Purpose Evidence trades request throughput for audit durability. This file records what -that trade costs, how it was measured, and the one change that would recover -most of the cost without weakening the guarantee. Nothing here is a Version 1 -commitment. Throughput is not a Definition of Done row and is not a `CONCEPT.md` -non-goal; it is ordinary engineering work that has been deliberately deferred. +that trade costs, how it was measured, and the change that recovered most of +the cost without weakening the guarantee: group commit in the audit sink, +implemented in `crates/registry-evidence/src/audit.rs`. The current end-to-end +measurement lives in `OPERATOR-CONTRACT.md` under "Measured throughput". +Nothing here is a Version 1 commitment. Throughput is not a Definition of Done +row and is not a `CONCEPT.md` non-goal. ## The guarantee that sets the ceiling @@ -27,11 +29,14 @@ call `sync_all`, so their records sit in the page cache and are lost on power failure. Evidence is the only one of the three that survives that failure, and the ceiling below is the price of it. -## Measured baseline +## Measured baseline before group commit -Measured with `soak_reports_request_throughput_against_the_audit_ceiling` in +This section records the measurement that motivated group commit and is kept +as the before-figure. Measured with +`soak_reports_request_throughput_against_the_audit_ceiling` in `crates/registry-evidence/src/runtime_tests.rs`. Two release-profile runs, 512 -requests at 32 concurrent, against a local mock source: +requests at 32 concurrent, against a local mock source, with each append +taking its own barrier: | | run 1 | run 2 | |---|---|---| @@ -66,38 +71,37 @@ audit path. Nothing else is shared between requests. N processes with N distinct audit paths therefore give N times the throughput with no code change. Only vertical throughput is capped. -## Deferred work: group commit +## Group commit The lever for vertical throughput is batching the barrier, not removing it. +The audit sink in `crates/registry-evidence/src/audit.rs` implements this: +appends that arrive while a durable write is in flight form the next batch, +and one `fsync` covers the whole batch. There is no timer and no configured +window; a batch is exactly what queued behind the in-flight barrier, so the +sink degrades to one barrier per append when requests do not overlap. -Today each append takes the chain mutex, writes, and fsyncs alone. Under -concurrency the appends already queue, so the records that queue behind an -in-flight barrier could be written and covered by a single subsequent barrier. -One fsync would then serve many records instead of one. - -Properties that must survive the change: +Properties that survived the change, each held by tests in `audit.rs`: - durability before release: an append resolves only after the barrier that covers its own bytes has completed, so no caller receives evidence ahead of its durable record; - chain ordering: records are hash-linked in the order they were chained, and - the on-disk order matches; + the on-disk order matches; batching must not drop or duplicate a record; - fail-closed: a failed barrier fails every append it covers, and none of them may report success; - fork detection: the pinned-path, fingerprint, and tail checks in - `DurableJsonlSink::write` still bracket the batched write. - -Expected gain is roughly the batch size, bounded by concurrent arrivals, so it -scales with load rather than helping a single idle request. - -### Preconditions - -1. Re-measure on the target Linux host. If the Linux ceiling already clears the - deployment's required rate, do not do this work. -2. Treat it as a security-sensitive change to audit integrity. It needs explicit - review notes and focused negative tests for each property above, per the - root `AGENTS.md` rules and the phase-3 invariant discipline in - `products/evidence/AGENTS.md`. + `DurableJsonlSink::write` still bracket the batched write, and a batch that + crosses the segment bound is split so each segment stays self-consistent. + +The gain scales with concurrent arrivals rather than helping a single idle +request. With group commit in place, the end-to-end measurement in +`OPERATOR-CONTRACT.md` under "Measured throughput" sustained 7057 +requests/second at 128 concurrent on the same host class that measured the +baseline rows in this file, with both durable audit appends per request kept. + +The macOS caveat still applies to every figure in this file and in +`OPERATOR-CONTRACT.md`: re-measure on the target Linux host before quoting +production numbers. ## Regression baseline diff --git a/products/evidence/README.md b/products/evidence/README.md index fc1e4b43f..e7603d79d 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -86,6 +86,48 @@ re-implements evaluation, signing, or verification. It must not depend on `registry-notary*`, and its source and scaffold templates are covered by the same source-product and domain neutrality checks as the runtime. +`evidencectl source suggest` drafts one source from an OpenAPI description: +it derives a closed response schema, an extraction script, and the facts schema +from the chosen operation, the projection the operator selects, and an optional +sample response, leaving an explicit `TODO` wherever a bound cannot be derived +so `evidence check` rejects the draft until a human resolves it. + +```bash +evidencectl source suggest --openapi ./api.yaml --project ./deployment-project +``` + +## Installing the toolset + +Releases that include the Evidence toolset publish reproducible bare binaries +named `---` (for example `evidence-v1.2.0-linux-amd64`) +plus a `SHA256SUMS` file that is cosign-signed at promotion. Older releases do +not carry these assets. For a release that does, install the pinned installer +asset directly: + +```sh +curl -fsSL https://github.com/registrystack/registry-stack/releases/download//evidencectl--install.sh | bash +``` + +The installer installs the three-binary Evidence toolset, the `evidence` +runtime, `evidencectl` adopter tooling, and the `mint` token issuer, together +or not at all, verifying every asset against `SHA256SUMS` before anything +reaches the install directory. It supports Linux amd64, Linux arm64, and +macOS arm64. It checks integrity, not authenticity: for a higher-assurance +install, follow [`release/VERIFY.md`](../../release/VERIFY.md) for the pinned +tag, then rerun the installer with `EVIDENCECTL_ASSET_DIR` pointed at that +verified directory. + +Three environment variables configure the installer: `EVIDENCECTL_VERSION` +pins a `vMAJOR.MINOR.PATCH` tag, `EVIDENCECTL_INSTALL_DIR` sets the install +directory (default `~/.local/bin`), and `EVIDENCECTL_ASSET_DIR` installs from +a locally verified asset directory instead of downloading. + +To build the toolset from source instead: + +```sh +cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint +``` + ## Discovering available evidence An authenticated caller lists the complete Evidence request shapes it can diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index 2de0ff6a4..4d7fba62c 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -109,6 +109,7 @@ response_shape: stage: After projection and before conversion to Rhai, so no response outside its declared shape reaches a script. artifact: One required bundle-relative responseSchema per source, in the same closed JSON Schema subset as adapterParametersSchema and factSchema and validated by the same startup checks. role_relaxation: A response schema may list fewer required members than it declares properties, because projection legitimately drops a selected leaf the record did not carry. The adapter-parameter and fact roles keep the exact required-equals-properties rule. + required_rule: Projection already decides which members a schema may require. A selected leaf can be absent, so requiring one turns an ordinary incomplete record into a source-protocol failure and must instead be checked by the script on the records that have to carry it. An intermediate container cannot be absent, because projection rejects the response before the shape is read, so requiring one only repeats the projection. nullable_form: A response schema node may write its type as the pair [T, "null"]. A source reports an explicit null where it holds no value and projection carries that null through verbatim, so the shape has to be able to say so. This is the only union the subset admits and only in the response role; null reaches the script as the same unit marker is_missing already reads. division_of_labour: schema: Member presence where the shape can guarantee it, member types, array bounds and uniqueness, string bounds and formats, and enumerated or constant values. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai index cb99dc4c8..978ec7825 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/adult-status-extract.rhai @@ -22,6 +22,15 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; + // The shape cannot require this of every record, because the ambiguous + // page is decided from the pager before any record is read. A single + // matched record with no reference of its own is a source this adapter + // does not understand. attributes needs no check: it carries projected + // children, so a record without it never survives projection. + if is_missing(record["trackedEntity"]) { + throw("source_protocol_error"); + } + // The attribute is named by an adapter parameter, and a register that // reports it twice has no single value to read, so this loop stays. let date_of_birth = (); diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai index 539c717b5..dd8c42d01 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/adapters/professional-licence-extract.rhai @@ -73,6 +73,15 @@ fn extract(source_response, parameters) { if records.len != 1 { throw("source_protocol_error"); } let record = records[0]; + // The shape cannot require this of every record, because the ambiguous + // page is decided from the pager before any record is read. A single + // matched record with no reference of its own is a source this adapter + // does not understand. attributes needs no check: it carries projected + // children, so a record without it never survives projection. + if is_missing(record["trackedEntity"]) { + throw("source_protocol_error"); + } + // The validity attributes are named by adapter parameters, and a register // that reports one of them twice has no single value to read, so this loop // stays. diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml index 78849303d..57a1dac78 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/adult-status-cases.yaml @@ -139,8 +139,12 @@ cases: trackedEntities: - trackedEntity: Tei00000001 attributes: [] - - trackedEntity: Tei00000002 - attributes: [] + # The second record carries no reference of its own, only the + # attribute list the projection insists on. The pager decides this page + # before any record is read, so a response shape that demanded a + # complete record here would turn an ordinary ambiguous lookup into a + # source-protocol failure. + - attributes: [] expected: lookup: ambiguous publicProblem: evidence_not_available diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml index ff61fcb07..b02f4072b 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/fixtures/professional-licence-cases.yaml @@ -285,8 +285,12 @@ cases: - trackedEntity: Tei00000001 attributes: [] enrollments: [] - - trackedEntity: Tei00000002 - attributes: [] + # The second record carries no reference of its own, only the lists + # the projection insists on. The pager decides this page before any + # record is read, so a response shape that demanded a complete record + # here would turn an ordinary ambiguous lookup into a source-protocol + # failure. + - attributes: [] enrollments: [] expected: lookup: ambiguous diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml index 0ccbdc0dc..184d03f48 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/adult-status-response.schema.yaml @@ -33,7 +33,14 @@ properties: items: type: object additionalProperties: false - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. What a single matched record must carry stays with the + # script. attributes is not listed either: projection rejects a record + # missing an intermediate its children hang from, so saying so here would + # only repeat the projection. + required: [] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml index bc3ae91fb..b17a3debd 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/schemas/professional-licence-response.schema.yaml @@ -33,9 +33,14 @@ properties: items: type: object additionalProperties: false - # enrollments is optional because a tracked entity may hold none, which - # the script reads as a record carrying no licence state. - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. attributes and enrollments are not listed either, for a + # different reason: projection rejects a record missing an intermediate + # its children hang from, so saying so here would only repeat the + # projection. + required: [] properties: trackedEntity: type: string diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai index 916daf387..c810fa14e 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/adapters/birth-parents-extract.rhai @@ -18,8 +18,14 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits one is a source this adapter does not understand. declaration is + // not checked here: it carries projected children, so a record without it + // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] { + result["status"] != parameters["registeredStatus"] || + is_missing(result["trackingId"]) { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml index 61fcd5362..f0aa2bc45 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-references-cases.yaml @@ -84,6 +84,10 @@ cases: derivationRuns: false signed: false - id: ambiguous-child + # The second record carries no leaf at all, only the declaration the + # projection insists on. An ambiguous page is refused before any record is + # read, so a response shape that demanded a complete record here would turn + # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -91,15 +95,25 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - type: birth - status: REGISTERED - trackingId: TRACKING-SYNTHETIC-002 - declaration: {father.personReference: PERSON-SYNTHETIC-B} + - declaration: {} expected: lookup: ambiguous publicProblem: evidence_not_available derivationRuns: false signed: false + - id: negative-matched-record-without-tracking-id + # The other side of the same rule: what a page need not carry, a single + # matched record must, and the script is what says so. + response: + total: 1 + results: + - type: birth + status: REGISTERED + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: negative-duplicate-parent-reference response: total: 1 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml index 8dfcaa054..ef5a70bc5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -100,6 +100,10 @@ cases: derivationRuns: false signed: false - id: ambiguous-child + # The second record carries no leaf at all, only the declaration the + # projection insists on. An ambiguous page is refused before any record is + # read, so a response shape that demanded a complete record here would turn + # an ordinary ambiguous lookup into a source-protocol failure. response: total: 2 results: @@ -107,15 +111,25 @@ cases: status: REGISTERED trackingId: TRACKING-SYNTHETIC-001 declaration: {mother.personReference: PERSON-SYNTHETIC-A} - - type: birth - status: REGISTERED - trackingId: TRACKING-SYNTHETIC-002 - declaration: {father.personReference: PERSON-SYNTHETIC-B} + - declaration: {} expected: lookup: ambiguous publicProblem: evidence_not_available derivationRuns: false signed: false + - id: matched-record-without-tracking-id + # The other side of the same rule: what a page need not carry, a single + # matched record must, and the script is what says so. + response: + total: 1 + results: + - type: birth + status: REGISTERED + declaration: {mother.personReference: PERSON-SYNTHETIC-A} + expected: + error: source_protocol_error + derivationRuns: false + signed: false - id: missing-parent-set response: total: 1 diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml index 53cc64688..c5b42eba8 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-adult-response.schema.yaml @@ -16,10 +16,10 @@ properties: items: type: object additionalProperties: false - # Nothing can be required of every record here. Projection drops a leaf - # the record did not carry, and an ambiguous page is decided before any - # record is read, so a record on that page need not be complete. Which - # leaves a single matched record must carry stays with the script. + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. required: [] properties: type: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml index 4203ebc18..f1808d22c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/schemas/birth-parents-response.schema.yaml @@ -16,7 +16,14 @@ properties: items: type: object additionalProperties: false - required: [type, status, trackingId, declaration] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. + # declaration is the one member a record cannot omit, because projection + # rejects a record missing an intermediate its children hang from, so + # saying so here would only repeat the projection. + required: [] properties: type: type: string diff --git a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml index 38daa53c4..fba4b7291 100644 --- a/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml +++ b/products/evidence/reference/request-adapter/dhis2-tracker/response.schema.yaml @@ -21,7 +21,14 @@ properties: items: type: object additionalProperties: false - required: [trackedEntity, attributes] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided from + # the pager before any record is read, so a record on that page need not + # be complete. What a single matched record must carry stays with the + # script. attributes is not listed either: projection rejects a record + # missing an intermediate its children hang from, so saying so here would + # only repeat the projection. + required: [] properties: trackedEntity: {type: string, minLength: 1, maxLength: 64} attributes: diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai index 49c6f894f..e1a42f85e 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/extract.rhai @@ -23,8 +23,14 @@ fn extract(source_response, parameters) { if results.len != 1 { throw("source_protocol_error"); } let result = results[0]; + // The shape cannot require these of every record, because the ambiguous + // page is decided before any record is read. A single matched record that + // omits one is a source this adapter does not understand. declaration is + // not checked here: it carries projected children, so a record without it + // never survives projection. if result["type"] != parameters["eventType"] || - result["status"] != parameters["registeredStatus"] { + result["status"] != parameters["registeredStatus"] || + is_missing(result["trackingId"]) { throw("source_protocol_error"); } diff --git a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml index 75d8853b5..b9a2d6b67 100644 --- a/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml +++ b/products/evidence/reference/request-adapter/opencrvs-event-search/response.schema.yaml @@ -14,7 +14,14 @@ properties: items: type: object additionalProperties: false - required: [type, status, trackingId, declaration] + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + # Which leaves a single matched record must carry stays with the script. + # declaration is the one member a record cannot omit, because projection + # rejects a record missing an intermediate its children hang from, so + # saying so here would only repeat the projection. + required: [] properties: type: {type: string, minLength: 1, maxLength: 64} status: {type: string, minLength: 1, maxLength: 64} diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 86b6c18fa..34f028767 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -95,6 +95,14 @@ docker run --rm \ --features registry-notary-server/registry-notary-cel cp target/release/registry-notary-cel-worker "dist/bin/registry-notary-cel-worker-${RELEASE_TAG}-linux-amd64" cp target/release/registry-notary-cel-worker dist/image-bin/registry-notary-cel-worker + + cargo build --release --locked \ + -p registry-evidence \ + -p registry-evidencectl \ + -p registry-mint + cp target/release/evidence "dist/bin/evidence-${RELEASE_TAG}-linux-amd64" + cp target/release/evidencectl "dist/bin/evidencectl-${RELEASE_TAG}-linux-amd64" + cp target/release/mint "dist/bin/mint-${RELEASE_TAG}-linux-amd64" ' printf '%s\n' "${release_builder_image}" > "${repo_root}/dist/image-bin/RELEASE_BUILDER_IMAGE" @@ -105,6 +113,9 @@ chmod 0755 \ "${repo_root}/dist/bin/registry-relay-rhai-worker-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-notary-cel-worker-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/evidence-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/evidencectl-${tag}-linux-amd64" \ + "${repo_root}/dist/bin/mint-${tag}-linux-amd64" \ "${repo_root}/dist/image-bin/registry-notary" \ "${repo_root}/dist/image-bin/registry-notary-cel-worker" \ "${repo_root}/dist/image-bin/registry-relay" \ @@ -113,6 +124,9 @@ chmod 0755 \ ( cd -- "${repo_root}/dist/bin" sha256sum -- \ + "evidence-${tag}-linux-amd64" \ + "evidencectl-${tag}-linux-amd64" \ + "mint-${tag}-linux-amd64" \ "registry-manifest-${tag}-linux-amd64" \ "registry-notary-${tag}-linux-amd64" \ "registry-notary-cel-worker-${tag}-linux-amd64" \ From f9318038312a82ce5015dd5bb2664b11f8a2dfdd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 09:50:53 +0700 Subject: [PATCH 053/136] docs(site): state the exact immutability refusal wording in tutorials The runtime prints the refused input after the error prefix, and the secret provider root has its own ownership refusal; both tutorials now quote the real messages. Tutorial gate dry-run still passes. Signed-off-by: Jeremi Joslin --- .../src/content/docs/tutorials/first-evidence-assertion.mdx | 5 +++-- .../docs/tutorials/move-evidence-to-production-signing.mdx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index b8f7363e8..3cd51c4bb 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -112,8 +112,9 @@ mutable deployment input, so make them read-only before anything runs: chmod -R a-w bundle && chmod 444 runtime.yaml ``` -If you skip this step, the next command fails with `deployment input is not immutable`. That -refusal is the runtime working as designed, not a broken install. +If you skip this step, the next command fails with `deployment input is not immutable:` followed +by the input it refused, such as `the bundle directory has a writable entry`. That refusal is the +runtime working as designed, not a broken install. ## Run the fixtures diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx index 41563e557..55eda9231 100644 --- a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -138,7 +138,7 @@ provider's authority to act for the named legal issuer. |---|---| | `evidence check` rejects the signing key | The private JWK's `kid` does not equal `signing.activeKeyId`. Align the bundle field with the generated identifier. | | Check refuses secret material | A secret file is group- or world-accessible, a symlink, or shorter than 32 raw bytes. Regenerate with `evidencectl keygen` and keep mode `0600`. | -| `deployment input is not immutable` | The bundle or runtime file is writable to the service process. Remove the write bits or mount read-only. | +| `deployment input is not immutable: ...` | The named input is writable to the service process, or the secret provider root is readable beyond its owner. Remove the write bits, mount read-only, or `chmod 700` the secret root. | | `/ready` stays unready while `/health` is 200 | A source credential failed its readiness check. Readiness owns source credentials; check dropped none of them. | ## Next From 5750cb9eb42e59fe4f0a4c6a397c64ffc56ab55a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 10:13:53 +0700 Subject: [PATCH 054/136] fix(evidencectl): make the evidence binary resolver crate-visible suggest::emit calls fixtures::resolve_evidence_binary, which was private to its own module. The binary target does not declare mod suggest yet, so the error only surfaced under --all-targets, where the suggest_emit test target includes both modules and fails to compile. Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/src/fixtures.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/registry-evidencectl/src/fixtures.rs b/crates/registry-evidencectl/src/fixtures.rs index 4acda05e5..8e5ddf60f 100644 --- a/crates/registry-evidencectl/src/fixtures.rs +++ b/crates/registry-evidencectl/src/fixtures.rs @@ -232,7 +232,8 @@ fn discover_fixtures(bundle_config_path: &Path) -> Result> { /// Resolve the `evidence` binary: an explicit `--evidence-bin`, else /// `EVIDENCE_BIN`, else the first `evidence` found on `PATH`. -fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { +/// Crate-visible so `suggest::emit` resolves the runtime binary the same way. +pub(crate) fn resolve_evidence_binary(explicit: Option<&Path>) -> Result { if let Some(path) = explicit { if !path.is_file() { bail!("evidence binary not found at {}", path.display()); From 44897ef1debf1168323d6c27f5e83d8e1409d315 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 10:28:14 +0700 Subject: [PATCH 055/136] fix(evidencectl): dispatch the source suggest command from the binary The suggest feature merged with its modules, fixtures and five test files but without `mod suggest`, without the `Source` subcommand, and without the `inquire` dependency its interactive front-end imports, so the crate did not build and the command was unreachable. Four of the five suggest test files pull modules in with `#[path = "../src/suggest/X.rs"] mod X;` because the crate has only a `[[bin]]` target. That compiles a module whether or not `main.rs` declares it, which is how a fully tested command shipped unreachable. `tests/cli_surface.rs` drives the real binary over the whole top-level subcommand set, asserting each one is both listed and dispatchable, so the same omission fails next time. Verified red at db20eab0: `--help` lists four commands, not five. `inquire` joins the workspace dependency table with its default features, matching every other dependency in the crate. Default features are kept because dropping them swaps the fuzzy filter behind `Select`/`MultiSelect` for substring matching, which is a behaviour change rather than a wiring fix. Signed-off-by: Jeremi Joslin --- Cargo.lock | 119 ++++++++++++++++++ Cargo.toml | 1 + crates/registry-evidencectl/Cargo.toml | 1 + crates/registry-evidencectl/src/main.rs | 5 + .../registry-evidencectl/tests/cli_surface.rs | 61 +++++++++ 5 files changed, 187 insertions(+) create mode 100644 crates/registry-evidencectl/tests/cli_surface.rs diff --git a/Cargo.lock b/Cargo.lock index f1be4f6d9..947b3a831 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1155,6 +1155,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1306,6 +1315,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crosswalk-cel" version = "0.2.0" @@ -2325,6 +2361,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "diff" version = "0.1.13" @@ -2366,6 +2424,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2821,6 +2888,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -3607,6 +3683,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags 2.13.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "insta" version = "1.48.0" @@ -4025,6 +4115,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -4153,6 +4249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -5404,6 +5501,7 @@ dependencies = [ "clap", "ed25519-dalek", "getrandom 0.4.3", + "inquire", "registry-platform-crypto", "serde", "serde_json", @@ -6731,6 +6829,27 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" diff --git a/Cargo.toml b/Cargo.toml index c4761029b..7f9ef7875 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,6 +118,7 @@ http = { version = "1" } humantime-serde = { version = "1" } hyper = { version = "1", features = ["http1", "http2", "server"] } hyper-util = { version = "0.1", features = ["server-auto", "tokio"] } +inquire = { version = "0.9.4" } insta = { version = "1", features = ["json"] } ipnet = { version = "2" } jsonschema = { version = "0.18", features = ["draft202012"] } diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 860a67499..11ae68e96 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -21,6 +21,7 @@ base64.workspace = true clap.workspace = true ed25519-dalek.workspace = true getrandom.workspace = true +inquire.workspace = true registry-platform-crypto.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs index a1b1ada69..c93ed6344 100644 --- a/crates/registry-evidencectl/src/main.rs +++ b/crates/registry-evidencectl/src/main.rs @@ -10,6 +10,7 @@ mod fixtures; mod jwks; mod keygen; mod scaffold; +mod suggest; #[derive(Debug, Parser)] #[command( @@ -34,6 +35,9 @@ enum Command { /// Drive the evidence binary across a project's bundle fixtures. #[command(subcommand)] Fixtures(fixtures::FixturesCommand), + /// Work with a project's sources, starting from their own API documents. + #[command(subcommand)] + Source(suggest::SourceCommand), } fn main() -> ExitCode { @@ -43,6 +47,7 @@ fn main() -> ExitCode { Command::Jwks(args) => jwks::run(args), Command::New(args) => scaffold::run(args), Command::Fixtures(command) => fixtures::run(command), + Command::Source(command) => suggest::run(command), }; match result { Ok(code) => code, diff --git a/crates/registry-evidencectl/tests/cli_surface.rs b/crates/registry-evidencectl/tests/cli_surface.rs new file mode 100644 index 000000000..af5f07d36 --- /dev/null +++ b/crates/registry-evidencectl/tests/cli_surface.rs @@ -0,0 +1,61 @@ +//! Every command module is reachable from the binary. +//! +//! The crate has only a `[[bin]]` target, so the other integration tests pull +//! command modules in with `#[path = "../src/..."] mod`. That compiles a module +//! whether or not `main.rs` declares it, which lets a command ship with a full +//! test suite and still be unreachable from `evidencectl`. This file drives the +//! real binary so the top-level surface cannot drift away from `src/`. + +use std::process::Command; + +/// The complete top-level subcommand set. Adding a command means adding it +/// here; the point of the list is that an omission fails rather than passes. +const TOP_LEVEL_COMMANDS: [&str; 5] = ["keygen", "jwks", "new", "fixtures", "source"]; + +#[test] +fn every_top_level_command_is_listed_and_dispatchable() { + let help = evidencectl(&["--help"]); + for command in TOP_LEVEL_COMMANDS { + assert!( + help.contains(command), + "`evidencectl --help` does not list `{command}`:\n{help}" + ); + + // Listing is not dispatch: clap prints help for a declared variant even + // when the arm behind it is missing, so ask the command itself. + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args([command, "--help"]) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "`evidencectl {command} --help` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn an_undeclared_command_is_still_refused() { + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(["not-a-command", "--help"]) + .output() + .expect("run evidencectl"); + assert!( + !output.status.success(), + "an unknown subcommand must not succeed" + ); +} + +fn evidencectl(arguments: &[&str]) -> String { + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("run evidencectl"); + assert!( + output.status.success(), + "evidencectl {arguments:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} From 3b8b94d5996a294a49290119da14d004f8b52507 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 10:40:00 +0700 Subject: [PATCH 056/136] fix(evidencectl): correct the bounds and quoting the suggest draft emits Review of the merged `source suggest` pipeline found seven defects that each let a draft claim more than the evidence behind it supports. Page-size detection matched any parameter name containing "page", "size" or "limit", so a `page` index beside a `pageSize` ceiling, and unrelated names like `fileSizeCeiling`, both fed the array bound. The comparison is now against a closed list of whole normalized names. When several genuine size parameters are advertised, the fallback takes the smallest rather than the largest: only the smallest ceiling is one a response can actually reach, and bounding above it points away from minimum disclosure. The emitted artifacts escaped too little. The reproduce line now shell-quotes any path outside a safe character set, the extract script emits a real Rhai string literal for the pointer, and drafted YAML quotes on any control character rather than newline alone. A root-level array carried no bound annotation at all, and TODO paths named the template directory rather than the scaffolded project's, so neither told the operator where to act. `evidence check` classification keyed on a substring, so a bundle rejection whose message merely mentioned a runtime stage was reported as unprovisioned secrets. It now matches the runtime's fixed stage messages at the start of stderr. An unnamed root pointer renders as "(response root)", the wording narrow.rs already uses. The OpenAPI loader gained the size ceiling the sampler already enforces. Every fix carries a test proved failing first. Signed-off-by: Jeremi Joslin --- .../registry-evidencectl/src/suggest/emit.rs | 132 ++++++++++--- .../registry-evidencectl/src/suggest/mod.rs | 11 +- .../src/suggest/narrow.rs | 2 +- .../src/suggest/openapi.rs | 60 +++++- .../fixtures/openapi/paging-parameters.yaml | 90 +++++++++ .../registry-evidencectl/tests/suggest_e2e.rs | 41 ++++ .../tests/suggest_emit.rs | 181 ++++++++++++++++++ .../tests/suggest_openapi.rs | 51 +++++ 8 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml diff --git a/crates/registry-evidencectl/src/suggest/emit.rs b/crates/registry-evidencectl/src/suggest/emit.rs index 9d25ee647..d2d3532b2 100644 --- a/crates/registry-evidencectl/src/suggest/emit.rs +++ b/crates/registry-evidencectl/src/suggest/emit.rs @@ -275,12 +275,29 @@ pub fn verify(project: &Path, evidence_bin: Option<&Path>) -> Result initialization failed" messages, which mean the bundle -/// itself was accepted and only local secret/runtime material is missing. +/// The runtime initialization stages that fail only because local secret or +/// key material has not been provisioned yet. Reaching any of them means the +/// bundle itself was already accepted. +/// +/// The list is exact rather than a `runtime ... initialization failed` shape, +/// because the runtime reports bundle, source and rate-limit failures through +/// the same shape. Matching the shape would report a draft the runtime refused +/// as a success. +const SECRET_STAGE_MESSAGES: [&str; 3] = [ + "evidence: runtime secret initialization failed", + "evidence: runtime audit initialization failed", + "evidence: runtime signing initialization failed", +]; + +/// True when `stderr` names one of [`SECRET_STAGE_MESSAGES`]. +/// +/// The comparison is a prefix so a stage message that grows a trailing reason +/// still classifies; the stage name itself is still matched in full. fn is_secrets_unprovisioned(stderr: &str) -> bool { let trimmed = stderr.trim(); - trimmed.starts_with("evidence: runtime ") && trimmed.ends_with("initialization failed") + SECRET_STAGE_MESSAGES + .iter() + .any(|message| trimmed.starts_with(message)) } /// Derive a plain RFC 6901 `get_path` pointer from an extended projection @@ -416,7 +433,8 @@ impl SchemaAnnotations { let key = (pointer.to_owned(), kind.clone()); if self.unresolved.contains(&key) { return Some(format!( - "# TODO(evidencectl): {pointer} needs {}", + "# TODO(evidencectl): {} needs {}", + display_pointer(pointer), kind.label() )); } @@ -429,6 +447,16 @@ impl SchemaAnnotations { } } +/// Renders a pointer for a message, naming the root rather than printing an +/// empty string. Matches the wording `narrow` and `flatten` already use. +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(response root)" + } else { + pointer + } +} + fn push_line(out: &mut String, indent: usize, text: &str) { for _ in 0..indent { out.push_str(" "); @@ -451,13 +479,48 @@ fn yaml_flow_scalar_string(value: &str) -> String { fn quote_if(value: &str, quoted: bool) -> String { if quoted { - let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{escaped}\"") + format!("\"{}\"", escape_double_quoted(value)) } else { value.to_owned() } } +/// Escapes `value` for the interior of a double-quoted YAML or Rhai string. +/// +/// One function serves both because the two grammars agree on every escape +/// used here: `\\`, `\"`, `\n`, `\r`, `\t`, and `\xNN` for the remaining +/// control characters, all of which are below `U+00A0` and so fit two hex +/// digits. +/// +/// Every value passed here originates in the OpenAPI document: property names, +/// media types, enum members. A property name may legally contain a quote, a +/// backslash or a control character, and none of those may reach the output +/// raw. A raw newline folds a YAML scalar (turning the rest of the value into +/// a sibling mapping entry) and terminates a Rhai literal; a raw quote ends +/// either one early. +fn escape_double_quoted(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\\' => escaped.push_str(r"\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str(r"\n"), + '\r' => escaped.push_str(r"\r"), + '\t' => escaped.push_str(r"\t"), + control if control.is_control() => { + escaped.push_str(&format!(r"\x{:02x}", control as u32)); + } + other => escaped.push(other), + } + } + escaped +} + +/// Renders `value` as a complete double-quoted Rhai string literal. +fn rhai_string_literal(value: &str) -> String { + format!("\"{}\"", escape_double_quoted(value)) +} + fn needs_yaml_quoting(value: &str) -> bool { if value.is_empty() { return true; @@ -504,7 +567,9 @@ fn needs_yaml_quoting(value: &str) -> bool { if value.contains(": ") || value.ends_with(':') || value.contains(" #") { return true; } - value.contains('\n') + // Any control character, not just a newline: a plain scalar carrying one + // either folds or is rejected outright, and the quoted form escapes it. + value.chars().any(char::is_control) } fn render_key(key: &str) -> String { @@ -609,6 +674,15 @@ fn render_response_schema(inputs: &EmitInputs) -> String { "# states where a bound came from so it can be reviewed rather than trusted", ); push_line(&mut out, 0, "# blindly."); + // A response body that is itself an array needs a `maxItems` like any other + // array, but the root node has no property line to hang the comment on: + // `render_schema_node` annotates children only. It is annotated here or + // nowhere. + if let Some(kind) = inputs.narrowed.schema.as_object().and_then(bound_kind_of) { + if let Some(comment) = annotations.comment_for("", kind) { + push_line(&mut out, 0, &comment); + } + } render_schema_node(&inputs.narrowed.schema, "", 0, &annotations, &mut out); out } @@ -879,7 +953,10 @@ fn render_extract_script(inputs: &EmitInputs, get_paths: &[(String, String)]) -> push_line( &mut out, 1, - &format!("let {variable} = get_path(source_response, \"{get_path_pointer}\");"), + &format!( + "let {variable} = get_path(source_response, {});", + rhai_string_literal(get_path_pointer) + ), ); push_line(&mut out, 1, &format!("if is_missing({variable}) {{")); push_line( @@ -1040,7 +1117,7 @@ fn render_source_block(inputs: &EmitInputs, method: &str) -> String { push_line( &mut out, 3, - "# templates/bundle/evidence.yaml (sources.source-a.request.selectorInputs)", + "# bundle/evidence.yaml (sources.source-a.request.selectorInputs)", ); push_line( &mut out, @@ -1052,25 +1129,17 @@ fn render_source_block(inputs: &EmitInputs, method: &str) -> String { 3, "# TODO(evidencectl): prepareScript — author this script from", ); - push_line( - &mut out, - 3, - "# templates/bundle/adapters/source-a-prepare.rhai.", - ); + push_line(&mut out, 3, "# bundle/adapters/source-a-prepare.rhai."); push_line( &mut out, 3, "# TODO(evidencectl): adapterParameters and adapterParametersSchema — copy the", ); + push_line(&mut out, 3, "# shape from bundle/evidence.yaml and"); push_line( &mut out, 3, - "# shape from templates/bundle/evidence.yaml and", - ); - push_line( - &mut out, - 3, - "# templates/bundle/schemas/adapter-parameters.schema.yaml.", + "# bundle/schemas/adapter-parameters.schema.yaml.", ); // The two channels are chosen from the method, not fixed: the runtime // rejects a GET source whose JSON body channel is anything but forbidden, @@ -1228,7 +1297,7 @@ fn render_report(inputs: &EmitInputs) -> String { for need in &inputs.narrowed.unresolved { out.push_str(&format!( " - TODO(evidencectl): {} needs {}\n", - need.pointer, + display_pointer(&need.pointer), need.kind.label() )); } @@ -1267,14 +1336,19 @@ fn path_display(path: &Path) -> String { /// matches nothing. Single quotes are used because they suppress every /// expansion; the only character they cannot carry is the single quote itself, /// which is spliced in as `'\''`. +/// +/// The decision to quote is an allowlist rather than a list of characters +/// known to be special. A denylist has to be complete to be correct, and the +/// cost of an omission is not a cosmetic one: an unquoted `$` expands a +/// parameter and an unquoted backtick runs a command, so a pointer through a +/// property named `$id` would silently reproduce a different run. fn shell_quote(value: &str) -> String { - const SHELL_SPECIAL: [char; 19] = [ - '*', '?', '[', ']', '{', '}', '\n', '"', '\'', '\\', '|', '&', ';', '<', '>', '(', ')', - '~', '#', - ]; - let unsafe_value = - value.is_empty() || value.chars().any(char::is_whitespace) || value.contains(SHELL_SPECIAL); - if !unsafe_value { + const SHELL_SAFE: [char; 6] = ['_', '.', '/', ':', '@', '-']; + let safe = !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || SHELL_SAFE.contains(&character)); + if safe { return value.to_owned(); } format!("'{}'", value.replace('\'', r"'\''")) diff --git a/crates/registry-evidencectl/src/suggest/mod.rs b/crates/registry-evidencectl/src/suggest/mod.rs index 3f07b280a..a79330639 100644 --- a/crates/registry-evidencectl/src/suggest/mod.rs +++ b/crates/registry-evidencectl/src/suggest/mod.rs @@ -347,7 +347,7 @@ fn accept_suggestions(needs: &[BoundNeed]) -> BTreeMap<(String, BoundKind), Boun eprintln!( "evidencectl: adopting {} for `{}`{derivation}{note}", describe_bound(&suggestion.values), - need.pointer, + narrow::display_pointer(&need.pointer), ); resolutions.insert( (need.pointer.clone(), need.kind.clone()), @@ -357,7 +357,7 @@ fn accept_suggestions(needs: &[BoundNeed]) -> BTreeMap<(String, BoundKind), Boun None => eprintln!( "evidencectl: nothing implies {} for `{}`; left as a TODO in the draft", need.kind.label(), - need.pointer + narrow::display_pointer(&need.pointer) ), } } @@ -412,11 +412,16 @@ fn with_page_size_fallback( if !needs.iter().any(eligible) { return Ok(needs); } + // The smallest advertised ceiling is the only one a response can actually + // reach: a server honouring both `pageSize` (max 50) and `limit` (max 200) + // never returns more than 50 items. Taking the largest would bound the + // array well above anything the operation can produce, which is the wrong + // direction for a minimum-disclosure bound. let Some(maximum) = spec .page_size_maximums(operation)? .into_iter() .filter(|maximum| *maximum > 0) - .max() + .min() else { return Ok(needs); }; diff --git a/crates/registry-evidencectl/src/suggest/narrow.rs b/crates/registry-evidencectl/src/suggest/narrow.rs index 1a0c17318..b1f79014a 100644 --- a/crates/registry-evidencectl/src/suggest/narrow.rs +++ b/crates/registry-evidencectl/src/suggest/narrow.rs @@ -456,7 +456,7 @@ fn child_pointer(parent: &str, key: &str) -> String { /// Renders a pointer for a message, naming the root rather than printing an /// empty string. -fn display_pointer(pointer: &str) -> &str { +pub fn display_pointer(pointer: &str) -> &str { if pointer.is_empty() { "(response root)" } else { diff --git a/crates/registry-evidencectl/src/suggest/openapi.rs b/crates/registry-evidencectl/src/suggest/openapi.rs index 8133d35d6..df622180d 100644 --- a/crates/registry-evidencectl/src/suggest/openapi.rs +++ b/crates/registry-evidencectl/src/suggest/openapi.rs @@ -24,6 +24,44 @@ use super::types::{OperationKey, OperationSummary, ResolvedSchema}; /// listing is filtered here rather than at the far end of the pipeline. const OPERATION_METHODS: [&str; 2] = ["get", "post"]; +/// OpenAPI documents larger than this are rejected before they are read, the +/// way `sample::load_sample` rejects an oversized sample. The largest published +/// registry API descriptions are a few megabytes; a document past this ceiling +/// is a mistaken path rather than a specification to draft from. +const MAX_DOCUMENT_BYTES: u64 = 16 * 1024 * 1024; + +/// Query parameter names that bound how many items one response carries, +/// compared against the parameter's name lowercased with `_`, `-` and `.` +/// removed. +/// +/// The list is deliberately closed. A name outside it yields no page-size +/// value at all, so the bound it would have set stays an unresolved +/// `TODO(evidencectl)` for the operator to answer, which is the outcome this +/// tool prefers over a bound it cannot justify. `page`, `pageNumber`, +/// `pageIndex`, `offset` and `start` are absent on purpose: they count pages +/// or positions, not items. +const PAGE_SIZE_NAMES: [&str; 8] = [ + "pagesize", + "perpage", + "size", + "limit", + "pagelimit", + "count", + "maxresults", + "maxrecords", +]; + +/// Whether `name` names a page-size query parameter, comparing the whole +/// normalized name against [`PAGE_SIZE_NAMES`]. +fn is_page_size_name(name: &str) -> bool { + let normalized: String = name + .chars() + .filter(|character| !matches!(character, '_' | '-' | '.')) + .flat_map(char::to_lowercase) + .collect(); + PAGE_SIZE_NAMES.contains(&normalized.as_str()) +} + /// A loaded, dialect-checked OpenAPI document. /// /// `load` accepts OpenAPI 3.0.x and 3.1.x, in YAML or JSON, from a local @@ -42,6 +80,16 @@ impl Spec { /// version string. No network access is performed; `path` must name a /// local file. pub fn load(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("reading OpenAPI document metadata at {}", path.display()))?; + if metadata.len() > MAX_DOCUMENT_BYTES { + bail!( + "OpenAPI document at {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_DOCUMENT_BYTES + ); + } let text = std::fs::read_to_string(path) .with_context(|| format!("reading OpenAPI document at {}", path.display()))?; let document: Value = serde_norway::from_str(&text) @@ -207,14 +255,19 @@ impl Spec { } /// Integer `maximum` values found on `key`'s query parameters (path-item - /// level and operation level) whose name contains `page`, `size`, or - /// `limit`, case-insensitively. + /// level and operation level) whose name is one of [`PAGE_SIZE_NAMES`]. /// /// This is a naming heuristic, not a semantic one: it does not attempt /// to determine whether a matching parameter actually bounds page size, /// and it looks only at the parameter's own `schema.maximum`. Later /// pipeline stages decide whether and how to use the values as /// `Provenance::PageSize`. + /// + /// The match is on the whole normalized name rather than a substring, + /// because a substring match cannot tell a page size from a page index: + /// `page` and `pageSize` both contain `page`, but only one of them bounds + /// how many items a response carries, and reading the other as an item + /// count would suggest a bound orders of magnitude too generous. pub fn page_size_maximums(&self, key: &OperationKey) -> Result> { let paths = self .document @@ -246,8 +299,7 @@ impl Spec { if parameter.get("in").and_then(Value::as_str) != Some("query") { continue; } - let lower = name.to_ascii_lowercase(); - if !(lower.contains("page") || lower.contains("size") || lower.contains("limit")) { + if !is_page_size_name(name) { continue; } let Some(schema) = parameter.get("schema") else { diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml new file mode 100644 index 000000000..d6119fc0d --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/paging-parameters.yaml @@ -0,0 +1,90 @@ +openapi: 3.0.3 +info: + title: Paging parameter shapes + version: "1.0.0" +paths: + # A page index and a page size side by side. The index bounds how many pages + # exist, not how many items a response carries, so it must not set an array + # bound; the size does. + /records: + get: + summary: Search records + parameters: + - name: page + in: query + schema: + type: integer + maximum: 10000 + - name: pageSize + in: query + schema: + type: integer + maximum: 50 + responses: + "200": + description: A page of records + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + # Two genuine size ceilings. The smaller one is the one a response can + # actually reach. + /events: + get: + summary: Search events + parameters: + - name: per_page + in: query + schema: + type: integer + maximum: 25 + - name: limit + in: query + schema: + type: integer + maximum: 200 + responses: + "200": + description: A page of events + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' + # Names that merely contain a matching word without naming a page size. + /reports: + get: + summary: Search reports + parameters: + - name: pageToken + in: query + schema: + type: string + - name: fileSizeCeiling + in: query + schema: + type: integer + maximum: 9000 + - name: rateLimitBurst + in: query + schema: + type: integer + maximum: 8000 + responses: + "200": + description: A page of reports + content: + application/json: + schema: + $ref: '#/components/schemas/RecordSearchResponse' +components: + schemas: + RecordSearchResponse: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: string diff --git a/crates/registry-evidencectl/tests/suggest_e2e.rs b/crates/registry-evidencectl/tests/suggest_e2e.rs index 27282c94f..ac9a8ce6e 100644 --- a/crates/registry-evidencectl/tests/suggest_e2e.rs +++ b/crates/registry-evidencectl/tests/suggest_e2e.rs @@ -303,6 +303,47 @@ fn a_page_size_bounds_the_collection_but_not_an_array_inside_a_record() { ); } +/// When an operation advertises more than one size ceiling, the bound has to +/// be one a response can actually reach. The smallest ceiling is that bound; +/// the largest is a number the server will never return. +#[test] +fn the_smallest_advertised_size_ceiling_bounds_the_collection() { + let workspace = tempfile::tempdir().expect("tempdir"); + let document = OPENAPI_DOCUMENT.replace( + " - name: pageSize\n", + " - name: limit\n in: query\n schema:\n type: integer\n maximum: 200\n - name: pageSize\n", + ); + let openapi = write(workspace.path(), "records.openapi.yaml", &document); + + let output = evidencectl(&[ + "source".to_owned(), + "suggest".to_owned(), + "--openapi".to_owned(), + path_argument(&openapi), + "--operation".to_owned(), + "GET /records".to_owned(), + "--select".to_owned(), + "/records/*/trackingId".to_owned(), + "--source-id".to_owned(), + "source-f".to_owned(), + ]); + assert!( + output.status.success(), + "source suggest failed: {}", + stderr_of(&output) + ); + + let stdout = stdout_of(&output); + assert!( + stdout.contains("maxItems: 50"), + "the smallest ceiling must bound the collection: {stdout}" + ); + assert!( + !stdout.contains("maxItems: 200"), + "a ceiling the response cannot reach must not become the bound: {stdout}" + ); +} + /// The runtime's fixed-request method is an enumeration of two. An operation /// outside it is refused by name, before any file is drafted. #[test] diff --git a/crates/registry-evidencectl/tests/suggest_emit.rs b/crates/registry-evidencectl/tests/suggest_emit.rs index b95f58a81..9e540afda 100644 --- a/crates/registry-evidencectl/tests/suggest_emit.rs +++ b/crates/registry-evidencectl/tests/suggest_emit.rs @@ -901,3 +901,184 @@ fn verify_classifies_a_runtime_initialization_message_as_secrets_unprovisioned() let classification = emit::verify(&project, Some(&stub)).expect("verify"); assert_eq!(classification, CheckClassification::SecretsUnprovisioned); } + +// --- Escaping and pointer rendering ------------------------------------- + +/// The reproduce line is documented as paste-ready. A path carrying `$` or a +/// backtick must therefore reach the shell as literal text: unquoted, `$HOME` +/// expands and a backtick pair runs a command, so the pasted line reproduces +/// something other than the run it claims to reproduce. +#[test] +fn the_reproduce_line_quotes_shell_expansion_characters() { + let mut inputs = base_inputs(); + inputs.openapi_path = PathBuf::from("/srv/specs/$HOME/records`id`.yaml"); + let artifacts = emit::draft(&inputs).expect("draft"); + + assert!( + artifacts + .equivalent_command + .contains("'/srv/specs/$HOME/records`id`.yaml'"), + "expansion characters must be single-quoted: {}", + artifacts.equivalent_command + ); +} + +/// A property name may legally contain a double quote or a backslash. Both +/// terminate or escape inside a Rhai string literal, so an unescaped one +/// produces an extract script that does not parse, or parses as something +/// other than the pointer it was drafted from. +#[test] +fn the_extract_script_escapes_quotes_in_a_pointer() { + let mut inputs = base_inputs(); + inputs.selection = vec![r#"/say"hi"#.to_owned(), r"/back\slash".to_owned()]; + let artifacts = emit::draft(&inputs).expect("draft"); + let extract = file_contents(&artifacts, "adapters/search-a-extract.rhai"); + + assert!( + extract.contains(r#"get_path(source_response, "/say\"hi")"#), + "a double quote must be escaped in the Rhai literal:\n{extract}" + ); + assert!( + extract.contains(r#"get_path(source_response, "/back\\slash")"#), + "a backslash must be escaped in the Rhai literal:\n{extract}" + ); +} + +/// The same applies to the drafted YAML: a double-quoted scalar carrying a raw +/// newline or tab folds, silently changing the value the runtime reads. +#[test] +fn drafted_yaml_escapes_control_characters_in_a_scalar() { + let mut inputs = base_inputs(); + inputs.media_type = "application/json\nx-injected: true".to_owned(); + let artifacts = emit::draft(&inputs).expect("draft"); + let source_block = artifacts + .files + .iter() + .find(|file| file.bundle_relative_path.ends_with(".yaml")) + .map(|file| file.contents.clone()) + .unwrap_or_default(); + let combined = format!("{}\n{}", artifacts.source_block, source_block); + + assert!( + combined.contains(r"application/json\nx-injected"), + "a newline must be escaped rather than folded:\n{combined}" + ); + // A raw newline inside the quoted scalar would make the injected key a + // sibling mapping entry. + assert!( + !combined.contains("\nx-injected: true"), + "the scalar must not break out into its own mapping key:\n{combined}" + ); +} + +/// The emitted TODOs are read inside an adopter's project, where the scaffold +/// wrote `bundle/evidence.yaml`. `templates/` is an evidencectl source +/// directory that does not exist there. +#[test] +fn emitted_todos_name_paths_that_exist_in_a_scaffolded_project() { + let artifacts = emit::draft(&base_inputs()).expect("draft"); + let all = artifacts + .files + .iter() + .fold(artifacts.source_block.clone(), |mut text, file| { + text.push('\n'); + text.push_str(&file.contents); + text + }); + + assert!( + all.contains("bundle/evidence.yaml"), + "the guidance must name the scaffolded path:\n{all}" + ); + assert!( + !all.contains("templates/bundle/"), + "an evidencectl source path is not a path in the adopter's project:\n{all}" + ); +} + +/// A response body that is itself an array still needs a `maxItems`. The bound +/// belongs to the root node, which has no property to hang a comment on and no +/// pointer text of its own. +#[test] +fn a_root_level_array_carries_its_own_bound_annotation() { + let mut inputs = base_inputs(); + inputs.selection = vec!["/*/trackingId".to_owned()]; + inputs.narrowed = NarrowOutcome { + schema: json!({ + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": {"trackingId": {"type": "string", "minLength": 0, "maxLength": 64}} + } + }), + unresolved: vec![BoundNeed { + pointer: String::new(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }], + }; + inputs.needs = vec![BoundNeed { + pointer: String::new(), + kind: BoundKind::ArrayMaxItems, + suggestion: None, + }]; + + let artifacts = emit::draft(&inputs).expect("draft"); + let response_schema = file_contents(&artifacts, "schemas/search-a-response.schema.yaml"); + + assert!( + response_schema.contains("TODO(evidencectl): (response root) needs maxItems"), + "the root array's own bound must be annotated:\n{response_schema}" + ); + assert!( + !response_schema.contains("TODO(evidencectl): "), + "an empty pointer must not render as blank text:\n{response_schema}" + ); + assert!( + !artifacts.report.contains("TODO(evidencectl): "), + "an empty pointer must not render as blank text in the report:\n{}", + artifacts.report + ); +} + +/// A rejected bundle and unprovisioned secrets are opposite outcomes: one means +/// the draft is wrong, the other means the draft is fine and the operator has +/// not generated keys yet. Classifying a bundle rejection as the latter reports +/// success for a draft the runtime refused. +#[cfg(unix)] +#[test] +fn verify_never_classifies_a_bundle_rejection_as_unprovisioned_secrets() { + for stage in ["bundle", "source", "rate-limit"] { + let temp = tempfile::tempdir().expect("tempdir"); + let script = format!( + "#!/bin/sh\nprintf 'evidence: runtime {stage} initialization failed\\n' >&2\nexit 1\n" + ); + let stub = write_stub_evidence(temp.path(), &script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert!( + matches!(classification, CheckClassification::BundleRejected { .. }), + "a {stage}-stage failure is a rejected bundle, got {classification:?}" + ); + } +} + +/// A future runtime may append a reason to a secret-stage message. The +/// classification must survive that without loosening into a prefix match that +/// also catches the bundle stage. +#[cfg(unix)] +#[test] +fn verify_classifies_a_secret_stage_message_carrying_a_reason() { + let temp = tempfile::tempdir().expect("tempdir"); + let script = "#!/bin/sh\nprintf 'evidence: runtime audit initialization failed: the audit chain head does not verify\\n' >&2\nexit 1\n"; + let stub = write_stub_evidence(temp.path(), script); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).expect("mkdir project"); + + let classification = emit::verify(&project, Some(&stub)).expect("verify"); + assert_eq!(classification, CheckClassification::SecretsUnprovisioned); +} diff --git a/crates/registry-evidencectl/tests/suggest_openapi.rs b/crates/registry-evidencectl/tests/suggest_openapi.rs index 1e88c9a5d..ac1f04393 100644 --- a/crates/registry-evidencectl/tests/suggest_openapi.rs +++ b/crates/registry-evidencectl/tests/suggest_openapi.rs @@ -254,6 +254,39 @@ fn page_size_maximums_matches_limit_named_parameters() { assert_eq!(maximums, vec![50]); } +#[test] +fn page_size_maximums_ignores_a_page_index_beside_a_page_size() { + let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/records")) + .expect("no ref errors"); + // `page` bounds how many pages exist, not how many items one carries. + // Reading its maximum as an item count would bound the array at 10000. + assert_eq!(maximums, vec![50]); +} + +#[test] +fn page_size_maximums_reads_every_genuine_size_parameter() { + let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let mut maximums = spec + .page_size_maximums(&operation("GET", "/events")) + .expect("no ref errors"); + maximums.sort_unstable(); + assert_eq!(maximums, vec![25, 200]); +} + +#[test] +fn page_size_maximums_ignores_names_that_only_contain_a_matching_word() { + let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let maximums = spec + .page_size_maximums(&operation("GET", "/reports")) + .expect("no ref errors"); + assert!( + maximums.is_empty(), + "a byte ceiling and a rate-limit burst are not page sizes: {maximums:?}" + ); +} + // --- flatten::candidate_leaves ------------------------------------------------ #[test] @@ -383,3 +416,21 @@ fn candidate_leaves_truncates_at_depth_limit_and_warns() { "warnings: {warnings:#?}" ); } + +/// The sampler refuses an oversized sample before reading it. The loader is +/// held to the same rule, so a mistaken path is named as one rather than read +/// into memory whole. +#[test] +fn an_oversized_document_is_refused_before_it_is_read() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("huge.openapi.yaml"); + let file = std::fs::File::create(&path).expect("create"); + // Sparse where the filesystem supports it: the point is the declared + // length, not the bytes. + file.set_len(17 * 1024 * 1024).expect("set_len"); + drop(file); + + let error = openapi::Spec::load(&path).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("exceeding the"), "message was: {message}"); +} From 7f512c22ac7409726271b689ac48828b9bcd23bf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:01:58 +0700 Subject: [PATCH 057/136] feat(evidence): add a protected-read reference deployment project The two existing reference projects both read a registry product's own API and neither covers residence region. This one reads a protected, scoped, read-only registry API of the shape Registry Relay presents, and signs residence region as a coarse controlled code mapped from the register's own code, so all four coequal acceptance definitions now have a deployment-shaped worked example. It earns its place on lookup shape rather than branding: the envelope reports no total, only a page of records and a further-pages flag, so uniqueness is decided from both signals together and a single record on a page claiming further pages is ambiguous. The bundle names no registry product and depends on none. Eighteen fixture cases run through the production ABI offline. No Evidence production code changes; the only Rust edits extend the three existing #[cfg(test)] reference-project lists. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/bundle.rs | 6 +- crates/registry-evidence/src/main.rs | 6 +- .../tests/deployment_projects.rs | 6 +- ...tary-retirement-and-evidence-onboarding.md | 19 +- .../deployment-projects/README.md | 8 +- .../relay-protected-read-evidence/README.md | 124 ++++++++ .../bundle/adapters/prepare.rhai | 11 + .../adapters/residence-region-extract.rhai | 42 +++ .../bundle/codelists/residence-regions.yaml | 11 + .../bundle/derivations/residence-region.rhai | 19 ++ .../bundle/evidence.yaml | 154 ++++++++++ .../fixtures/residence-region-cases.yaml | 286 ++++++++++++++++++ ...ence-region-adapter-parameters.schema.yaml | 9 + .../residence-region-facts.schema.yaml | 12 + .../residence-region-response.schema.yaml | 35 +++ .../runtime.yaml | 24 ++ 16 files changed, 766 insertions(+), 6 deletions(-) create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/prepare.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/residence-region-extract.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/codelists/residence-regions.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/derivations/residence-region.rhai create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/fixtures/residence-region-cases.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-adapter-parameters.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-facts.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-response.schema.yaml create mode 100644 products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml diff --git a/crates/registry-evidence/src/bundle.rs b/crates/registry-evidence/src/bundle.rs index 04b37efb2..72cdbd97c 100644 --- a/crates/registry-evidence/src/bundle.rs +++ b/crates/registry-evidence/src/bundle.rs @@ -2098,7 +2098,11 @@ mod tests { fn deployment_reference_projects_are_complete_compilable_bundles() { let projects_root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../products/evidence/reference/request-adapter/deployment-projects"); - for project in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { + for project in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { let project_root = projects_root.join(project); RuntimeConfig::parse_yaml( &fs::read(project_root.join("runtime.yaml")).expect("read reference runtime"), diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index d88166858..6df10832d 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -3146,7 +3146,11 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn offline_cli_evaluates_every_reference_deployment_fixture() { - for project in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { + for project in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { let directory = tempfile::tempdir().expect("temporary bundle"); let source = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../products/evidence/reference/request-adapter/deployment-projects") diff --git a/crates/registry-evidence/tests/deployment_projects.rs b/crates/registry-evidence/tests/deployment_projects.rs index 0ade51b7c..e6633e5eb 100644 --- a/crates/registry-evidence/tests/deployment_projects.rs +++ b/crates/registry-evidence/tests/deployment_projects.rs @@ -194,7 +194,11 @@ impl Drop for LoadedProject { #[tokio::test] async fn reference_deployment_projects_execute_the_closed_fixture_contract() { - for project_name in ["dhis2-tracker-evidence", "opencrvs-family-evidence"] { + for project_name in [ + "dhis2-tracker-evidence", + "opencrvs-family-evidence", + "relay-protected-read-evidence", + ] { let project = load_project(project_name); let signer = fixture_signer().await; for requirement in &project.bundle.config.requirements { diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 8af592e7a..d33b5504f 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -57,7 +57,7 @@ gates for its area (see Verification), and committed. - [x] A2. An ordinary sanitized Relay-shaped mock test in `crates/registry-evidence` proves a full signed assertion over an OAuth client-credentials source, with zero production-code changes. -- [ ] A3. A reference deployment-project example for the Relay-backed +- [x] A3. A reference deployment-project example for the Relay-backed pattern exists under `products/evidence/reference`. ### B. Evidence onboarding (docs site, evidencectl, CI) @@ -321,3 +321,20 @@ is parallel; B has no upstream dependencies and is the standing priority tutorial readers use to build the RFC 7523 client assertion for Mint (the demo uses a Python walkthrough; an evidencectl helper would be new CLI surface outside the frozen runtime contract). +- 2026-08-03: A3 done, zero production-code changes; the only Rust edits + are the three `#[cfg(test)]` project lists. The new + products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/ + is a complete deployable bundle plus runtime file for a protected, + scoped, read-only registry API of the shape Relay presents, and it + covers residence region, the coequal acceptance definition the other + two reference projects do not. It earns its place on lookup shape, not + on branding: this envelope reports no total, only a page and a + further-pages flag, so uniqueness is decided from both signals + together and a single record on a page claiming more pages is + ambiguous, unlike the tracker project's pager arithmetic. The bundle + names no registry product. Eighteen fixture cases run through the + production ABI offline; a deliberate tamper (REGION-SOUTH to + REGION-NORTH) failed with a scalar value mismatch, proving the + expectations are load-bearing rather than vacuous. Next in A: none, A + is complete, which unblocks D. Next unblocked elsewhere: B5 tutorials + E3 to E5 (E2 still waits on the client-assertion tooling call above). diff --git a/products/evidence/reference/request-adapter/deployment-projects/README.md b/products/evidence/reference/request-adapter/deployment-projects/README.md index 5aa19c71c..c300a1670 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/README.md @@ -37,6 +37,10 @@ the same fixtures with `evidence evaluate` before deployment. - [`opencrvs-family-evidence/`](opencrvs-family-evidence/) resolves one registered birth event and supports adult status, exact registered-parent confirmation, and bounded registered-parent identification. +- [`relay-protected-read-evidence/`](relay-protected-read-evidence/) resolves + one record through a protected, scoped, read-only registry API of the shape + Registry Relay presents, and supports residence region as a coarse + controlled code mapped from the register's own code. Every hostname, issuer, identifier, and fixture value is synthetic. `.example` hosts must be replaced during deployment. Secret files are referenced only by @@ -57,7 +61,7 @@ validation, audience-scoped entity references, signing, and disclosure audit. Scripts are reviewed and trusted but remain deterministic and unable to perform I/O. -Both projects declare `responseFormats: [signed-jws]` at the bundle level and +Every project declares `responseFormats: [signed-jws]` at the bundle level and on every grant, so they release only signed flattened JWS. That is the production-shaped default: unsigned output is a development convenience that a deployment must enable deliberately in both places. @@ -77,7 +81,7 @@ complete for the declared relationship contract. If a provider's namespace or contract varies by record, extraction must derive and validate that value from projected provider data instead of copying a bundle constant. -Before copying either project, apply the +Before copying a project, apply the [provider prerequisites](CONFIG.md#provider-prerequisites). A source that cannot distinguish zero, one, and multiple matches in one bounded request is not a Version 1 integration even if its JSON can otherwise be mapped by Rhai. diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md new file mode 100644 index 000000000..d7850f649 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/README.md @@ -0,0 +1,124 @@ +# Protected-read deployment project + +This complete target bundle reads a protected, scoped, read-only registry API +rather than a registry product's own API, and supports one minimum-disclosure +requirement: residence region as a coarse controlled code. + +It is the pattern to copy when the registry data already sits behind an API +that projects fields, filters by an exact reference, and reports whether a +result page is complete. Registry Relay presents sources that way, so a Relay +deployment is the worked example, but nothing in this bundle names Relay or +depends on it. Any protected read with the same three properties fits. + +The reviewed governance bundle is under `bundle/`. Process-local paths and +listener settings are in `runtime.yaml`. Deployments review and mount both +files read-only, but staging and production may use different runtime files +without changing evidence semantics. + +Before deployment, the operator changes only: + +- `.example` token-issuer and registry-API hosts; +- the dataset, entity, and field names in the request path and + `providerFields`; +- issuer, provider, trust-domain, framework, evidence-type, and concept URIs; +- the codelist entries and disclosed region codes; +- authority tags and purposes; +- the referenced secret files; and +- the runtime paths and listener binding. + +## Lookup shape + +The source performs one collection read filtered to exactly one record +reference, projected to the two fields the requirement needs, with a page +bound of two. A second page is never followed. That two-result ceiling is +governed adapter policy declared by this reviewed bundle and rendered by +`adapters/prepare.rhai`, so one bounded response separates a unique match from +ambiguity. It is not a Rust domain rule and not a property of any registry +product. + +This envelope reports no total count, only a page of records and a flag saying +whether further pages exist. Uniqueness is therefore decided from both signals +together: exactly one record and no further pages is a unique match, more than +one record or a claim of further pages is ambiguous, and an empty page that +still claims further pages contradicts itself and fails as a protocol error. +That is the general shape for cursor-paginated collections; a provider that +reports a total instead is read the way the tracker project reads its pager. + +A source that cannot distinguish zero, one, and multiple matches in one bounded +request is not a Version 1 integration. See the +[provider prerequisites](../CONFIG.md#provider-prerequisites). + +Because the protected read already restricts the response to the requested +fields, the source declares `field-projected` posture. Extraction carries the +returned record identifier only as a transient fact, and the derivation +requires its exact equality with the authorized subject selector before +evaluating anything else. A returned-record mismatch fails closed as the +internal `derivation_input_error` category and collapses publicly into the same +`evidence_not_available` problem as an unresolved lookup, so the caller cannot +learn that a record was found. + +`providerFields` and `resultLimit` are strings because they become lexical URL +query values, and both are pinned by the adapter-parameter schema: the field +list is the projection this requirement is entitled to, and the page bound is +the ambiguity signal, so neither is an operator dial. + +## Purpose declaration + +The registry API requires a declared purpose on every request. It is pinned as +a fixed header in the reviewed bundle, so no caller and no script can widen the +purpose the registry sees or record. The purpose the registry logs is the same +purpose the authority profile grants and the assertion carries. + +## Residence region + +One record is resolved by exact reference and one controlled code is signed. +The register's own region code never leaves the service: `codelists/` +maps several register codes onto each disclosed region, and only the codes in +`allowed_outputs` can pass the output gate. A register code with no reviewed +mapping leaves the requirement unresolved rather than passing the precise code +through, and a record carrying no region at all is refused by the fact schema +before derivation runs, so an absent region can never be read as a region. + +## Authentication + +Inbound callers present access tokens from the deployment's own issuer. A +deployment with no identity provider runs Registry Mint as that issuer: +Evidence verifies Mint-issued tokens exactly the way it verifies any other OIDC +issuer, and the protected registry API in front of the source data is pointed +at the same issuer. Neither service depends on the other. + +Outbound, the source authenticates to the registry API with the OAuth 2.0 +client-credentials grant against that same issuer, placing the credentials in +the form body and caching the token for at most a minute. A deployment whose +registry API accepts a different credential kind changes only the source's +`authentication` block. + +The registry API here is presented over ordinary public TLS, so `runtime.yaml` +declares no private trust profile and the source names none. A deployment whose +registry API sits behind an internal CA adds a profile to `outboundTls` and +names it on the source, the way the other reference projects do. + +## Secrets + +Required secret files beneath `/run/secrets/registry-evidence`, each owned by +the service identity with mode `0600`, are: + +```text +signing-ed25519-private-jwk +audit-hmac-key +subject-binding-hmac-key +registry-api-client-id +registry-api-client-secret +``` + +The audit and subject-binding files must contain independently generated raw +key material of at least 32 bytes each; they are not base64-decoded. The +signing file contains one private Ed25519 JWK. No secret value is stored in +this project. + +Author with synthetic fixtures first, then promote the same reviewed `bundle/` +bytes through staging and production. Bind environment-specific runtime paths, +credentials, and signing key in each environment. Staging must verify the +configured `at+jwt` header and claims, readiness, one approved synthetic source +lookup, audit durability, and JWS verification. See the +[authoring and promotion workflow](../CONFIG.md#authoring-and-promotion-workflow). diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/prepare.rhai b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/prepare.rhai new file mode 100644 index 000000000..afd0b834a --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/prepare.rhai @@ -0,0 +1,11 @@ +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "id", value: subject["values"]["record_reference"]}, + #{name: "fields", value: parameters["providerFields"]}, + #{name: "limit", value: parameters["resultLimit"]} + ], + body: () + } +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/residence-region-extract.rhai b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/residence-region-extract.rhai new file mode 100644 index 000000000..f81addd38 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/adapters/residence-region-extract.rhai @@ -0,0 +1,42 @@ +// The protected read returns a page of matching records and a pagination +// envelope that says whether further pages exist. It carries no total count, +// so uniqueness is decided from the page itself: how many records came back, +// and whether the provider claims more. The prepared request asks for a page +// of two against an exact reference filter, which is the smallest page that +// can still distinguish one match from several. + +fn extract(source_response, parameters) { + let records = source_response["data"]; + let has_more = source_response["pagination"]["has_more"]; + + if records.len == 0 { + // An empty page that still claims further pages contradicts itself. + // Reading it as no match would turn a broken provider into a clean + // negative answer. + if has_more { + throw("source_protocol_error"); + } + return #{outcome: "no_match"}; + } + if records.len > 1 || has_more { + return #{outcome: "ambiguous"}; + } + + let record = records[0]; + // The shape cannot require this of every record, because an ambiguous page + // is decided before any record is read. A single matched record with no + // reference of its own is a source this adapter does not understand. + if is_missing(record["id"]) { + throw("source_protocol_error"); + } + + let facts = #{record_reference: record["id"]}; + // A record that carries no region is a record this requirement cannot + // answer from. The fact schema refuses it, which leaves the requirement + // unresolved rather than letting derivation invent a region. + if !is_missing(record["region"]) { + facts["official_residence_code"] = record["region"]; + } + + #{outcome: "match", facts: facts} +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/codelists/residence-regions.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/codelists/residence-regions.yaml new file mode 100644 index 000000000..3b7b512d4 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/codelists/residence-regions.yaml @@ -0,0 +1,11 @@ +id: urn:gov:example:codelist:residence-regions +version: '2026-01' +# Several register codes map onto one disclosed region. The mapping is the +# minimization: the disclosed code is coarser than the code the register holds, +# and only the outputs listed here can ever leave the service. +entries: + R-101: REGION-NORTH + R-102: REGION-NORTH + R-201: REGION-SOUTH + R-202: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/derivations/residence-region.rhai b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/derivations/residence-region.rhai new file mode 100644 index 000000000..320d72d72 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/derivations/residence-region.rhai @@ -0,0 +1,19 @@ +fn derive(facts, selectors, evaluation_context) { + if facts["record_reference"] != + selectors["subject"]["values"]["record_reference"] { + throw("derivation_input_error"); + } + // The register's own region code is never disclosed. It is mapped to a + // coarser reviewed code, and a source code with no mapping leaves the + // requirement unresolved rather than passing the precise code through. + let mapped = codelist_lookup( + evaluation_context["codelists"]["residence-regions"], + required(facts["official_residence_code"], "required_fact_missing") + ); + [ + #{ + concept_id: "urn:gov:example:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + } + ] +} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml new file mode 100644 index 000000000..8a6ffc0c0 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/evidence.yaml @@ -0,0 +1,154 @@ +version: 1 +service: + providerId: urn:gov:example:evidence:residence + trustDomain: urn:gov:example:trust-domain:social-protection +issuer: + id: urn:gov:example:issuer:residence-authority +# Inbound callers present access tokens from the deployment's own issuer. A +# deployment with no identity provider runs Registry Mint as that issuer: +# Evidence verifies Mint-issued tokens exactly the way it verifies any other +# OIDC issuer, and the protected registry API in front of the source data is +# pointed at the same issuer. Neither service depends on the other. +authentication: + kind: oidc-access-token + issuer: https://tokens.gov.example + audiences: [registry-evidence] + tokenTypes: [at+jwt] + algorithms: [EdDSA] + jwksUri: https://tokens.gov.example/.well-known/jwks.json + principalClaim: sub + requesterTagsClaim: evidence_tags + evidenceAudienceClaim: evidence_audience + grantIdClaim: evidence_grant_id + grantAuthorityClaim: evidence_authority +audit: + format: keyed-jsonl + hashSecretRef: secret:file/audit-hmac-key + hashKeyVersion: 1 + failClosed: true +subjectBinding: + secretRef: secret:file/subject-binding-hmac-key + keyVersion: 1 +rateLimits: + requestsPerPrincipalPerMinute: 60 + burstPerPrincipal: 10 + failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10 +signing: + format: flattened-jws-json + algorithm: EdDSA + activeKeyId: evidence-signing-2026-01 + activeKeyRef: secret:file/signing-ed25519-private-jwk + retiredPublicJwkFiles: [] + jwksPath: /.well-known/evidence/jwks.json + maximumAssertionValiditySeconds: 86400 + verifierClockSkewSeconds: 30 +responseFormats: [signed-jws] + +selectorProfiles: + residence-record-v1: + maximumAggregateBytes: 96 + fields: + record_reference: + type: string + minimumBytes: 1 + maximumBytes: 64 +sources: + residence-register: + transport: http-json + baseUrl: https://registry-api.gov.example + # The protected read already restricts the response to the fields named in + # the request, and this deployment's projection keeps only two of them. + posture: field-projected + authentication: + kind: oauth2-client-credentials + tokenEndpoint: https://tokens.gov.example/oauth2/token + clientIdRef: secret:file/registry-api-client-id + clientSecretRef: secret:file/registry-api-client-secret + scope: registry.read + credentialPlacement: form-body + maximumCacheSeconds: 60 + request: + method: GET + path: /v1/datasets/residence-register/entities/residence-record/records + fixedHeaders: + - name: Accept + value: application/json + # The protected read requires a declared purpose on every request. It + # is pinned here as reviewed bundle policy, so no caller and no script + # can widen the purpose the registry sees. + - name: Data-Purpose + value: https://relying.gov.example/purpose/residence-verification + selectorInputs: + - role: subject + alternatives: + - profile: residence-record-v1 + fields: [record_reference] + prepareScript: adapters/prepare.rhai + adapterParameters: + providerFields: id,region + resultLimit: "2" + adapterParametersSchema: schemas/residence-region-adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + projection: + - /data/*/id + - /data/*/region + - /pagination/has_more + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/residence-region-response.schema.yaml + extractScript: adapters/residence-region-extract.rhai + factSchema: schemas/residence-region-facts.schema.yaml +authorityProfiles: + residence-verifier-v1: + kind: statutory + requesterTags: [residence-verifier] + grants: + - requirement: urn:gov:example:requirement:residence-region:v1 + purpose: residence-verification + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: residence-record-v1 + valueOrigin: request +requirements: + - id: urn:gov:example:requirement:residence-region:v1 + kind: information-requirement + source: residence-register + purposes: [residence-verification] + subjectRoles: + - role: subject + cardinality: one + selectorProfiles: [residence-record-v1] + referenceFrameworks: [urn:gov:example:framework:residence-region:v1] + evidenceType: urn:gov:example:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: + script: derivations/residence-region.rhai + selectorInputs: + - role: subject + alternatives: + - profile: residence-record-v1 + fields: [record_reference] + parameters: {} + concepts: + - id: urn:gov:example:concept:residence-region + form: controlled-code + required: true + constraints: + codelist: codelists/residence-regions.yaml + codelistVersion: '2026-01' + maximumBytes: 32 + fixtures: fixtures/residence-region-cases.yaml + disclosureGuard: + families: [urn:gov:example:disclosure-family:residence-region] + existenceDisclosure: collapse-unresolved diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/fixtures/residence-region-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/fixtures/residence-region-cases.yaml new file mode 100644 index 000000000..8cebd5c17 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/fixtures/residence-region-cases.yaml @@ -0,0 +1,286 @@ +fixture: registry.evidence.reference.relay-residence-region/v1 +synthetic_only: true +common: + observed_at: "2026-08-02T00:00:00Z" + selectors: + subject: + profile: residence-record-v1 + values: {record_reference: REC-0001} + derivationSelectorInputs: + subject: + profile: residence-record-v1 + values: {record_reference: REC-0001} + expectedRequestParts: + query: + - name: id + value: REC-0001 + - name: fields + value: "id,region" + - name: limit + value: "2" + body: null + expectedTransport: + path: /v1/datasets/residence-register/entities/residence-record/records + fixedHeaders: + - name: Accept + value: application/json + - name: Data-Purpose + value: https://relying.gov.example/purpose/residence-verification +cases: + # The record carries a field this requirement never asked for. The declared + # projection drops it before any script runs, so it cannot reach a fact, a + # derivation, or the signed assertion. + - id: positive + response: + data: + - id: REC-0001 + region: R-101 + area_geometry: SOURCE-UNRELATED-CANARY + pagination: + has_more: false + next_cursor: null + expected: + lookup: match + derivationRuns: true + signed: true + facts: + record_reference: REC-0001 + official_residence_code: R-101 + value: REGION-NORTH + + # A second register code mapping to the same disclosed region proves the + # mapping is a real narrowing rather than a rename. + - id: boundary-second-source-code-same-region + response: + data: + - id: REC-0001 + region: R-102 + pagination: + has_more: false + next_cursor: null + expected: + lookup: match + derivationRuns: true + signed: true + value: REGION-NORTH + + - id: boundary-other-region + response: + data: + - id: REC-0001 + region: R-201 + pagination: + has_more: false + next_cursor: null + expected: + lookup: match + derivationRuns: true + signed: true + value: REGION-SOUTH + + # A register code with no reviewed mapping leaves the requirement unresolved. + # Disclosing the unmapped code, or guessing a region for it, would both cross + # the minimization boundary this codelist exists to hold. + - id: negative-unmapped-source-code + response: + data: + - id: REC-0001 + region: R-999 + pagination: + has_more: false + next_cursor: null + expected: + lookup: match + publicProblem: evidence_not_available + derivationRuns: true + signed: false + + # A record with no region at all is refused by the fact schema, before + # derivation, so an absent region can never be read as a region. + - id: missing-fact + response: + data: + - id: REC-0001 + pagination: + has_more: false + next_cursor: null + expected: + publicProblem: evidence_not_available + derivationRuns: false + signed: false + + - id: no-match + response: + data: [] + pagination: + has_more: false + next_cursor: null + expected: + lookup: no_match + publicProblem: evidence_not_available + derivationRuns: false + signed: false + + # Two records on one page is the ordinary ambiguity signal. + - id: ambiguous-page + response: + data: + - id: REC-0001 + region: R-101 + - id: REC-0002 + region: R-201 + pagination: + has_more: true + next_cursor: CURSOR-SYNTHETIC-002 + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false + + # This envelope reports no total, so a single record on a page that claims + # further pages is still ambiguous. A provider that paginates an exact + # reference filter has not proved the match is unique, and Version 1 makes + # exactly one evidence-data request, so the further pages are never fetched. + - id: ambiguous-further-pages + response: + data: + - id: REC-0001 + region: R-101 + pagination: + has_more: true + next_cursor: CURSOR-SYNTHETIC-002 + expected: + lookup: ambiguous + publicProblem: evidence_not_available + derivationRuns: false + signed: false + + - id: empty-page-claiming-more + response: + data: [] + pagination: + has_more: true + next_cursor: CURSOR-SYNTHETIC-002 + expected: + error: source_protocol_error + derivationRuns: false + signed: false + + - id: record-without-identifier + response: + data: + - region: R-101 + pagination: + has_more: false + next_cursor: null + expected: + error: source_protocol_error + derivationRuns: false + signed: false + + - id: non-string-region + response: + data: + - id: REC-0001 + region: 101 + pagination: + has_more: false + next_cursor: null + expected: + error: source_protocol_error + derivationRuns: false + signed: false + + # A provider that ignores the requested page bound is a provider whose + # ambiguity signal cannot be trusted, so the page is not read at all. + - id: oversized-page + response: + data: + - id: REC-0001 + region: R-101 + - id: REC-0002 + region: R-201 + - id: REC-0003 + region: R-202 + pagination: + has_more: false + next_cursor: null + expected: + error: source_protocol_error + derivationRuns: false + signed: false + + # A record returned under a different reference than the authorized one is an + # inconsistent input. It collapses publicly with the unresolved classes rather + # than signing a region for a record nobody asked about. + - id: returned-subject-mismatch + response: + data: + - id: REC-0002 + region: R-201 + pagination: + has_more: false + next_cursor: null + expected: + lookup: match + error: derivation_input_error + publicProblem: evidence_not_available + derivationRuns: true + signed: false + + - id: source-failure + sourceFailure: timeout + expected: + publicProblem: dependency_unavailable + signed: false + + # A protected read that answers with a sign-in page instead of JSON is a + # dependency failure, not evidence of anything about the subject. + - id: source-sign-in-page + sourceFailure: invalid-media-type + expected: + publicProblem: dependency_unavailable + signed: false + + # The reference is caller-supplied. Rust materializes the transport, so a + # reference carrying query and header syntax lands percent-encoded inside one + # query value and cannot add a parameter, widen the field projection, or + # inject a header. + - id: hostile-reference + selectorOverrides: + subject: + values: {record_reference: "X&fields=*%0D%0AInjected:yes"} + expected: + sourceRequestCount: 1 + expectedTransport: + path: /v1/datasets/residence-register/entities/residence-record/records + query: "id=X%26fields%3D%2A%250D%250AInjected%3Ayes&fields=id%2Cregion&limit=2" + body: null + + - id: output-gate-refuses-a-raw-reference + derivationMutation: return-raw-reference + expected: + outputGate: rejected + signed: false + + - id: anti-reconstruction + bundleMutation: duplicate-disclosure-family + expected: {bundle: rejected} +privacyExpectation: + evidenceContains: + - urn:gov:example:concept:residence-region + - REGION-NORTH + - REGION-SOUTH + evidenceExcludes: + - official_residence_code + - record_reference + - residence-record-v1 + - REC-0001 + - REC-0002 + - R-101 + - R-102 + - R-201 + - area_geometry + - SOURCE-UNRELATED-CANARY + diagnosticsExclude: [REC-0001, REC-0002, R-101, R-201, SOURCE-UNRELATED-CANARY] diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-adapter-parameters.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-adapter-parameters.schema.yaml new file mode 100644 index 000000000..45315fa62 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-adapter-parameters.schema.yaml @@ -0,0 +1,9 @@ +type: object +additionalProperties: false +required: [providerFields, resultLimit] +properties: + # Both parameters are pinned. The field list is the projection the protected + # read applies, and the page size is the smallest one that can still tell a + # unique match from an ambiguous one, so neither is an operator dial. + providerFields: {const: "id,region"} + resultLimit: {const: "2"} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-facts.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-facts.schema.yaml new file mode 100644 index 000000000..c69a313e8 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-facts.schema.yaml @@ -0,0 +1,12 @@ +type: object +additionalProperties: false +required: [record_reference, official_residence_code] +properties: + record_reference: + type: string + minLength: 1 + maxLength: 64 + official_residence_code: + type: string + minLength: 1 + maxLength: 32 diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-response.schema.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-response.schema.yaml new file mode 100644 index 000000000..c0f025bc3 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/bundle/schemas/residence-region-response.schema.yaml @@ -0,0 +1,35 @@ +# Shape of the projected collection response, checked before extraction runs. +# The prepared request asks for a page of two, so a third record is a provider +# that ignored the bound rather than an ambiguous lookup. Whether the page and +# the further-pages flag agree stays with the script: that is a reading across +# fields, not a shape. +type: object +additionalProperties: false +required: [data, pagination] +properties: + data: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + # No projected leaf can be required of every record. Projection drops a + # leaf the record did not carry, and an ambiguous page is decided before + # any record is read, so a record on that page need not be complete. + required: [] + properties: + id: + type: string + minLength: 1 + maxLength: 64 + region: + type: string + minLength: 1 + maxLength: 32 + pagination: + type: object + additionalProperties: false + required: [has_more] + properties: + has_more: {type: boolean} diff --git a/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml new file mode 100644 index 000000000..6d1cf4e77 --- /dev/null +++ b/products/evidence/reference/request-adapter/deployment-projects/relay-protected-read-evidence/runtime.yaml @@ -0,0 +1,24 @@ +version: 1 +bundleDirectory: /etc/registry-evidence/bundle +listener: + bindHost: 127.0.0.1 + port: 8080 + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: /run/secrets/registry-evidence +auditStorage: + path: /var/lib/registry-evidence/audit/evidence.jsonl + maximumFileBytes: 1073741824 +# The registry API this project reads is presented over ordinary public TLS, +# so no private trust profile is declared and the source names none. A +# deployment whose registry API sits behind an internal CA adds a profile here +# and names it on the source, the way the other reference projects do. +outboundTls: + systemRoots: true + trustProfiles: {} From 7e1f90c92f6a6bb5c3316efd4be6318d4d243150 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:09:26 +0700 Subject: [PATCH 058/136] docs(site): correct the first-assertion tutorial against the shipped scaffold The tutorial described a residence-region acceptance definition the scaffold does not ship, listed a codelists directory it does not create, and printed a fixtures-run output that predates the case counts evidencectl now reports. A reader following it saw different output than documented on their first command. The gate did not catch the output drift because it substring-matched the count-free prefix, so pin the full documented lines instead. The gate also failed confusingly, mid-journey, when handed a relative binary path; refuse that up front with a message that names the cause. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 15 +++++++++++-- .../scripts/check-evidence-tutorials.test.mjs | 16 ++++++++++++-- .../tutorials/first-evidence-assertion.mdx | 22 ++++++++++--------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 19f726c68..a5cb00841 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -45,7 +45,7 @@ REQUIRED_LITERALS=( 'evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1' 'chmod -R a-w bundle && chmod 444 runtime.yaml' 'evidencectl fixtures run --project .' - '2 passed, 0 failed' + '2 passed, 0 failed (12 cases evaluated)' ) DRY_RUN=0 @@ -129,6 +129,14 @@ if [[ -z "${EVIDENCE_BIN:-}" || -z "${EVIDENCECTL_BIN:-}" ]]; then EVIDENCECTL_BIN="$TARGET_DIR/$profile_dir/evidencectl" fi for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN"; do + # Absoluteness first: the reader journey runs from its own directory and + # reaches the binaries through symlinks, so a relative path resolves against + # the wrong directory and would otherwise surface much later, mid-journey, + # as "command not found". + if [[ "$bin" != /* ]]; then + printf 'toolset binary path must be absolute: %s\n' "$bin" >&2 + exit 1 + fi if [[ ! -x "$bin" ]]; then printf 'toolset binary not executable: %s\n' "$bin" >&2 exit 1 @@ -161,7 +169,10 @@ if ! (cd "$READER_DIR" && PATH="$SHIM_DIR:$PATH" bash "$RUN_SCRIPT") 2>&1 | exit 1 fi -for expected in 'PASS: check' 'PASS: fixtures/cases.yaml' '2 passed, 0 failed'; do +for expected in \ + 'PASS: check' \ + 'PASS: fixtures/cases.yaml (12 cases)' \ + '2 passed, 0 failed (12 cases evaluated)'; do if ! grep -F -q -- "$expected" "$RUN_LOG"; then printf 'tutorial output drift: expected "%s" in the fixtures run output\n' \ "$expected" >&2 diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs index aededd159..cd0ff5b3e 100644 --- a/docs/site/scripts/check-evidence-tutorials.test.mjs +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -15,9 +15,9 @@ const tutorial = resolve( '../src/content/docs/tutorials/first-evidence-assertion.mdx', ); -async function runGate(env = {}) { +async function runGate(env = {}, args = ['--dry-run']) { try { - const { stdout, stderr } = await execFileAsync('bash', [gate, '--dry-run'], { + const { stdout, stderr } = await execFileAsync('bash', [gate, ...args], { env: { ...process.env, ...env }, }); return { code: 0, output: `${stdout}${stderr}` }; @@ -51,6 +51,18 @@ test('removing a documented command block fails the drift check', async () => { } }); +test('a relative toolset binary path is refused before anything runs', async () => { + // The journey runs from its own directory and reaches the binaries through + // symlinks, so a relative path would resolve against the wrong directory and + // surface much later as "command not found". + const { code, output } = await runGate( + { EVIDENCE_BIN: 'bin/evidence', EVIDENCECTL_BIN: 'bin/evidencectl' }, + [], + ); + assert.notEqual(code, 0, 'a relative binary path must fail the gate'); + assert.match(output, /toolset binary path must be absolute/u); +}); + test('changing the fence count fails the drift check', async () => { const workDir = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); try { diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index 3cd51c4bb..fbc9a7e4a 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -74,11 +74,13 @@ cd hello-evidence ``` The scaffold prints where everything went and the exact commands that come next. It creates -`bundle/` (the governed deployment contract: acceptance definitions, adapters, derivations, -schemas, fixtures, codelists), `runtime.yaml` (process-local paths and the listener), `secrets/` -(empty, owner-only), `audit/`, and a project `README.md`. The bundle's one acceptance definition -is a neutral residence-region example; `evidencectl new --help` also documents `--with-mint`, -which pairs a Registry Mint configuration for the tutorial that serves assertions over HTTP. +`bundle/` (the governed deployment contract: `evidence.yaml` plus the adapters, derivations, +schemas, and fixtures it references), `runtime.yaml` (process-local paths and the listener), +`secrets/` (empty, owner-only), `audit/`, and a project `README.md`. The bundle's one acceptance +definition is deliberately abstract: it answers whether a recorded event date is at least a +reviewed number of years in the past, which is the shape of an adult-status question with the +jurisdiction left out. `evidencectl new --help` also documents `--with-mint`, which pairs a +Registry Mint configuration for the tutorial that serves assertions over HTTP. ## Generate key material @@ -128,15 +130,15 @@ Expected output: ```text PASS: check -PASS: fixtures/cases.yaml -2 passed, 0 failed +PASS: fixtures/cases.yaml (12 cases) +2 passed, 0 failed (12 cases evaluated) ``` Two things passed. `check` loaded, compiled, and validated the complete project: every selector, role, and source binding resolves, every script compiles, and the key material you generated -parses. Then the bundle's synthetic fixture cases replayed through the real evaluation pipeline: -request preparation, extraction, and the residence-region derivation, with no source system and -no network. That is your first passing Evidence assertion run. +parses. Then the bundle's twelve synthetic fixture cases replayed through the real evaluation +pipeline: request preparation, extraction, and the derivation, with no source system and no +network. That is your first passing Evidence assertion run. ## Cleanup From 1b1fcae5ba8ab0c78a07f0e70864784b05d15fa1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:12:18 +0700 Subject: [PATCH 059/136] fix(evidencectl): read real-world OpenAPI documents in source suggest Trialling `source suggest` against two published descriptions, a DHIS2 2.42 document and the OpenCRVS Events 2.0.0 document, showed the tool refusing schemas the closed subset already admits, and refusing whole operations over constructs it could bound instead. - A `$ref` cycle no longer fails the operation. The repeat is cut with a marker that declares no type, so the flattener skips it and never offers it as a leaf, and a whole-subtree selection over it is rejected by name. A cycle is not missing information, it is an expansion with no end; cutting it keeps the rest of the operation selectable. - An `anyOf`/`oneOf` of exactly two members where one is `{"type": "null"}` collapses into the kept member carrying `[T, "null"]`. This is how a generated 3.1 document spells nullability, and the subset already admits the result. - A `["null", T]` pair is reordered to `[T, "null"]` at resolution time. Flattening accepted either order and narrowing required one, so a document writing the pair the other way passed one stage and failed the next. - A node stating no `type` but stating `properties` or `items` is read as `object` or `array`, with a note naming the reading. - A bound clamped to the subset ceiling is now credited to the ceiling, not to the document. A specification writing `maxItems: 2147483647` is declining to bound the array; carrying its name onto the 256 the draft ends up with would read as a promise the source never made. - A run with no terminal now lists the operations, or the leaves of the chosen operation, alongside the flags it needs, and a URL passed to `--openapi` is refused with the command that fetches it. Measured on the two documents: the DHIS2 sweep of 154 GET operations goes from 87 yielding nothing to 1, and that one describes a schema with no declared properties. On the OpenCRVS document `GET /config` goes from failing outright to 32 leaves, `POST /events/search` from 10 leaves to 31, and `GET /locations` from 2 to 6. No source-product name enters production code, dependencies, features, configuration schemas, routes, or CLI options; the new fixtures are neutral. Signed-off-by: Jeremi Joslin --- .../registry-evidencectl/src/suggest/emit.rs | 11 +- .../src/suggest/flatten.rs | 18 +- .../src/suggest/interactive.rs | 1 + .../registry-evidencectl/src/suggest/mod.rs | 65 ++++- .../src/suggest/narrow.rs | 32 ++- .../src/suggest/openapi.rs | 252 ++++++++++++++++-- .../registry-evidencectl/src/suggest/types.rs | 25 ++ .../fixtures/openapi/implicit-types.yaml | 49 ++++ .../fixtures/openapi/nullable-unions.yaml | 57 ++++ .../fixtures/openapi/recursive-tree.yaml | 33 +++ .../tests/suggest_narrow.rs | 8 +- .../tests/suggest_openapi.rs | 217 ++++++++++++++- 12 files changed, 707 insertions(+), 61 deletions(-) create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml create mode 100644 crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml diff --git a/crates/registry-evidencectl/src/suggest/emit.rs b/crates/registry-evidencectl/src/suggest/emit.rs index d2d3532b2..3fb5e1975 100644 --- a/crates/registry-evidencectl/src/suggest/emit.rs +++ b/crates/registry-evidencectl/src/suggest/emit.rs @@ -342,6 +342,9 @@ pub(super) fn provenance_label(provenance: &Provenance) -> Option<&'static str> Provenance::Format => Some("its declared format"), Provenance::Sample => Some("the sample response (widened)"), Provenance::PageSize => Some("a page-size parameter in the spec"), + Provenance::SubsetCeiling => { + Some("the subset ceiling, because the document states a larger bound") + } Provenance::Operator => None, } } @@ -1291,8 +1294,12 @@ fn render_report(inputs: &EmitInputs) -> String { } out.push_str(&format!( - "Still needs your input ({} schema bound(s), plus the source block below):\n", - inputs.narrowed.unresolved.len() + "Still needs your input ({}):\n", + match inputs.narrowed.unresolved.len() { + 0 => "the source block below".to_owned(), + 1 => "1 schema bound, plus the source block below".to_owned(), + count => format!("{count} schema bounds, plus the source block below"), + } )); for need in &inputs.narrowed.unresolved { out.push_str(&format!( diff --git a/crates/registry-evidencectl/src/suggest/flatten.rs b/crates/registry-evidencectl/src/suggest/flatten.rs index 130fb7e35..699864e4a 100644 --- a/crates/registry-evidencectl/src/suggest/flatten.rs +++ b/crates/registry-evidencectl/src/suggest/flatten.rs @@ -6,13 +6,14 @@ //! reserved segment `*` for "every array element". Constructs the closed //! schema subset cannot express as a selectable leaf (a real union via //! `oneOf`/`anyOf`, an `allOf` merging more than one schema, an -//! `additionalProperties` schema, an untyped node, a pointer past the depth -//! limit) are skipped with a warning rather than failing the whole spec: one -//! exotic node should not block drafting from the rest of the operation. +//! `additionalProperties` schema, an untyped node, a cut `$ref` cycle, a +//! pointer past the depth limit) are skipped with a warning rather than +//! failing the whole spec: one exotic node should not block drafting from the +//! rest of the operation. use serde_json::Value; -use super::types::{CandidateLeaf, ResolvedSchema}; +use super::types::{CandidateLeaf, ResolvedSchema, RECURSIVE_REF_KEY}; /// The extended-pointer form shares `get_path`'s 16-segment ceiling (see /// `primitive-library.yaml`), so a projection pointer this stage produces @@ -45,6 +46,15 @@ fn walk( return; }; + if let Some(reference) = object.get(RECURSIVE_REF_KEY).and_then(Value::as_str) { + warnings.push(format!( + "`{}` repeats the $ref cycle `{reference}`; a schema with no end cannot be \ + projected, so the repeat is skipped", + display_pointer(&pointer) + )); + return; + } + if object.contains_key("oneOf") || object.contains_key("anyOf") { warnings.push(format!( "unsupported oneOf/anyOf union at `{}`; skipped", diff --git a/crates/registry-evidencectl/src/suggest/interactive.rs b/crates/registry-evidencectl/src/suggest/interactive.rs index e2af43efd..a82dd62a5 100644 --- a/crates/registry-evidencectl/src/suggest/interactive.rs +++ b/crates/registry-evidencectl/src/suggest/interactive.rs @@ -188,6 +188,7 @@ fn provenance_phrase(provenance: &Provenance) -> &'static str { Provenance::Format => "the declared format", Provenance::Sample => "the sample response, widened", Provenance::PageSize => "a page-size parameter in the spec", + Provenance::SubsetCeiling => "the subset ceiling; the document states a larger bound", Provenance::Operator => "your own answer", } } diff --git a/crates/registry-evidencectl/src/suggest/mod.rs b/crates/registry-evidencectl/src/suggest/mod.rs index a79330639..47874b94c 100644 --- a/crates/registry-evidencectl/src/suggest/mod.rs +++ b/crates/registry-evidencectl/src/suggest/mod.rs @@ -114,8 +114,17 @@ fn suggest(args: SuggestArgs) -> Result { } let flag_driven = args.operation.is_some() && !args.selection.is_empty(); - if !flag_driven && !interactive::is_interactive() { - bail!(missing_flags_message(&args)); + // A run with no terminal to prompt on still gets told what it could have + // asked for, and each answer is only knowable once the one before it is + // settled: the operations come from the document, the leaves from the + // chosen operation's response schema. So the two refusals happen at the two + // points where the answer exists, not together at the top. + if args.operation.is_none() && !interactive::is_interactive() { + bail!( + "{}\n\nthis document declares:\n{}", + missing_flags_message(&args), + list_operations(&operations) + ); } let summary = match &args.operation { @@ -141,7 +150,11 @@ fn suggest(args: SuggestArgs) -> Result { } let operation = summary.key.clone(); - let schema = spec.response_schema(&operation, &args.status, &args.media_type)?; + let resolved = spec.response_schema(&operation, &args.status, &args.media_type)?; + for note in &resolved.notes { + eprintln!("evidencectl: {note}"); + } + let schema = resolved.schema; let (leaves, warnings) = flatten::candidate_leaves(&schema); for warning in &warnings { eprintln!("evidencectl: {warning}"); @@ -158,6 +171,15 @@ fn suggest(args: SuggestArgs) -> Result { } let selection = if args.selection.is_empty() { + if !interactive::is_interactive() { + bail!( + "{}\n\nthis operation's `{}` `{}` response offers:\n{}", + missing_flags_message(&args), + args.status, + args.media_type, + list_leaves(&leaves) + ); + } interactive::choose_leaves(&leaves)? } else { check_selection(&args.selection, &leaves)?; @@ -426,10 +448,17 @@ fn with_page_size_fallback( return Ok(needs); }; let clamped = u64::try_from(maximum.min(MAX_PROJECTED_ITEMS)).unwrap_or(1); + // A page size above the subset ceiling is not what the draft ends up + // stating, so the parameter does not get the credit for what it does. + let provenance = if maximum <= MAX_PROJECTED_ITEMS { + Provenance::PageSize + } else { + Provenance::SubsetCeiling + }; for need in needs.iter_mut().filter(|need| eligible(need)) { need.suggestion = Some(SuggestedBound { values: BoundValues::MaxItems(clamped), - provenance: Provenance::PageSize, + provenance: provenance.clone(), }); } Ok(needs) @@ -466,19 +495,33 @@ fn find_operation<'a>( .iter() .find(|operation| operation.key == *key) .ok_or_else(|| { - let available = operations - .iter() - .map(|operation| format!(" {} {}", operation.key.method, operation.key.path)) - .collect::>() - .join("\n"); anyhow::anyhow!( - "this document declares no `{} {}` with a JSON response schema; it declares:\n{available}", + "this document declares no `{} {}` with a JSON response schema; it declares:\n{}", key.method, - key.path + key.path, + list_operations(operations) ) }) } +/// The document's operations, one `--operation` argument per line. +fn list_operations(operations: &[OperationSummary]) -> String { + operations + .iter() + .map(|operation| format!(" {} {}", operation.key.method, operation.key.path)) + .collect::>() + .join("\n") +} + +/// The response's selectable leaves, one `--select` argument per line. +fn list_leaves(leaves: &[CandidateLeaf]) -> String { + leaves + .iter() + .map(|leaf| format!(" {}", leaf.pointer)) + .collect::>() + .join("\n") +} + fn describe_responses(responses: &[(String, String)]) -> String { responses .iter() diff --git a/crates/registry-evidencectl/src/suggest/narrow.rs b/crates/registry-evidencectl/src/suggest/narrow.rs index b1f79014a..4d7a59db3 100644 --- a/crates/registry-evidencectl/src/suggest/narrow.rs +++ b/crates/registry-evidencectl/src/suggest/narrow.rs @@ -31,7 +31,7 @@ use serde_json::{Map as JsonMap, Value}; use super::types::{ BoundKind, BoundNeed, BoundValues, NarrowOutcome, Observations, Observed, Provenance, - ResolvedSchema, SuggestedBound, + ResolvedSchema, SuggestedBound, RECURSIVE_REF_KEY, }; /// The largest `maxItems` the closed subset admits. @@ -534,6 +534,16 @@ impl Narrowing<'_> { display_pointer(pointer) ) })?; + // Reachable only by selecting a whole subtree that contains a cut + // recursion: the flattener never offers the cut node itself as a leaf. + if let Some(reference) = node.get(RECURSIVE_REF_KEY).and_then(Value::as_str) { + bail!( + "the node at `{}` repeats the $ref cycle `{reference}`; a schema with no end \ + cannot be projected, so select the members you need rather than the subtree \ + above it", + display_pointer(pointer) + ); + } let Some((base, nullable)) = node_type(node, pointer)? else { // A node with no type but a bounded const is admitted as it is. reject_descent(selected, pointer)?; @@ -726,6 +736,11 @@ impl Narrowing<'_> { /// The best `maxItems` the inputs support: a stated bound clamped into the /// subset, else a widened sample observation, else nothing. + /// + /// A stated bound above the ceiling is reported as the ceiling's, not the + /// document's. A specification writing `maxItems: 2147483647` is declining + /// to bound the array; carrying its name onto the 256 the draft ends up + /// with would read as a promise the source never made. fn array_suggestion( &self, pointer: &str, @@ -734,9 +749,14 @@ impl Narrowing<'_> { ) -> Option { let floor = spec_minimum.unwrap_or(0).clamp(1, MAX_ITEMS_CEILING); if let Some(stated) = spec_maximum { + let clamped = stated.clamp(floor, MAX_ITEMS_CEILING); return Some(SuggestedBound { - values: BoundValues::MaxItems(stated.clamp(floor, MAX_ITEMS_CEILING)), - provenance: Provenance::Spec, + values: BoundValues::MaxItems(clamped), + provenance: if clamped == stated { + Provenance::Spec + } else { + Provenance::SubsetCeiling + }, }); } let observed = self.observed(pointer)?.max_array_items?; @@ -802,7 +822,9 @@ impl Narrowing<'_> { /// The best string length the inputs support: a stated bound clamped into /// the subset, else the fixed length a `uuid` format implies, else a - /// widened sample observation, else nothing. + /// widened sample observation, else nothing. A stated bound above the + /// ceiling is attributed to the ceiling, for the reason + /// [`Self::array_suggestion`] gives. fn string_suggestion( &self, pointer: &str, @@ -817,7 +839,7 @@ impl Narrowing<'_> { min_length: minimum.min(MAX_LENGTH_CEILING), max_length: MAX_LENGTH_CEILING, }, - provenance: Provenance::Spec, + provenance: Provenance::SubsetCeiling, }); } if format == Some("uuid") { diff --git a/crates/registry-evidencectl/src/suggest/openapi.rs b/crates/registry-evidencectl/src/suggest/openapi.rs index df622180d..ffa301631 100644 --- a/crates/registry-evidencectl/src/suggest/openapi.rs +++ b/crates/registry-evidencectl/src/suggest/openapi.rs @@ -4,17 +4,28 @@ //! //! Only local files are read and only local `#/components/...` refs are //! followed. An external or remote `$ref` (anything not starting with `#/`) -//! and a `$ref` cycle are both rejected with a clear error rather than -//! silently truncated or ignored, because a partially-resolved schema would -//! let the closed-subset narrowing stage draft against data the runtime -//! cannot actually see. +//! is rejected with a clear error rather than silently truncated or ignored, +//! because a partially-resolved schema would let the closed-subset narrowing +//! stage draft against data the runtime cannot actually see. A `$ref` cycle +//! is different in kind: it is not missing information but an expansion with +//! no end, so the repeat is cut in place, marked, and reported, and the rest +//! of the operation stays draftable. +//! +//! Resolution also canonicalizes the two dialect spellings that describe +//! something the closed subset already admits: a two-member union against +//! `null` becomes the type pair `[T, "null"]`, and a node declaring +//! `properties` or `items` and no `type` is read as the type that keyword +//! belongs to. Neither adds a constraint the document does not state; each is +//! reported as a note so the reading stays the operator's to reject. use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; -use super::types::{OperationKey, OperationSummary, ResolvedSchema}; +use super::types::{ + OperationKey, OperationSummary, ResolvedResponse, ResolvedSchema, RECURSIVE_REF_KEY, +}; /// Path Item Object keys this pipeline can draft a source from. /// @@ -80,6 +91,12 @@ impl Spec { /// version string. No network access is performed; `path` must name a /// local file. pub fn load(path: &Path) -> Result { + if let Some(url) = looks_like_url(path) { + bail!( + "`{url}` is a URL; this reads a local file only. Fetch the document first, \ + for example `curl -sSL -o openapi.yaml {url}`, then pass the file" + ); + } let metadata = std::fs::metadata(path) .with_context(|| format!("reading OpenAPI document metadata at {}", path.display()))?; if metadata.len() > MAX_DOCUMENT_BYTES { @@ -179,14 +196,14 @@ impl Spec { } /// The response schema for `key`'s `status`/`media_type` response, with - /// every local `$ref` inlined and OpenAPI 3.0 `nullable: true` rewritten - /// to the 3.1 type pair `[T, "null"]`. + /// every local `$ref` inlined, the dialect normalized, and a note for each + /// reading the normalization had to make. pub fn response_schema( &self, key: &OperationKey, status: &str, media_type: &str, - ) -> Result { + ) -> Result { let operation = self.find_operation(key)?; let responses = operation .get("responses") @@ -227,15 +244,19 @@ impl Spec { key.path ) })?; + let mut notes = Vec::new(); let resolved = self - .inline_schema(schema, &mut Vec::new()) + .inline_schema(schema, "", &mut Vec::new(), &mut notes) .with_context(|| { format!( "resolving the `{status}` `{media_type}` response schema of {} {}", key.method, key.path ) })?; - Ok(ResolvedSchema(resolved)) + Ok(ResolvedResponse { + schema: ResolvedSchema(resolved), + notes, + }) } /// Base URLs from the document's top-level `servers` array, in document @@ -306,7 +327,7 @@ impl Spec { continue; }; let resolved = self - .inline_schema(schema, &mut Vec::new()) + .inline_schema(schema, "", &mut Vec::new(), &mut Vec::new()) .with_context(|| format!("resolving the schema of query parameter `{name}`"))?; if let Some(maximum) = resolved.get("maximum").and_then(Value::as_i64) { out.push(maximum); @@ -361,24 +382,42 @@ impl Spec { } /// Recursively inlines every local `$ref` inside a Schema Object and - /// normalizes OpenAPI 3.0 `nullable: true` to the 3.1 type pair. Per - /// OpenAPI 3.0 semantics, a schema node carrying `$ref` has any sibling - /// keywords ignored; this function does the same, uniformly, for - /// simplicity. - fn inline_schema(&self, node: &Value, stack: &mut Vec) -> Result { + /// normalizes the dialect. Per OpenAPI 3.0 semantics, a schema node + /// carrying `$ref` has any sibling keywords ignored; this function does the + /// same, uniformly, for simplicity. + /// + /// `pointer` locates `node` inside the response schema so a note can name + /// where it applies; it is the same extended projection form the flattener + /// produces, so the two read alike. + fn inline_schema( + &self, + node: &Value, + pointer: &str, + stack: &mut Vec, + notes: &mut Vec, + ) -> Result { let Value::Object(object) = node else { return Ok(node.clone()); }; if let Some(reference) = object.get("$ref").and_then(Value::as_str) { - let pointer = local_ref_pointer(reference)?; + let target_pointer = local_ref_pointer(reference)?; if stack.iter().any(|seen| seen == reference) { - bail!("$ref cycle detected at `{reference}`"); + notes.push(format!( + "`{}` repeats the $ref cycle `{reference}`; the repeat is cut there, so \ + nothing below it can be projected", + display_pointer(pointer) + )); + return Ok(Value::Object( + [(RECURSIVE_REF_KEY.to_owned(), Value::from(reference))] + .into_iter() + .collect(), + )); } - let target = resolve_pointer(&self.document, pointer) + let target = resolve_pointer(&self.document, target_pointer) .with_context(|| format!("resolving $ref `{reference}`"))? .clone(); stack.push(reference.to_string()); - let inlined = self.inline_schema(&target, stack); + let inlined = self.inline_schema(&target, pointer, stack, notes); stack.pop(); return inlined; } @@ -390,23 +429,29 @@ impl Spec { Some(members) => { let mut properties = serde_json::Map::with_capacity(members.len()); for (member_name, member_schema) in members { + let member_pointer = + format!("{pointer}/{}", escape_pointer_segment(member_name)); properties.insert( member_name.clone(), - self.inline_schema(member_schema, stack)?, + self.inline_schema(member_schema, &member_pointer, stack, notes)?, ); } Value::Object(properties) } None => value.clone(), }, - "items" | "not" | "additionalProperties" if value.is_object() => { - self.inline_schema(value, stack)? + "items" if value.is_object() => { + self.inline_schema(value, &format!("{pointer}/*"), stack, notes)? + } + "not" | "additionalProperties" if value.is_object() => { + self.inline_schema(value, pointer, stack, notes)? } "allOf" | "oneOf" | "anyOf" => { if let Some(members) = value.as_array() { let mut inlined_members = Vec::with_capacity(members.len()); for member in members { - inlined_members.push(self.inline_schema(member, stack)?); + inlined_members + .push(self.inline_schema(member, pointer, stack, notes)?); } Value::Array(inlined_members) } else { @@ -418,6 +463,9 @@ impl Spec { result.insert(key.clone(), inlined_value); } normalize_nullable(&mut result); + collapse_null_union(&mut result); + order_nullable_pair(&mut result); + infer_structural_type(&mut result, pointer, notes); Ok(Value::Object(result)) } } @@ -451,6 +499,162 @@ fn normalize_nullable(object: &mut serde_json::Map) { } } +/// Rewrites a two-member `anyOf`/`oneOf` whose members are one typed schema +/// and the schema `{"type": "null"}` into that typed schema carrying the pair +/// `[T, "null"]`. +/// +/// This is the shape generators emit for an optional field, and it states +/// exactly what the closed subset's one admitted union states. Rewriting it +/// adds no constraint: the members are kept as they stand, and a keyword on +/// the union node itself (a `description`, say) is carried over only where the +/// kept member does not already state one, so nothing the document said is +/// overwritten. Any other union, including one against `null` whose other +/// member declares no type to pair against, is left for the flattening stage +/// to skip and warn about. +fn collapse_null_union(object: &mut serde_json::Map) { + let keyword = ["anyOf", "oneOf"] + .into_iter() + .find(|keyword| object.contains_key(*keyword)); + let Some(keyword) = keyword else { + return; + }; + let Some(members) = object.get(keyword).and_then(Value::as_array) else { + return; + }; + let [first, second] = members.as_slice() else { + return; + }; + let kept = match (is_null_schema(first), is_null_schema(second)) { + (true, false) => second, + (false, true) => first, + _ => return, + }; + let Some(kept) = kept.as_object() else { + return; + }; + let Some(paired) = nullable_type(kept.get("type")) else { + return; + }; + + let mut collapsed = kept.clone(); + collapsed.insert("type".to_owned(), paired); + for (key, value) in object.iter() { + if key == keyword || collapsed.contains_key(key) { + continue; + } + collapsed.insert(key.clone(), value.clone()); + } + *object = collapsed; +} + +/// Whether `node` is the schema that admits only `null`. +fn is_null_schema(node: &Value) -> bool { + node.get("type").and_then(Value::as_str) == Some("null") +} + +/// The `[T, "null"]` pair for an existing `type` keyword, or `None` when there +/// is no single non-`null` type to pair. +fn nullable_type(declared: Option<&Value>) -> Option { + let null = Value::from("null"); + match declared? { + Value::String(name) if name != "null" => { + Some(Value::Array(vec![Value::String(name.clone()), null])) + } + Value::Array(names) => { + let mut non_null = names + .iter() + .filter(|name| name.as_str() != Some("null")) + .cloned(); + let single = non_null.next()?; + non_null + .next() + .is_none() + .then(|| Value::Array(vec![single, null])) + } + _ => None, + } +} + +/// Writes a nullable type pair in the one order the closed subset admits. +/// A document spelling it `["null", T]` describes the same node, and the +/// spelling is not a reason to refuse it later. +fn order_nullable_pair(object: &mut serde_json::Map) { + let Some(Value::Array(names)) = object.get("type") else { + return; + }; + let [first, second] = names.as_slice() else { + return; + }; + if first.as_str() == Some("null") && second.as_str().is_some_and(|name| name != "null") { + let reordered = Value::Array(vec![second.clone(), first.clone()]); + object.insert("type".to_owned(), reordered); + } +} + +/// Reads the type of a node that declares a structural keyword and no `type`. +/// +/// `properties` describes members of an object and `items` describes elements +/// of an array; neither means anything on any other type, so the node is not +/// ambiguous and reading it costs nothing the document did not already say. +/// Several large registry APIs publish their collection wrappers exactly this +/// way, and refusing them yields no draft at all rather than a narrower one. +/// +/// The reading stops there. A node carrying no structural keyword is left +/// untyped for the flattening stage to skip: there would be nothing to read it +/// from, and guessing a scalar type is the kind of invention this tool does +/// not do. A node already carrying `type`, a bounded `const`/`enum`, or a +/// union keyword states its own shape and is left alone. +fn infer_structural_type( + object: &mut serde_json::Map, + pointer: &str, + notes: &mut Vec, +) { + let stated = ["type", "const", "enum", "allOf", "oneOf", "anyOf"] + .into_iter() + .any(|keyword| object.contains_key(keyword)); + if stated || object.contains_key(RECURSIVE_REF_KEY) { + return; + } + let (keyword, inferred) = if object.contains_key("properties") { + ("properties", "object") + } else if object.contains_key("items") { + ("items", "array") + } else { + return; + }; + object.insert("type".to_owned(), Value::from(inferred)); + notes.push(format!( + "`{}` declares no `type` but does declare `{keyword}`, so it is read as `{inferred}`", + display_pointer(pointer) + )); +} + +/// Whether `path` was written as a URL rather than a file to read, returned as +/// the text to quote back. Checked against the scheme rather than the whole +/// string so a local path merely containing `://` is not mistaken for one. +fn looks_like_url(path: &Path) -> Option<&str> { + let text = path.to_str()?; + ["http://", "https://"] + .into_iter() + .any(|scheme| text.starts_with(scheme)) + .then_some(text) +} + +/// Escapes an object member name into one RFC 6901 pointer segment, matching +/// the flattening stage so a note and a candidate leaf name the same node the +/// same way. +fn escape_pointer_segment(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn display_pointer(pointer: &str) -> &str { + if pointer.is_empty() { + "(root)" + } else { + pointer + } +} + /// Validates that `reference` is a local same-document ref (`#/...` or the /// whole-document `#`) and returns its JSON Pointer (without the leading /// `#`). Rejects anything else — a relative or absolute document reference, diff --git a/crates/registry-evidencectl/src/suggest/types.rs b/crates/registry-evidencectl/src/suggest/types.rs index 6fb9e625b..40fd8c16b 100644 --- a/crates/registry-evidencectl/src/suggest/types.rs +++ b/crates/registry-evidencectl/src/suggest/types.rs @@ -32,6 +32,25 @@ pub struct OperationSummary { #[derive(Debug, Clone)] pub struct ResolvedSchema(pub serde_json::Value); +/// The marker left in place of a schema node that repeats a `$ref` already on +/// the resolution stack. Cutting the repeat bounds an otherwise infinite +/// expansion without discarding the rest of the operation; the marker declares +/// no type, so no stage can mistake it for something projectable, and the +/// flattener names the recursion it stands for. +pub const RECURSIVE_REF_KEY: &str = "x-evidencectl-recursive-ref"; + +/// A resolved response schema with the readings the resolver made on the way. +/// +/// A note records where the document was ambiguous or unrepresentable and what +/// was done about it: a cut recursion, or a type read from a structural +/// keyword. Every note is reported to the operator, because a reading the tool +/// made on their behalf is one they may need to disagree with. +#[derive(Debug, Clone)] +pub struct ResolvedResponse { + pub schema: ResolvedSchema, + pub notes: Vec, +} + /// One selectable leaf of the resolved schema, presented to the user and /// mapped one-to-one onto a projection allowlist entry. /// @@ -76,6 +95,12 @@ pub enum Provenance { Sample, /// Derived from a page-size parameter in the spec. PageSize, + /// The closed subset's own ceiling, used because the document states a + /// bound above it. This is deliberately not [`Provenance::Spec`]: the + /// number in the draft is not the number the document states, and + /// crediting the document for it would tell a reviewer the source promised + /// something it never promised. + SubsetCeiling, /// Chosen by the operator at the prompt, either where nothing could be /// derived or in place of a suggestion they edited. Nothing the tool /// derived carries this: it is the one provenance that is not a diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml new file mode 100644 index 000000000..134ad8fe0 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/implicit-types.yaml @@ -0,0 +1,49 @@ +openapi: 3.0.3 +info: + title: Implicit Types + version: "1.0.0" +paths: + # The collection-wrapper shape published by several large registry APIs: + # `properties` with no `type: object` beside it. + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + properties: + pager: + properties: + page: + type: integer + minimum: 1 + maximum: 1000 + records: + items: + properties: + id: + type: string + minLength: 11 + maxLength: 11 + maxItems: 50 + # A node carrying neither a type nor a structural keyword stays untyped: + # nothing here guesses at a scalar. + /opaque: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + known: + type: string + minLength: 1 + maxLength: 8 + anything: + description: no type, no properties, no items diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml new file mode 100644 index 000000000..02618aeb0 --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/nullable-unions.yaml @@ -0,0 +1,57 @@ +openapi: 3.1.0 +info: + title: Nullable Unions + version: "1.0.0" +paths: + /records: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + # The idiom generated by zod-to-openapi and friends: an + # optional scalar written as a two-member anyOf against + # `null` rather than as the 3.1 type pair. + note: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + # The same idiom spelled with `oneOf`, with the null member + # first, and carrying a description on the union node. + count: + description: how many were seen + oneOf: + - type: "null" + - type: integer + minimum: 0 + maximum: 99 + # A 3.1 type pair written null-first. + reversedPair: + type: ["null", string] + minLength: 1 + maxLength: 8 + # Nullable containers collapse the same way, so their members + # stay selectable. + parent: + anyOf: + - type: object + additionalProperties: false + properties: + id: + type: string + minLength: 1 + maxLength: 36 + - type: "null" + # A union of two real types is not a nullable spelling and is + # left alone for the flattener to skip. + either: + anyOf: + - type: string + - type: integer diff --git a/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml b/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml new file mode 100644 index 000000000..d78fd4bfd --- /dev/null +++ b/crates/registry-evidencectl/tests/fixtures/openapi/recursive-tree.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Recursive Tree + version: "1.0.0" +paths: + /nodes: + get: + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Node' +components: + schemas: + Node: + type: object + additionalProperties: false + properties: + id: + type: string + minLength: 1 + maxLength: 36 + label: + type: string + minLength: 1 + maxLength: 64 + children: + type: array + maxItems: 10 + items: + $ref: '#/components/schemas/Node' diff --git a/crates/registry-evidencectl/tests/suggest_narrow.rs b/crates/registry-evidencectl/tests/suggest_narrow.rs index b86a58909..644c4111c 100644 --- a/crates/registry-evidencectl/tests/suggest_narrow.rs +++ b/crates/registry-evidencectl/tests/suggest_narrow.rs @@ -458,7 +458,7 @@ fn a_large_sample_array_is_clamped_to_the_subset_ceiling() { } #[test] -fn a_spec_max_items_outside_the_subset_is_clamped_and_still_reported() { +fn a_spec_max_items_outside_the_subset_is_clamped_and_credited_to_the_ceiling() { let input = root( json!({ "records": { @@ -477,8 +477,10 @@ fn a_spec_max_items_outside_the_subset_is_clamped_and_still_reported() { needs[0].suggestion.clone().expect("suggestion"), SuggestedBound { values: BoundValues::MaxItems(256), - provenance: Provenance::Spec, - } + provenance: Provenance::SubsetCeiling, + }, + "5000 is not the number the draft ends up stating, so the document does not \ + get the credit for the 256 that replaces it" ); let outcome = apply(&input, &["/records/*"], &[]); diff --git a/crates/registry-evidencectl/tests/suggest_openapi.rs b/crates/registry-evidencectl/tests/suggest_openapi.rs index ac1f04393..2eacae2c0 100644 --- a/crates/registry-evidencectl/tests/suggest_openapi.rs +++ b/crates/registry-evidencectl/tests/suggest_openapi.rs @@ -133,7 +133,7 @@ fn response_schema_inlines_local_refs_and_normalizes_nullable() { .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); - let rendered = resolved.0.to_string(); + let rendered = resolved.schema.0.to_string(); assert!( !rendered.contains("$ref"), "refs should be fully inlined: {rendered}" @@ -141,16 +141,16 @@ fn response_schema_inlines_local_refs_and_normalizes_nullable() { // Top-level `nullable: true` string becomes the 3.1 type pair. assert_eq!( - resolved.0["properties"]["recordedOn"]["type"], + resolved.schema.0["properties"]["recordedOn"]["type"], serde_json::json!(["string", "null"]) ); - assert!(resolved.0["properties"]["recordedOn"] + assert!(resolved.schema.0["properties"]["recordedOn"] .get("nullable") .is_none()); // The $ref'd array item schema (Record) is inlined in place, and its own // nested `nullable: true` (notes) is normalized too. - let record_item = &resolved.0["properties"]["results"]["items"]; + let record_item = &resolved.schema.0["properties"]["results"]["items"]; assert_eq!( record_item["properties"]["trackingId"]["type"], serde_json::json!("string") @@ -172,7 +172,7 @@ fn response_schema_passes_through_3_1_type_arrays_unchanged() { ) .expect("resolves"); assert_eq!( - resolved.0["properties"]["status"]["type"], + resolved.schema.0["properties"]["status"]["type"], serde_json::json!(["string", "null"]) ); } @@ -190,14 +190,207 @@ fn response_schema_rejects_external_ref() { ); } +/// A recursive `$ref` bounds how deep the response can be described, not +/// whether the operation can be drafted from at all. The repeat is cut and +/// named, and everything beside it stays selectable. #[test] -fn response_schema_rejects_ref_cycle() { +fn response_schema_cuts_a_ref_cycle_and_notes_it() { let spec = openapi::Spec::load(&fixture("ref-cycle.yaml")).expect("loads"); - let error = spec + let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") - .unwrap_err(); + .expect("resolves"); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("cycle") && note.contains("#/components/schemas/A")), + "notes were: {:#?}", + resolved.notes + ); + + let (_, warnings) = flatten::candidate_leaves(&resolved.schema); + assert!( + warnings + .iter() + .any(|warning| warning.contains("/child/parent") + && warning.contains("#/components/schemas/A")), + "warnings were: {warnings:#?}" + ); +} + +#[test] +fn a_recursive_schema_still_offers_its_non_recursive_leaves() { + let spec = openapi::Spec::load(&fixture("recursive-tree.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/nodes"), "200", "application/json") + .expect("resolves"); + let (leaves, _) = flatten::candidate_leaves(&resolved.schema); + + // The repeat is cut where it first repeats, so the recursive branch offers + // nothing and the record's own scalars stay selectable. + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/id", "/label"]); +} + +// --- dialect normalization --------------------------------------------------- + +/// A two-member `anyOf`/`oneOf` against `null` is how several generators spell +/// the 3.1 nullable type pair. It states nothing the closed subset cannot +/// already express, so it is rewritten into the pair rather than skipped as an +/// unsupported union. +#[test] +fn a_two_member_union_against_null_becomes_the_nullable_type_pair() { + let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + let properties = &resolved.schema.0["properties"]; + + assert_eq!( + properties["note"]["type"], + serde_json::json!(["string", "null"]) + ); + // The collapsed member's own bounds survive the rewrite. + assert_eq!(properties["note"]["maxLength"], serde_json::json!(64)); + assert!(properties["note"].get("anyOf").is_none()); + + assert_eq!( + properties["count"]["type"], + serde_json::json!(["integer", "null"]) + ); + assert_eq!(properties["count"]["maximum"], serde_json::json!(99)); + // A keyword on the union node itself is not lost when the union collapses. + assert_eq!( + properties["count"]["description"], + serde_json::json!("how many were seen") + ); + + // A nullable object keeps its members addressable. + assert_eq!( + properties["parent"]["type"], + serde_json::json!(["object", "null"]) + ); + assert_eq!( + properties["parent"]["properties"]["id"]["maxLength"], + serde_json::json!(36) + ); +} + +/// The subset admits the pair in one order only, so a document writing it the +/// other way round describes something the subset can express and must not be +/// refused over the spelling. +#[test] +fn a_null_first_type_pair_is_reordered() { + let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + assert_eq!( + resolved.schema.0["properties"]["reversedPair"]["type"], + serde_json::json!(["string", "null"]) + ); +} + +#[test] +fn a_union_of_two_real_types_is_left_for_the_flattener_to_skip() { + let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + assert!(resolved.schema.0["properties"]["either"] + .get("anyOf") + .is_some()); + + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!( + pointers, + vec!["/count", "/note", "/parent/id", "/reversedPair"] + ); + assert!( + warnings.iter().any(|warning| warning.contains("/either")), + "warnings were: {warnings:#?}" + ); +} + +/// `properties` and `items` are meaningless on anything but an object and an +/// array, so a node carrying one and no `type` is not ambiguous. Reading it is +/// what lets the tool draft from the collection wrappers large registry APIs +/// actually publish; the reading is announced rather than made silently. +#[test] +fn a_structural_keyword_without_a_type_is_read_as_that_type_and_noted() { + let spec = openapi::Spec::load(&fixture("implicit-types.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/records"), "200", "application/json") + .expect("resolves"); + + assert_eq!(resolved.schema.0["type"], serde_json::json!("object")); + assert_eq!( + resolved.schema.0["properties"]["records"]["type"], + serde_json::json!("array") + ); + assert_eq!( + resolved.schema.0["properties"]["records"]["items"]["type"], + serde_json::json!("object") + ); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("(root)") && note.contains("object")), + "notes were: {:#?}", + resolved.notes + ); + assert!( + resolved + .notes + .iter() + .any(|note| note.contains("/records") && note.contains("array")), + "notes were: {:#?}", + resolved.notes + ); + + let (leaves, _) = flatten::candidate_leaves(&resolved.schema); + let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + pointers.sort(); + assert_eq!(pointers, vec!["/pager/page", "/records/*/id"]); +} + +#[test] +fn a_node_with_neither_a_type_nor_a_structural_keyword_stays_untyped() { + let spec = openapi::Spec::load(&fixture("implicit-types.yaml")).expect("loads"); + let resolved = spec + .response_schema(&operation("GET", "/opaque"), "200", "application/json") + .expect("resolves"); + assert!(resolved.schema.0["properties"]["anything"] + .get("type") + .is_none()); + + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); + let pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); + assert_eq!(pointers, vec!["/known"]); + assert!( + warnings.iter().any(|warning| warning.contains("/anything")), + "warnings were: {warnings:#?}" + ); +} + +// --- Spec::load, continued --------------------------------------------------- + +/// `--openapi` reads a local file. A URL is a common enough mistake that the +/// error says so, rather than reporting a missing file the operator never +/// expected to exist. +#[test] +fn load_names_a_url_as_one_rather_than_as_a_missing_file() { + let error = + openapi::Spec::load(Path::new("https://api.example.test/openapi.yaml")).unwrap_err(); let message = format!("{error:#}"); - assert!(message.contains("cycle"), "message was: {message}"); + assert!( + message.contains("local file") && message.contains("curl"), + "message was: {message}" + ); } #[test] @@ -295,7 +488,7 @@ fn candidate_leaves_flattens_arrays_and_nullable_records() { let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); - let (leaves, warnings) = flatten::candidate_leaves(&resolved); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); @@ -338,7 +531,7 @@ fn candidate_leaves_escapes_member_names_per_rfc_6901() { let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); - let (leaves, warnings) = flatten::candidate_leaves(&resolved); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); @@ -352,7 +545,7 @@ fn candidate_leaves_skips_and_warns_on_unsupported_constructs() { let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); - let (leaves, warnings) = flatten::candidate_leaves(&resolved); + let (leaves, warnings) = flatten::candidate_leaves(&resolved.schema); let mut pointers: Vec<&str> = leaves.iter().map(|leaf| leaf.pointer.as_str()).collect(); pointers.sort(); From 866564dc8af36a072aa439258d8b002d72c5f44b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:21:24 +0700 Subject: [PATCH 060/136] docs(site): run the Evidence tutorial gate over a registry of tutorials The gate replayed one hard-coded tutorial. The onboarding path is a series of them, so the drift knobs move into a per-tutorial spec: the sh fence count, the ordered reader journey, the literals the text must keep, and the lines the transcript must contain. Each tutorial replays in a reader directory of its own. A journey step is either a range of sh fences to execute or a documented before/after fence pair applied to a file the reader edits, which is what a tutorial that modifies the scaffolded bundle needs. Files a reader creates outright stay inside the sh fences that create them, so the text a reader copies is the text CI runs. Registering a tutorial is now a slug plus a spec branch. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 385 ++++++++++++------ .../scripts/check-evidence-tutorials.test.mjs | 72 +++- 2 files changed, 323 insertions(+), 134 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index a5cb00841..07ad0decc 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -4,63 +4,118 @@ # # This gate builds the Evidence toolset from the checked-out source unless # EVIDENCE_BIN and EVIDENCECTL_BIN select exact candidate or released bytes, -# then replays the first-assertion tutorial's own shell fences: scaffold, -# key generation, the immutability freeze, the fixtures run, and cleanup. -# The release-download fences are not executed here; the released-binary form -# of this gate arrives once a release ships the toolset (plan item F3). +# then replays each registered tutorial's own shell fences in its own reader +# directory. Every tutorial creates the files it needs from its documented +# commands, so what CI runs is what a reader copies. # # Usage: -# scripts/check-evidence-tutorials.sh extract, drift-check, execute -# scripts/check-evidence-tutorials.sh --dry-run extract and drift-check only +# scripts/check-evidence-tutorials.sh replay every tutorial +# scripts/check-evidence-tutorials.sh --dry-run drift-check only +# scripts/check-evidence-tutorials.sh --only one tutorial # -# Drift detection: -# - EXPECTED_SH_FENCES pins how many sh fences the tutorial holds; bump it -# when you intentionally add or remove a documented command block -# - RUNNABLE_FROM pins where the on-machine journey starts (the fences -# before it download a release and are replaced by the built binaries) -# - REQUIRED_LITERALS pins the commands and outputs the tutorial must keep -# documenting; the executed fences run verbatim, so a changed command is -# exercised as written +# Registering a tutorial means adding its slug to EVIDENCE_TUTORIALS and a +# branch to load_spec. Each spec pins: +# SPEC_FENCES how many sh fences the tutorial holds; bump it when you +# intentionally add or remove a documented command block +# SPEC_STEPS the reader journey, in order: +# run:N or run:N-M execute those sh fences +# edit:H|lang|occ|H2|lang2|occ2|target +# apply a documented before/after fence +# pair to an existing file +# SPEC_LITERALS commands and outputs the tutorial must keep documenting +# SPEC_OUTPUTS lines the replay transcript must contain # # Configuration: # EVIDENCE_BIN / EVIDENCECTL_BIN run these exact binaries instead of # building from source # EVIDENCE_TUTORIAL_CARGO_PROFILE ci (default) or release -# EVIDENCE_TUTORIAL_FILE tutorial path override (tests only) +# EVIDENCE_TUTORIAL_DOCS_ROOT tutorial directory override (tests) set -euo pipefail SITE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$SITE_ROOT/../.." && pwd)" -TUTORIAL="${EVIDENCE_TUTORIAL_FILE:-$SITE_ROOT/src/content/docs/tutorials/first-evidence-assertion.mdx}" +# A generic fence helper, not registryctl-specific: it locates a fence by +# heading, language and occurrence and applies it to a file. +HELPER="$SITE_ROOT/scripts/registryctl-tutorial.mjs" +DOCS_ROOT="${EVIDENCE_TUTORIAL_DOCS_ROOT:-$SITE_ROOT/src/content/docs/tutorials}" BUILD_PROFILE="${EVIDENCE_TUTORIAL_CARGO_PROFILE:-ci}" TARGET_DIR="$REPO_ROOT/target/evidence-tutorial-source" -EXPECTED_SH_FENCES=8 -RUNNABLE_FROM=3 -# shellcheck disable=SC2016 # the first entry is literal tutorial text, not an expansion -REQUIRED_LITERALS=( - 'evidencectl-${tag}-install.sh' - 'evidencectl new hello-evidence' - 'evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1' - 'chmod -R a-w bundle && chmod 444 runtime.yaml' - 'evidencectl fixtures run --project .' - '2 passed, 0 failed (12 cases evaluated)' +# --------------------------------------------------------------------------- +# Registered tutorials +# --------------------------------------------------------------------------- + +EVIDENCE_TUTORIALS=( + first-evidence-assertion ) +load_spec() { + SPEC_FENCES=0 + SPEC_STEPS=() + SPEC_LITERALS=() + SPEC_OUTPUTS=() + + case "$1" in + first-evidence-assertion) + SPEC_FENCES=8 + # Fences 1 and 2 download a published release; this gate substitutes the + # binaries under test, so the on-machine journey starts at fence 3. The + # released-binary form of that download arrives with plan item F3. + SPEC_STEPS=('run:3-8') + # shellcheck disable=SC2016 # the first entry is literal tutorial text + SPEC_LITERALS=( + 'evidencectl-${tag}-install.sh' + 'evidencectl new hello-evidence' + 'evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1' + 'chmod -R a-w bundle && chmod 444 runtime.yaml' + 'evidencectl fixtures run --project .' + '2 passed, 0 failed (12 cases evaluated)' + ) + SPEC_OUTPUTS=( + 'PASS: check' + 'PASS: fixtures/cases.yaml (12 cases)' + '2 passed, 0 failed (12 cases evaluated)' + ) + ;; + *) + printf '%s is not a registered Evidence tutorial\n' "$1" >&2 + exit 2 + ;; + esac +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- + DRY_RUN=0 -case "${1:-}" in -'') ;; ---dry-run) DRY_RUN=1 ;; -*) - printf 'unknown argument: %s (expected --dry-run or nothing)\n' "$1" >&2 - exit 2 - ;; -esac - -if [[ ! -f "$TUTORIAL" ]]; then - printf 'Evidence tutorial not found: %s\n' "$TUTORIAL" >&2 - exit 1 +ONLY="" +while (($# > 0)); do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --only) + if (($# < 2)); then + printf -- '--only needs a tutorial slug\n' >&2 + exit 2 + fi + ONLY="$2" + shift 2 + ;; + *) + printf 'unknown argument: %s (expected --dry-run or --only )\n' "$1" >&2 + exit 2 + ;; + esac +done + +if [[ -n "$ONLY" ]]; then + # load_spec exits on an unregistered slug, which is the check we want here. + load_spec "$ONLY" + EVIDENCE_TUTORIALS=("$ONLY") fi WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/evidence-tutorial.XXXXXX")" @@ -78,37 +133,10 @@ cleanup() { trap cleanup EXIT trap 'exit 130' HUP INT TERM -# Extract every sh fence, in order, into numbered files. -FENCE_DIR="$WORK_ROOT/fences" -mkdir -p "$FENCE_DIR" -fence_count="$(awk -v outdir="$FENCE_DIR" ' - /^```sh$/ { infence = 1; count += 1; next } - infence && /^```$/ { infence = 0; next } - infence { print > (outdir "/fence-" sprintf("%02d", count) ".sh") } - END { print count + 0 } -' "$TUTORIAL")" - -if [[ "$fence_count" -ne "$EXPECTED_SH_FENCES" ]]; then - printf 'tutorial drift: %s sh fences found, expected %s\n' \ - "$fence_count" "$EXPECTED_SH_FENCES" >&2 - printf 'Update EXPECTED_SH_FENCES and RUNNABLE_FROM in %s when the change is intentional.\n' \ - "${BASH_SOURCE[0]}" >&2 - exit 1 -fi - -for literal in "${REQUIRED_LITERALS[@]}"; do - if ! grep -F -q -- "$literal" "$TUTORIAL"; then - printf 'tutorial drift: required literal missing: %s\n' "$literal" >&2 - exit 1 - fi -done - -if ((DRY_RUN)); then - printf 'Extracted %s sh fences; every required literal present.\n' "$fence_count" - exit 0 -fi +# --------------------------------------------------------------------------- +# Toolset under test +# --------------------------------------------------------------------------- -# Resolve the toolset under test. resolve_profile_dir() { case "$BUILD_PROFILE" in ci | release) printf '%s' "$BUILD_PROFILE" ;; @@ -120,62 +148,189 @@ resolve_profile_dir() { esac } -if [[ -z "${EVIDENCE_BIN:-}" || -z "${EVIDENCECTL_BIN:-}" ]]; then - profile_dir="$(resolve_profile_dir)" - (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ - cargo build --locked --profile "$BUILD_PROFILE" \ - -p registry-evidence -p registry-evidencectl) - EVIDENCE_BIN="$TARGET_DIR/$profile_dir/evidence" - EVIDENCECTL_BIN="$TARGET_DIR/$profile_dir/evidencectl" +SHIM_DIR="$WORK_ROOT/bin" + +prepare_toolset() { + if [[ -z "${EVIDENCE_BIN:-}" || -z "${EVIDENCECTL_BIN:-}" ]]; then + local profile_dir + profile_dir="$(resolve_profile_dir)" + (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ + cargo build --locked --profile "$BUILD_PROFILE" \ + -p registry-evidence -p registry-evidencectl) + EVIDENCE_BIN="$TARGET_DIR/$profile_dir/evidence" + EVIDENCECTL_BIN="$TARGET_DIR/$profile_dir/evidencectl" + fi + local bin + for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN"; do + # Absoluteness first: the reader journey runs from its own directory and + # reaches the binaries through symlinks, so a relative path resolves + # against the wrong directory and would otherwise surface much later, + # mid-journey, as "command not found". + if [[ "$bin" != /* ]]; then + printf 'toolset binary path must be absolute: %s\n' "$bin" >&2 + exit 1 + fi + if [[ ! -x "$bin" ]]; then + printf 'toolset binary not executable: %s\n' "$bin" >&2 + exit 1 + fi + done + + # The tutorials call the binaries by name, so serve them from a shim dir. + mkdir -p "$SHIM_DIR" + ln -s "$EVIDENCE_BIN" "$SHIM_DIR/evidence" + ln -s "$EVIDENCECTL_BIN" "$SHIM_DIR/evidencectl" +} + +# --------------------------------------------------------------------------- +# Journey assembly +# --------------------------------------------------------------------------- + +# Emit the sh fences named by a run: step, in order. +emit_run_step() { + local slug="$1" range="$2" fence_dir="$3" + local first="${range%%-*}" + local last="${range##*-}" + local i fence + for ((i = first; i <= last; i++)); do + fence="$(printf '%s/fence-%02d.sh' "$fence_dir" "$i")" + if [[ ! -f "$fence" ]]; then + printf 'tutorial spec error in %s: run step names sh fence %d, which does not exist\n' \ + "$slug" "$i" >&2 + exit 2 + fi + printf '\nprintf "==> %s fence %02d\\n"\n' "$slug" "$i" + cat "$fence" + done +} + +# Emit a documented before/after fence pair applied to a file the reader edits. +emit_edit_step() { + local slug="$1" spec="$2" + local IFS='|' + # shellcheck disable=SC2206 # deliberate split on the field separator + local parts=($spec) + if ((${#parts[@]} != 7)); then + printf 'tutorial spec error in %s: edit step needs 7 fields, got %d: %s\n' \ + "$slug" "${#parts[@]}" "$spec" >&2 + exit 2 + fi + printf '\nprintf "==> %s edit %s\\n"\n' "$slug" "${parts[6]}" + # shellcheck disable=SC2016 # HELPER and TUTORIAL expand in the emitted script + printf 'node "$HELPER" replace-fence-pair "$TUTORIAL" %q %q %q %q %q %q %q\n' \ + "${parts[0]}" "${parts[1]}" "${parts[2]}" \ + "${parts[3]}" "${parts[4]}" "${parts[5]}" "${parts[6]}" +} + +emit_journey() { + local slug="$1" fence_dir="$2" tutorial_file="$3" + printf 'set -euo pipefail\n' + printf 'HELPER=%q\n' "$HELPER" + printf 'TUTORIAL=%q\n' "$tutorial_file" + local step + for step in ${SPEC_STEPS[@]+"${SPEC_STEPS[@]}"}; do + case "$step" in + run:*) emit_run_step "$slug" "${step#run:}" "$fence_dir" ;; + edit:*) emit_edit_step "$slug" "${step#edit:}" ;; + *) + printf 'tutorial spec error in %s: unknown step: %s\n' "$slug" "$step" >&2 + exit 2 + ;; + esac + done +} + +# How many sh fences a spec executes, for the summary line. +executed_fence_count() { + local step range first last total=0 + for step in ${SPEC_STEPS[@]+"${SPEC_STEPS[@]}"}; do + case "$step" in + run:*) + range="${step#run:}" + first="${range%%-*}" + last="${range##*-}" + total=$((total + last - first + 1)) + ;; + esac + done + printf '%d' "$total" +} + +# --------------------------------------------------------------------------- +# Replay +# --------------------------------------------------------------------------- + +if ((DRY_RUN == 0)); then + prepare_toolset fi -for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN"; do - # Absoluteness first: the reader journey runs from its own directory and - # reaches the binaries through symlinks, so a relative path resolves against - # the wrong directory and would otherwise surface much later, mid-journey, - # as "command not found". - if [[ "$bin" != /* ]]; then - printf 'toolset binary path must be absolute: %s\n' "$bin" >&2 + +for slug in "${EVIDENCE_TUTORIALS[@]}"; do + load_spec "$slug" + tutorial_file="$DOCS_ROOT/$slug.mdx" + if [[ ! -f "$tutorial_file" ]]; then + printf 'Evidence tutorial not found: %s\n' "$tutorial_file" >&2 exit 1 fi - if [[ ! -x "$bin" ]]; then - printf 'toolset binary not executable: %s\n' "$bin" >&2 + + # Extract every sh fence, in order, into numbered files. + fence_dir="$WORK_ROOT/fences/$slug" + mkdir -p "$fence_dir" + fence_count="$(awk -v outdir="$fence_dir" ' + /^```sh$/ { infence = 1; count += 1; next } + infence && /^```$/ { infence = 0; next } + infence { print > (outdir "/fence-" sprintf("%02d", count) ".sh") } + END { print count + 0 } + ' "$tutorial_file")" + + if [[ "$fence_count" -ne "$SPEC_FENCES" ]]; then + printf 'tutorial drift in %s: %s sh fences found, expected %s\n' \ + "$slug" "$fence_count" "$SPEC_FENCES" >&2 + printf 'Update SPEC_FENCES and SPEC_STEPS in %s when the change is intentional.\n' \ + "${BASH_SOURCE[0]}" >&2 exit 1 fi -done -# The tutorial calls the binaries by name, so serve them from a shim dir. -SHIM_DIR="$WORK_ROOT/bin" -mkdir -p "$SHIM_DIR" -ln -s "$EVIDENCE_BIN" "$SHIM_DIR/evidence" -ln -s "$EVIDENCECTL_BIN" "$SHIM_DIR/evidencectl" - -# Replay the on-machine journey: every fence from RUNNABLE_FROM onward, in -# order, in one shell so `cd` persists exactly as a reader experiences it. -READER_DIR="$WORK_ROOT/reader" -mkdir -p "$READER_DIR" -RUN_SCRIPT="$WORK_ROOT/run.sh" -{ - printf 'set -euo pipefail\n' - for ((i = RUNNABLE_FROM; i <= EXPECTED_SH_FENCES; i++)); do - printf '\nprintf "==> tutorial fence %02d\\n"\n' "$i" - cat "$(printf '%s/fence-%02d.sh' "$FENCE_DIR" "$i")" + for literal in ${SPEC_LITERALS[@]+"${SPEC_LITERALS[@]}"}; do + if ! grep -F -q -- "$literal" "$tutorial_file"; then + printf 'tutorial drift in %s: required literal missing: %s\n' \ + "$slug" "$literal" >&2 + exit 1 + fi done -} >"$RUN_SCRIPT" -RUN_LOG="$WORK_ROOT/run.log" -if ! (cd "$READER_DIR" && PATH="$SHIM_DIR:$PATH" bash "$RUN_SCRIPT") 2>&1 | - tee "$RUN_LOG"; then - printf 'tutorial execution failed; the transcript ends just before this line\n' >&2 - exit 1 -fi + printf '%s: %s sh fences, %s executed, %s required literals present\n' \ + "$slug" "$fence_count" "$(executed_fence_count)" "${#SPEC_LITERALS[@]}" -for expected in \ - 'PASS: check' \ - 'PASS: fixtures/cases.yaml (12 cases)' \ - '2 passed, 0 failed (12 cases evaluated)'; do - if ! grep -F -q -- "$expected" "$RUN_LOG"; then - printf 'tutorial output drift: expected "%s" in the fixtures run output\n' \ - "$expected" >&2 + if ((DRY_RUN)); then + continue + fi + + # Replay the journey in one shell so `cd` persists exactly as a reader + # experiences it, from a reader directory of this tutorial's own. + reader_dir="$WORK_ROOT/reader/$slug" + mkdir -p "$reader_dir" + run_script="$WORK_ROOT/run-$slug.sh" + emit_journey "$slug" "$fence_dir" "$tutorial_file" >"$run_script" + + run_log="$WORK_ROOT/run-$slug.log" + if ! (cd "$reader_dir" && PATH="$SHIM_DIR:$PATH" bash "$run_script") 2>&1 | + tee "$run_log"; then + printf 'tutorial %s failed; the transcript ends just before this line\n' \ + "$slug" >&2 exit 1 fi + + for expected in ${SPEC_OUTPUTS[@]+"${SPEC_OUTPUTS[@]}"}; do + if ! grep -F -q -- "$expected" "$run_log"; then + printf 'tutorial output drift in %s: expected "%s" in the transcript\n' \ + "$slug" "$expected" >&2 + exit 1 + fi + done done + +if ((${#EVIDENCE_TUTORIALS[@]} == 1)); then + printf 'Checked 1 tutorial.\n' +else + printf 'Checked %d tutorials.\n' "${#EVIDENCE_TUTORIALS[@]}" +fi diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs index cd0ff5b3e..19f1e20bf 100644 --- a/docs/site/scripts/check-evidence-tutorials.test.mjs +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import test from 'node:test'; @@ -10,10 +10,8 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const scriptDir = dirname(fileURLToPath(import.meta.url)); const gate = resolve(scriptDir, 'check-evidence-tutorials.sh'); -const tutorial = resolve( - scriptDir, - '../src/content/docs/tutorials/first-evidence-assertion.mdx', -); +const docsRoot = resolve(scriptDir, '../src/content/docs/tutorials'); +const firstTutorial = 'first-evidence-assertion.mdx'; async function runGate(env = {}, args = ['--dry-run']) { try { @@ -26,28 +24,36 @@ async function runGate(env = {}, args = ['--dry-run']) { } } -test('the dry-run gate passes against the published tutorial', async () => { +// Copy the published tutorials into a scratch docs root so a test can tamper +// with one without touching the tree. +async function scratchDocsRoot() { + const root = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + await cp(docsRoot, root, { recursive: true }); + return root; +} + +test('the dry-run gate passes against every registered tutorial', async () => { const { code, output } = await runGate(); assert.equal(code, 0, output); - assert.match(output, /Extracted 8 sh fences/u); + assert.match(output, /first-evidence-assertion: 8 sh fences/u); }); test('removing a documented command block fails the drift check', async () => { - const workDir = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + const root = await scratchDocsRoot(); try { - const source = await readFile(tutorial, 'utf8'); + const target = join(root, firstTutorial); + const source = await readFile(target, 'utf8'); const tampered = source.replace( 'evidencectl fixtures run --project .', 'evidencectl fixtures run', ); assert.notEqual(tampered, source, 'the tampering target must exist'); - const copy = join(workDir, 'tampered.mdx'); - await writeFile(copy, tampered); - const { code, output } = await runGate({ EVIDENCE_TUTORIAL_FILE: copy }); + await writeFile(target, tampered); + const { code, output } = await runGate({ EVIDENCE_TUTORIAL_DOCS_ROOT: root }); assert.notEqual(code, 0, 'a missing required literal must fail the gate'); assert.match(output, /required literal missing/u); } finally { - await rm(workDir, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); } }); @@ -64,15 +70,43 @@ test('a relative toolset binary path is refused before anything runs', async () }); test('changing the fence count fails the drift check', async () => { - const workDir = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + const root = await scratchDocsRoot(); try { - const source = await readFile(tutorial, 'utf8'); - const copy = join(workDir, 'extra-fence.mdx'); - await writeFile(copy, `${source}\n\`\`\`sh\necho extra\n\`\`\`\n`); - const { code, output } = await runGate({ EVIDENCE_TUTORIAL_FILE: copy }); + const target = join(root, firstTutorial); + const source = await readFile(target, 'utf8'); + await writeFile(target, `${source}\n\`\`\`sh\necho extra\n\`\`\`\n`); + const { code, output } = await runGate({ EVIDENCE_TUTORIAL_DOCS_ROOT: root }); assert.notEqual(code, 0, 'an added fence must fail the count check'); assert.match(output, /sh fences found, expected/u); } finally { - await rm(workDir, { recursive: true, force: true }); + await rm(root, { recursive: true, force: true }); + } +}); + +test('a registered tutorial that is not on disk fails by name', async () => { + const root = await mkdtemp(join(tmpdir(), 'evidence-tutorial-test-')); + try { + const { code, output } = await runGate({ EVIDENCE_TUTORIAL_DOCS_ROOT: root }); + assert.notEqual(code, 0, 'a missing tutorial must fail the gate'); + assert.match(output, /first-evidence-assertion/u); + assert.match(output, /not found/u); + } finally { + await rm(root, { recursive: true, force: true }); } }); + +test('--only refuses a slug that is not registered', async () => { + const { code, output } = await runGate({}, ['--dry-run', '--only', 'no-such-tutorial']); + assert.notEqual(code, 0, 'an unregistered slug must fail the gate'); + assert.match(output, /not a registered Evidence tutorial/u); +}); + +test('--only narrows the run to one registered tutorial', async () => { + const { code, output } = await runGate({}, [ + '--dry-run', + '--only', + 'first-evidence-assertion', + ]); + assert.equal(code, 0, output); + assert.match(output, /Checked 1 tutorial\./u); +}); From 08e7c1ef4e5556509ac1ed65dfd0165dc98c62c8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:25:12 +0700 Subject: [PATCH 061/136] ci: keep Evidence tutorial routing in step with the gate's registry The Evidence tutorial job watched one tutorial page by name. The onboarding track is a series of them, so the next tutorial added would not have triggered the job that replays it, and could have broken with no pull request noticing. The watched inputs become a named constant, and a test reads the gate's own tutorial registry and asserts every registered slug is watched. Adding a tutorial to the gate without wiring its page here now fails, naming the path that is missing. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 31 +++++++++++++++++------------- .github/scripts/test_ci_changes.py | 23 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 2f65db7d4..ae7a469de 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -61,6 +61,23 @@ for package in SHARDS[shard] ) | {"registry-config-report"} +# Every input the Evidence tutorial gate replays or is built from. The tutorial +# pages here must stay in step with the gate's own registry, which +# test_ci_changes.py enforces: a tutorial CI does not watch is a tutorial that +# rots silently. +EVIDENCE_TUTORIAL_INPUTS = frozenset( + { + "Cargo.lock", + "Cargo.toml", + "docs/site/package-lock.json", + "docs/site/package.json", + "docs/site/scripts/check-evidence-tutorials.sh", + "docs/site/scripts/check-evidence-tutorials.test.mjs", + "docs/site/scripts/registryctl-tutorial.mjs", + "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", + } +) + ROOT_RUST_INPUTS = { "Cargo.lock", "Cargo.toml", @@ -510,19 +527,7 @@ def classify( evidence_tutorial = ( complete - or any( - path - in { - "Cargo.lock", - "Cargo.toml", - "docs/site/package-lock.json", - "docs/site/package.json", - "docs/site/scripts/check-evidence-tutorials.sh", - "docs/site/scripts/check-evidence-tutorials.test.mjs", - "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", - } - for path in paths - ) + or any(path in EVIDENCE_TUTORIAL_INPUTS for path in paths) or bool(affected & EVIDENCE_PACKAGES) ) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 35dac8430..cfa5c95b5 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -14,6 +14,7 @@ from ci_changes import ( AUTHORING_REFERENCE_CONTRACT_SOURCES, AUTHORING_REFERENCE_INPUTS, + EVIDENCE_TUTORIAL_INPUTS, RELEASE_SECURITY_WORKFLOWS, SHARDS, Workspace, @@ -58,6 +59,28 @@ def test_example_pr_runs_only_affected_rust_shards(self) -> None: self.assertTrue(outputs["registryctl_tutorial"]) self.assertFalse(outputs["platform"]) + def test_evidence_tutorial_inputs_cover_every_registered_tutorial(self) -> None: + # The gate's registry is the source of truth for which tutorials exist. + # A tutorial missing here would not trigger the job that replays it, so + # it could break without any pull request noticing. + gate = ( + Path(__file__).resolve().parents[2] + / "docs/site/scripts/check-evidence-tutorials.sh" + ) + registry = re.search( + r"^EVIDENCE_TUTORIALS=\((.*?)^\)", gate.read_text(), re.DOTALL | re.MULTILINE + ) + if registry is None: + self.fail("the gate must declare EVIDENCE_TUTORIALS") + slugs = registry.group(1).split() + self.assertTrue(slugs, "the gate must register at least one tutorial") + for slug in slugs: + with self.subTest(slug=slug): + self.assertIn( + f"docs/site/src/content/docs/tutorials/{slug}.mdx", + EVIDENCE_TUTORIAL_INPUTS, + ) + def test_evidence_tutorial_routing(self) -> None: infrastructure = ( "docs/site/scripts/check-evidence-tutorials.sh", From fb2a58b70adc58ee03b9b60f93e4754f0f224479 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:29:19 +0700 Subject: [PATCH 062/136] feat(evidencectl): fetch the OpenAPI document for source suggest from a URL Registry APIs publish their description at a well-known URL far more often than they ship it as a file, so `--openapi` now takes either. Nothing about the draft changes: it is still reviewed by a human and still carries a TODO wherever a bound could not be derived. Security review note. The description decides which leaves the operator is offered, and the projection they pick from it is the data-minimization boundary of the finished source, so a tampered document could quietly widen what a deployment reads. The rule applied is the one the runtime already enforces for the source URLs it will itself call (`validate_source_url`): https anywhere, plain http only to a numeric loopback host, never a credential in the authority. The credential refusal does not echo the URL back, so a password cannot reach a terminal or a scrollback buffer. No request carries authentication; a description behind a token is fetched by the operator and passed as a file. Redirects are followed but bounded, and the URL the response came from is re-checked under the same rule. The body is capped while reading rather than from Content-Length. Fetched text reaches only the draft the operator sees. Transport is ureq, already a workspace dependency used by registryctl, so no crate enters the graph and cargo-deny is unaffected. Tests are hermetic: policy is a pure function, and transport runs against a throwaway server on a loopback port, which is also the one case where plain http is permitted. Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-evidencectl/Cargo.toml | 2 + .../registry-evidencectl/src/suggest/emit.rs | 9 +- .../registry-evidencectl/src/suggest/fetch.rs | 158 +++++++++++ .../registry-evidencectl/src/suggest/mod.rs | 13 +- .../src/suggest/openapi.rs | 93 +++--- .../registry-evidencectl/src/suggest/types.rs | 26 +- .../tests/suggest_emit.rs | 9 +- .../tests/suggest_fetch.rs | 265 ++++++++++++++++++ .../tests/suggest_openapi.rs | 91 +++--- 10 files changed, 557 insertions(+), 111 deletions(-) create mode 100644 crates/registry-evidencectl/src/suggest/fetch.rs create mode 100644 crates/registry-evidencectl/tests/suggest_fetch.rs diff --git a/Cargo.lock b/Cargo.lock index 947b3a831..467240afd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5507,6 +5507,8 @@ dependencies = [ "serde_json", "serde_norway", "tempfile", + "ureq", + "url", "zeroize", ] diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 11ae68e96..dd54d1a4d 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -26,6 +26,8 @@ registry-platform-crypto.workspace = true serde.workspace = true serde_json.workspace = true serde_norway.workspace = true +ureq.workspace = true +url.workspace = true zeroize.workspace = true [dev-dependencies] diff --git a/crates/registry-evidencectl/src/suggest/emit.rs b/crates/registry-evidencectl/src/suggest/emit.rs index 3fb5e1975..1826888f4 100644 --- a/crates/registry-evidencectl/src/suggest/emit.rs +++ b/crates/registry-evidencectl/src/suggest/emit.rs @@ -21,7 +21,7 @@ use serde_json::Value; use super::types::{ BoundKind, BoundNeed, BoundValues, DraftArtifacts, DraftFile, NarrowOutcome, OperationKey, - Provenance, SuggestedBound, + Provenance, SpecSource, SuggestedBound, }; /// Everything the emit stage needs to draft artifacts for one source. Built @@ -52,8 +52,9 @@ pub struct EmitInputs { /// comment; a need still unresolved is already covered by /// `narrowed.unresolved` and gets a TODO comment instead. pub needs: Vec, - /// The OpenAPI document path, echoed back in `equivalent_command`. - pub openapi_path: PathBuf, + /// Where the OpenAPI document was read from, echoed back in + /// `equivalent_command`. + pub openapi: SpecSource, /// The sample file path, if one was used, echoed back in /// `equivalent_command`. pub sample_path: Option, @@ -1369,7 +1370,7 @@ fn render_equivalent_command(inputs: &EmitInputs) -> String { ]; parts.push("--openapi".to_owned()); - parts.push(shell_quote(&path_display(&inputs.openapi_path))); + parts.push(shell_quote(&inputs.openapi.display())); parts.push("--operation".to_owned()); parts.push(shell_quote(&format!( "{} {}", diff --git a/crates/registry-evidencectl/src/suggest/fetch.rs b/crates/registry-evidencectl/src/suggest/fetch.rs new file mode 100644 index 000000000..49f65883a --- /dev/null +++ b/crates/registry-evidencectl/src/suggest/fetch.rs @@ -0,0 +1,158 @@ +//! Reads an OpenAPI document that is published at a URL rather than sitting +//! on disk. +//! +//! Registry APIs publish their description at a well-known URL far more often +//! than they ship it as a file, and making the operator fetch it by hand adds +//! a step that only invites a stale copy. Fetching it here is read-only and +//! touches nothing the runtime will later run: the draft is still reviewed by +//! a human and still carries a `TODO` wherever a bound could not be derived. +//! +//! What a URL does change is trust. The description decides which leaves the +//! operator is offered, and the projection they pick from it is the +//! data-minimization boundary of the finished source, so a tampered document +//! could quietly widen what a deployment reads. The rule applied here is +//! therefore the one the runtime already enforces for the source URLs it will +//! itself call: plain `http` only to a numeric loopback host, `https` +//! everywhere else, and never any credential in the authority. Two tools +//! reading the same rule is one rule to review. +//! +//! Nothing fetched is written anywhere except the draft the operator sees, +//! and no request carries authentication. A description behind a token is +//! fetched by the operator with their own client and passed as a file. + +use std::{io::Read, path::PathBuf, time::Duration}; + +use anyhow::{bail, Context, Result}; +use url::{Host, Url}; + +use super::types::SpecSource; + +/// How long a connection may take to establish, and how long the whole +/// exchange may take. A description is a single small document from a server +/// the operator chose; a request still running after this is a wedged host, +/// and failing beats hanging a terminal indefinitely. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Redirects are followed because a published description is very often a +/// stable URL pointing at a versioned one. The count is bounded so a +/// redirect loop fails rather than spins. +const MAX_REDIRECTS: u32 = 5; + +/// Decides what a `--openapi` argument names, and rejects a URL this tool +/// will not fetch before anything is read or any question is asked. +pub fn spec_source(value: &str) -> Result { + if looks_like_url(value) { + return Ok(SpecSource::Url(check_url(value)?)); + } + Ok(SpecSource::File(PathBuf::from(value))) +} + +/// Whether `value` names a location to fetch rather than a file to open. +/// +/// The test is for a URL scheme at the front, not for `://` anywhere in the +/// string, so a local path that merely contains those characters still opens +/// as a path. Any scheme is detected, not only the two that are permitted: +/// `ftp://spec.yaml` is a URL the operator meant as a URL, and telling them +/// the scheme is not permitted is more use than reporting that no such file +/// exists. +pub fn looks_like_url(value: &str) -> bool { + let Some((scheme, rest)) = value.split_once("://") else { + return false; + }; + !scheme.is_empty() + && !rest.is_empty() + && scheme.starts_with(|first: char| first.is_ascii_alphabetic()) + && scheme + .chars() + .all(|character| character.is_ascii_alphanumeric() || "+-.".contains(character)) +} + +/// Parses `value` and accepts it only if it is a URL this tool will fetch. +/// +/// Applied to the URL the operator passed and again to the URL the response +/// actually came from, so a document that arrived over a hop the operator +/// would not have permitted is refused rather than drafted from. The redirect +/// has already been followed by the time the second check runs; what it +/// prevents is reading a description whose last hop was open to tampering. +/// Checking the final URL is enough for that: an intermediate hop can only be +/// introduced by a server whose own response was already protected by the +/// scheme of the hop before it. +pub fn check_url(value: &str) -> Result { + let url = Url::parse(value).with_context(|| format!("parsing `{value}` as a URL"))?; + + // Reported without quoting the URL back: the thing that makes it + // unacceptable is the credential inside it, and echoing it to a terminal + // or a scrollback buffer is exactly what should not happen to a secret. + if !url.username().is_empty() || url.password().is_some() { + bail!( + "the OpenAPI URL carries credentials in its authority; pass a URL without them, and \ + fetch a description that needs authentication with your own client and pass the file" + ); + } + + match url.scheme() { + "https" => {} + "http" => match url.host() { + Some(Host::Ipv4(address)) if address.is_loopback() => {} + Some(Host::Ipv6(address)) if address.is_loopback() => {} + _ => bail!( + "`{url}` is plain http to a host that is not loopback; a description read in the \ + clear can be tampered with, and it decides what the drafted projection reads. \ + Use https, or a numeric loopback host such as `http://127.0.0.1:8080/...` for a \ + local server" + ), + }, + scheme => bail!("`{scheme}` URLs are not fetched; pass an https URL, or a local file path"), + } + Ok(url) +} + +/// Fetches `url` and returns the document text, refusing a body past +/// `max_bytes`. +/// +/// The limit is enforced while reading rather than from `Content-Length`, so +/// a response that never declares a length cannot talk this into buffering +/// whatever the server feels like sending. +pub fn get(url: &Url, max_bytes: u64) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .redirects(MAX_REDIRECTS) + .user_agent(concat!("evidencectl/", env!("CARGO_PKG_VERSION"))) + .build(); + + let response = match agent + .get(url.as_str()) + .set( + "Accept", + "application/yaml, application/json, text/yaml, */*", + ) + .call() + { + Ok(response) => response, + Err(ureq::Error::Status(status, _)) => bail!( + "fetching {url} returned HTTP {status}; a description behind authentication has to be \ + fetched with your own client and passed as a file" + ), + Err(error) => return Err(error).with_context(|| format!("fetching {url}")), + }; + + let landed = response.get_url().to_owned(); + if landed != url.as_str() { + check_url(&landed) + .with_context(|| format!("{url} redirected to a URL that is not read"))?; + } + + let mut body = Vec::new(); + response + .into_reader() + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut body) + .with_context(|| format!("reading the response body of {url}"))?; + if body.len() as u64 > max_bytes { + bail!("the document at {url} exceeds the {max_bytes} byte limit"); + } + + String::from_utf8(body).with_context(|| format!("decoding the document at {url} as UTF-8")) +} diff --git a/crates/registry-evidencectl/src/suggest/mod.rs b/crates/registry-evidencectl/src/suggest/mod.rs index 47874b94c..af224ea11 100644 --- a/crates/registry-evidencectl/src/suggest/mod.rs +++ b/crates/registry-evidencectl/src/suggest/mod.rs @@ -9,6 +9,7 @@ //! and ends by printing the equivalent fully-flagged command. pub mod emit; +pub mod fetch; pub mod flatten; pub mod interactive; pub mod narrow; @@ -36,9 +37,10 @@ pub enum SourceCommand { #[derive(Debug, Args)] pub struct SuggestArgs { - /// OpenAPI 3.0 or 3.1 document (YAML or JSON, local file only). + /// OpenAPI 3.0 or 3.1 document (YAML or JSON): a local file path, or an + /// https URL to fetch it from. #[arg(long)] - pub openapi: std::path::PathBuf, + pub openapi: String, /// Operation as "METHOD /path/template"; interactive selection if absent. #[arg(long)] @@ -104,12 +106,13 @@ const FALLBACK_SOURCE_ID: &str = "source-a"; /// command reproduces either run exactly, because every suggestion is /// derived deterministically from the same inputs. fn suggest(args: SuggestArgs) -> Result { - let spec = Spec::load(&args.openapi)?; + let source = fetch::spec_source(&args.openapi)?; + let spec = Spec::open(&source)?; let operations = spec.operations(); if operations.is_empty() { bail!( "{} declares no operation with a JSON response schema; there is nothing to draft from", - args.openapi.display() + source.display() ); } @@ -245,7 +248,7 @@ fn suggest(args: SuggestArgs) -> Result { selection: decisions.selection.clone(), narrowed, needs, - openapi_path: args.openapi.clone(), + openapi: source.clone(), sample_path: args.sample.clone(), project: args.project.clone(), }; diff --git a/crates/registry-evidencectl/src/suggest/openapi.rs b/crates/registry-evidencectl/src/suggest/openapi.rs index ffa301631..40f630afc 100644 --- a/crates/registry-evidencectl/src/suggest/openapi.rs +++ b/crates/registry-evidencectl/src/suggest/openapi.rs @@ -1,9 +1,11 @@ -//! Loads a local OpenAPI 3.0.x or 3.1.x document and resolves the pieces the -//! `source suggest` pipeline needs: operation listings and one operation's -//! response schema with every local `$ref` inlined. +//! Loads an OpenAPI 3.0.x or 3.1.x document, from a file or from a URL (see +//! [`super::fetch`]), and resolves the pieces the `source suggest` pipeline +//! needs: operation listings and one operation's response schema with every +//! local `$ref` inlined. //! -//! Only local files are read and only local `#/components/...` refs are -//! followed. An external or remote `$ref` (anything not starting with `#/`) +//! Only local `#/components/...` refs are followed, whichever way the +//! document arrived: one document is fetched, never a graph of them. An +//! external or remote `$ref` (anything not starting with `#/`) //! is rejected with a clear error rather than silently truncated or ignored, //! because a partially-resolved schema would let the closed-subset narrowing //! stage draft against data the runtime cannot actually see. A `$ref` cycle @@ -23,8 +25,9 @@ use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; +use super::fetch; use super::types::{ - OperationKey, OperationSummary, ResolvedResponse, ResolvedSchema, RECURSIVE_REF_KEY, + OperationKey, OperationSummary, ResolvedResponse, ResolvedSchema, SpecSource, RECURSIVE_REF_KEY, }; /// Path Item Object keys this pipeline can draft a source from. @@ -85,45 +88,31 @@ pub struct Spec { } impl Spec { - /// Reads and parses the OpenAPI document at `path`. Accepts YAML or - /// JSON (YAML is a superset for this purpose, so both are parsed the - /// same way) and requires a top-level `openapi: 3.0.x` or `3.1.x` - /// version string. No network access is performed; `path` must name a - /// local file. - pub fn load(path: &Path) -> Result { - if let Some(url) = looks_like_url(path) { - bail!( - "`{url}` is a URL; this reads a local file only. Fetch the document first, \ - for example `curl -sSL -o openapi.yaml {url}`, then pass the file" - ); - } - let metadata = std::fs::metadata(path) - .with_context(|| format!("reading OpenAPI document metadata at {}", path.display()))?; - if metadata.len() > MAX_DOCUMENT_BYTES { - bail!( - "OpenAPI document at {} is {} bytes, exceeding the {} byte limit", - path.display(), - metadata.len(), - MAX_DOCUMENT_BYTES - ); - } - let text = std::fs::read_to_string(path) - .with_context(|| format!("reading OpenAPI document at {}", path.display()))?; - let document: Value = serde_norway::from_str(&text) - .with_context(|| format!("parsing {} as YAML or JSON", path.display()))?; + /// Reads and parses the OpenAPI document `source` names, from disk or + /// from the network. Accepts YAML or JSON (YAML is a superset for this + /// purpose, so both are parsed the same way) and requires a top-level + /// `openapi: 3.0.x` or `3.1.x` version string. + pub fn open(source: &SpecSource) -> Result { + let text = match source { + SpecSource::File(path) => read_local(path)?, + SpecSource::Url(url) => fetch::get(url, MAX_DOCUMENT_BYTES)?, + }; + Spec::parse(&text, &source.display()) + } + + /// Parses one already-read document, naming it `origin` in any error so + /// the message points at the file path or URL the operator passed rather + /// than at a buffer. + fn parse(text: &str, origin: &str) -> Result { + let document: Value = serde_norway::from_str(text) + .with_context(|| format!("parsing {origin} as YAML or JSON"))?; let version = document .get("openapi") .and_then(Value::as_str) - .ok_or_else(|| { - anyhow!( - "{} has no top-level `openapi` version string", - path.display() - ) - })?; + .ok_or_else(|| anyhow!("{origin} has no top-level `openapi` version string"))?; if !(version.starts_with("3.0.") || version.starts_with("3.1.")) { bail!( - "{} declares `openapi: {version}`; only OpenAPI 3.0.x and 3.1.x are supported", - path.display() + "{origin} declares `openapi: {version}`; only OpenAPI 3.0.x and 3.1.x are supported" ); } Ok(Spec { document }) @@ -629,15 +618,21 @@ fn infer_structural_type( )); } -/// Whether `path` was written as a URL rather than a file to read, returned as -/// the text to quote back. Checked against the scheme rather than the whole -/// string so a local path merely containing `://` is not mistaken for one. -fn looks_like_url(path: &Path) -> Option<&str> { - let text = path.to_str()?; - ["http://", "https://"] - .into_iter() - .any(|scheme| text.starts_with(scheme)) - .then_some(text) +/// Reads a local document, refusing one past the size ceiling before any of +/// it is read into memory. +fn read_local(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("reading OpenAPI document metadata at {}", path.display()))?; + if metadata.len() > MAX_DOCUMENT_BYTES { + bail!( + "OpenAPI document at {} is {} bytes, exceeding the {} byte limit", + path.display(), + metadata.len(), + MAX_DOCUMENT_BYTES + ); + } + std::fs::read_to_string(path) + .with_context(|| format!("reading OpenAPI document at {}", path.display())) } /// Escapes an object member name into one RFC 6901 pointer segment, matching diff --git a/crates/registry-evidencectl/src/suggest/types.rs b/crates/registry-evidencectl/src/suggest/types.rs index 40fd8c16b..42a02e8cb 100644 --- a/crates/registry-evidencectl/src/suggest/types.rs +++ b/crates/registry-evidencectl/src/suggest/types.rs @@ -7,7 +7,31 @@ //! through the types here so the interactive front-end and the flag-driven //! front-end share one deterministic core. -use std::collections::BTreeMap; +use std::{collections::BTreeMap, path::PathBuf}; + +/// Where the OpenAPI document is read from. +/// +/// The two cases stay distinguishable all the way to the reproduce line, so +/// the command printed at the end of a run names the same document the run +/// actually read. Deciding which case a `--openapi` argument is happens once, +/// before anything is read, so an unusable URL fails before the operator is +/// asked a single question. +#[derive(Debug, Clone)] +pub enum SpecSource { + File(PathBuf), + Url(url::Url), +} + +impl SpecSource { + /// The document as it should be named in a message or echoed back in the + /// reproduce command. + pub fn display(&self) -> String { + match self { + SpecSource::File(path) => path.to_string_lossy().into_owned(), + SpecSource::Url(url) => url.to_string(), + } + } +} /// One operation in the OpenAPI document: an uppercase HTTP method and the /// literal path template, e.g. `GET` and `/records/{id}`. diff --git a/crates/registry-evidencectl/tests/suggest_emit.rs b/crates/registry-evidencectl/tests/suggest_emit.rs index 9e540afda..6aea87374 100644 --- a/crates/registry-evidencectl/tests/suggest_emit.rs +++ b/crates/registry-evidencectl/tests/suggest_emit.rs @@ -24,7 +24,8 @@ use serde_json::json; use emit::{CheckClassification, EmitInputs}; use types::{ - BoundKind, BoundNeed, BoundValues, NarrowOutcome, OperationKey, Provenance, SuggestedBound, + BoundKind, BoundNeed, BoundValues, NarrowOutcome, OperationKey, Provenance, SpecSource, + SuggestedBound, }; /// A schema exercising every case the response-schema renderer must handle: @@ -126,7 +127,7 @@ fn base_inputs() -> EmitInputs { ], narrowed: narrow_outcome_fixture(), needs: needs_fixture(), - openapi_path: PathBuf::from("tests/fixtures/openapi/example.yaml"), + openapi: SpecSource::File(PathBuf::from("tests/fixtures/openapi/example.yaml")), sample_path: None, project: None, } @@ -820,7 +821,7 @@ fn equivalent_command_is_deterministic_with_the_documented_flag_order() { fn the_reproduce_command_quotes_every_value_a_shell_would_rewrite() { let mut inputs = base_inputs(); inputs.selection = vec!["/results/*/status".to_owned(), "/a?b".to_owned()]; - inputs.openapi_path = PathBuf::from("/tmp/spec (copy).yaml"); + inputs.openapi = SpecSource::File(PathBuf::from("/tmp/spec (copy).yaml")); let command = emit::draft(&inputs).expect("draft").equivalent_command; assert!( @@ -911,7 +912,7 @@ fn verify_classifies_a_runtime_initialization_message_as_secrets_unprovisioned() #[test] fn the_reproduce_line_quotes_shell_expansion_characters() { let mut inputs = base_inputs(); - inputs.openapi_path = PathBuf::from("/srv/specs/$HOME/records`id`.yaml"); + inputs.openapi = SpecSource::File(PathBuf::from("/srv/specs/$HOME/records`id`.yaml")); let artifacts = emit::draft(&inputs).expect("draft"); assert!( diff --git a/crates/registry-evidencectl/tests/suggest_fetch.rs b/crates/registry-evidencectl/tests/suggest_fetch.rs new file mode 100644 index 000000000..fd2096112 --- /dev/null +++ b/crates/registry-evidencectl/tests/suggest_fetch.rs @@ -0,0 +1,265 @@ +//! Reading an OpenAPI document from a URL: which URLs are fetched at all, and +//! what happens to the response. +//! +//! Every test here is offline. The policy half is a pure function over a URL +//! string, and the transport half runs against a throwaway HTTP server bound +//! to a loopback port in this process, which is also the one case where plain +//! `http` is permitted. Nothing in this file reaches a public host, so the +//! suite stays hermetic and runs the same on a machine with no network. + +#[path = "../src/suggest/fetch.rs"] +mod fetch; +// `openapi.rs` pulls in the whole pipeline's type vocabulary; this binary +// exercises the loading slice of it. +#[allow(dead_code)] +#[path = "../src/suggest/openapi.rs"] +mod openapi; +#[allow(dead_code)] +#[path = "../src/suggest/types.rs"] +mod types; + +use std::{ + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::Path, +}; + +use types::SpecSource; + +// --- which URLs are fetched -------------------------------------------------- + +#[test] +fn an_https_url_is_read_as_a_url() { + let source = fetch::spec_source("https://api.example.test/openapi.yaml").expect("accepted"); + assert!(matches!(source, SpecSource::Url(_))); + assert_eq!(source.display(), "https://api.example.test/openapi.yaml"); +} + +/// The URL test is for a scheme at the front of the argument, so an ordinary +/// path is still a path even when it contains the characters a URL uses. +#[test] +fn a_path_is_read_as_a_path() { + for argument in [ + "openapi.yaml", + "./specs/openapi.yaml", + "/srv/specs/openapi.yaml", + "specs/http://not-a-url.yaml", + ] { + let source = fetch::spec_source(argument).expect("accepted"); + assert!( + matches!(source, SpecSource::File(_)), + "`{argument}` should be read as a file" + ); + } +} + +/// A description read in the clear can be tampered with, and it decides which +/// leaves the operator is offered, so the projection a drafted source ends up +/// reading is only as trustworthy as the transport that carried it. This is +/// the same rule the runtime applies to the source URLs it will itself call. +#[test] +fn plain_http_to_a_host_that_is_not_loopback_is_refused() { + for url in [ + "http://api.example.test/openapi.yaml", + "http://192.0.2.10/openapi.yaml", + "http://localhost:3000/openapi.yaml", + ] { + let error = fetch::spec_source(url).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("loopback"), + "`{url}` message was: {message}" + ); + } +} + +#[test] +fn plain_http_to_a_numeric_loopback_host_is_accepted() { + for url in [ + "http://127.0.0.1:8080/openapi.yaml", + "http://[::1]:8080/openapi.yaml", + ] { + let source = fetch::spec_source(url).unwrap_or_else(|error| panic!("{url}: {error:#}")); + assert!(matches!(source, SpecSource::Url(_)), "`{url}` was refused"); + } +} + +/// The refusal must not quote the URL back: what makes it unacceptable is the +/// credential inside it, and a message is printed to a terminal and kept in a +/// scrollback buffer. +#[test] +fn a_url_carrying_credentials_is_refused_without_echoing_them() { + let error = + fetch::spec_source("https://reader:hunter2@api.example.test/openapi.yaml").unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("credentials"), "message was: {message}"); + assert!( + !message.contains("hunter2") && !message.contains("reader"), + "the refusal echoed the credential: {message}" + ); +} + +#[test] +fn a_scheme_that_is_not_http_is_refused() { + for url in [ + "ftp://example.test/openapi.yaml", + "file:///tmp/openapi.yaml", + ] { + let error = fetch::spec_source(url).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("not fetched"), + "`{url}` message was: {message}" + ); + } +} + +// --- what happens to the response -------------------------------------------- + +#[test] +fn a_document_served_over_loopback_is_opened_exactly_as_the_file_would_be() { + let fixture = fixture_text("records-3.0.yaml"); + let address = serve(move |_target| ok(&fixture, "application/yaml")); + + let from_url = openapi::Spec::open(&url_source(address, "/openapi.yaml")).expect("opens"); + let from_file = + openapi::Spec::open(&SpecSource::File(fixture_path("records-3.0.yaml"))).expect("opens"); + + assert_eq!( + from_url + .operations() + .into_iter() + .map(|summary| summary.key) + .collect::>(), + from_file + .operations() + .into_iter() + .map(|summary| summary.key) + .collect::>() + ); +} + +/// A description behind authentication answers with a status, not a document. +/// Saying which status came back is what tells the operator to fetch it +/// themselves rather than to go looking for a malformed file. +#[test] +fn a_non_success_status_is_reported_with_its_code() { + let address = serve(|_target| { + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + }); + + let error = openapi::Spec::open(&url_source(address, "/openapi.yaml")).unwrap_err(); + let message = format!("{error:#}"); + assert!( + message.contains("401") && message.contains("authentication"), + "message was: {message}" + ); +} + +/// The ceiling is enforced while reading rather than from the declared length, +/// so a response that never says how long it is cannot talk this into +/// buffering whatever the server feels like sending. +#[test] +fn a_body_past_the_ceiling_is_refused() { + let declared = serve(|_target| ok(&"x".repeat(4096), "application/yaml")); + let undeclared = serve(|_target| { + let mut response = + b"HTTP/1.1 200 OK\r\nContent-Type: application/yaml\r\nConnection: close\r\n\r\n" + .to_vec(); + response.extend_from_slice("x".repeat(4096).as_bytes()); + response + }); + + for address in [declared, undeclared] { + let SpecSource::Url(url) = url_source(address, "/openapi.yaml") else { + unreachable!("built as a URL") + }; + let error = fetch::get(&url, 512).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("512 byte limit"), "message was: {message}"); + } +} + +/// A published description is very often a stable URL pointing at a versioned +/// one, so a redirect is followed; where it lands is checked under the same +/// rule as where it started. +#[test] +fn a_redirect_is_followed_and_its_destination_is_read() { + let fixture = fixture_text("records-3.0.yaml"); + let destination = serve(move |_target| ok(&fixture, "application/yaml")); + let entry = serve(move |_target| { + format!( + "HTTP/1.1 302 Found\r\nLocation: http://{destination}/versioned.yaml\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes() + }); + + let spec = openapi::Spec::open(&url_source(entry, "/latest.yaml")).expect("opens"); + assert!(!spec.operations().is_empty()); +} + +// --- helpers ----------------------------------------------------------------- + +fn fixture_path(name: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/openapi") + .join(name) +} + +fn fixture_text(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)).expect("fixture readable") +} + +fn url_source(address: SocketAddr, path: &str) -> SpecSource { + fetch::spec_source(&format!("http://{address}{path}")).expect("loopback URL accepted") +} + +fn ok(body: &str, content_type: &str) -> Vec { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() +} + +/// Binds a loopback port and answers every request with `respond`, returning +/// the address it bound. +/// +/// The serving thread is left running for the rest of the test binary's life: +/// it holds nothing but a socket, and a test that finished has no way to be +/// waiting on it. Requests are read only far enough to know what was asked +/// for, which is all any test here needs to decide what to send back. +fn serve(respond: impl Fn(&str) -> Vec + Send + 'static) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("binds a loopback port"); + let address = listener.local_addr().expect("has an address"); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + handle(stream, &respond); + } + }); + address +} + +fn handle(mut stream: TcpStream, respond: &impl Fn(&str) -> Vec) { + let mut reader = BufReader::new(stream.try_clone().expect("clones the stream")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + loop { + let mut header = String::new(); + match reader.read_line(&mut header) { + Ok(0) => break, + Ok(_) if header == "\r\n" || header == "\n" => break, + Ok(_) => {} + Err(_) => return, + } + } + let target = request_line.split_whitespace().nth(1).unwrap_or("/"); + let response = respond(target); + let _ = stream.write_all(&response); + let _ = stream.flush(); +} diff --git a/crates/registry-evidencectl/tests/suggest_openapi.rs b/crates/registry-evidencectl/tests/suggest_openapi.rs index 2eacae2c0..d5acecf33 100644 --- a/crates/registry-evidencectl/tests/suggest_openapi.rs +++ b/crates/registry-evidencectl/tests/suggest_openapi.rs @@ -7,6 +7,11 @@ //! here, mirroring their nesting under `src/suggest/`, so the `super::types` //! imports inside `openapi.rs` and `flatten.rs` resolve unchanged. +// `openapi.rs` dispatches a file path or a URL through `fetch`; this binary +// only ever opens files, so the fetching half of that module is unused here. +#[allow(dead_code)] +#[path = "../src/suggest/fetch.rs"] +mod fetch; #[path = "../src/suggest/flatten.rs"] mod flatten; #[path = "../src/suggest/openapi.rs"] @@ -21,7 +26,7 @@ mod types; use std::path::{Path, PathBuf}; -use types::{OperationKey, ResolvedSchema}; +use types::{OperationKey, ResolvedSchema, SpecSource}; fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -29,6 +34,12 @@ fn fixture(name: &str) -> PathBuf { .join(name) } +/// Opens a fixture through the same entry point the CLI uses, naming it as a +/// local file. Fetching over HTTP is covered in `suggest_fetch.rs`. +fn load(path: &Path) -> anyhow::Result { + openapi::Spec::open(&SpecSource::File(path.to_path_buf())) +} + fn operation(method: &str, path: &str) -> OperationKey { OperationKey { method: method.to_string(), @@ -36,23 +47,23 @@ fn operation(method: &str, path: &str) -> OperationKey { } } -// --- Spec::load ------------------------------------------------------------ +// --- Spec::open ----------------------------------------------------------- #[test] fn load_accepts_openapi_3_0_yaml() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); assert!(!spec.operations().is_empty()); } #[test] fn load_accepts_openapi_3_1_json() { - let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let spec = load(&fixture("records-3.1.json")).expect("loads"); assert!(!spec.operations().is_empty()); } #[test] fn load_rejects_unsupported_openapi_version() { - let error = openapi::Spec::load(&fixture("unsupported-version.yaml")).unwrap_err(); + let error = load(&fixture("unsupported-version.yaml")).unwrap_err(); let message = format!("{error:#}"); assert!( message.contains("3.0") || message.contains("3.1"), @@ -62,7 +73,7 @@ fn load_rejects_unsupported_openapi_version() { #[test] fn load_rejects_missing_file() { - let error = openapi::Spec::load(&fixture("does-not-exist.yaml")).unwrap_err(); + let error = load(&fixture("does-not-exist.yaml")).unwrap_err(); let message = format!("{error:#}"); assert!( message.contains("does-not-exist.yaml"), @@ -77,7 +88,7 @@ fn load_rejects_missing_file() { /// /records` carries a JSON response and is still absent from the listing. #[test] fn operations_lists_only_the_methods_the_runtime_admits() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let mut keys: Vec = spec .operations() .into_iter() @@ -92,7 +103,7 @@ fn operations_lists_only_the_methods_the_runtime_admits() { #[test] fn operations_reports_summary_and_json_responses() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let get_records = spec .operations() .into_iter() @@ -107,7 +118,7 @@ fn operations_reports_summary_and_json_responses() { #[test] fn operations_collects_every_json_response_status() { - let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let spec = load(&fixture("records-3.1.json")).expect("loads"); let get_record = spec .operations() .into_iter() @@ -128,7 +139,7 @@ fn operations_collects_every_json_response_status() { #[test] fn response_schema_inlines_local_refs_and_normalizes_nullable() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -163,7 +174,7 @@ fn response_schema_inlines_local_refs_and_normalizes_nullable() { #[test] fn response_schema_passes_through_3_1_type_arrays_unchanged() { - let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let spec = load(&fixture("records-3.1.json")).expect("loads"); let resolved = spec .response_schema( &operation("GET", "/records/{id}"), @@ -179,7 +190,7 @@ fn response_schema_passes_through_3_1_type_arrays_unchanged() { #[test] fn response_schema_rejects_external_ref() { - let spec = openapi::Spec::load(&fixture("external-ref.yaml")).expect("loads"); + let spec = load(&fixture("external-ref.yaml")).expect("loads"); let error = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .unwrap_err(); @@ -195,7 +206,7 @@ fn response_schema_rejects_external_ref() { /// named, and everything beside it stays selectable. #[test] fn response_schema_cuts_a_ref_cycle_and_notes_it() { - let spec = openapi::Spec::load(&fixture("ref-cycle.yaml")).expect("loads"); + let spec = load(&fixture("ref-cycle.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -220,7 +231,7 @@ fn response_schema_cuts_a_ref_cycle_and_notes_it() { #[test] fn a_recursive_schema_still_offers_its_non_recursive_leaves() { - let spec = openapi::Spec::load(&fixture("recursive-tree.yaml")).expect("loads"); + let spec = load(&fixture("recursive-tree.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/nodes"), "200", "application/json") .expect("resolves"); @@ -241,7 +252,7 @@ fn a_recursive_schema_still_offers_its_non_recursive_leaves() { /// unsupported union. #[test] fn a_two_member_union_against_null_becomes_the_nullable_type_pair() { - let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -282,7 +293,7 @@ fn a_two_member_union_against_null_becomes_the_nullable_type_pair() { /// refused over the spelling. #[test] fn a_null_first_type_pair_is_reordered() { - let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -294,7 +305,7 @@ fn a_null_first_type_pair_is_reordered() { #[test] fn a_union_of_two_real_types_is_left_for_the_flattener_to_skip() { - let spec = openapi::Spec::load(&fixture("nullable-unions.yaml")).expect("loads"); + let spec = load(&fixture("nullable-unions.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -321,7 +332,7 @@ fn a_union_of_two_real_types_is_left_for_the_flattener_to_skip() { /// actually publish; the reading is announced rather than made silently. #[test] fn a_structural_keyword_without_a_type_is_read_as_that_type_and_noted() { - let spec = openapi::Spec::load(&fixture("implicit-types.yaml")).expect("loads"); + let spec = load(&fixture("implicit-types.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -360,7 +371,7 @@ fn a_structural_keyword_without_a_type_is_read_as_that_type_and_noted() { #[test] fn a_node_with_neither_a_type_nor_a_structural_keyword_stays_untyped() { - let spec = openapi::Spec::load(&fixture("implicit-types.yaml")).expect("loads"); + let spec = load(&fixture("implicit-types.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/opaque"), "200", "application/json") .expect("resolves"); @@ -377,25 +388,9 @@ fn a_node_with_neither_a_type_nor_a_structural_keyword_stays_untyped() { ); } -// --- Spec::load, continued --------------------------------------------------- - -/// `--openapi` reads a local file. A URL is a common enough mistake that the -/// error says so, rather than reporting a missing file the operator never -/// expected to exist. -#[test] -fn load_names_a_url_as_one_rather_than_as_a_missing_file() { - let error = - openapi::Spec::load(Path::new("https://api.example.test/openapi.yaml")).unwrap_err(); - let message = format!("{error:#}"); - assert!( - message.contains("local file") && message.contains("curl"), - "message was: {message}" - ); -} - #[test] fn response_schema_rejects_unknown_status() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let error = spec .response_schema(&operation("GET", "/records"), "500", "application/json") .unwrap_err(); @@ -407,7 +402,7 @@ fn response_schema_rejects_unknown_status() { #[test] fn servers_lists_declared_base_urls_in_order() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); assert_eq!( spec.servers(), vec!["https://records.example.test/api".to_string()] @@ -416,13 +411,13 @@ fn servers_lists_declared_base_urls_in_order() { #[test] fn servers_is_empty_when_undeclared() { - let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let spec = load(&fixture("records-3.1.json")).expect("loads"); assert!(spec.servers().is_empty()); } #[test] fn page_size_maximums_reads_matching_query_parameters() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let maximums = spec .page_size_maximums(&operation("GET", "/records")) .expect("no ref errors"); @@ -431,7 +426,7 @@ fn page_size_maximums_reads_matching_query_parameters() { #[test] fn page_size_maximums_is_empty_without_matching_parameters() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let maximums = spec .page_size_maximums(&operation("POST", "/records")) .expect("no ref errors"); @@ -440,7 +435,7 @@ fn page_size_maximums_is_empty_without_matching_parameters() { #[test] fn page_size_maximums_matches_limit_named_parameters() { - let spec = openapi::Spec::load(&fixture("records-3.1.json")).expect("loads"); + let spec = load(&fixture("records-3.1.json")).expect("loads"); let maximums = spec .page_size_maximums(&operation("GET", "/records/{id}")) .expect("no ref errors"); @@ -449,7 +444,7 @@ fn page_size_maximums_matches_limit_named_parameters() { #[test] fn page_size_maximums_ignores_a_page_index_beside_a_page_size() { - let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); let maximums = spec .page_size_maximums(&operation("GET", "/records")) .expect("no ref errors"); @@ -460,7 +455,7 @@ fn page_size_maximums_ignores_a_page_index_beside_a_page_size() { #[test] fn page_size_maximums_reads_every_genuine_size_parameter() { - let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); let mut maximums = spec .page_size_maximums(&operation("GET", "/events")) .expect("no ref errors"); @@ -470,7 +465,7 @@ fn page_size_maximums_reads_every_genuine_size_parameter() { #[test] fn page_size_maximums_ignores_names_that_only_contain_a_matching_word() { - let spec = openapi::Spec::load(&fixture("paging-parameters.yaml")).expect("loads"); + let spec = load(&fixture("paging-parameters.yaml")).expect("loads"); let maximums = spec .page_size_maximums(&operation("GET", "/reports")) .expect("no ref errors"); @@ -484,7 +479,7 @@ fn page_size_maximums_ignores_names_that_only_contain_a_matching_word() { #[test] fn candidate_leaves_flattens_arrays_and_nullable_records() { - let spec = openapi::Spec::load(&fixture("records-3.0.yaml")).expect("loads"); + let spec = load(&fixture("records-3.0.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -527,7 +522,7 @@ fn candidate_leaves_flattens_arrays_and_nullable_records() { #[test] fn candidate_leaves_escapes_member_names_per_rfc_6901() { - let spec = openapi::Spec::load(&fixture("escaping.yaml")).expect("loads"); + let spec = load(&fixture("escaping.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -541,7 +536,7 @@ fn candidate_leaves_escapes_member_names_per_rfc_6901() { #[test] fn candidate_leaves_skips_and_warns_on_unsupported_constructs() { - let spec = openapi::Spec::load(&fixture("unsupported-constructs.yaml")).expect("loads"); + let spec = load(&fixture("unsupported-constructs.yaml")).expect("loads"); let resolved = spec .response_schema(&operation("GET", "/records"), "200", "application/json") .expect("resolves"); @@ -623,7 +618,7 @@ fn an_oversized_document_is_refused_before_it_is_read() { file.set_len(17 * 1024 * 1024).expect("set_len"); drop(file); - let error = openapi::Spec::load(&path).unwrap_err(); + let error = load(&path).unwrap_err(); let message = format!("{error:#}"); assert!(message.contains("exceeding the"), "message was: {message}"); } From b18214a66113e984c63caffc383c41315c033e5e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:29:30 +0700 Subject: [PATCH 063/136] docs(evidence): note the URL form of source suggest --openapi States the fetch rule where the flag is documented, so an operator reading the README learns the constraint before hitting the refusal. Signed-off-by: Jeremi Joslin --- products/evidence/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/products/evidence/README.md b/products/evidence/README.md index e7603d79d..3c416e039 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -96,6 +96,12 @@ so `evidence check` rejects the draft until a human resolves it. evidencectl source suggest --openapi ./api.yaml --project ./deployment-project ``` +`--openapi` also takes the URL a description is published at, which is read +under the same rule the runtime applies to the source URLs it will itself call: +`https` anywhere, plain `http` only to a numeric loopback host, and never a +credential in the URL. A description behind authentication is fetched with your +own client and passed as a file. + ## Installing the toolset Releases that include the Evidence toolset publish reproducible bare binaries From 5221470f3cf544f1a5f80dd568c9e0b8fecea56d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 10:06:06 +0700 Subject: [PATCH 064/136] fix(evidence): name why the audit boundary refused to start Startup printed one line, "runtime audit initialization failed", for four unrelated faults with four unrelated remedies: a configuration bound out of range, an unusable hash secret, a file or lock that is not owner-only, a second writer holding the sink lock, and a chain that no longer verifies. From outside the process they are indistinguishable, and the wrong guess sends an operator hunting for tampering in what is a permission bit. Classify the fault at the boundary and append its cause to the same fixed message, in the shape bundle immutability failures already use. Security review notes: - Each cause is a fixed &'static str chosen from the fault class. No path, no secret, no chain content, and no operating-system message reaches it; the audit path an operator needs is already in their runtime file. - The classification is reporting only. Every fault stays fail-closed and the service still refuses to start on any of them. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/main.rs | 35 +++++++--- crates/registry-evidence/src/runtime.rs | 86 ++++++++++++++++++++--- crates/registry-evidence/tests/cli.rs | 93 ++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 18 deletions(-) diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 6df10832d..4984a7029 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -29,8 +29,8 @@ use registry_evidence::{ problem::ProblemCode, rhai_runtime::{DerivedConceptValue, DerivedValue, RequestParts}, runtime::{ - source_failure_problem, validate_secret_material, EvidenceRuntime, - RuntimeInitializationError, + source_failure_problem, validate_secret_material, AuditInitializationFault, + EvidenceRuntime, RuntimeInitializationError, }, secrets::{SecretProvider, SecretResolver}, selector::{ @@ -146,6 +146,12 @@ impl std::error::Error for CliError {} enum CommandError { Cli(CliError), Deployment(&'static str, ArtifactFault), + /// The audit boundary refused, with the value-free cause it reported. + /// + /// It is the one startup boundary that separates its causes, because a + /// permission bit, a chain that no longer verifies, and a second writer + /// holding the sink lock have nothing in common but the moment they fail. + Audit(&'static str, AuditInitializationFault), Service(String), } @@ -154,6 +160,7 @@ impl fmt::Display for CommandError { match self { Self::Cli(error) => fmt::Display::fmt(error, formatter), Self::Deployment(message, fault) => write!(formatter, "{message}: {fault}"), + Self::Audit(message, fault) => write!(formatter, "{message}: {fault}"), Self::Service(reason) => write!(formatter, "service failed: {reason}"), } } @@ -290,15 +297,25 @@ fn deployment_load_error(error: BundleError) -> CommandError { } } -fn runtime_initialization_error(error: RuntimeInitializationError) -> CliError { +fn runtime_initialization_error(error: RuntimeInitializationError) -> CommandError { match error { - RuntimeInitializationError::Bundle => CliError("runtime bundle initialization failed"), - RuntimeInitializationError::Secrets => CliError("runtime secret initialization failed"), - RuntimeInitializationError::Audit => CliError("runtime audit initialization failed"), - RuntimeInitializationError::Signing => CliError("runtime signing initialization failed"), - RuntimeInitializationError::Source => CliError("runtime source initialization failed"), + RuntimeInitializationError::Bundle => { + CliError("runtime bundle initialization failed").into() + } + RuntimeInitializationError::Secrets => { + CliError("runtime secret initialization failed").into() + } + RuntimeInitializationError::Audit(fault) => { + CommandError::Audit("runtime audit initialization failed", fault) + } + RuntimeInitializationError::Signing => { + CliError("runtime signing initialization failed").into() + } + RuntimeInitializationError::Source => { + CliError("runtime source initialization failed").into() + } RuntimeInitializationError::RateLimit => { - CliError("runtime rate-limit initialization failed") + CliError("runtime rate-limit initialization failed").into() } } } diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 807e4dcfd..28a9b7054 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, + fmt, path::Path, str, sync::Arc, @@ -9,7 +10,7 @@ use std::{ }; use chrono::Utc; -use registry_platform_audit::AuditHashSecret; +use registry_platform_audit::{AuditError, AuditHashSecret}; use registry_platform_crypto::{LocalJwkSigner, PrivateJwk, PublicJwk}; use serde_json::{Map as JsonMap, Value}; use thiserror::Error; @@ -17,8 +18,8 @@ use thiserror::Error; use crate::{ audit::{ AuditAuthority, AuditDecision, AuditPhase, AuditSubject, - AuthorityKind as AuditAuthorityKind, EvidenceAuditEvent, EvidenceAuditLog, - ResponseProtection, + AuthorityKind as AuditAuthorityKind, EvidenceAuditError, EvidenceAuditEvent, + EvidenceAuditLog, ResponseProtection, }, auth::{AuthenticatedContext, Authenticator}, bundle::{Bundle, DeploymentInputs}, @@ -58,8 +59,8 @@ pub enum RuntimeInitializationError { Bundle, #[error("the Evidence secret resolver could not initialize")] Secrets, - #[error("the Evidence audit boundary could not initialize")] - Audit, + #[error("the Evidence audit boundary could not initialize: {0}")] + Audit(AuditInitializationFault), #[error("the Evidence signing boundary could not initialize")] Signing, #[error("an Evidence source plan could not initialize")] @@ -68,6 +69,75 @@ pub enum RuntimeInitializationError { RateLimit, } +/// Why the audit boundary refused to initialize. +/// +/// A mode an operator fixes with `chmod`, a chain that no longer verifies, and +/// a second writer already holding the sink lock are three unrelated faults +/// with three unrelated remedies. They are reported separately because from +/// outside the process they are indistinguishable, and the wrong guess sends an +/// operator hunting for tampering in what is a permission bit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditInitializationFault { + /// `auditStorage` bounds or the audit key version are out of range. + Configuration, + /// The audit hash secret is missing, unreadable, or too weak. + Secret, + /// The audit file, its lock, or its directory cannot be opened as an + /// owner-only, singly linked regular file. + Storage, + /// Another writer already holds the sink's single-writer lock. + Locked, + /// A chain is present but its records do not verify against the head. + Chain, +} + +impl AuditInitializationFault { + /// The value-free cause, for the operator message this fault appears in. + /// It names the fault and never the audit path, which the operator already + /// has in the runtime file. + pub fn cause(self) -> &'static str { + match self { + Self::Configuration => "the audit storage configuration is out of range", + Self::Secret => "the audit hash secret is unusable", + Self::Storage => { + "the audit file, its lock, or its directory is unavailable or is not owner-only" + } + Self::Locked => "another writer already holds the audit sink lock", + Self::Chain => "the existing audit chain did not verify", + } + } +} + +impl fmt::Display for AuditInitializationFault { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.cause()) + } +} + +impl From<&EvidenceAuditError> for AuditInitializationFault { + fn from(error: &EvidenceAuditError) -> Self { + match error { + EvidenceAuditError::Configuration => Self::Configuration, + EvidenceAuditError::InvalidEvent | EvidenceAuditError::SegmentMissing { .. } => { + Self::Chain + } + EvidenceAuditError::Audit(audit) => match audit { + AuditError::Io(_) => Self::Storage, + AuditError::SinkLocked { .. } => Self::Locked, + AuditError::EmptyEnvVarName + | AuditError::EnvVarUnavailable { .. } + | AuditError::EnvVarNotUnicode { .. } + | AuditError::EmptySecret { .. } + | AuditError::WeakSecret { .. } => Self::Secret, + // Everything else the sink can report at startup is a statement + // about chain state: a record that does not parse, a hash that + // does not match, or a head that cannot be read. + _ => Self::Chain, + }, + } + } +} + /// Deployment secret material, resolved and validated exactly as service /// startup validates it. pub struct ValidatedSecretMaterial { @@ -90,9 +160,9 @@ pub async fn validate_secret_material( ) -> Result { let audit_secret = secrets .resolve(bundle.config.audit.hash_secret_ref.as_str()) - .map_err(|_| RuntimeInitializationError::Audit)?; + .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; AuditHashSecret::new(audit_secret.expose_secret().to_vec()) - .map_err(|_| RuntimeInitializationError::Audit)?; + .map_err(|_| RuntimeInitializationError::Audit(AuditInitializationFault::Secret))?; let subject_binding_secret = secrets .resolve(bundle.config.subject_binding.secret_ref.as_str()) @@ -280,7 +350,7 @@ impl EvidenceRuntime { bundle.config.audit.hash_key_version, ) .await - .map_err(|_| RuntimeInitializationError::Audit)?; + .map_err(|error| RuntimeInitializationError::Audit((&error).into()))?; let mut sources = BTreeMap::new(); for (source_id, source) in bundle.config.sources.iter() { diff --git a/crates/registry-evidence/tests/cli.rs b/crates/registry-evidence/tests/cli.rs index 620aa8a83..9387bf1ba 100644 --- a/crates/registry-evidence/tests/cli.rs +++ b/crates/registry-evidence/tests/cli.rs @@ -318,7 +318,8 @@ fn check_rejects_secret_material_the_server_would_refuse_at_startup() { SecretFailureCase { label: "audit hash key below the minimum length", break_secrets: |deployment| deployment.write_secret("audit-hash-key", "short"), - expected: "evidence: runtime audit initialization failed\n", + expected: "evidence: runtime audit initialization failed: the audit hash secret is \ + unusable\n", }, SecretFailureCase { label: "subject binding key missing", @@ -352,6 +353,83 @@ fn check_rejects_secret_material_the_server_would_refuse_at_startup() { } } +/// One audit initialization failure class, with the exact operator text it +/// must produce and any handle the fault needs held open while `serve` runs. +struct AuditFaultCase { + label: &'static str, + break_audit: fn(&Deployment) -> Option, + expected: &'static str, +} + +/// The audit boundary refuses to start for unrelated reasons, and from outside +/// the process they are indistinguishable: a mode an operator fixes with +/// `chmod`, a chain that no longer verifies, and a second writer already +/// holding the sink lock are three different questions with three different +/// answers. Each names itself, and none of them names the audit path, which +/// the operator already has in the runtime file. +#[test] +fn serve_names_why_the_audit_boundary_refused_to_initialize() { + let cases = [ + AuditFaultCase { + label: "an audit file readable beyond its owner", + break_audit: |deployment| { + deployment.stage_audit_chain(""); + set_mode(&deployment.path("audit.jsonl"), 0o644); + None + }, + expected: "evidence: runtime audit initialization failed: the audit file, its lock, \ + or its directory is unavailable or is not owner-only\n", + }, + AuditFaultCase { + label: "an audit chain that does not verify", + break_audit: |deployment| { + deployment.stage_audit_chain("{\"not\":\"an audit record\"}\n"); + None + }, + expected: "evidence: runtime audit initialization failed: the existing audit chain \ + did not verify\n", + }, + AuditFaultCase { + label: "a second writer holding the audit sink lock", + break_audit: |deployment| { + let path = deployment.path("audit.jsonl.lock"); + fs::write(&path, "").expect("stage audit sink lock"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only audit lock mode"); + let held = fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("open audit sink lock"); + held.try_lock().expect("hold the audit sink lock"); + Some(held) + }, + expected: "evidence: runtime audit initialization failed: another writer already \ + holds the audit sink lock\n", + }, + ]; + + for case in cases { + let deployment = Deployment::stage_on_port("all-definitions", free_port()); + deployment.stage_acceptance_secrets(); + deployment.seal(); + let _held = (case.break_audit)(&deployment); + let output = invoke(&deployment.path("runtime.yaml"), &["serve"]); + deployment.unseal(); + + assert!( + !output.status.success(), + "{}: serve started on a refused audit boundary", + case.label + ); + let stderr = std::str::from_utf8(&output.stderr).expect("stderr is UTF-8"); + assert_eq!( + stderr, case.expected, + "{}: unexpected diagnostic", + case.label + ); + } +} + /// The documented audit rotation procedure, executed against the real binary. /// /// The procedure is: stop the service with SIGTERM, archive the audit file by @@ -1020,6 +1098,15 @@ outboundTls: self.write_secret("source-d-token", "synthetic-source-token"); } + /// Place an audit chain the service will find on start, owner-only as the + /// sink requires. A case that is about a mode widens it afterwards. + fn stage_audit_chain(&self, contents: &str) { + let path = self.path("audit.jsonl"); + fs::write(&path, contents).expect("stage audit chain"); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .expect("set owner-only audit chain mode"); + } + /// Overwrite the staged signing key with a fresh key whose kid is not /// the bundle's `signing.activeKeyId`. fn write_mismatched_signing_key(&self) { @@ -1113,6 +1200,10 @@ fn copy_tree(source: &Path, destination: &Path) { } } +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set staged mode"); +} + fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { let metadata = fs::symlink_metadata(path).expect("staged path metadata"); if metadata.is_dir() { From d30b5c5235b11864df57a25ca03707562645e488 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 10:21:04 +0700 Subject: [PATCH 065/136] feat(evidencectl): report every artifact mode the runtime will refuse Evidence and Mint refuse a deployment artifact whose permissions or ownership are wrong, and each refusal names one artifact. An operator who has run `chmod -R` over a project therefore discovers a project-wide mistake one restart at a time, and the audit chain and the caller keys are refused by paths that are not part of the freeze the generated README documents. `evidencectl doctor --project ` walks the project once and reports all of them: the bundle tree and `runtime.yaml` under the immutability rule, the secret root and every secret the bundle references, the audit chain and its lock companion once they exist, and the key files of a paired Mint. It starts nothing and needs no `evidence` binary, so it is usable before the deployment is complete. Secrets are discovered from the bundle's own `secret:file/` references rather than by listing the secret directory, because `keygen signing` writes the public half of a key into that directory at 0644 by design and a directory walk would report a project the runtime accepts. Security review notes: this is advisory and read-only. It stats artifacts and reads only the two YAML configuration documents; it never opens a secret, a key or the audit chain, and it reports modes, link counts and uids but no file contents. It relaxes nothing: `evidence check` and startup remain the only authority on what a deployment may run with, and doctor compares ownership against the user running it rather than the user the service runs as, which the module doc and the generated README both state. Signed-off-by: Jeremi Joslin --- Cargo.lock | 1 + crates/registry-evidencectl/Cargo.toml | 1 + crates/registry-evidencectl/src/doctor.rs | 557 ++++++++++++++++++ crates/registry-evidencectl/src/main.rs | 4 + .../registry-evidencectl/templates/README.md | 15 + crates/registry-evidencectl/tests/doctor.rs | 291 +++++++++ 6 files changed, 869 insertions(+) create mode 100644 crates/registry-evidencectl/src/doctor.rs create mode 100644 crates/registry-evidencectl/tests/doctor.rs diff --git a/Cargo.lock b/Cargo.lock index 947b3a831..7bca9ba54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5503,6 +5503,7 @@ dependencies = [ "getrandom 0.4.3", "inquire", "registry-platform-crypto", + "rustix", "serde", "serde_json", "serde_norway", diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 11ae68e96..07e990e9f 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -23,6 +23,7 @@ ed25519-dalek.workspace = true getrandom.workspace = true inquire.workspace = true registry-platform-crypto.workspace = true +rustix.workspace = true serde.workspace = true serde_json.workspace = true serde_norway.workspace = true diff --git a/crates/registry-evidencectl/src/doctor.rs b/crates/registry-evidencectl/src/doctor.rs new file mode 100644 index 000000000..580164330 --- /dev/null +++ b/crates/registry-evidencectl/src/doctor.rs @@ -0,0 +1,557 @@ +//! Deployment-project mode walk. +//! +//! Evidence and Mint refuse, at startup, any deployment artifact whose +//! permissions or ownership are wrong: a bundle they could write to, a secret +//! readable past its owner, an audit chain another user could edit. Each +//! refusal is correct and each names one artifact, so an operator who has just +//! run `chmod -R` over a project discovers them one restart at a time. +//! +//! This walks the whole project and reports every artifact that would be +//! refused, in one pass, without starting anything. It re-states the runtime's +//! rules rather than deciding anything of its own: it makes no Evidence +//! semantic decision, needs no `evidence` binary, and is advisory. What the +//! runtime accepts at startup remains the only authority. +//! +//! Two caveats belong to the operator rather than to the code. Ownership is +//! compared against the user running this check, which is not necessarily the +//! user the service runs as. And a project on a read-only mount satisfies the +//! immutability rule whatever its modes say, which this mirrors. + +use std::{ + fs::{self, Metadata}, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; +use serde::Serialize; +use serde_norway::Value as YamlValue; + +/// How a bundle names a secret the file provider resolves. +const SECRET_REFERENCE_PREFIX: &str = "secret:file/"; + +/// The paired Mint configuration `evidencectl new --with-mint` renders. +const MINT_CONFIG_FILE: &str = "mint/mint.yaml"; + +/// The example caller's own signing key, which `mint token --key` reads under +/// the same owner-only rule as Mint's. Named rather than discovered: this is +/// the filename `evidencectl keygen signing` writes. +const CALLER_SIGNING_KEY: &str = "caller/signing-ed25519-private-jwk"; + +#[derive(Debug, Args)] +pub struct DoctorArgs { + /// Deployment project directory containing runtime.yaml and bundle/. + #[arg(long)] + pub project: PathBuf, + + /// Emit one machine-readable JSON report on standard output. + #[arg(long)] + pub json: bool, +} + +/// One artifact this walk refuses, and why. +#[derive(Debug, Serialize)] +struct Finding { + path: String, + problem: String, +} + +/// One group of artifacts governed by a single runtime rule. +#[derive(Debug, Serialize)] +struct Check { + name: &'static str, + passed: bool, + inspected: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + findings: Vec, +} + +#[derive(Debug, Serialize)] +struct DoctorReport { + checks: Vec, + passed: bool, + /// Artifacts this walk looked at, summed. A reader mistakes the check + /// count for coverage otherwise: six checks say nothing about whether the + /// bundle beneath them held four files or four hundred. + inspected: usize, +} + +pub fn run(args: DoctorArgs) -> Result { + let project = args.project.as_path(); + let runtime_path = project.join("runtime.yaml"); + if !runtime_path.is_file() { + bail!( + "runtime configuration not found at {} (expected a deployment project directory containing runtime.yaml)", + runtime_path.display() + ); + } + let runtime = read_yaml(&runtime_path)?; + let bundle_directory = resolve_bundle_directory(&runtime, &runtime_path, project)?; + let bundle_config_path = bundle_directory.join("evidence.yaml"); + let bundle = read_yaml(&bundle_config_path)?; + + let mut checks = vec![ + check_runtime_file(project, &runtime_path), + check_bundle(project, &bundle_directory), + ]; + checks.extend(check_secrets(project, &runtime, &runtime_path, &bundle)); + checks.push(check_audit(project, &runtime, &runtime_path)); + checks.extend(check_mint(project)?); + + let passed = checks.iter().all(|check| check.passed); + let inspected = checks.iter().map(|check| check.inspected).sum(); + let report = DoctorReport { + checks, + passed, + inspected, + }; + + if args.json { + print_diagnostics(&report, true); + let encoded = serde_json::to_string(&report).context("failed to encode the JSON report")?; + println!("{encoded}"); + } else { + print_diagnostics(&report, false); + } + + Ok(if passed { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +/// The runtime file itself: a deployment input the service refuses to start +/// from if it could write to it. +fn check_runtime_file(project: &Path, runtime_path: &Path) -> Check { + let mut run = CheckRun::new("runtime file", project); + let read_only_mount = mount_is_read_only(runtime_path); + require_immutable(&mut run, runtime_path, read_only_mount); + run.finish() +} + +/// The bundle directory and everything beneath it, under the same rule. +fn check_bundle(project: &Path, bundle_directory: &Path) -> Check { + let mut run = CheckRun::new("bundle", project); + let read_only_mount = mount_is_read_only(bundle_directory); + walk_immutable(&mut run, bundle_directory, read_only_mount); + run.finish() +} + +/// The secret root and every secret the bundle names. +/// +/// The secrets are discovered from the bundle's own `secret:file/` references +/// rather than by listing the secret directory. The difference matters: the +/// public half of a signing key is written into that directory at mode 0644 by +/// design, and a directory walk would report a project the runtime accepts. +fn check_secrets( + project: &Path, + runtime: &YamlValue, + runtime_path: &Path, + bundle: &YamlValue, +) -> Vec { + let references = secret_references(bundle); + let root = runtime + .get("secretProviders") + .and_then(|providers| providers.get("file")) + .and_then(|file| file.get("root")) + .and_then(YamlValue::as_str); + + let Some(root) = root else { + if references.is_empty() { + return Vec::new(); + } + let mut run = CheckRun::new("secrets", project); + run.refuse( + runtime_path, + format!( + "names no file secret provider, and the bundle references {} secret(s) through one", + references.len() + ), + ); + return vec![run.finish()]; + }; + let root = resolve_against(runtime_path, project, Path::new(root)); + + let mut root_run = CheckRun::new("secret root", project); + if let Some(metadata) = root_run.stat(&root) { + if metadata.file_type().is_symlink() { + root_run.refuse(&root, "is a symbolic link; the runtime requires a directory reached without traversing one".to_owned()); + } else if !metadata.is_dir() { + root_run.refuse(&root, "is not a directory".to_owned()); + } else if metadata.permissions().mode() & 0o077 != 0 { + root_run.refuse(&root, group_or_other(&metadata, 0o700)); + } + } + + let mut secret_run = CheckRun::new("secrets", project); + for name in references { + let path = root.join(&name); + let Some(metadata) = secret_run.stat(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + secret_run.refuse( + &path, + "is not a regular file reached without traversing a symbolic link".to_owned(), + ); + continue; + } + // Secrets are the one artifact the runtime pins to an exact mode + // rather than to a bound, so report the exact mode back. + let mode = metadata.permissions().mode() & 0o7777; + if mode != 0o600 { + secret_run.refuse( + &path, + format!("has mode {mode:04o}; the runtime requires exactly 0600 (chmod 600)"), + ); + } + require_sole_owner(&mut secret_run, &path, &metadata); + } + + vec![root_run.finish(), secret_run.finish()] +} + +/// The audit chain and its lock companion, when they exist. Absence is not a +/// finding: the service creates both on first write. +fn check_audit(project: &Path, runtime: &YamlValue, runtime_path: &Path) -> Check { + let mut run = CheckRun::new("audit", project); + let Some(path) = runtime + .get("auditStorage") + .and_then(|storage| storage.get("path")) + .and_then(YamlValue::as_str) + else { + return run.finish(); + }; + let path = resolve_against(runtime_path, project, Path::new(path)); + let lock = lock_companion(&path); + for candidate in [path, lock] { + if !candidate.exists() { + continue; + } + let Some(metadata) = run.stat(&candidate) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + run.refuse(&candidate, "is not a regular file".to_owned()); + continue; + } + if metadata.permissions().mode() & 0o077 != 0 { + run.refuse(&candidate, group_or_other(&metadata, 0o600)); + } + require_sole_owner(&mut run, &candidate, &metadata); + } + run.finish() +} + +/// Mint's signing key and the example caller's, when the project carries a +/// paired Mint configuration. Both are read under the same owner-only rule. +fn check_mint(project: &Path) -> Result> { + let config_path = project.join(MINT_CONFIG_FILE); + if !config_path.is_file() { + return Ok(Vec::new()); + } + let config = read_yaml(&config_path)?; + + let mut run = CheckRun::new("mint keys", project); + if let Some(key) = config + .get("signing") + .and_then(|signing| signing.get("activeKeyFile")) + .and_then(YamlValue::as_str) + { + let key = resolve_against(&config_path, project, Path::new(key)); + require_owner_only_file(&mut run, &key); + } + let caller_key = project.join(CALLER_SIGNING_KEY); + if caller_key.exists() { + require_owner_only_file(&mut run, &caller_key); + } + Ok(vec![run.finish()]) +} + +/// One check under construction: the artifacts it looked at, and the reasons it +/// refused any of them. +struct CheckRun<'a> { + name: &'static str, + project: &'a Path, + inspected: usize, + findings: Vec, +} + +impl<'a> CheckRun<'a> { + fn new(name: &'static str, project: &'a Path) -> Self { + Self { + name, + project, + inspected: 0, + findings: Vec::new(), + } + } + + /// Read one artifact's own metadata, counting it as inspected. Symbolic + /// links are not followed: every rule here is about the named entry. + fn stat(&mut self, path: &Path) -> Option { + self.inspected += 1; + match fs::symlink_metadata(path) { + Ok(metadata) => Some(metadata), + Err(error) => { + self.refuse(path, format!("cannot be read: {error}")); + None + } + } + } + + fn refuse(&mut self, path: &Path, problem: String) { + self.findings.push(Finding { + path: self.display(path), + problem, + }); + } + + /// Project-relative where possible, so a report reads as a list of things + /// to fix rather than a column of temporary directory prefixes. + fn display(&self, path: &Path) -> String { + path.strip_prefix(self.project) + .unwrap_or(path) + .display() + .to_string() + } + + fn finish(self) -> Check { + Check { + name: self.name, + passed: self.findings.is_empty(), + inspected: self.inspected, + findings: self.findings, + } + } +} + +/// No write bits for anyone: what the runtime requires of a deployment input. +fn require_immutable(run: &mut CheckRun, path: &Path, read_only_mount: bool) { + let Some(metadata) = run.stat(path) else { + return; + }; + refuse_unless_immutable(run, path, &metadata, read_only_mount); +} + +/// The same rule over a directory tree, entry by entry. +fn walk_immutable(run: &mut CheckRun, path: &Path, read_only_mount: bool) { + let Some(metadata) = run.stat(path) else { + return; + }; + if metadata.file_type().is_symlink() { + run.refuse( + path, + "is a symbolic link; the runtime refuses one anywhere in a bundle".to_owned(), + ); + return; + } + refuse_unless_immutable(run, path, &metadata, read_only_mount); + if !metadata.is_dir() { + return; + } + match fs::read_dir(path) { + Ok(entries) => { + for entry in entries { + match entry { + Ok(entry) => walk_immutable(run, &entry.path(), read_only_mount), + Err(error) => run.refuse(path, format!("cannot be listed: {error}")), + } + } + } + Err(error) => run.refuse(path, format!("cannot be listed: {error}")), + } +} + +fn refuse_unless_immutable( + run: &mut CheckRun, + path: &Path, + metadata: &Metadata, + read_only_mount: bool, +) { + let mode = metadata.permissions().mode() & 0o7777; + if !read_only_mount && mode & 0o222 != 0 { + run.refuse( + path, + format!("has mode {mode:04o}; the runtime requires no write bits (chmod a-w)"), + ); + } +} + +/// A regular file no one but its owner can reach: what Mint requires of a +/// private key file. +fn require_owner_only_file(run: &mut CheckRun, path: &Path) { + let Some(metadata) = run.stat(path) else { + return; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + run.refuse( + path, + "is not a regular file reached without traversing a symbolic link".to_owned(), + ); + return; + } + if metadata.permissions().mode() & 0o077 != 0 { + run.refuse(path, group_or_other(&metadata, 0o600)); + } + require_sole_owner(run, path, &metadata); +} + +/// Owned by this user and reachable under one name only. A second hard link is +/// a second name for the same bytes, which survives a permission change on the +/// first. +fn require_sole_owner(run: &mut CheckRun, path: &Path, metadata: &Metadata) { + let euid = rustix::process::geteuid().as_raw(); + if metadata.uid() != euid { + run.refuse( + path, + format!( + "is owned by uid {}, not by the user running this check (uid {euid}); the runtime requires the user it runs as", + metadata.uid() + ), + ); + } + if metadata.nlink() != 1 { + run.refuse( + path, + format!( + "has {} hard links; the runtime requires exactly one", + metadata.nlink() + ), + ); + } +} + +fn group_or_other(metadata: &Metadata, required: u32) -> String { + let mode = metadata.permissions().mode() & 0o7777; + format!( + "has mode {mode:04o}; the runtime requires no group or other access (chmod {required:o})" + ) +} + +/// The audit sink's lock companion, `.lock`. +fn lock_companion(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".lock"); + PathBuf::from(name) +} + +/// Whether the filesystem carrying `path` is mounted read-only, in which case +/// the runtime accepts write bits it would otherwise refuse. An unreadable +/// mount is treated as writable, which reports rather than hides. +fn mount_is_read_only(path: &Path) -> bool { + rustix::fs::statvfs(path).is_ok_and(|status| { + status + .f_flag + .contains(rustix::fs::StatVfsMountFlags::RDONLY) + }) +} + +/// Resolve a configured path: absolute as written, relative against the +/// configuration file's own directory. +fn resolve_against(config_path: &Path, project: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + config_path.parent().unwrap_or(project).join(path) + } +} + +/// Resolve the bundle directory a project's `runtime.yaml` names, on the same +/// terms `evidencectl fixtures run` does. +fn resolve_bundle_directory( + runtime: &YamlValue, + runtime_path: &Path, + project: &Path, +) -> Result { + match runtime.get("bundleDirectory") { + Some(value) => { + let value = value.as_str().ok_or_else(|| { + anyhow!( + "bundleDirectory in {} is not a string", + runtime_path.display() + ) + })?; + Ok(resolve_against(runtime_path, project, Path::new(value))) + } + None => Ok(project.join("bundle")), + } +} + +/// Every `secret:file/` a bundle names, wherever in the document it sits. +/// +/// This is discovery, not validation: the reference is found by its own prefix +/// rather than by the field carrying it, so a secret a future field names is +/// checked without this walk learning that field. `evidence check` is left to +/// reject a bundle that is otherwise malformed. +fn secret_references(bundle: &YamlValue) -> Vec { + let mut names = Vec::new(); + collect_secret_references(bundle, &mut names); + names +} + +fn collect_secret_references(value: &YamlValue, names: &mut Vec) { + match value { + YamlValue::String(text) => { + if let Some(name) = text.strip_prefix(SECRET_REFERENCE_PREFIX) { + let name = name.to_owned(); + if !names.contains(&name) { + names.push(name); + } + } + } + YamlValue::Sequence(items) => { + for item in items { + collect_secret_references(item, names); + } + } + YamlValue::Mapping(entries) => { + for (_, entry) in entries { + collect_secret_references(entry, names); + } + } + _ => {} + } +} + +fn read_yaml(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + serde_norway::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display())) +} + +/// Print one line per check, every finding beneath it, and a summary line. +/// +/// In JSON mode this goes to stderr, keeping stdout reserved for the single +/// JSON document; in human mode it is the entire report and goes to stdout. +/// Findings are never elided: a walk that reports some of what is broken sends +/// an operator back for a second restart, which is what this exists to avoid. +fn print_diagnostics(report: &DoctorReport, to_stderr: bool) { + let mut lines = Vec::new(); + for check in &report.checks { + let status = if check.passed { "PASS" } else { "FAIL" }; + lines.push(format!( + "{status}: {} ({} inspected)", + check.name, check.inspected + )); + for finding in &check.findings { + lines.push(format!(" {}: {}", finding.path, finding.problem)); + } + } + let passed = report.checks.iter().filter(|check| check.passed).count(); + let failed = report.checks.len() - passed; + lines.push(format!( + "{passed} passed, {failed} failed ({} artifacts inspected)", + report.inspected + )); + + for line in lines { + if to_stderr { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } +} diff --git a/crates/registry-evidencectl/src/main.rs b/crates/registry-evidencectl/src/main.rs index c93ed6344..eadfd0753 100644 --- a/crates/registry-evidencectl/src/main.rs +++ b/crates/registry-evidencectl/src/main.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; +mod doctor; mod fixtures; mod jwks; mod keygen; @@ -38,6 +39,8 @@ enum Command { /// Work with a project's sources, starting from their own API documents. #[command(subcommand)] Source(suggest::SourceCommand), + /// Report every project artifact whose mode or owner the runtime refuses. + Doctor(doctor::DoctorArgs), } fn main() -> ExitCode { @@ -48,6 +51,7 @@ fn main() -> ExitCode { Command::New(args) => scaffold::run(args), Command::Fixtures(command) => fixtures::run(command), Command::Source(command) => suggest::run(command), + Command::Doctor(args) => doctor::run(args), }; match result { Ok(code) => code, diff --git a/crates/registry-evidencectl/templates/README.md b/crates/registry-evidencectl/templates/README.md index e9115b3aa..70a7cce2a 100644 --- a/crates/registry-evidencectl/templates/README.md +++ b/crates/registry-evidencectl/templates/README.md @@ -93,6 +93,21 @@ chmod -R a-w {{bundle_directory}} chmod 444 {{project_root}}/runtime.yaml ``` +Each refusal names one artifact, so a `chmod -R` over the whole project is +discovered one restart at a time. To see all of them at once: + +```bash +evidencectl doctor --project {{project_root}} +``` + +`doctor` walks the project and reports every artifact whose mode or owner the +runtime would refuse: the bundle tree, `runtime.yaml`, the secret root, each +secret the bundle references, the audit chain once it exists, and the key files +of a paired Mint. It starts nothing and needs no `evidence` binary, so it works +before the deployment is complete. It is advisory: `evidence check` and startup +remain authoritative, and `doctor` compares ownership against the user running +it rather than the user the service runs as. + ## Signing the revision Evidence computes a revision hash over the exact bundle bytes it loaded and diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs new file mode 100644 index 000000000..9003ffcf7 --- /dev/null +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -0,0 +1,291 @@ +#![cfg(unix)] + +//! `evidencectl doctor` over a real deployment project. +//! +//! Every assertion here is about a mode or an owner the Evidence or Mint +//! runtime refuses at startup, so the project under test is built with the real +//! `evidencectl new` and `evidencectl keygen` rather than from hand-written +//! files: a check that mirrors the runtime is only worth having if it is +//! measured against what the tooling actually produces. No `evidence` binary is +//! involved anywhere in this file, which is the other half of the promise: +//! `doctor` is a filesystem walk, and an adopter who cannot yet start the +//! service is exactly the one who needs it. Nothing here prints key material. + +use std::{ + fs, + os::unix::fs::PermissionsExt as _, + path::Path, + process::{Command, Output}, +}; + +const SIGNING_KID: &str = "scaffold-signing-key-1"; +const MINT_KID: &str = "scaffold-mint-key-1"; +const CALLER_KID: &str = "scaffold-client-key-1"; +const SECRET_FILES: [&str; 2] = ["audit-hmac-key", "subject-binding-hmac-key"]; + +#[test] +fn doctor_passes_a_frozen_project_and_leaves_the_public_key_beside_it_alone() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + + // `keygen signing` writes the public half into the secret root at 0644 by + // design. The runtime never resolves it as a secret, so doctor must not + // report it. A walk of the secret directory would; a walk of the secret + // references the bundle actually names does not. + let public_key = project.join("secrets/signing-ed25519-public.jwk.json"); + assert_eq!( + mode_of(&public_key), + 0o644, + "the scaffolded public key is no longer world-readable, so this test proves nothing" + ); + + freeze(&project); + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + output.status.success(), + "doctor failed on a correctly provisioned project:\n{stdout}{}", + stderr_of(&output) + ); + assert!( + stdout.contains("0 failed"), + "unexpected doctor summary: {stdout}" + ); + assert!( + !stdout.contains("signing-ed25519-public.jwk.json"), + "doctor reported the public key that sits beside the private one: {stdout}" + ); +} + +#[test] +fn doctor_names_every_artifact_whose_mode_the_runtime_refuses() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + freeze(&project); + + // One artifact per rule the runtimes enforce, each widened past it. A + // `chmod -R` an operator runs over a project produces exactly this state. + let refused = [ + "runtime.yaml", + "bundle/evidence.yaml", + "secrets", + "secrets/audit-hmac-key", + "audit/evidence.jsonl", + "mint/secrets/signing-ed25519-private-jwk", + "caller/signing-ed25519-private-jwk", + ]; + fs::write(project.join("audit/evidence.jsonl"), "").expect("stage an audit chain"); + for path in refused { + set_mode(&project.join(path), 0o755); + } + + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + !output.status.success(), + "doctor passed a project the runtime would refuse:\n{stdout}" + ); + for path in refused { + assert!( + stdout.contains(path), + "doctor did not report {path}:\n{stdout}" + ); + } +} + +#[test] +fn doctor_reports_a_secret_the_bundle_references_and_the_project_does_not_hold() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + + // Every secret but the source bearer token, which the README tells an + // adopter to obtain from the source system rather than generate. Forgetting + // it is the ordinary way a project reaches this state. + freeze(&project); + let output = doctor(&project, &[]); + unfreeze(&project); + + let stdout = stdout_of(&output); + assert!( + !output.status.success(), + "doctor passed a project missing a secret the bundle references:\n{stdout}" + ); + assert!( + stdout.contains("secrets/source-bearer-token"), + "doctor did not name the missing secret:\n{stdout}" + ); +} + +#[test] +fn doctor_json_puts_one_document_on_stdout_and_the_report_on_stderr() { + let workspace = tempfile::tempdir().expect("tempdir"); + let project = workspace.path().join("project"); + provision(&project); + provision_bearer_token(&project); + + freeze(&project); + let output = doctor(&project, &["--json"]); + unfreeze(&project); + + assert!( + output.status.success(), + "doctor --json failed on a correctly provisioned project:\n{}", + stderr_of(&output) + ); + let stdout = stdout_of(&output); + let lines: Vec<&str> = stdout.lines().filter(|line| !line.is_empty()).collect(); + assert_eq!( + lines.len(), + 1, + "stdout must carry exactly one JSON document: {stdout}" + ); + let report: serde_json::Value = serde_json::from_str(lines[0]).expect("parse the JSON report"); + assert_eq!(report["passed"], serde_json::Value::Bool(true)); + let checks = report["checks"].as_array().expect("checks array"); + assert!( + checks.iter().all(|check| check["passed"] == true), + "a check failed in the JSON report: {stdout}" + ); + assert!( + stderr_of(&output).contains("0 failed"), + "the human report did not reach stderr in JSON mode" + ); +} + +/// Scaffold a project with its paired Mint configuration and generate every +/// key the two runtimes read, exactly as the generated README instructs. +fn provision(project: &Path) { + run_ok(&[ + "new", + "--with-mint", + project.to_str().expect("project path"), + ]); + + let secrets = project.join("secrets"); + run_ok(&[ + "keygen", + "signing", + "--out-dir", + secrets.to_str().expect("secret root"), + "--kid", + SIGNING_KID, + ]); + for name in SECRET_FILES { + let out = secrets.join(name); + run_ok(&["keygen", "secret", "--out", out.to_str().expect("secret")]); + } + + run_ok(&[ + "keygen", + "signing", + "--out-dir", + project + .join("mint/secrets") + .to_str() + .expect("mint secret root"), + "--kid", + MINT_KID, + ]); + run_ok(&[ + "keygen", + "signing", + "--out-dir", + project.join("caller").to_str().expect("caller secret root"), + "--kid", + CALLER_KID, + ]); +} + +/// The one secret the scaffolded source needs and `provision` leaves out, so a +/// test can choose whether the project is complete. +fn provision_bearer_token(project: &Path) { + let out = project.join("secrets/source-bearer-token"); + run_ok(&["keygen", "token", "--out", out.to_str().expect("token")]); +} + +fn doctor(project: &Path, extra: &[&str]) -> Output { + let mut arguments = vec![ + "doctor", + "--project", + project.to_str().expect("project path"), + ]; + arguments.extend_from_slice(extra); + evidencectl(&arguments) +} + +fn run_ok(arguments: &[&str]) { + let output = evidencectl(arguments); + assert!( + output.status.success(), + "evidencectl {} failed: {}", + arguments[0], + stderr_of(&output) + ); +} + +fn evidencectl(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .args(arguments) + .output() + .expect("running evidencectl") +} + +fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf8 stdout") +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf8 stderr") +} + +fn mode_of(path: &Path) -> u32 { + fs::symlink_metadata(path) + .expect("artifact metadata") + .permissions() + .mode() + & 0o7777 +} + +fn set_mode(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("setting a mode"); +} + +/// The documented freeze: no write bits anywhere in the bundle, and a read-only +/// runtime file. Evidence refuses a deployment input it could write. +fn freeze(project: &Path) { + set_tree_mode(&project.join("bundle"), 0o555, 0o444); + set_mode(&project.join("runtime.yaml"), 0o444); +} + +/// Restore write permissions so the temporary directory can be removed. +fn unfreeze(project: &Path) { + set_tree_mode(&project.join("bundle"), 0o755, 0o644); + set_mode(&project.join("runtime.yaml"), 0o644); + set_mode(&project.join("secrets"), 0o700); +} + +fn set_tree_mode(path: &Path, directory_mode: u32, file_mode: u32) { + let metadata = fs::symlink_metadata(path).expect("tree entry"); + if metadata.is_dir() { + set_mode(path, 0o755); + for entry in fs::read_dir(path).expect("reading a directory") { + set_tree_mode( + &entry.expect("tree entry").path(), + directory_mode, + file_mode, + ); + } + set_mode(path, directory_mode); + } else { + set_mode(path, file_mode); + } +} From d185d53cd09b6d5670a922056088f7ad21dd7146 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:27:21 +0700 Subject: [PATCH 066/136] fix(evidence): name an unreachable access-token issuer and keep serving through its outage A deployment whose `jwksUri` could not be retrieved answered `/ready` 200 and then rejected every request with the same closed `401` a bad token receives, with nothing in the log naming the issuer, the URI, or the transport error underneath. Ready, 401, silence. Underneath both symptoms was a third problem: `key_for_kid` propagated the refresh transport error from its stale-cache arm, and `refresh` only writes keys on success. Past `cache_ttl`, an issuer that had merely gone quiet caused total rejection using a valid key still sitting in the cache. The verifier now keeps serving an already-retrieved key set for `cache_ttl` plus a bounded `outage_tolerance`, and only while the issuer cannot be reached. Startup attempts the key set once and names a `jwksUri` it cannot use. Readiness asks on every check and reports what comes back without letting the answer decide readiness. Two rate-limited WARNs name an unretrievable key set and a key set being served without being confirmed. Security review notes: - Authentication surface. No change to what is accepted: signature, issuer, audience, algorithm, token type and claim extraction are untouched, and the caller-facing rejection stays the same closed `401` with no added detail. The new material is operator-facing logging and cache lifetime. - Bounded outage tolerance. A key the issuer has withdrawn stays trusted for at most `cache_ttl` (600s) plus `outage_tolerance` (900s), and only while the issuer is unreachable; any successful refresh clears it immediately. The window is bounded rather than open-ended precisely because an attacker who can keep the issuer unreachable would otherwise hold it open indefinitely. This trades a bounded revocation delay during an outage against total rejection during one; the outage is reported for its whole duration rather than degrading silently to a cliff. - Log content. The WARNs carry the operator-configured `jwksUri`, an outage duration, and a cause chain bounded to three causes and 512 bytes. No token, claim, key material, or caller-supplied value reaches them, and they are rate-limited to one line per minute so an issuer outage cannot drive log volume. - Readiness. Deliberately not a gate on the issuer. It is a shared dependency no replica owns, so failing readiness on it would remove every replica from rotation at once for a cause rotation cannot address, while the verifier was still serving requests. This follows the same conclusion Envoy reaches with its `fast_listener` escape hatch and Kubernetes guidance names as a cascading-failure anti-pattern. Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/auth.rs | 458 +++++++++++++++++- crates/registry-evidence/src/runtime.rs | 15 + crates/registry-evidence/src/runtime_tests.rs | 87 ++++ crates/registry-evidence/src/server.rs | 5 + crates/registry-platform-oidc/src/lib.rs | 413 +++++++++++++++- crates/registry-platform-testing/src/lib.rs | 2 + .../tests/cross_crate_integration.rs | 1 + products/evidence/OPERATOR-CONTRACT.md | 16 +- 8 files changed, 977 insertions(+), 20 deletions(-) diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index 6c36b87c1..9595347ce 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -1,12 +1,15 @@ //! Strict OIDC access-token authentication and configured claim extraction. -use std::sync::Arc; +use std::{ + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::{Duration, Instant}, +}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_platform_crypto::parse_json_strict; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ - JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig, VerifiedToken, + JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, VerifiedToken, }; use serde_json::{Map, Value}; use thiserror::Error; @@ -19,6 +22,39 @@ const MAX_CLAIMS_BYTES: usize = 64 * 1024; const MAX_PRINCIPAL_BYTES: usize = 512; const MAX_TAGS: usize = 32; +/// How long an unreachable issuer key set stays quiet after it has been named +/// once. +/// +/// A deployment that cannot reach the key set rejects every request, so the +/// unthrottled report is one log line per request: the fault would bury the +/// traffic that revealed it, and a caller could provoke the writing. One line a +/// minute names it and keeps naming it while it lasts. +const KEY_SOURCE_REPORT_INTERVAL: Duration = Duration::from_secs(60); + +/// How long a failed readiness probe is trusted before another is attempted. +/// +/// Readiness is polled on a schedule the service does not choose. Without this, +/// a probe every second against an issuer that is down is an outbound request +/// every second, from every replica. A successful probe needs no equivalent: +/// the verifier's own cache answers it without a request. +const KEY_SOURCE_PROBE_INTERVAL: Duration = Duration::from_secs(15); + +/// Stands in for the key-set location in the log when there is none to name. +/// +/// Only an in-memory key set reaches this, which no deployment configures. +const STATIC_KEY_SOURCE: &str = ""; + +/// How many causes below the reported error are rendered. +/// +/// `reqwest` reports a transport failure as "error sending request" and keeps +/// the reason underneath it: the connection that was refused, the certificate +/// that did not verify. That reason is the whole diagnosis, and it is two or +/// three levels down. +const REPORTED_CAUSES: usize = 3; + +/// The bound on the rendered cause, which is remote text in part. +const MAX_CAUSE_BYTES: usize = 512; + #[derive(Debug, Clone)] pub struct AuthenticationClaimsConfig { pub principal_claim: String, @@ -33,6 +69,19 @@ pub struct AuthenticationClaimsConfig { pub struct Authenticator { verifier: Arc, claims: AuthenticationClaimsConfig, + key_source: Arc>, +} + +/// What is known about the issuer key set, and when it was last said aloud. +/// +/// Shared rather than copied when the authenticator is cloned, so the two +/// intervals bound the process and not each handle. +#[derive(Debug, Default)] +struct KeySourceState { + /// When the failure was last written to the log. + last_reported: Option, + /// When a readiness probe last failed, and so has not been retried since. + last_failed_probe: Option, } impl std::fmt::Debug for Authenticator { @@ -213,7 +262,11 @@ impl Authenticator { } pub fn new(verifier: Arc, claims: AuthenticationClaimsConfig) -> Self { - Self { verifier, claims } + Self { + verifier, + claims, + key_source: Arc::new(Mutex::new(KeySourceState::default())), + } } pub async fn authenticate( @@ -221,14 +274,147 @@ impl Authenticator { access_token: &str, ) -> Result { strict_jwt_preflight(access_token)?; - let verified = self - .verifier - .verify(access_token) - .await - .map_err(|_| AuthenticationError::Verification)?; + let verified = match self.verifier.verify(access_token).await { + Ok(verified) => verified, + Err(error) => { + if is_key_source_failure(&error) { + self.report_key_source_failure(&error); + } + return Err(AuthenticationError::Verification); + } + }; self.extract_context(verified) } + /// Ask the issuer for its key set on a readiness check, and name what comes + /// back, without letting the answer decide readiness. + /// + /// Readiness answers whether this deployment should be sent traffic, and + /// the honest answer during an issuer outage is yes: the verifier keeps + /// serving a key set it cannot recheck for a bounded while, so requests + /// carrying tokens signed by keys already held still succeed. Withholding + /// readiness would take every replica out of rotation at once for a + /// dependency none of them owns, which is the shape of a cascading failure + /// rather than a diagnosis. So the probe reports and the report is the + /// point: an operator watching this deployment is told the issuer has gone + /// quiet while requests still work, and told again when the allowance is + /// what stands between them and rejecting everything. + /// + /// A failed probe is remembered for a short interval, so an orchestrator's + /// polling schedule cannot become this deployment's retry schedule against + /// an issuer that is down. + pub async fn probe_key_source(&self) { + if self.probe_is_suppressed(Instant::now()) { + return; + } + match self.verifier.key_source().ensure_key_set().await { + Ok(()) => { + self.lock_key_source().last_failed_probe = None; + self.report_key_source_outage().await; + } + Err(error) => { + self.report_key_source_failure(&error); + self.lock_key_source().last_failed_probe = Some(Instant::now()); + } + } + } + + /// Attempt the issuer's key set once at startup, and name it if it cannot + /// be had. + /// + /// A misspelled or unreachable `jwksUri` is otherwise discovered one + /// rejected request at a time, and the rejection an operator sees is the + /// same closed `401` a bad token gets. Startup is where an operator is + /// looking, so startup is where it should be said. + /// + /// It reports rather than refuses, for the same reason readiness does: + /// refusing would tie this deployment's start to the issuer's, so a restart + /// during an issuer outage could not come back, and an issuer that starts + /// alongside this service would race it. + pub async fn announce_key_source(&self) { + if let Err(error) = self.verifier.key_source().ensure_key_set().await { + self.report_key_source_failure(&error); + } + } + + /// Whether the last probe failed recently enough to stand for this one. + fn probe_is_suppressed(&self, now: Instant) -> bool { + self.lock_key_source() + .last_failed_probe + .is_some_and(|failed| now.duration_since(failed) < KEY_SOURCE_PROBE_INTERVAL) + } + + /// Name an unreachable issuer key set, at most once per interval. + /// + /// The caller learns nothing from this. Their rejection is the same closed + /// `401` whether the token was bad or this deployment could not check it, + /// which is the right answer to give a caller and the reason the operator + /// has nothing else to go on. The distinction exists only here. + fn report_key_source_failure(&self, error: &OidcError) { + if !self.claim_report_interval(Instant::now()) { + return; + } + tracing::warn!( + target: "registry_evidence::authentication", + jwks_uri = self + .verifier + .key_source() + .jwks_uri() + .unwrap_or(STATIC_KEY_SOURCE), + cause = describe_causes(error), + "the access-token issuer key set could not be retrieved; every request is rejected until it can be" + ); + } + + /// Name a key set that is being served without being confirmed, at most + /// once per interval. + /// + /// Nothing else would say so. The verifier keeps serving a key set it + /// cannot recheck, so requests succeed, readiness holds, and the only + /// thing that has changed is that the issuer has stopped answering. That + /// is worth an operator's attention before the allowance runs out and the + /// deployment starts rejecting everything. + async fn report_key_source_outage(&self) { + let Some(outage) = self.verifier.key_source().outage_duration().await else { + return; + }; + if !self.claim_report_interval(Instant::now()) { + return; + } + tracing::warn!( + target: "registry_evidence::authentication", + jwks_uri = self + .verifier + .key_source() + .jwks_uri() + .unwrap_or(STATIC_KEY_SOURCE), + outage_seconds = outage.as_secs(), + "the access-token issuer key set has not been retrievable; the key set already held is still being accepted, and requests will be rejected once its allowance runs out" + ); + } + + /// Take the reporting interval if it is free, so exactly one caller logs. + fn claim_report_interval(&self, now: Instant) -> bool { + let mut state = self.lock_key_source(); + if state + .last_reported + .is_some_and(|reported| now.duration_since(reported) < KEY_SOURCE_REPORT_INTERVAL) + { + return false; + } + state.last_reported = Some(now); + true + } + + /// Recover the guard even from a poisoned lock: the state behind it is two + /// timestamps, which a panicking holder cannot leave inconsistent, and + /// readiness must not panic because a report once did. + fn lock_key_source(&self) -> MutexGuard<'_, KeySourceState> { + self.key_source + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + fn extract_context( &self, verified: VerifiedToken, @@ -292,6 +478,70 @@ impl Authenticator { } } +/// Whether a verification failure was this deployment's key source rather than +/// the caller's token. +/// +/// Every known failure is listed rather than folded into the wildcard, which +/// covers only variants added to the shared verifier after this was written. +/// Those default to the caller's side: a failure this code has never seen is +/// one it cannot honestly describe as an unreachable key set, and an operator +/// misdirected by a confident wrong message is worse off than one who reads +/// the same `authentication_failed` twice. +fn is_key_source_failure(error: &OidcError) -> bool { + match error { + OidcError::Transport(_) + | OidcError::BoundedRead(_) + | OidcError::FetchUrl(_) + | OidcError::HttpStatus(_) + | OidcError::InvalidUrl + | OidcError::Parse + | OidcError::InvalidJwk + | OidcError::EmptyKeySet + | OidcError::MissingIssuer => true, + OidcError::IssuerMismatch { .. } + | OidcError::MalformedToken + | OidcError::AlgorithmNotAllowed + | OidcError::TokenTypeNotAllowed + | OidcError::MissingKid + | OidcError::KidTooLong + | OidcError::UnknownKid + | OidcError::TokenExpired + | OidcError::TokenNotYetValid + | OidcError::AudienceMismatch + | OidcError::SignatureInvalid + | OidcError::InvalidToken + | OidcError::ClientNotAllowed => false, + _ => false, + } +} + +/// Render an error together with the causes beneath it, bounded. +/// +/// Separated from the logging call so what reaches the log can be asserted +/// directly. Every part of it is either this crate's own text or the transport +/// library's account of a connection to an address the bundle configured; +/// nothing the caller supplied passes through here. +fn describe_causes(error: &dyn std::error::Error) -> String { + let mut rendered = error.to_string(); + let mut cause = error.source(); + let mut remaining = REPORTED_CAUSES; + while let (Some(current), 1..) = (cause, remaining) { + rendered.push_str(": "); + rendered.push_str(¤t.to_string()); + cause = current.source(); + remaining -= 1; + } + if rendered.len() > MAX_CAUSE_BYTES { + let mut end = MAX_CAUSE_BYTES; + while !rendered.is_char_boundary(end) { + end -= 1; + } + rendered.truncate(end); + rendered.push_str("..."); + } + rendered +} + pub fn strict_jwt_preflight(token: &str) -> Result<(), AuthenticationError> { if token.is_empty() || token.len() > MAX_TOKEN_BYTES @@ -448,6 +698,198 @@ mod tests { assert!(resolve_claim_path(&claims, "grant..subject-id").is_none()); } + #[test] + fn key_source_failures_are_separated_from_token_failures() { + // The operator's half: nothing here is anything the caller did, and + // each one leaves the deployment unable to verify any token at all. + for error in [ + OidcError::HttpStatus(503), + OidcError::InvalidUrl, + OidcError::Parse, + OidcError::InvalidJwk, + OidcError::EmptyKeySet, + OidcError::MissingIssuer, + ] { + assert!( + is_key_source_failure(&error), + "{error} is a fault in this deployment's key source" + ); + } + + // The caller's half: reporting these would let a caller write to the + // operator log by presenting bad tokens, and none of them says + // anything about the deployment. + for error in [ + OidcError::MalformedToken, + OidcError::AlgorithmNotAllowed, + OidcError::TokenTypeNotAllowed, + OidcError::MissingKid, + OidcError::KidTooLong, + OidcError::UnknownKid, + OidcError::TokenExpired, + OidcError::TokenNotYetValid, + OidcError::AudienceMismatch, + OidcError::SignatureInvalid, + OidcError::InvalidToken, + OidcError::ClientNotAllowed, + OidcError::IssuerMismatch { + expected: "https://issuer.invalid".to_owned(), + actual: "https://other.invalid".to_owned(), + }, + ] { + assert!( + !is_key_source_failure(&error), + "{error} is a fault in the presented token" + ); + } + } + + #[derive(Debug)] + struct Layered { + message: &'static str, + below: Option>, + } + + impl std::fmt::Display for Layered { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message) + } + } + + impl std::error::Error for Layered { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.below + .as_deref() + .map(|below| below as &(dyn std::error::Error + 'static)) + } + } + + fn layered(messages: &[&'static str]) -> Layered { + let mut layers = messages.iter().rev(); + let mut error = Layered { + message: layers.next().expect("at least one layer"), + below: None, + }; + for message in layers { + error = Layered { + message, + below: Some(Box::new(error)), + }; + } + error + } + + #[test] + fn the_reported_cause_reaches_past_the_summary_to_the_reason() { + // The shape a refused TLS handshake arrives in: the top layer says + // only that a request failed, and the layer an operator needs is + // underneath it. + let described = describe_causes(&layered(&[ + "error sending request", + "connection error", + "invalid peer certificate: UnknownIssuer", + ])); + assert_eq!( + described, + "error sending request: connection error: invalid peer certificate: UnknownIssuer" + ); + } + + #[test] + fn the_reported_cause_is_bounded_in_depth_and_length() { + let deep = describe_causes(&layered(&["first", "second", "third", "fourth", "fifth"])); + assert_eq!(deep, "first: second: third: fourth"); + + let long = describe_causes(&layered(&["x".repeat(4096).leak()])); + assert!( + long.len() <= MAX_CAUSE_BYTES + 3, + "an unbounded remote message reached the log: {} bytes", + long.len() + ); + assert!(long.ends_with("..."), "truncation is not marked: {long}"); + } + + #[test] + fn a_failing_key_source_is_named_once_per_interval() { + let authenticator = Authenticator::new( + Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.invalid".to_owned(), + vec!["urn:example:audience".to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new( + "https://issuer.invalid/jwks".to_owned(), + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ); + + let now = Instant::now(); + assert!( + authenticator.claim_report_interval(now), + "the first failure is always named" + ); + assert!( + !authenticator.claim_report_interval(now + KEY_SOURCE_REPORT_INTERVAL / 2), + "a deployment rejecting every request must not log every request" + ); + assert!( + authenticator.claim_report_interval(now + KEY_SOURCE_REPORT_INTERVAL), + "a fault that lasts keeps being named" + ); + } + + #[test] + fn a_failed_readiness_probe_stands_in_for_the_next_one() { + let authenticator = Authenticator::new( + Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.invalid".to_owned(), + vec!["urn:example:audience".to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new( + "https://issuer.invalid/jwks".to_owned(), + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ); + + let now = Instant::now(); + assert!( + !authenticator.probe_is_suppressed(now), + "nothing is known yet, so the first probe must run" + ); + authenticator.lock_key_source().last_failed_probe = Some(now); + assert!( + authenticator.probe_is_suppressed(now + KEY_SOURCE_PROBE_INTERVAL / 2), + "an orchestrator's polling rate must not become the retry rate" + ); + assert!( + !authenticator.probe_is_suppressed(now + KEY_SOURCE_PROBE_INTERVAL), + "an issuer that comes back must be found" + ); + } + #[test] fn authenticated_context_debug_redacts_claim_material() { let mut context = AuthenticatedContext::test_context( diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index 28a9b7054..c6e79d558 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -439,7 +439,15 @@ impl EvidenceRuntime { /// Readiness proves all locally required material and source credentials. /// It never performs an evidence-data request. + /// + /// It also asks the access-token issuer for its key set, but only so an + /// issuer that has gone quiet is named in the log while requests still + /// work. The answer does not decide readiness: the verifier keeps serving + /// a key set it cannot recheck for a bounded while, and an issuer outage + /// that pulled every replica out of rotation at once would be a cascading + /// failure, not a diagnosis. pub async fn ready(&self) -> bool { + self.authenticator.probe_key_source().await; if validate_subject_binding_key( self.subject_binding_secret.expose_secret(), self.bundle().config.subject_binding.key_version, @@ -459,6 +467,13 @@ impl EvidenceRuntime { true } + /// Attempt the access-token issuer's key set once, so a `jwksUri` this + /// deployment cannot use is named at startup rather than discovered one + /// rejected request at a time. It reports; it does not refuse to start. + pub async fn announce_key_source(&self) { + self.authenticator.announce_key_source().await; + } + /// List only the complete request shapes that the authenticated caller can /// currently invoke. Discovery performs no source access, credential /// resolution, signing, or evidence-data audit. diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 6c0665152..29f3f5ea1 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -22,6 +22,7 @@ use registry_platform_crypto::{ sign, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, SigningProvider, }; +use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; use serde_json::{json, Value}; use tempfile::TempDir; @@ -1387,6 +1388,59 @@ async fn readiness_fails_for_missing_credentials_tampered_audit_and_unready_sign ); } +/// Readiness asks the access-token issuer for its key set and does not let the +/// answer decide readiness. +/// +/// The issuer is not this deployment's to fix, and every replica shares it. A +/// readiness check that failed on it would take the whole deployment out of +/// rotation at once, for a cause removing it from rotation cannot address, and +/// would do so while the verifier was still accepting tokens signed by keys +/// already in hand. The probe stays because the log line it produces is worth +/// having; readiness stays local because the traffic decision is. +#[tokio::test] +async fn readiness_reports_an_unretrievable_issuer_key_set_without_denying_readiness() { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let issuer = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"keys": [private.public()]}))) + .mount(&issuer) + .await; + + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let reachable = EvidenceRuntime::initialize_with_authenticator( + &prepared.runtime_path, + fetching_authenticator(&format!("{}/jwks", issuer.uri())), + ) + .await + .expect("runtime initializes"); + assert!( + reachable.ready().await, + "a retrievable issuer key set is ready" + ); + + // Port 1 on the loopback interface refuses: the shape of a private CA the + // service does not trust or an issuer that is down, where the address is + // configured and nothing answers on it. + let prepared = prepare_acceptance("subject-binding-secret-canary-32-bytes-minimum").await; + let unreachable = EvidenceRuntime::initialize_with_authenticator( + &prepared.runtime_path, + fetching_authenticator("http://127.0.0.1:1/jwks"), + ) + .await + .expect("runtime initializes"); + assert!( + unreachable.ready().await, + "an unretrievable issuer key set is reported, not made a traffic decision" + ); + // The probe answered, which is what the report is made from, and answering + // did not disturb the local readiness verdict on a second check either. + assert!( + unreachable.ready().await, + "a repeated check under a suppressed probe holds the same verdict" + ); +} + #[tokio::test] async fn access_audit_failure_blocks_credentials_and_source_access() { let fixture = acceptance_runtime().await; @@ -3898,6 +3952,39 @@ fn authenticator() -> Authenticator { ) } +/// The same authenticator, but resolving the issuer's keys over HTTP from a +/// given address rather than from a key set held in memory. +/// +/// The fetch policy is the permissive one so a loopback test server is a legal +/// address; the deployed policy is built in `Authenticator::from_config` and is +/// not what this exercises. +fn fetching_authenticator(jwks_uri: &str) -> Authenticator { + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ), + Arc::new(JwksFetcher::new_with_fetch_url_policy( + jwks_uri.to_owned(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + grant_id_claim: "evidence_grant_id".to_owned(), + grant_authority_claim: "evidence_authority".to_owned(), + actor_claim: None, + }, + ) +} + fn access_token(extra: Option) -> String { access_token_for("requester-principal-canary", extra) } diff --git a/crates/registry-evidence/src/server.rs b/crates/registry-evidence/src/server.rs index 052489676..3ab93429a 100644 --- a/crates/registry-evidence/src/server.rs +++ b/crates/registry-evidence/src/server.rs @@ -230,6 +230,7 @@ where let bundle_revision = runtime.bundle().revision().to_owned(); let runtime_revision = runtime.runtime_revision().to_owned(); let listener = bind(&listener_config.bind_host, listener_config.port).await?; + let startup_runtime = Arc::clone(&runtime); let (app, evaluations, metrics) = build_app_with_tracker(runtime); // Both listeners are bound before either serves, so a misconfigured @@ -254,6 +255,10 @@ where metrics = metrics_config.is_some(), "evidence service listening" ); + // Said here rather than left to the first rejected request, which an + // operator reads as a bad token. Reported, not fatal: readiness is what + // withholds traffic until the key set is in hand. + startup_runtime.announce_key_source().await; let (stop_metrics, metrics_stopped) = tokio::sync::watch::channel(()); let metrics_server = metrics_listener.map(|listener| { let mut stopped = metrics_stopped; diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index 102de8c66..2e807ae47 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -118,6 +118,17 @@ pub struct JwksFetcherConfig { pub refresh_cooldown: Duration, pub max_doc_bytes: u64, pub request_timeout: Duration, + /// How long past `cache_ttl` a key set already held keeps being served when + /// the issuer cannot be reached to replace it. + /// + /// An expired cache is not a statement that the keys in it are wrong; it is + /// a statement that they have not been rechecked. Discarding them because + /// the issuer is briefly unreachable turns an issuer blip into total + /// rejection here, for keys that would have verified. The allowance is + /// bounded rather than open-ended because it is also the window in which a + /// key the issuer has since withdrawn stays trusted, and an attacker who + /// can keep the issuer unreachable would otherwise hold that window open. + pub outage_tolerance: Duration, } impl JwksFetcherConfig { @@ -129,6 +140,7 @@ impl JwksFetcherConfig { refresh_cooldown: Duration::from_secs(30), max_doc_bytes: DEFAULT_DOC_BYTES, request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::from_secs(900), } } } @@ -139,6 +151,13 @@ struct JwksState { fetched_at: Option, last_forced_refresh: Option, negative: HashMap, + /// Since when the issuer has been unreachable while the key set already + /// held stood in for the replacement, if it is standing in now. + /// + /// Kept so a caller can report a deployment that is verifying tokens + /// against keys it can no longer confirm. Nothing else would say so: every + /// such request succeeds. + tolerating_since: Option, } #[allow(clippy::large_enum_variant)] @@ -227,6 +246,170 @@ impl JwksFetcher { } } + /// The configured key-set location, when this fetcher has one. + /// + /// A static source has none, which is the honest answer: nothing is + /// fetched, so there is no address for an operator to go and check. + #[must_use] + pub fn jwks_uri(&self) -> Option<&str> { + match &self.source { + JwksSource::Http { jwks_uri, .. } => Some(jwks_uri), + JwksSource::Static(_) => None, + } + } + + /// Confirm a usable key set is in hand, fetching one if the cache is empty + /// or stale. + /// + /// This answers the question a resource server has before it accepts + /// traffic: not whether one particular key exists, but whether the issuer's + /// keys can be reached at all. A deployment that cannot reach them rejects + /// every token presented to it, and each rejection is indistinguishable + /// from an invalid token. + /// + /// A fresh cache is proof enough and costs no request, so a probe repeated + /// every few seconds fetches at most once per `cache_ttl`. A failure is + /// deliberately not cached: how often to retry an issuer that is down is + /// the caller's decision, since only the caller knows how often it probes. + /// + /// This answers the same question the verification path asks, including + /// outage tolerance, so it never reports unready a deployment that is in + /// fact still verifying tokens. Use `outage_duration` to tell the two + /// apart. + pub async fn ensure_key_set(&self) -> Result<(), OidcError> { + if self + .has_key_set_within(Instant::now(), self.config.cache_ttl) + .await + { + return Ok(()); + } + let _guard = self.refresh_lock.lock().await; + if self + .has_key_set_within(Instant::now(), self.config.cache_ttl) + .await + { + return Ok(()); + } + match self.refresh(false).await { + Ok(()) => { + if self + .has_key_set_within(Instant::now(), self.config.cache_ttl) + .await + { + Ok(()) + } else { + Err(OidcError::EmptyKeySet) + } + } + Err(error) => { + if self + .has_key_set_within(Instant::now(), self.tolerated_age()) + .await + { + self.begin_tolerating(Instant::now()).await; + Ok(()) + } else { + Err(error) + } + } + } + } + + /// How long this fetcher has been serving a key set the issuer could not be + /// asked to replace, or `None` when the key set is current. + /// + /// Exposed so the deployment can say so. A tolerated outage is invisible + /// from every other vantage point: requests succeed, the key set verifies, + /// and the only thing that has changed is that nothing has confirmed those + /// keys for a while. + pub async fn outage_duration(&self) -> Option { + let now = Instant::now(); + self.state + .read() + .await + .tolerating_since + .map(|since| now.duration_since(since)) + } + + /// The oldest a key set may be and still be served during an outage. + fn tolerated_age(&self) -> Duration { + self.config.cache_ttl + self.config.outage_tolerance + } + + /// Whether the cache holds at least one key and is no older than `allowance`. + /// + /// Emptiness matters on its own: a key set with no keys parses, so the + /// fetch reports success, and every token verified against it then fails + /// for want of a key. + async fn has_key_set_within(&self, now: Instant, allowance: Duration) -> bool { + let state = self.state.read().await; + !state.keys.is_empty() + && state + .fetched_at + .is_some_and(|fetched| now.duration_since(fetched) <= allowance) + } + + /// Record that the key set already held is standing in for a replacement, + /// keeping the start of an outage already under way. + async fn begin_tolerating(&self, now: Instant) { + let mut state = self.state.write().await; + state.tolerating_since.get_or_insert(now); + } + + /// Replace an aged-out key set, or keep serving the one already held when + /// the issuer cannot be reached to replace it. + /// + /// An expired cache means the keys have not been rechecked, not that they + /// are wrong. Failing every request for want of a recheck would turn a + /// short issuer outage into a total one here, using keys that are sitting + /// in the cache and would have verified. + async fn refresh_or_tolerate_outage( + &self, + kid: &str, + forced: bool, + ) -> Result { + let error = match self.refresh_and_cached_key(kid, forced).await { + Ok(lookup) => return Ok(lookup), + Err(error) => error, + }; + let now = Instant::now(); + match self.tolerated_key(kid, now).await? { + Some(key) => { + self.begin_tolerating(now).await; + Ok(JwksCacheLookup::Hit(key)) + } + None => Err(error), + } + } + + /// The key already held for `kid`, if the key set is young enough to keep + /// serving through an outage. + async fn tolerated_key( + &self, + kid: &str, + now: Instant, + ) -> Result, OidcError> { + let state = self.state.read().await; + if state + .fetched_at + .is_none_or(|fetched| now.duration_since(fetched) > self.tolerated_age()) + { + return Ok(None); + } + let Some(jwk) = state.keys.get(kid) else { + return Ok(None); + }; + validate_jwk(jwk)?; + DecodingKey::from_jwk(jwk) + .map(|decoding_key| { + Some(CachedJwkKey { + decoding_key, + jwk: jwk.clone(), + }) + }) + .map_err(|_| OidcError::InvalidJwk) + } + pub async fn key_for_kid(&self, kid: &str) -> Result { if kid.is_empty() { return Err(OidcError::MissingKid); @@ -249,14 +432,16 @@ impl JwksFetcher { return Err(OidcError::UnknownKid); } JwksCacheLookup::FreshMiss => {} - JwksCacheLookup::StaleOrEmpty => match self.refresh_and_cached_key(kid, false).await? { - JwksCacheLookup::Hit(key) => return Ok(key.decoding_key), - JwksCacheLookup::NegativeMiss => return Err(OidcError::UnknownKid), - JwksCacheLookup::FreshMiss | JwksCacheLookup::StaleOrEmpty => { - self.remember_unknown_kid(kid).await; - return Err(OidcError::UnknownKid); + JwksCacheLookup::StaleOrEmpty => { + match self.refresh_or_tolerate_outage(kid, false).await? { + JwksCacheLookup::Hit(key) => return Ok(key.decoding_key), + JwksCacheLookup::NegativeMiss => return Err(OidcError::UnknownKid), + JwksCacheLookup::FreshMiss | JwksCacheLookup::StaleOrEmpty => { + self.remember_unknown_kid(kid).await; + return Err(OidcError::UnknownKid); + } } - }, + } } if self.should_force_refresh(Instant::now()).await { @@ -302,7 +487,9 @@ impl JwksFetcher { } JwksCacheLookup::FreshMiss => {} JwksCacheLookup::StaleOrEmpty => { - if let JwksCacheLookup::Hit(key) = self.refresh_and_cached_key(kid, false).await? { + if let JwksCacheLookup::Hit(key) = + self.refresh_or_tolerate_outage(kid, false).await? + { return Ok(key); } self.remember_unknown_kid(kid).await; @@ -429,6 +616,7 @@ impl JwksFetcher { state.keys = keys; state.fetched_at = Some(Instant::now()); state.negative.clear(); + state.tolerating_since = None; if forced { state.last_forced_refresh = Some(Instant::now()); } @@ -781,6 +969,16 @@ impl TokenVerifier { } } + /// The key source this verifier resolves signing keys through. + /// + /// Exposed so a resource server can prove at readiness that the issuer's + /// keys are reachable, rather than discovering they are not one rejected + /// request at a time. + #[must_use] + pub fn key_source(&self) -> &Arc { + &self.fetcher + } + pub async fn verify(&self, token: &str) -> Result { self.verify_access_token(token, true).await } @@ -1210,6 +1408,8 @@ pub enum OidcError { UnknownKid, #[error("JWK is invalid")] InvalidJwk, + #[error("key set contains no usable keys")] + EmptyKeySet, #[error("token is expired")] TokenExpired, #[error("token is not yet valid")] @@ -1229,12 +1429,12 @@ pub enum OidcError { #[cfg(test)] mod tests { use super::*; - use axum::{routing::get, Json, Router}; + use axum::{http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; use jsonwebtoken::{encode, EncodingKey, Header}; use serde_json::json; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use tokio::net::TcpListener; async fn serve_discovery(jwks_uri: &str) -> String { @@ -1282,6 +1482,36 @@ mod tests { format!("http://{addr}/jwks") } + /// A key-set endpoint that can be made unreachable and reachable again, + /// standing in for an issuer that goes down while this process keeps + /// running. + async fn serve_jwks_with_outage( + document: Arc>, + outage: Arc, + ) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("read listener addr"); + let app = Router::new().route( + "/jwks", + get(move || { + let document = Arc::clone(&document); + let outage = Arc::clone(&outage); + async move { + if outage.load(Ordering::SeqCst) { + return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({}))).into_response(); + } + Json(document.read().await.clone()).into_response() + } + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve test app"); + }); + format!("http://{addr}/jwks") + } + fn jwks_with_kids(kids: &[&str]) -> Value { let keys: Vec = kids .iter() @@ -1305,6 +1535,7 @@ mod tests { refresh_cooldown: Duration::from_secs(3600), max_doc_bytes: DEFAULT_DOC_BYTES, request_timeout: Duration::from_secs(1), + outage_tolerance: Duration::from_secs(3600), } } @@ -2640,6 +2871,168 @@ mod tests { assert!(rendered.contains("source: Static")); } + #[tokio::test] + async fn ensure_key_set_fetches_once_and_then_answers_from_cache() { + let document = Arc::new(RwLock::new(jwks_with_kids(&["readiness-kid"]))); + let requests = Arc::new(AtomicUsize::new(0)); + let jwks_uri = serve_jwks(Arc::clone(&document), Arc::clone(&requests)).await; + let fetcher = JwksFetcher::new_with_fetch_url_policy( + jwks_uri, + jwks_test_config(), + FetchUrlPolicy::dev(), + ); + + // A readiness probe repeated every few seconds must not become an + // outbound request every few seconds: a fresh cache is proof enough. + for _ in 0..5 { + fetcher + .ensure_key_set() + .await + .expect("a reachable issuer key set is ready"); + } + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ensure_key_set_reports_an_unreachable_issuer() { + // Port 1 on the loopback interface refuses, which is the shape of the + // failure an operator hits: the address resolves and nothing answers. + let fetcher = JwksFetcher::new_with_fetch_url_policy( + "http://127.0.0.1:1/jwks".to_owned(), + jwks_test_config(), + FetchUrlPolicy::dev(), + ); + + let error = fetcher + .ensure_key_set() + .await + .expect_err("an unreachable issuer key set is not ready"); + assert!(matches!(error, OidcError::Transport(_))); + } + + #[tokio::test] + async fn ensure_key_set_rejects_a_key_set_with_no_usable_keys() { + // The document parses and the fetch succeeds, so nothing below this + // would fail: only the emptiness distinguishes it from a healthy issuer. + let document = Arc::new(RwLock::new(jwks_with_kids(&[]))); + let requests = Arc::new(AtomicUsize::new(0)); + let jwks_uri = serve_jwks(Arc::clone(&document), Arc::clone(&requests)).await; + let fetcher = JwksFetcher::new_with_fetch_url_policy( + jwks_uri, + jwks_test_config(), + FetchUrlPolicy::dev(), + ); + + let error = fetcher + .ensure_key_set() + .await + .expect_err("an empty key set verifies nothing"); + assert!(matches!(error, OidcError::EmptyKeySet)); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ensure_key_set_is_satisfied_by_a_static_key_set_and_names_no_uri() { + let keys: JwkSet = + serde_json::from_value(jwks_with_kids(&["static-kid"])).expect("static key set parses"); + let fetcher = JwksFetcher::new_static(keys, jwks_test_config()); + + fetcher + .ensure_key_set() + .await + .expect("a static key set is always in hand"); + assert!(fetcher.jwks_uri().is_none()); + } + + /// An expired cache says the keys have not been rechecked, not that they are + /// wrong. Discarding them because the issuer is briefly unreachable would + /// reject every request using keys that are sitting in the cache and would + /// have verified: a short outage at the issuer becomes a total one here. + #[tokio::test] + async fn an_unreachable_issuer_does_not_discard_the_key_set_already_held() { + let outage = Arc::new(AtomicBool::new(false)); + let jwks_uri = serve_jwks_with_outage( + Arc::new(RwLock::new(jwks_with_kids(&["held-kid"]))), + Arc::clone(&outage), + ) + .await; + let mut config = jwks_test_config(); + config.cache_ttl = Duration::from_millis(50); + config.outage_tolerance = Duration::from_secs(3600); + let fetcher = + JwksFetcher::new_with_fetch_url_policy(jwks_uri, config, FetchUrlPolicy::dev()); + + fetcher + .key_for_kid("held-kid") + .await + .expect("the key set is retrievable to begin with"); + assert_eq!(fetcher.outage_duration().await, None); + + outage.store(true, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(120)).await; + + fetcher + .key_for_kid("held-kid") + .await + .expect("a key already held still verifies while the issuer is unreachable"); + assert!( + fetcher.outage_duration().await.is_some(), + "the deployment can say it is running on a key set it cannot confirm" + ); + fetcher + .ensure_key_set() + .await + .expect("readiness reports what the verification path actually does"); + + outage.store(false, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(60)).await; + fetcher + .key_for_kid("held-kid") + .await + .expect("the issuer is reachable again"); + assert_eq!( + fetcher.outage_duration().await, + None, + "a reachable issuer ends the outage" + ); + } + + /// The allowance is bounded because it is also the window in which a key the + /// issuer has withdrawn stays trusted here. Left open-ended, anyone able to + /// keep the issuer unreachable could hold that window open. + #[tokio::test] + async fn a_key_set_past_its_outage_allowance_is_no_longer_served() { + let outage = Arc::new(AtomicBool::new(false)); + let jwks_uri = serve_jwks_with_outage( + Arc::new(RwLock::new(jwks_with_kids(&["expiring-kid"]))), + Arc::clone(&outage), + ) + .await; + let mut config = jwks_test_config(); + config.cache_ttl = Duration::from_millis(20); + config.outage_tolerance = Duration::from_millis(30); + let fetcher = + JwksFetcher::new_with_fetch_url_policy(jwks_uri, config, FetchUrlPolicy::dev()); + + fetcher + .key_for_kid("expiring-kid") + .await + .expect("the key set is retrievable to begin with"); + + outage.store(true, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(200)).await; + + let error = fetcher + .key_for_kid("expiring-kid") + .await + .expect_err("the allowance runs out"); + assert!(matches!(error, OidcError::HttpStatus(503)), "{error:?}"); + assert!( + fetcher.ensure_key_set().await.is_err(), + "readiness stops vouching for it at the same moment" + ); + } + #[tokio::test] async fn jwks_fetcher_caches_keys_until_ttl() { let document = Arc::new(RwLock::new(jwks_with_kids(&["cached-kid"]))); diff --git a/crates/registry-platform-testing/src/lib.rs b/crates/registry-platform-testing/src/lib.rs index 95acf4c70..bff047c6b 100644 --- a/crates/registry-platform-testing/src/lib.rs +++ b/crates/registry-platform-testing/src/lib.rs @@ -716,6 +716,7 @@ mod tests { refresh_cooldown: Duration::from_millis(1), max_doc_bytes: 16 * 1024, request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::from_secs(900), }, FetchUrlPolicy::dev(), )); @@ -773,6 +774,7 @@ mod tests { refresh_cooldown: Duration::from_millis(1), max_doc_bytes: 16 * 1024, request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::from_secs(900), }, FetchUrlPolicy::dev(), )); diff --git a/crates/registry-platform-testing/tests/cross_crate_integration.rs b/crates/registry-platform-testing/tests/cross_crate_integration.rs index 833a5c95f..c4360a0ff 100644 --- a/crates/registry-platform-testing/tests/cross_crate_integration.rs +++ b/crates/registry-platform-testing/tests/cross_crate_integration.rs @@ -137,6 +137,7 @@ async fn sample_axum_app_wires_middleware_oidc_and_audit_chain() { refresh_cooldown: Duration::from_millis(10), max_doc_bytes: 16 * 1024, request_timeout: Duration::from_secs(5), + outage_tolerance: Duration::from_secs(900), }, FetchUrlPolicy::dev(), )); diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index e873655fe..a73f7e9b9 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -746,14 +746,26 @@ material parsed, and the audit chain opened and verified. Readiness rechecks the subject-binding key, signing provider, pinned audit sink, and every source credential. Basic, static Bearer, and static API-key credentials are checked locally. OAuth client-credentials readiness performs its bounded token -bootstrap against the configured token endpoint. OIDC JWKS retrieval is lazy -and follows the verifier cache lifecycle, so readiness does not prefetch it. +bootstrap against the configured token endpoint. Neither startup nor readiness sends an evidence-data request or probes a source data endpoint. Readiness fails when a required local runtime or bundle input, selector binding, credential, CA binding, audit dependency, or signing dependency is absent, mutable, or invalid. +The access-token issuer's `jwksUri` is retrieved once at startup and again on +each readiness check, subject to the verifier cache lifecycle and a short +suppression interval after a failure. Both report and neither refuses: a +`jwksUri` that cannot be used is named in the log at startup rather than +discovered one rejected request at a time, but the issuer is a shared +dependency this deployment does not own, so an issuer outage does not withhold +its readiness or prevent it from starting. A key set already retrieved keeps +being accepted for a bounded allowance past its cache lifetime while the issuer +is unreachable, so a brief issuer outage does not turn into total rejection +here; once that allowance runs out, every request is rejected with the same +closed `401` a bad token receives, and the reason appears only in this +deployment's log. + The native operations are: ```text From ecf00079d7cf197c015ec8e040c11f03c9ecdd02 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:38:54 +0700 Subject: [PATCH 067/136] docs(evidence): add the acceptance-definition authoring tutorial The first tutorial gets a reader to one passing assertion but leaves the scaffold's abstract boolean in place. This second one has them author a coequal acceptance definition end to end: a narrower selector profile, a second source, a codelist that maps a register's fine-grained code to a coarse disclosed one, a derivation, and its own fixture file. Registered in the tutorial gate, which replays all thirteen command blocks and applies the four documented before/after edits to bundle/evidence.yaml. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + docs/site/scripts/check-evidence-tutorials.sh | 29 + .../author-an-acceptance-definition.mdx | 787 ++++++++++++++++++ 3 files changed, 817 insertions(+) create mode 100644 docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 4d21ed840..3b2620f42 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -328,6 +328,7 @@ export default defineConfig({ label: 'Answer with Evidence', items: [ { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, + { label: 'Author an acceptance definition', slug: 'tutorials/author-an-acceptance-definition' }, { label: 'Configure Evidence', slug: 'configure/evidence' }, { label: 'Configure Registry Mint', slug: 'configure/mint' }, { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 07ad0decc..363f3adce 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -48,6 +48,7 @@ TARGET_DIR="$REPO_ROOT/target/evidence-tutorial-source" EVIDENCE_TUTORIALS=( first-evidence-assertion + author-an-acceptance-definition ) load_spec() { @@ -78,6 +79,34 @@ load_spec() { '2 passed, 0 failed (12 cases evaluated)' ) ;; + author-an-acceptance-definition) + SPEC_FENCES=13 + # The reader already has the toolset from the first tutorial, so the + # whole page is executable. Each edit applies a documented before/after + # yaml pair under one heading: occurrence 1 is found, 2 replaces it. + SPEC_STEPS=( + 'run:1-3' + 'edit:Add a narrower selector profile|yaml|1|Add a narrower selector profile|yaml|2|bundle/evidence.yaml' + 'edit:Add the register as a second source|yaml|1|Add the register as a second source|yaml|2|bundle/evidence.yaml' + 'run:4-9' + 'edit:Add the residence-region requirement|yaml|1|Add the residence-region requirement|yaml|2|bundle/evidence.yaml' + 'edit:Grant the new requirement|yaml|1|Grant the new requirement|yaml|2|bundle/evidence.yaml' + 'run:10-13' + ) + SPEC_LITERALS=( + 'evidencectl new region-evidence' + 'mkdir -p bundle/codelists' + 'chmod -R a-w bundle && chmod 444 runtime.yaml' + 'evidencectl fixtures run --project .' + '3 passed, 0 failed (23 cases evaluated)' + ) + SPEC_OUTPUTS=( + 'PASS: check' + 'PASS: fixtures/cases.yaml (12 cases)' + 'PASS: fixtures/residence-region-cases.yaml (11 cases)' + '3 passed, 0 failed (23 cases evaluated)' + ) + ;; *) printf '%s is not a registered Evidence tutorial\n' "$1" >&2 exit 2 diff --git a/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx new file mode 100644 index 000000000..d88a349bf --- /dev/null +++ b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx @@ -0,0 +1,787 @@ +--- +title: Author an acceptance definition +description: Add a second acceptance definition to a scaffolded Evidence project, disclosing a residence region as one coarse code mapped down from a register's own finer code, and prove it with synthetic fixture cases. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: tutorial +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +The scaffolded project you get from `evidencectl new` answers one deliberately abstract question: +whether a recorded event date is at least a reviewed number of years in the past. Here you add a +second acceptance definition that is different in kind. It does not answer yes or no; it discloses +one code from a closed list, and that code is coarser than the code the source register holds. + +Adding it walks the whole loop an author walks: a source, a request, a response schema, an extract +script, a fact schema, a codelist, a derivation, a requirement with its concept and disclosure +guard, an authority grant, and the synthetic cases that prove the definition before anything is +served. + + + +Everything below is synthetic. Do not add production endpoints, source credentials, or personal +data to this project. + +## Before you start + +[Get a first Evidence assertion](../first-evidence-assertion/) installs the toolset and explains +what the scaffold contains. This tutorial assumes `evidence` and `evidencectl` already resolve on +your `PATH`, and it starts from a fresh project rather than the one you built there. + +## Scaffold a project + +```sh +evidencectl new region-evidence +cd region-evidence +``` + +The scaffold prints where everything went and the exact commands that come next. Every command +after this point runs from inside `region-evidence`. + +## Generate key material + +```sh +evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1 +evidencectl keygen secret --out secrets/audit-hmac-key +evidencectl keygen secret --out secrets/subject-binding-hmac-key +``` + +Expected output: + +```text +wrote secrets/signing-ed25519-private-jwk +wrote secrets/signing-ed25519-public.jwk.json +kid: scaffold-signing-key-1 +wrote secrets/audit-hmac-key +wrote secrets/subject-binding-hmac-key +``` + +## What you are adding + +Evidence Version 1 has four coequal acceptance definitions: adult status, residence region, +professional licence status, and legal-parent relationship. None of them is privileged in the +runtime. There is no residence type, no region route, and no special case in Rust. A region +answer is built out of the same parts as any other answer, which is exactly why building one is a +useful way to learn the parts. + +The definition you are about to add says: for this subject, which reviewed region do they reside +in? The register that holds the answer records a finer area code than the answer discloses. +Mapping the register's code down to a coarse region is the minimization, and it happens inside the +bundle, in reviewed configuration, not in code. + +Do not freeze the bundle yet. Everything from here until the freeze step edits files under +`bundle/`, and the runtime refuses a bundle that is not read-only at startup. + +## The vocabulary you will disclose + +Start at the end, with the closed set of codes that may ever leave the service. + +```sh +mkdir -p bundle/codelists +cat > bundle/codelists/residence-regions.yaml <<'YAML' +# The disclosed region codes, and the register codes that map onto them. +# +# The mapping is the minimization. Several register codes collapse onto one +# disclosed region, so the answer is coarser than the code the register holds, +# and `allowed_outputs` is the closed set of codes that may ever leave the +# service. +id: urn:example:scaffold:codelist:residence-regions +version: '2026-01' +entries: + AREA-11: REGION-NORTH + AREA-12: REGION-NORTH + AREA-21: REGION-SOUTH + AREA-22: REGION-SOUTH +allowed_outputs: [REGION-NORTH, REGION-SOUTH] +YAML +``` + +A codelist comes in two forms. A plain one lists `codes` and nothing else, which is what a +controlled category or a bucket scheme needs. This one is a mapping: `entries` translates a source +code into a disclosed code, and `allowed_outputs` is the separate, closed list of what the output +gate will accept. Two register codes behind one region is what makes the mapping a narrowing +rather than a rename: an answer of `REGION-NORTH` cannot tell `AREA-11` and `AREA-12` apart. + +`bundle/codelists/` is one of the five path roots a bundle recognises, beside `adapters/`, +`derivations/`, `schemas/` and `fixtures/`. Nothing else may appear under `bundle/`, and the +codelist is handed to derivation scripts under its file stem, `residence-regions`. + +## Add a narrower selector profile + +A selector profile is the closed set of fields a requester may send to identify a subject. The +residence register finds a record from its reference alone, so this definition should ask for less +than the scaffold's does. Add a second profile beside the first. + +Find this in `bundle/evidence.yaml`: + +```yaml + registry_code: + type: string + minimumBytes: 1 + maximumBytes: 64 +``` + +Replace it with: + +```yaml + registry_code: + type: string + minimumBytes: 1 + maximumBytes: 64 + + # The residence register finds a record from its reference alone, so this + # acceptance definition asks for less than the one above. A profile is the + # smallest identifying set an answer needs, not a shared address book. + residence-lookup-v1: + maximumAggregateBytes: 200 + fields: + record_reference: + type: string + minimumBytes: 1 + maximumBytes: 200 +``` + +## Add the register as a second source + +The scaffold's `source-a` posts a lookup body and gets a record back. The residence register is a +collection read: it returns a page of records and a flag saying whether further pages exist. That +is a different contract, so it gets its own response schema and its own extract script rather than +sharing `source-a`'s. + +Find this in `bundle/evidence.yaml`: + +```yaml + responseSchema: schemas/response.schema.yaml + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/facts.schema.yaml +``` + +Replace it with: + +```yaml + responseSchema: schemas/response.schema.yaml + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/facts.schema.yaml + + # The second source. It is a collection read that returns a page of records + # and a further-pages flag, so it gets its own response schema and its own + # extract script rather than sharing source-a's. + residence-register: + transport: http-json + baseUrl: https://residence-register.invalid + posture: field-projected + authentication: + kind: static-bearer + tokenRef: secret:file/residence-register-bearer-token + request: + method: GET + path: /v1/residence-records + fixedHeaders: + - name: Accept + value: application/json + selectorInputs: + - role: subject + alternatives: + - profile: residence-lookup-v1 + fields: [record_reference] + prepareScript: adapters/residence-register-prepare.rhai + adapterParameters: + providerFields: id,area_code + resultLimit: "2" + adapterParametersSchema: schemas/residence-register-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 64 + maximumQueryValueBytes: 1024 + maximumNormalizedBytes: 4096 + # Two record leaves and the further-pages flag. Everything else the source + # returns is discarded before any script runs. + projection: + - /records/*/id + - /records/*/area_code + - /pagination/has_more + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/residence-register-response.schema.yaml + extractScript: adapters/residence-register-extract.rhai + factSchema: schemas/residence-register-facts.schema.yaml +``` + +The bearer token is named, not stored. Offline fixture runs never open it, so you do not need to +create `secrets/residence-register-bearer-token` for this tutorial; the first live request is where +a missing one would be discovered. Against a real source that file holds that system's own token; +`evidencectl keygen token --out secrets/residence-register-bearer-token` generates a stand-in. +Not `keygen secret`, which makes HMAC key material whose raw bytes an HTTP header value rejects. + +## The request the source sees + +Reviewed constants come first, because they decide what the prepare script is even allowed to ask +for. + +```sh +cat > bundle/schemas/residence-register-parameters.schema.yaml <<'YAML' +# Closed schema for the reviewed adapter parameters of residence-register. +# Both values are pinned constants: the field list is the projection the source +# is asked for, and the page size is the smallest one that can still tell a +# unique record from an ambiguous lookup. Neither is an operator dial. +type: object +additionalProperties: false +required: [providerFields, resultLimit] +properties: + providerFields: {const: "id,area_code"} + resultLimit: {const: "2"} +YAML +``` + +A page size of two is not an arbitrary bound. One is too few to notice that a second record +existed, and anything larger buys nothing: the only question this lookup asks is whether the +reference resolved to exactly one record. + +```sh +cat > bundle/adapters/residence-register-prepare.rhai <<'RHAI' +// Request preparation for the residence register. This source is a collection +// read, so the request is a query string rather than a body: the subject's +// reference, the projection this deployment is entitled to, and the page size. +// Preparation invents nothing. Every value below is either a validated selector +// value or a reviewed adapter parameter. +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "record_reference", value: subject["values"]["record_reference"]}, + #{name: "fields", value: parameters["providerFields"]}, + #{name: "limit", value: parameters["resultLimit"]} + ], + body: () + } +} +RHAI +``` + +The source's `preparationLimits` declared `query: required` and `jsonBody: forbidden`, so a script +that returned a body here would be refused rather than sent. + +## The response the source returns + +```sh +cat > bundle/schemas/residence-register-response.schema.yaml <<'YAML' +# Closed schema for the projected residence-register response, checked before +# the extract script runs. +# +# The prepared request asks for a page of two, so a third record is a source +# that ignored the bound rather than an ambiguous lookup. No record leaf can be +# required: projection drops a leaf the source did not return, and a page is +# judged ambiguous before any record is read. Whether the page and the +# further-pages flag agree is a reading across fields, so it stays in the +# script. +type: object +additionalProperties: false +required: [records, pagination] +properties: + records: + type: array + minItems: 0 + maxItems: 2 + items: + type: object + additionalProperties: false + required: [] + properties: + id: + type: string + minLength: 1 + maxLength: 200 + area_code: + type: string + minLength: 1 + maxLength: 32 + pagination: + type: object + additionalProperties: false + required: [has_more] + properties: + has_more: {type: boolean} +YAML +``` + +Note what is required and what is not. The envelope is required, including `has_more`, because +this adapter cannot decide uniqueness without it. No leaf inside a record is required, because +projection drops a leaf the source did not return, and because a page of two is judged ambiguous +before any record is read. Marking a leaf required is a statement that a response without it is +unusable, so only require the leaves the projection genuinely always yields. + +```sh +cat > bundle/adapters/residence-register-extract.rhai <<'RHAI' +// Fact extraction for the residence register. The source returns a page of +// matching records and a flag saying whether further pages exist. It carries no +// total, so uniqueness is decided from the page itself: how many records came +// back, and whether the source claims more. +// +// The response schema has already rejected anything outside the declared shape, +// so what is left here is the part a shape cannot state: how the fields relate, +// and what an absent optional leaf means for this requirement. +fn extract(source_response, parameters) { + let records = source_response["records"]; + let has_more = source_response["pagination"]["has_more"]; + + if records.len == 0 { + // An empty page that still claims further pages contradicts itself. + // Reading it as no match would turn a broken source into a clean answer. + if has_more { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if records.len > 1 || has_more { + return #{outcome: "ambiguous"}; + } + + let record = records[0]; + // The shape cannot require a reference of every record, because an ambiguous + // page is judged before any record is read. A single matched record with no + // reference is a source this adapter does not understand. + if is_missing(record["id"]) { throw("source_protocol_error"); } + + let facts = #{record_reference: record["id"]}; + if !is_missing(record["area_code"]) { + facts["official_area_code"] = record["area_code"]; + } + #{outcome: "match", facts: facts} +} +RHAI +``` + +An extract script returns one of three outcomes: `no_match`, `ambiguous`, or `match` with facts. A +response it does not understand is a protocol error, never a quiet `no_match`, so a source that +changes shape cannot be mistaken for an answer about a person. + +```sh +cat > bundle/schemas/residence-register-facts.schema.yaml <<'YAML' +# Closed schema for the facts extraction may hand to this derivation. Unlike +# the response schema, every fact is required: a record that carries no area +# code is a record this requirement cannot answer from, and rejecting it here +# leaves the requirement unresolved instead of letting derivation invent a +# region. +type: object +additionalProperties: false +required: [record_reference, official_area_code] +properties: + record_reference: + type: string + minLength: 1 + maxLength: 200 + official_area_code: + type: string + minLength: 1 + maxLength: 32 +YAML +``` + +The fact schema is the opposite of the response schema. The response schema describes what a +source may send; the fact schema states what this definition needs. Making `official_area_code` +required is what turns a record with no area code into an unresolved requirement rather than a +derivation guessing. + +## The derivation + +```sh +cat > bundle/derivations/residence-region.rhai <<'RHAI' +// Requirement derivation. The register's own area code enters here and never +// leaves: only the coarse region code below reaches the signed assertion. +fn derive(facts, selectors, evaluation_context) { + // The record that came back must be the record that was asked about. + if facts["record_reference"] != + selectors["subject"]["values"]["record_reference"] { + throw("derivation_input_error"); + } + // A register code with no reviewed mapping leaves the requirement + // unresolved. It is never passed through as itself. + let mapped = codelist_lookup( + evaluation_context["codelists"]["residence-regions"], + required(facts["official_area_code"], "required_fact_missing") + ); + [#{ + concept_id: "urn:example:scaffold:concept:residence-region", + value: required(mapped, "unknown_controlled_code") + }] +} +RHAI +``` + +`codelist_lookup` is the only way a script reaches a codelist, and the handle it takes comes from +`evaluation_context["codelists"]` keyed by the codelist's file stem. A code with no entry returns +nothing at all, and `required` turns that into an unresolved requirement. There is no branch here +that could disclose `AREA-11`, which is the property the fixture cases will check. + +## Add the residence-region requirement + +The requirement is where the source, the derivation, the concept it discloses, and its fixtures +become one reviewed unit. + +Find this in `bundle/evidence.yaml`: + +```yaml + disclosureGuard: + families: [urn:example:scaffold:disclosure-family:example-flag] + existenceDisclosure: collapse-unresolved +``` + +Replace it with: + +```yaml + disclosureGuard: + families: [urn:example:scaffold:disclosure-family:example-flag] + existenceDisclosure: collapse-unresolved + + # The second acceptance definition. It answers a different question, from a + # different source, in a different form: not a boolean but one code from a + # closed list. + - id: urn:example:scaffold:requirement:residence-region:v1 + kind: information-requirement + source: residence-register + purposes: [residence-verification] + subjectRoles: + - role: subject + cardinality: one + selectorProfiles: [residence-lookup-v1] + referenceFrameworks: [urn:example:scaffold:framework:residence-region:v1] + evidenceType: urn:example:scaffold:evidence-type:residence-region:v1 + validitySeconds: 86400 + derivation: + script: derivations/residence-region.rhai + # The derivation sees the subject reference as well as the facts, so it + # can check that the record it is reducing is the record that was asked + # about. + selectorInputs: + - role: subject + alternatives: + - profile: residence-lookup-v1 + fields: [record_reference] + parameters: {} + concepts: + - id: urn:example:scaffold:concept:residence-region + form: controlled-code + required: true + constraints: + codelist: codelists/residence-regions.yaml + codelistVersion: '2026-01' + maximumBytes: 32 + fixtures: fixtures/residence-region-cases.yaml + # A different family from the requirement above, because the two answers + # cannot be combined into anything finer than either one discloses. Two + # requirements that could be combined must share a family, and a bundle that + # enables both is then refused at load. + disclosureGuard: + families: [urn:example:scaffold:disclosure-family:residence-region] + existenceDisclosure: collapse-unresolved +``` + +The disclosure guard is the part to be deliberate about. Requirements that share a family are +refused at load, because answers in the same family can be combined into something finer than any +one of them discloses. Here the two families genuinely differ: a boolean about how long ago an +event happened and a coarse region code say nothing about each other, and no sequence of the two +narrows either. If you later added a second region definition on overlapping boundaries, that one +would share the region family, and the bundle would then be refused, which is the point. + +The concept's `form: controlled-code` is what binds it to the codelist. `codelistVersion` must +match the codelist's own `version`, so a codelist revision cannot be swapped in under a +requirement that was reviewed against a different one. + +## Grant the new requirement + +A requirement nobody may ask for is inert. Add a grant so an authenticated requester can reach it. + +Find this in `bundle/evidence.yaml`: + +```yaml + subjects: + - role: subject + selectorProfile: subject-lookup-v1 + valueOrigin: request +``` + +Replace it with: + +```yaml + subjects: + - role: subject + selectorProfile: subject-lookup-v1 + valueOrigin: request + # A second grant, its own purpose, its own narrower selector profile. The + # same requester may hold both because the two answers are not combinable; + # the disclosure guard below is what states that. + - requirement: urn:example:scaffold:requirement:residence-region:v1 + purpose: residence-verification + audienceFrom: authenticated-requester + responseFormats: [signed-jws] + subjects: + - role: subject + selectorProfile: residence-lookup-v1 + valueOrigin: request +``` + +The grant's purpose must be one the requirement declares, and its selector profile must be one the +requirement's subject role accepts. Both are checked when the bundle loads, so a grant cannot +widen what a requirement agreed to. + +## The fixture cases + +Every requirement names a fixture file, and every fixture file must cover a fixed set of +categories before the bundle will load at all. + +```sh +cat > bundle/fixtures/residence-region-cases.yaml <<'YAML' +# Synthetic acceptance cases for the residence-region requirement. Every case +# replays offline through the reviewed adapter, derivation and output gate, so +# no source and no network are involved. +# +# The bundle is rejected unless the case identifiers cover every required +# category: `positive`, `no-match`, `source-failure`, `anti-reconstruction`, +# and at least one identifier each starting with `negative`, `boundary`, +# `missing` and `ambiguous`. +fixture: registry.evidence.scaffold.residence-region/v1 +coequal_acceptance_definition: true +synthetic_only: true + +common: + observed_at: '2026-08-02T00:00:00Z' + selectors: + subject: + profile: residence-lookup-v1 + # Synthetic identifiers only. These are asserted to be absent from + # diagnostics at the end of this file, so keep them distinctive. + values: + record_reference: RCD-000123 + +cases: + # One record, one mapped area code, one coarse region disclosed. + - id: positive + source: + records: + - id: RCD-000123 + area_code: AREA-11 + pagination: + has_more: false + expected_value: REGION-NORTH + expected_lookup: match + derivation_runs: true + signed_success: true + + # A second register code behind the same disclosed region. This is what makes + # the mapping a narrowing rather than a rename: the answer cannot tell the two + # register codes apart. + - id: boundary-second-code-same-region + source: + records: + - id: RCD-000123 + area_code: AREA-12 + pagination: + has_more: false + expected_value: REGION-NORTH + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-other-region + source: + records: + - id: RCD-000123 + area_code: AREA-21 + pagination: + has_more: false + expected_value: REGION-SOUTH + expected_lookup: match + derivation_runs: true + signed_success: true + + # A register code outside the reviewed mapping. The requirement is left + # unresolved rather than disclosing the register's own finer code. A value the + # derivation required and did not get collapses into the same unresolved + # outcome as a fact that never arrived, which is why this case declares + # `derivation_runs: false`: nothing the derivation could disclose survived. + - id: negative-unmapped-area-code + source: + records: + - id: RCD-000123 + area_code: AREA-99 + pagination: + has_more: false + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # The output gate rejects a derived code outside the codelist's allowed + # outputs, so a script defect cannot become signed evidence. + - id: negative-code-outside-allowed-outputs + injected_derivation: + - concept_id: 'urn:example:scaffold:concept:residence-region' + value: 'REGION-EAST' + expected: output-gate-rejection + + # One record resolved, but it carries no area code. The fact schema refuses + # the incomplete extraction before any derivation runs. + - id: missing-area-code + source: + records: + - id: RCD-000123 + pagination: + has_more: false + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + - id: no-match + source: + records: [] + pagination: + has_more: false + expected_lookup: no_match + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + - id: ambiguous-two-records + source: + records: + - id: RCD-000123 + area_code: AREA-11 + - id: RCD-000124 + area_code: AREA-21 + pagination: + has_more: false + expected_lookup: ambiguous + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # One record on the page, but the source says more pages exist. A page that + # happens to hold one record is not a unique match. + - id: ambiguous-further-pages + source: + records: + - id: RCD-000123 + area_code: AREA-11 + pagination: + has_more: true + expected_lookup: ambiguous + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # A source that never answers is a dependency problem, never a region. + - id: source-failure + source_failure: timeout + expected_public_problem: dependency_unavailable + signed_success: false + + # Two requirements that share a disclosure family could be combined into a + # finer location than either discloses, so such a bundle must not load. + - id: anti-reconstruction + companion_bundle: geographic-overlap + expected: bundle-rejection + +# What the signed assertion must and must not carry. The disclosed region codes +# are required to be present; the register's own area codes are required to be +# absent. +privacy_expectation: + evidence_contains: + - 'urn:example:scaffold:concept:residence-region' + - REGION-NORTH + - REGION-SOUTH + evidence_excludes: [AREA-11, AREA-12, AREA-21, official_area_code, record_reference] + diagnostics_exclude: [RCD-000123, AREA-11] +YAML +``` + +Three things in that file are worth reading twice. + +The case identifiers are policed, not decorative. A file needs a case named exactly `positive`, one +named exactly `no-match`, one named exactly `source-failure`, one named exactly +`anti-reconstruction`, and at least one identifier each beginning with `negative`, `boundary`, +`missing` and `ambiguous`. The refusal names the file but not the category, so if you see +`fixture category coverage is incomplete`, walk that list. + +The `anti-reconstruction` case is not a comment. It names a combination from the conformance +matrix, `geographic-overlap`, and the run actually rebuilds this bundle with a second requirement +in the same disclosure family and requires the result to be refused. It is proof that your +disclosure guard is load-bearing. + +The `privacy_expectation` block is checked against the signed, verified assertions the successful +cases produced. `REGION-NORTH` and `REGION-SOUTH` must appear in them; `AREA-11`, `AREA-12`, +`AREA-21` and the fact name `official_area_code` must not. That is the minimization claim of this +whole definition, written as an assertion rather than as prose. + +## Freeze and run + +Evidence treats the bundle and the runtime file as trusted, startup-only artifacts and refuses +mutable deployment input, so make them read-only now that the editing is done: + +```sh +chmod -R a-w bundle && chmod 444 runtime.yaml +``` + +```sh +evidencectl fixtures run --project . +``` + +Expected output: + +```text +PASS: check +PASS: fixtures/cases.yaml (12 cases) +PASS: fixtures/residence-region-cases.yaml (11 cases) +3 passed, 0 failed (23 cases evaluated) +``` + +Three steps passed. `check` loaded and validated the whole project, including the parts that only +exist because you added a second definition: the new source's selector bindings, the codelist +identity and version behind the concept, the new grant, and the fact that the two requirements do +not share a disclosure family. Then both fixture files replayed through the real evaluation +pipeline, offline, with no source and no network. + +## When it does not pass + +To edit the bundle again after freezing it, run `chmod -R u+w bundle` first. Without that, the run +fails with `deployment input is not immutable` before it looks at anything you changed. + +Four refusals account for most of what goes wrong here, and each of them is the runtime working as +designed: + +- `enabled requirements share a disclosure family` means both requirements name the same family. + Give the new one its own, or, if the two answers really are combinable, do not enable both. +- `fixture category coverage is incomplete` names the fixture file but not the missing category. + Check the eight identifiers listed above. +- `bundle root contains a file other than the configuration` means a stray artifact is inside + `bundle/`. An editor backup such as `evidence.yaml.bak` will do it. The bundle is a closed set of + reviewed artifacts, so anything unrecognised is refused rather than ignored. +- `fixture prohibited disclosure is present` means a string your `privacy_expectation` excludes + showed up in a signed assertion. Read it as a real finding before you read it as a fixture bug. + +## Cleanup + +```sh +cd .. +chmod -R u+w region-evidence +rm -rf region-evidence +``` + +## Next + +- [Configure Evidence](../../configure/evidence/) documents every field you touched here. +- [Evidence security model](../../security/evidence/) explains the disclosure guard, the existence + collapse, and the immutability refusal. +- [Registry Evidence API](../../reference/apis/registry-evidence/) documents the HTTP surface a + served deployment exposes for the definitions you author. From a21ffb58ec7d924bf895e9c67bc51925b76b41bf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:49:00 +0700 Subject: [PATCH 068/136] docs(evidence): add the institution-source connection tutorial The first two tutorials work against the scaffold's stand-in source. This third one connects a real institution read API: draft the source from its OpenAPI document with evidencectl source suggest, review the draft rather than trust it, write the prepare and extract scripts, and prove the adapter offline from sanitized fixture cases before any live request. It teaches the two runtime rules that are easiest to get wrong: a response schema describes the response after projection, not what the API returns, and an absent key needs get_path rather than direct indexing. Registered in the tutorial gate, which replays all eleven command blocks and applies the three documented before/after edits. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + docs/site/scripts/check-evidence-tutorials.sh | 26 + .../connect-an-institution-source.mdx | 919 ++++++++++++++++++ 3 files changed, 946 insertions(+) create mode 100644 docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 3b2620f42..89b8a5173 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -329,6 +329,7 @@ export default defineConfig({ items: [ { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, { label: 'Author an acceptance definition', slug: 'tutorials/author-an-acceptance-definition' }, + { label: 'Connect an institution source', slug: 'tutorials/connect-an-institution-source' }, { label: 'Configure Evidence', slug: 'configure/evidence' }, { label: 'Configure Registry Mint', slug: 'configure/mint' }, { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 363f3adce..52869092d 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -49,6 +49,7 @@ TARGET_DIR="$REPO_ROOT/target/evidence-tutorial-source" EVIDENCE_TUTORIALS=( first-evidence-assertion author-an-acceptance-definition + connect-an-institution-source ) load_spec() { @@ -107,6 +108,31 @@ load_spec() { '3 passed, 0 failed (23 cases evaluated)' ) ;; + connect-an-institution-source) + SPEC_FENCES=11 + # The drafting tool writes three files into the project, so two of the + # edits repair its output in place and one repoints the requirement. + SPEC_STEPS=( + 'run:1-4' + 'edit:Narrow the drafted response schema|yaml|1|Narrow the drafted response schema|yaml|2|bundle/schemas/event-records-response.schema.yaml' + 'run:5-6' + 'edit:Replace the placeholder source|yaml|1|Replace the placeholder source|yaml|2|bundle/evidence.yaml' + 'edit:Repoint the requirement|yaml|1|Repoint the requirement|yaml|2|bundle/evidence.yaml' + 'run:7-11' + ) + SPEC_LITERALS=( + 'evidencectl new connect-a-source' + 'evidencectl source suggest' + 'chmod -R a-w bundle && chmod 444 runtime.yaml' + 'evidencectl fixtures run --project .' + '2 passed, 0 failed (12 cases evaluated)' + ) + SPEC_OUTPUTS=( + 'PASS: check' + 'PASS: fixtures/cases.yaml (12 cases)' + '2 passed, 0 failed (12 cases evaluated)' + ) + ;; *) printf '%s is not a registered Evidence tutorial\n' "$1" >&2 exit 2 diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx new file mode 100644 index 000000000..30fe8a2f5 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -0,0 +1,919 @@ +--- +title: Connect an institution source +description: Repoint a scaffolded Evidence project from its placeholder source at a real institution API, draft the source configuration from an OpenAPI document, and prove the adapter offline with sanitized synthetic fixtures. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: tutorial +locale: en +standards_referenced: [] +--- + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +A scaffolded Evidence project answers its acceptance definition from a placeholder source called +`source-a`, pointed at `https://source.invalid`, which never answers. Here you replace that +placeholder with an adapter for an institution's own read API: you draft the source configuration +from the institution's OpenAPI document, review the draft by hand, and prove the whole adapter +offline against sanitized fixtures. No request leaves your machine at any point. + + + +Everything in this tutorial is synthetic: a fictional institution, a fictional API on a hostname +that never resolves, and fixture data invented for the purpose. Do not put production endpoints, +source credentials, real sample responses, or personal data into a project you share. + +## Check the operation before you draft from it + +Evidence answers one bounded question about one subject, so the operation you read has to be able +to say three different things in a single bounded request: nothing matched, exactly one thing +matched, or more than one thing matched. An operation that cannot distinguish those three is not a +Version 1 integration, and no amount of adapter work fixes it. + +Read the candidate operation against these four questions before writing any configuration: + +1. Can one request select a single subject from the selector fields the deployment holds? An + operation that only lists everything and expects the caller to filter is not usable: filtering + locally means reading records that are not the subject's. +2. Does the response distinguish zero matches from many? A bare array is ambiguous at the edges, + because a truncated page of one looks the same as a genuine single match. A separate total, an + exact-match endpoint, or a documented uniqueness guarantee resolves it. +3. Is the response bounded? Every array needs a maximum length, every string a maximum size. If + the API documents neither, the deployment has to impose bounds and defend them in review. +4. Does the operation authenticate a service, rather than a person? Evidence holds a deployment + credential, never an end user's session. + +The synthetic API in this tutorial answers all four, and it answers the second one with a +`matchCount` field that is the operation's own count of matching records, independent of how many +records the page returns. + +## Scaffold a project to work in + +Create a fresh project and enter it: + +```sh +evidencectl new connect-a-source +cd connect-a-source +``` + +Generate the key material the runtime requires: + +```sh +evidencectl keygen signing --out-dir secrets --kid scaffold-signing-key-1 +evidencectl keygen secret --out secrets/audit-hmac-key +evidencectl keygen secret --out secrets/subject-binding-hmac-key +``` + +Expected output: + +```text +wrote secrets/signing-ed25519-private-jwk +wrote secrets/signing-ed25519-public.jwk.json +kid: scaffold-signing-key-1 +wrote secrets/audit-hmac-key +wrote secrets/subject-binding-hmac-key +``` + +[Get a first Evidence assertion](../first-evidence-assertion/) explains what the scaffold created +and what each secret is for. Leave the bundle writable for now: the freeze comes at the end, once +the adapter is finished. + +## Save the institution's API document + +An adopter normally downloads this document from the institution, or is sent it. Write the +synthetic stand-in used by this tutorial: + +```sh +mkdir -p provider +cat > provider/records-office.openapi.yaml <<'YAML' +# Public API document of the Norhaven Records Office, a fictional institution. +# It stands in for the document a real institution publishes. Nothing in it +# resolves: `records.norhaven.example` is a reserved name that never answers. +openapi: 3.0.3 +info: + title: Norhaven Records Office public API + version: "2.4.0" +servers: + - url: https://records.norhaven.example/public-api +paths: + /v1/event-records: + get: + operationId: searchEventRecords + summary: Search recorded events by reference and issuing office. + parameters: + - name: reference + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 64 + - name: office + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 32 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 25 + default: 10 + responses: + "200": + description: A page of matching records, with the total match count. + content: + application/json: + schema: + $ref: '#/components/schemas/EventRecordPage' +components: + schemas: + EventRecordPage: + type: object + required: [matchCount, records] + properties: + matchCount: + type: integer + minimum: 0 + maximum: 100000 + records: + type: array + items: + $ref: '#/components/schemas/EventRecord' + EventRecord: + type: object + required: [recordId, officeCode, registeredAt] + properties: + recordId: + type: string + format: uuid + eventDate: + type: string + format: date + officeCode: + type: string + maxLength: 32 + registeredAt: + type: string + format: date-time + remarks: + type: string + maxLength: 4096 +YAML +``` + +The document is read from disk and never fetched. `evidencectl` does not resolve remote `$ref` +targets and does not contact the server the document names. + +## Draft the source configuration + +The scaffold's acceptance definition needs exactly one fact: the recorded event date. That is one +leaf of one operation, so the selection is two JSON pointers: the count that resolves cardinality, +and the date itself. + +```sh +evidencectl source suggest \ + --openapi provider/records-office.openapi.yaml \ + --operation 'GET /v1/event-records' \ + --source-id event-records \ + --project . \ + --select /matchCount \ + --select '/records/*/eventDate' +``` + +Run without `--operation` and `--select` and the command becomes interactive, listing the +operations it found and the candidate leaves of the response you pick. The flags make the same run +reproducible in a script or a review. + +One note goes to standard error, and it is the most important line of the run: + +```text +evidencectl: adopting maxItems 25 for `/records`, derived from a page-size parameter in the spec +``` + +Expected output, first part: + +```text +wrote ./bundle/schemas/event-records-response.schema.yaml +wrote ./bundle/adapters/event-records-extract.rhai +wrote ./bundle/schemas/event-records-facts.schema.yaml +--- the block to paste under `sources:` in bundle/evidence.yaml --- +# Paste this block under `sources:` in bundle/evidence.yaml, then resolve +# every TODO(evidencectl) comment below before running `evidence check`. +sources: + event-records: + transport: http-json + # TODO(evidencectl): confirm this base URL against the intended deployment; + # derived from the OpenAPI servers list, which states an origin only here + # and any path prefix on the request path below. + baseUrl: https://records.norhaven.example + # TODO(evidencectl): upgrade to field-projected or source-derived only if the + # source's pre-projection response really carries no more than this. + posture: record-transformed + # TODO(evidencectl): review authentication; static-bearer is a placeholder. + # See CONFIG.md#source-authentication for the other supported kinds. Do not + # map OpenAPI security schemes automatically. + authentication: + kind: static-bearer + tokenRef: secret:file/event-records-bearer-token + request: + method: GET + path: /public-api/v1/event-records + fixedHeaders: + - name: Accept + value: application/json + # TODO(evidencectl): selectorInputs — copy the shape from + # bundle/evidence.yaml (sources.source-a.request.selectorInputs) + # and name this source's real selector profile and fields. + # TODO(evidencectl): prepareScript — author this script from + # bundle/adapters/source-a-prepare.rhai. + # TODO(evidencectl): adapterParameters and adapterParametersSchema — copy the + # shape from bundle/evidence.yaml and + # bundle/schemas/adapter-parameters.schema.yaml. + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 32 + maximumQueryValueBytes: 256 + maximumNormalizedBytes: 4096 + projection: [/matchCount, /records/*/eventDate] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + responseSchema: schemas/event-records-response.schema.yaml + extractScript: adapters/event-records-extract.rhai + factSchema: schemas/event-records-facts.schema.yaml +``` + +Expected output, second part: + +```text +not verified: pass --evidence-bin to run `evidence check` once the project is frozen and provisioned. +evidencectl source suggest: draft for source `event-records` (GET /v1/event-records) + +Derived automatically: + - /records (maxItems): derived from a page-size parameter in the spec + +Still needs your input (0 schema bound(s), plus the source block below): + - TODO(evidencectl): sources..request selectorInputs, prepareScript, + adapterParameters, and adapterParametersSchema in the pasted source block. + - TODO(evidencectl): review authentication and baseUrl in the pasted source block. + +Next steps: + 1. Resolve every TODO(evidencectl) comment in schemas/event-records-response.schema.yaml, + adapters/event-records-extract.rhai, schemas/event-records-facts.schema.yaml, and the pasted source block. + 2. Paste the source block under `sources:` in bundle/evidence.yaml. + 3. Run `evidence check --runtime /runtime.yaml`. + +Until step 2 is done `evidence check` fails, naming every drafted file, with +`deployment artifact closure is invalid`: the bundle now carries artifacts +evidence.yaml does not declare yet. That error is the remaining to-do list, not a +broken draft. + +Reproduce this run with: + evidencectl source suggest --openapi provider/records-office.openapi.yaml --operation 'GET /v1/event-records' --status 200 --media-type application/json --source-id event-records --project . --select /matchCount --select '/records/*/eventDate' +``` + +## Read the draft as a draft + +The command wrote three files and printed a fourth thing it refuses to write: the source block for +`bundle/evidence.yaml`. That split is the point. `evidencectl` will draft shapes it can derive from +a document, and it will not silently edit the governed contract that says which system a deployment +reads and with what credential. + +Read the run report as a list of claims to check, in three groups. + +**What it derived, with its provenance.** One line: `maxItems 25 for /records`. The document does +not bound the `records` array, so a maximum length was taken from the `limit` parameter's own +`maximum`. A page-size maximum is a real bound and a weak one: it is the largest page the operation +will return, not a promise about the collection. Every derived bound carries a provenance comment +in the file for exactly this reason. A bound from the response schema is stronger than one from a +parameter, which is stronger than one observed in a sample. + +**What it refuses to guess.** `selectorInputs`, `prepareScript`, `adapterParameters` and its +schema stay as TODO comments, because they encode which of the deployment's selector fields are +allowed to reach this institution, which is a disclosure decision and not a fact about the +document. `authentication` is drafted as `static-bearer` and flagged for review: OpenAPI security +schemes are deliberately not mapped, because the credential kind a deployment holds is an operator +decision. + +**What it got structurally right and you still have to confirm.** `baseUrl` carries the origin only +and the document's `/public-api` prefix moved onto `request.path`, because an Evidence `baseUrl` is +a bare origin. The `posture` is `record-transformed`, which is the honest label when a whole record +crosses the wire and the projection narrows it locally. Weakening that to `field-projected` claims +the source itself disclosed no more than the projection keeps, which this operation does not. + +The drafted response schema is the file worth reading closely, because it is the shape contract the +runtime enforces before your script sees anything: + +```text +type: object +additionalProperties: false +required: [matchCount, records] +properties: + matchCount: + type: integer + minimum: 0 + maximum: 100000 + # derived from a page-size parameter in the spec + records: + type: array + minItems: 0 + maxItems: 25 + items: + type: object + additionalProperties: false + required: [] + properties: + eventDate: + type: string + format: date +``` + +Two properties, and everything else the API returns is gone: `recordId`, `officeCode`, +`registeredAt` and `remarks` were never selected, so the projection discards them before this +schema runs and before any script is compiled. + +## Narrow the drafted response schema + +One line of that draft is wrong, and it is wrong in a way worth understanding. An Evidence response +schema describes the response **after** projection, not the response the API returns. The draft +copied `required: [matchCount, records]` from the document, where both fields are indeed always +present. But projection keeps a key only when it yields at least one selected leaf underneath it, +and the only selected leaf under `records` is `eventDate`, which the document marks optional. A +genuine zero-match response, `{"matchCount": 0, "records": []}`, projects to `{"matchCount": 0}`, +and a schema that requires `records` rejects it. + +Find this line in `bundle/schemas/event-records-response.schema.yaml`: + +```yaml +required: [matchCount, records] +``` + +Replace it with: + +```yaml +required: [matchCount] +``` + +The rule generalizes: mark a leaf required only when the projection always yields it. `matchCount` +qualifies, because it is a required scalar at the top of the response. Nothing under an optional +leaf does. + +## Write the request adapter + +The prepare script turns validated selector values into the query this operation expects. It reads +two things and nothing else: the selector values the requester supplied, and reviewed constants +from the bundle. It cannot reach configuration, the environment, or the network. + +```sh +cat > bundle/adapters/event-records-prepare.rhai <<'RHAI' +// Request preparation for event-records. Turns the already validated selector +// values and the reviewed adapter parameters into the query the operation +// expects. Preparation may not invent fields: every pair emitted comes from a +// selector value or from a closed adapter parameter. +fn prepare(selectors, parameters) { + let subject = selectors["subject"]; + #{ + query: [ + #{name: "reference", value: subject["values"]["record_reference"]}, + #{name: "office", value: subject["values"]["registry_code"]}, + #{name: "limit", value: parameters["resultLimit"]} + ], + body: () + } +} +RHAI +cat > bundle/schemas/event-records-adapter-parameters.schema.yaml <<'YAML' +# Closed schema for the reviewed adapter parameters of event-records. Constants +# keep the request shape under review rather than under operator control. +type: object +additionalProperties: false +required: [resultLimit] +properties: + resultLimit: + const: "1" +YAML +``` + +`resultLimit` is pinned to `"1"` by a `const` in the schema, so a page of one is a reviewed +property of the deployment rather than a value an operator can raise. Asking for one record is a +minimization decision: cardinality comes from `matchCount`, so a larger page would fetch records +belonging to people who are not the subject in order to learn nothing extra. The value is a string +because query values are strings on the wire. + +## Write the extract script and the facts schema + +The generated extract script is a skeleton with the cardinality decision left as a TODO, which is +correct: nothing in an OpenAPI document says which field means "several subjects matched". Replace +it with the real mapping, and replace the placeholder facts schema with the one fact the derivation +consumes. + +```sh +cat > bundle/adapters/event-records-extract.rhai <<'RHAI' +// Fact extraction for event-records, from the skeleton +// `evidencectl source suggest` drafted for GET /v1/event-records. +// +// The response schema has already rejected anything outside the declared shape, +// so this script carries no presence or type checks. What is left is the part a +// shape cannot state: matchCount is the operation's own count of matching +// records, so it, and not the length of the returned page, decides whether the +// lookup resolved to zero, one, or several subjects. +fn extract(source_response, parameters) { + let match_count = source_response["matchCount"]; + if match_count == 0 { + // The operation says it matched nothing, so it must not also return a + // record. A response that does is a source change, not an answer. + let records = get_path(source_response, "/records"); + if !is_missing(records) && records.len != 0 { throw("source_protocol_error"); } + return #{outcome: "no_match"}; + } + if match_count > 1 { + return #{outcome: "ambiguous"}; + } + // Exactly one match, and the request asked for a page of one, so index 0 is + // the only element the page can carry. + let event_date = get_path(source_response, "/records/0/eventDate"); + if is_missing(event_date) { + return #{outcome: "match", facts: #{}}; + } + #{outcome: "match", facts: #{event_date: event_date}} +} +RHAI +cat > bundle/schemas/event-records-facts.schema.yaml <<'YAML' +# Closed schema for the facts extraction may hand to the derivation for +# event-records. Extraction output that does not match exactly is rejected +# before any derivation runs. +type: object +additionalProperties: false +required: [event_date] +properties: + event_date: + type: string + format: date +YAML +``` + +Three details in that script repay attention. + +The three outcomes are named, not inferred. `no_match` and `ambiguous` are outcomes an adapter +returns, not errors it raises, and Evidence turns both into the same `evidence_not_available` +answer for the requester. A source that cannot tell them apart has to return one of them for both, +which is a real loss of fidelity and the reason the operation check comes first. + +`source_response["matchCount"]` indexes directly, because the response schema has already made that +key required. `get_path` is used for `/records` and for the date, because both can legitimately be +absent from the projected response and direct indexing throws on an absent key. Reach for `get_path` +plus `is_missing` exactly where the schema permits absence. + +`records.len` is a property on an array, not a function call. The scaffold's own extract script +writes `len(source_response)`, which is the function form, and that form works there because the +argument is a map. Passing an array to `len()` fails at runtime. + +## Replace the placeholder source + +The drafted source block goes into `bundle/evidence.yaml` by hand, with the TODO comments resolved. +Find this block: + +```yaml +# One generic JSON over HTTP source. Point baseUrl and path at the system that +# already holds the data, and keep the projection as narrow as the derivation +# needs: anything outside the projection is discarded before extraction runs. +sources: + source-a: + transport: http-json + # Replace with the origin of the system that holds the data. The path below + # is appended to it. + baseUrl: https://source.invalid + posture: field-projected + # The bearer token is read from the runtime secret root, never from this + # file. Name the secret file here and put its bytes there. + authentication: + kind: static-bearer + tokenRef: secret:file/source-bearer-token + request: + method: POST + path: /v1/facts + fixedHeaders: + - name: Accept + value: application/json + # Which selector profile fields reach this source, and in which role. + # Add an alternative for each accepted combination of fields. + selectorInputs: + - role: subject + alternatives: + - profile: subject-lookup-v1 + fields: [record_reference, registry_code] + prepareScript: adapters/source-a-prepare.rhai + # Reviewed constants the prepare script may read. They are validated + # against the schema below, so change the two together. + adapterParameters: + requestedFields: [event_date] + resultLimit: 2 + adapterParametersSchema: schemas/adapter-parameters.schema.yaml + preparationLimits: + query: forbidden + jsonBody: required + maximumJsonDepth: 8 + maximumCollectionItems: 16 + maximumStringBytes: 256 + maximumNormalizedBytes: 4096 + projection: [/total, /event_date] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/response.schema.yaml + extractScript: adapters/source-a-extract.rhai + factSchema: schemas/facts.schema.yaml +``` + +Replace it with the reviewed block: + +```yaml +# The institution's own read API. baseUrl carries an origin and nothing else: +# the `/public-api` prefix the OpenAPI servers list declared belongs on the +# request path instead. Keep the projection as narrow as the derivation needs: +# anything outside the projection is discarded before extraction runs. +sources: + event-records: + transport: http-json + baseUrl: https://records.norhaven.example + # The operation answers with whole records and this deployment narrows them + # after the fact, so record-transformed is the honest posture: a local + # projection bounds what Evidence keeps, never what the source disclosed. + posture: record-transformed + # The bearer token is read from the runtime secret root, never from this + # file. Name the secret file here and put its bytes there. + authentication: + kind: static-bearer + tokenRef: secret:file/event-records-bearer-token + request: + method: GET + path: /public-api/v1/event-records + fixedHeaders: + - name: Accept + value: application/json + # Which selector profile fields reach this source, and in which role. + selectorInputs: + - role: subject + alternatives: + - profile: subject-lookup-v1 + fields: [record_reference, registry_code] + prepareScript: adapters/event-records-prepare.rhai + # Reviewed constants the prepare script may read. They are validated + # against the schema named under them, so change the two together. + adapterParameters: + resultLimit: "1" + adapterParametersSchema: schemas/event-records-adapter-parameters.schema.yaml + preparationLimits: + query: required + jsonBody: forbidden + maximumQueryPairs: 8 + maximumQueryNameBytes: 32 + maximumQueryValueBytes: 256 + maximumNormalizedBytes: 4096 + projection: [/matchCount, /records/*/eventDate] + redirects: deny + timeoutMilliseconds: 3000 + maximumResponseBytes: 65536 + concurrencyLimit: 8 + # Shape contract for the projected response, checked before extraction runs. + responseSchema: schemas/event-records-response.schema.yaml + extractScript: adapters/event-records-extract.rhai + factSchema: schemas/event-records-facts.schema.yaml +``` + +The `preparationLimits` changed shape along with the method. The placeholder was a POST source, so +it declared `jsonBody: required` and JSON body limits. A GET source declares `query: required`, +`jsonBody: forbidden`, and per-pair query limits instead. Declaring the wrong family is a +configuration error, not a silently ignored setting. + +## Repoint the requirement + +The requirement still names the source that no longer exists. Find this line: + +```yaml + source: source-a +``` + +Replace it with: + +```yaml + source: event-records +``` + +## Remove the placeholder artifacts + +A bundle's artifact list is closed in both directions: an artifact `evidence.yaml` does not +reference is refused, and a reference to a file that is not present is refused. The five files the +placeholder source used are now referenced by nothing, so remove them. + +```sh +rm bundle/adapters/source-a-prepare.rhai \ + bundle/adapters/source-a-extract.rhai \ + bundle/schemas/response.schema.yaml \ + bundle/schemas/facts.schema.yaml \ + bundle/schemas/adapter-parameters.schema.yaml +``` + +Between drafting the source and finishing this edit the project does not load, and +`deployment artifact closure is invalid` names every file that is on one side of the contract and +not the other. That message is a to-do list. + +## Sanitize a sample response into fixture cases + +Ask the institution for a sample response, or capture one against a test instance. A real one for +this operation looks like this: + +```text +{ + "matchCount": 1, + "records": [ + { + "recordId": "7d1f0a26-9c4b-4f3e-8a12-5b6e4c0d2f18", + "eventDate": "1994-03-17", + "officeCode": "NRW-114", + "registeredAt": "1994-03-24T11:02:00Z", + "remarks": "Amended 1996-08-02 under file 114/96." + } + ] +} +``` + +That sample is personal data. It is useful for its shape and nothing else, so read the shape off it +and delete it. It must not enter the bundle, a commit, a snapshot, or a log, and the fixture cases +you write from it carry invented values only. + +The cases file is the substitute. It replays the real evaluation pipeline (request preparation, +projection, response schema, extraction, derivation, output gate) with the source response supplied +from disk. The bundle refuses to load unless the case identifiers cover every required category, so +the set is a floor and not a sample. + +```sh +cat > bundle/fixtures/cases.yaml <<'YAML' +# Synthetic acceptance cases for the requirement, sanitized from one sample +# response of GET /v1/event-records. `evidence evaluate` replays every case +# offline against the reviewed adapter, derivation and output gate, so no +# source and no network are involved. +# +# Every `source:` block is the response as the operation returns it, before this +# deployment's projection runs. The extra fields in the first case are there on +# purpose: the projection is what drops them, and a case carrying them proves it. +# +# The bundle is rejected unless the case identifiers cover every required +# category: `positive`, `no-match`, `source-failure`, `anti-reconstruction`, +# and at least one identifier each starting with `negative`, `boundary`, +# `missing` and `ambiguous`. +fixture: registry.evidence.scaffold.example-flag/v1 +coequal_acceptance_definition: true +synthetic_only: true + +common: + observed_at: '2026-08-02T00:00:00Z' + legal_local_date: '2026-08-02' + selectors: + subject: + profile: subject-lookup-v1 + # Synthetic identifiers only. These are asserted to be absent from + # diagnostics at the end of this file, so keep them distinctive. + values: + record_reference: RCD-000123 + registry_code: RGC-07 + +cases: + # One match past the threshold produces a signed true. This is the only case + # that carries a whole record: everything outside the projection is discarded + # before the response schema runs, so the case passes only if that happens. + - id: positive + source: + matchCount: 1 + records: + - recordId: 00000000-0000-4000-8000-000000000001 + eventDate: '2000-01-01' + officeCode: OFC-01 + registeredAt: '2000-01-08T09:00:00Z' + remarks: not projected + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + # One match short of the threshold produces a signed false. A negative answer + # is a successful answer, not an error. + - id: negative-false-is-success + source: + matchCount: 1 + records: + - eventDate: '2010-01-01' + expected_value: false + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-before + legal_local_date: '2026-08-01' + source: + matchCount: 1 + records: + - eventDate: '2008-08-02' + expected_value: false + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-on + legal_local_date: '2026-08-02' + source: + matchCount: 1 + records: + - eventDate: '2008-08-02' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-after + legal_local_date: '2026-08-03' + source: + matchCount: 1 + records: + - eventDate: '2008-08-02' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + - id: boundary-leap-day + legal_local_date: '2026-02-28' + source: + matchCount: 1 + records: + - eventDate: '2008-02-29' + expected_value: true + expected_lookup: match + derivation_runs: true + signed_success: true + + # One record resolved, but it carries no event date. The operation's own + # schema marks that field optional, so this is a record the source really can + # return. The requester learns only that evidence is not available. + - id: missing-event-date + source: + matchCount: 1 + records: + - recordId: 00000000-0000-4000-8000-000000000002 + officeCode: OFC-01 + registeredAt: '2019-05-02T08:15:00Z' + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + - id: no-match + source: + matchCount: 0 + records: [] + expected_lookup: no_match + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # The count, not the length of the returned page, is what says several + # subjects matched. The page carries one record because the request asked for + # one; the answer is still ambiguous. + - id: ambiguous-multiple-matches + source: + matchCount: 3 + records: + - eventDate: '2000-01-01' + expected_lookup: ambiguous + expected_public_problem: evidence_not_available + derivation_runs: false + signed_success: false + + # A source that never answers is a dependency problem, never a negative. + - id: source-failure + source_failure: timeout + expected_public_problem: dependency_unavailable + signed_success: false + + # The output gate rejects a derivation result whose type contradicts the + # declared concept form, so a script defect cannot become signed evidence. + - id: negative-wrong-derived-type + injected_derivation: + - concept_id: 'urn:example:scaffold:concept:example-flag' + value: 'true' + expected: output-gate-rejection + + # Two requirements that share a disclosure family could be combined to + # reconstruct the underlying value, so such a bundle must not load. + - id: anti-reconstruction + companion_bundle: threshold-ladder + expected: bundle-rejection + +# What the signed assertion must and must not carry. +privacy_expectation: + evidence_contains: ['urn:example:scaffold:concept:example-flag'] + evidence_excludes: + - event_date + - eventDate + - matchCount + - recordId + - officeCode + - record_reference + - registry_code + - selector-profile + diagnostics_exclude: [RCD-000123, RGC-07, OFC-01, '2000-01-01'] +YAML +``` + +The `privacy_expectation` block at the end is the part specific to connecting a source. Every field +name this API taught the deployment about is asserted absent from the signed assertion, and the +synthetic identifier values are asserted absent from diagnostics. Adding a source adds vocabulary +that could leak, so extend both lists whenever an adapter learns a new field. + +## Freeze and prove the adapter offline + +Evidence refuses mutable deployment input, so make the bundle and the runtime file read-only: + +```sh +chmod -R a-w bundle && chmod 444 runtime.yaml +``` + +Run the whole project: + +```sh +evidencectl fixtures run --project . +``` + +Expected output: + +```text +PASS: check +PASS: fixtures/cases.yaml (12 cases) +2 passed, 0 failed (12 cases evaluated) +``` + +`check` proved the configuration is coherent: the source resolves, the selector profile fields the +prepare script reads are the ones `selectorInputs` admits, the adapter parameters match their +schema, all three scripts compile, and every artifact is declared exactly once. Then twelve cases +replayed through the real pipeline. The adapter is proven without a request, a credential, or a +network. + +To keep editing, make the bundle writable again with `chmod -R u+w bundle` and freeze it after each +round. + +## What is still missing before a live request + +Passing fixtures prove the adapter's logic against responses you wrote. Three things separate that +from a working integration, and none is in scope for a local project: + +- **A credential.** `tokenRef: secret:file/event-records-bearer-token` names a file under the + runtime's secret root that does not exist yet. The institution issues that token to the + deployment, not to a person, and its bytes never enter the bundle. Write it to that path with + mode 0600. (`evidencectl keygen token` generates one for a stand-in source you control; + `keygen secret` does not, because it produces raw HMAC key material that an HTTP header value + rejects.) +- **A confirmed contract.** The bounds in the response schema came from a document. Bounds derived + from a page-size parameter, and any bound a sample suggested, are the ones to raise with the + institution: an operation that can return a longer array than its documented page size will be + refused at runtime rather than silently truncated. +- **An agreement.** An institution granting read access to records about people is a governance + decision with a lawful basis, a retention position, and an audit expectation behind it. The + narrow projection and the `privacy_expectation` assertions are what a deployment brings to that + conversation. + +## Cleanup + +To remove the project: + +```sh +cd .. +chmod -R u+w connect-a-source +rm -rf connect-a-source +``` + +## Next + +- [Author an acceptance definition](../author-an-acceptance-definition/) adds a second question to + the same project, answered from the same source. +- [Configure Evidence](../../configure/evidence/) documents every source field this tutorial + edited, including the other authentication kinds and postures. +- [Evidence security model](../../security/evidence/) explains the projection, the closed response + schema, and the artifact closure rule as invariants rather than as configuration. +- [Evidence problems](../../reference/evidence-problems/) lists what a requester sees for each + outcome, including `evidence_not_available` and `dependency_unavailable`. From 6c4b615b8f653ed1034217fd2a2dee306ce164ea Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:49:09 +0700 Subject: [PATCH 069/136] ci: route the two new Evidence tutorials to the gate that replays them A tutorial the change filter does not watch is a tutorial that can break without any pull request noticing. The parity test already refuses a registered tutorial missing from this set. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index ae7a469de..b437cce5a 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -74,6 +74,8 @@ "docs/site/scripts/check-evidence-tutorials.sh", "docs/site/scripts/check-evidence-tutorials.test.mjs", "docs/site/scripts/registryctl-tutorial.mjs", + "docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx", + "docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx", "docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx", } ) From 40dffd747f008b7d326214544ca54f92c329c5ae Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:58:57 +0700 Subject: [PATCH 070/136] fix(docs): restore the docs site check for the Evidence surface The Evidence onboarding surface landed with three defects the site check catches and nothing else does, so `npm run check` has been red since. `reference/mint.mdx` wrote byte and entry ceilings as bare `<=` inside table cells, which MDX reads as the start of a JSX tag and refuses to parse, taking the whole build down. State the ceilings in words instead. The Evidence API reference was registered with starlight-openapi but not added to the list of generated bases, which three separate consumers each kept their own copy of. Its pages therefore advertised a Markdown twin the build never generates, failing both the coverage check and the built link check. Move the list to one module, read it everywhere, and pin it to the bases astro.config.mjs actually registers so the next API reference cannot repeat this. `security/evidence.mdx` linked to `report-a-vulnerability/` relative to a page one level deeper than the one it was copied from. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-llms.mjs | 11 ++-- .../site/scripts/generated-api-bases.test.mjs | 53 +++++++++++++++++++ docs/site/src/components/RegistryHead.astro | 4 +- .../src/components/RegistryPageTitle.astro | 4 +- docs/site/src/content/docs/reference/mint.mdx | 6 +-- .../src/content/docs/security/evidence.mdx | 4 +- docs/site/src/lib/generated-api-bases.mjs | 29 ++++++++++ 7 files changed, 94 insertions(+), 17 deletions(-) create mode 100644 docs/site/scripts/generated-api-bases.test.mjs create mode 100644 docs/site/src/lib/generated-api-bases.mjs diff --git a/docs/site/scripts/check-llms.mjs b/docs/site/scripts/check-llms.mjs index ac10c8ae6..7b28b815e 100644 --- a/docs/site/scripts/check-llms.mjs +++ b/docs/site/scripts/check-llms.mjs @@ -11,6 +11,7 @@ import { readFile, access, readdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import YAML from 'yaml'; +import { isGeneratedApiDir } from '../src/lib/generated-api-bases.mjs'; const here = fileURLToPath(new URL('.', import.meta.url)); const distDir = process.env.DOCS_DIST_DIR @@ -289,13 +290,7 @@ if (await exists(archFile)) { // starlight-openapi injects the API reference operation pages as virtual routes // (not docs content-collection entries), so they have no per-page .md twin. They // are excluded from the llms corpus (reference/apis/** in astro.config.mjs) and -// from this coverage check. Matches the generated bases reference/apis/relay and -// reference/apis/notary, but NOT the hand-authored narrative pages -// reference/apis/registry-relay / registry-notary, which keep their .md. -const generatedApiBases = ['reference/apis/relay', 'reference/apis/notary']; -const isGeneratedApiPage = (dir) => - generatedApiBases.some((b) => dir === b || dir.startsWith(`${b}/`)); - +// from this coverage check. const pageDirs = await findPageDirs(); let covered = 0; let skipped = 0; @@ -304,7 +299,7 @@ for (const dir of pageDirs) { skipped += 1; continue; } - if (isGeneratedApiPage(dir)) { + if (isGeneratedApiDir(dir)) { skipped += 1; // plugin-generated API route, no backing .md by design continue; } diff --git a/docs/site/scripts/generated-api-bases.test.mjs b/docs/site/scripts/generated-api-bases.test.mjs new file mode 100644 index 000000000..447a76d6e --- /dev/null +++ b/docs/site/scripts/generated-api-bases.test.mjs @@ -0,0 +1,53 @@ +// Unit tests for src/lib/generated-api-bases.mjs. +// +// Run with: node --test scripts/generated-api-bases.test.mjs +// (also picked up by `npm test` via "scripts/**/*.test.mjs") + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +import { + GENERATED_API_BASES, + isGeneratedApiDir, + isGeneratedApiPath, +} from '../src/lib/generated-api-bases.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); + +test('every starlight-openapi base registered in astro.config.mjs is listed', () => { + // astro.config.mjs is where a new API reference is registered. Anything it + // registers has no Markdown twin, so a base missing from this list ships + // pages that advertise a .md the build never generates. + const config = readFileSync(resolve(here, '../astro.config.mjs'), 'utf8'); + const registered = [...config.matchAll(/^\s*base:\s*'(reference\/apis\/[^']+)'/gm)].map( + (match) => match[1], + ); + + assert.ok(registered.length > 0, 'astro.config.mjs must register at least one API base'); + for (const base of registered) { + assert.ok( + GENERATED_API_BASES.includes(base), + `${base} is registered in astro.config.mjs but missing from GENERATED_API_BASES`, + ); + } +}); + +test('a generated base and its descendants are recognised', () => { + assert.equal(isGeneratedApiDir('reference/apis/evidence'), true); + assert.equal(isGeneratedApiDir('reference/apis/evidence/operations/createevidence'), true); + assert.equal(isGeneratedApiPath('/reference/apis/evidence/'), true); + assert.equal(isGeneratedApiPath('/reference/apis/evidence/operations/gethealth/'), true); +}); + +test('the hand-authored narrative pages keep their Markdown twin', () => { + // reference/apis/registry-evidence is a real content-collection entry and + // shares a prefix with the generated base, so prefix matching must not + // swallow it. + assert.equal(isGeneratedApiDir('reference/apis/registry-evidence'), false); + assert.equal(isGeneratedApiPath('/reference/apis/registry-evidence/'), false); + assert.equal(isGeneratedApiDir('reference/apis'), false); + assert.equal(isGeneratedApiPath('/reference/apis/'), false); +}); diff --git a/docs/site/src/components/RegistryHead.astro b/docs/site/src/components/RegistryHead.astro index f842f2035..d40904377 100644 --- a/docs/site/src/components/RegistryHead.astro +++ b/docs/site/src/components/RegistryHead.astro @@ -1,6 +1,7 @@ --- import docsetsManifest from '../data/generated/docsets.json'; import { mdHrefForPath } from '../lib/md-href'; +import { isGeneratedApiPath } from '../lib/generated-api-bases.mjs'; const activeDocsetId = process.env.DOCS_DOCSET || docsetsManifest.current; const activeDocset = @@ -28,8 +29,7 @@ const path = Astro.url.pathname.replace(import.meta.env.BASE_URL, '/'); const is404 = path === '/404/' || path === '/404'; // starlight-openapi reference routes are virtual pages with no per-page .md // twin, so they must not advertise a (broken) Markdown alternate link. -const isGeneratedApi = - path.startsWith('/reference/apis/relay/') || path.startsWith('/reference/apis/notary/'); +const isGeneratedApi = isGeneratedApiPath(path); const mdHref = mdHrefForPath(Astro.url.pathname, import.meta.env.BASE_URL); const umamiWebsiteId = isSearchExcluded ? '' : import.meta.env.PUBLIC_UMAMI_WEBSITE_ID?.trim(); const umamiScriptSrc = diff --git a/docs/site/src/components/RegistryPageTitle.astro b/docs/site/src/components/RegistryPageTitle.astro index f06003627..454c9291d 100644 --- a/docs/site/src/components/RegistryPageTitle.astro +++ b/docs/site/src/components/RegistryPageTitle.astro @@ -1,5 +1,6 @@ --- import { mdHrefForPath } from '../lib/md-href'; +import { isGeneratedApiPath } from '../lib/generated-api-bases.mjs'; const { starlightRoute } = Astro.locals; const PAGE_TITLE_ID = '_top'; @@ -33,8 +34,7 @@ const isHome = path === '/'; const is404 = parts.length === 1 && parts[0] === '404'; // starlight-openapi reference routes are virtual pages with no per-page .md // twin, so the Copy/View as Markdown affordances would dangle. Skip them. -const isGeneratedApi = - path.startsWith('/reference/apis/relay/') || path.startsWith('/reference/apis/notary/'); +const isGeneratedApi = isGeneratedApiPath(path); const section = parts[0] ? sectionLabels.get(parts[0]) : undefined; const current = currentLabels.get(path) ?? starlightRoute.entry.data.title; const isWide = starlightRoute.entry.data.wide === true; diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx index c91764c58..7a5bf3b86 100644 --- a/docs/site/src/content/docs/reference/mint.mdx +++ b/docs/site/src/content/docs/reference/mint.mdx @@ -121,12 +121,12 @@ One file per client, parsed by `crates/registry-mint/src/clients.rs`. | Field | Type | Default | Notes | | --- | --- | --- | --- | | `clientId` | string | required | | -| `principal` | string (<=512 bytes) | required | | +| `principal` | string (at most 512 bytes) | required | | | `evidenceAudience` | string | required | | -| `requesterTags` | list of strings (<=32 entries) | required | | +| `requesterTags` | list of strings (at most 32 entries) | required | | | `grant` | object: `id`, `authority` | none, optional | Required together or not at all. | | `delegation` | object: `actors`, `subjectClaims` | none, optional | Enables delegated tokens bound to one subject; see `crates/registry-mint/README.md`. | -| `keys` | list of public JWKs (<=8 entries) | required | A document carrying a private key member is rejected. | +| `keys` | list of public JWKs (at most 8 entries) | required | A document carrying a private key member is rejected. | ## Token endpoint contract diff --git a/docs/site/src/content/docs/security/evidence.mdx b/docs/site/src/content/docs/security/evidence.mdx index 1f9502c8d..23cef0700 100644 --- a/docs/site/src/content/docs/security/evidence.mdx +++ b/docs/site/src/content/docs/security/evidence.mdx @@ -229,10 +229,10 @@ authentication bypass, audit redaction failure, source connector data leakage, and signing-key handling bugs, go through the private disclosure process in [SECURITY.md](https://github.com/registrystack/registry-stack/blob/main/SECURITY.md), never a public issue or pull request. See -[Report a vulnerability](report-a-vulnerability/) for the complete in-scope +[Report a vulnerability](../report-a-vulnerability/) for the complete in-scope list and reporting steps. ## Next - [Security overview](../) -- [Report a vulnerability](report-a-vulnerability/) +- [Report a vulnerability](../report-a-vulnerability/) diff --git a/docs/site/src/lib/generated-api-bases.mjs b/docs/site/src/lib/generated-api-bases.mjs new file mode 100644 index 000000000..0d373f36d --- /dev/null +++ b/docs/site/src/lib/generated-api-bases.mjs @@ -0,0 +1,29 @@ +/** + * The site bases starlight-openapi owns. + * + * Pages under these bases are virtual routes rather than docs content-collection + * entries, so they have no per-page Markdown twin: they must not advertise a + * Markdown alternate, must not offer the Copy or View as Markdown affordances, + * and are excluded from the exhaustive .md coverage check. Every consumer reads + * this list, so registering a new API reference in astro.config.mjs is the only + * place that has to learn about it. + * + * These are the generated bases, not the hand-authored narrative pages + * reference/apis/registry-relay, registry-notary and registry-evidence, which + * keep their .md. + */ +export const GENERATED_API_BASES = [ + 'reference/apis/relay', + 'reference/apis/notary', + 'reference/apis/evidence', +]; + +/** True when a dist-relative page directory is a generated API route. */ +export function isGeneratedApiDir(dir) { + return GENERATED_API_BASES.some((base) => dir === base || dir.startsWith(`${base}/`)); +} + +/** True when a root-relative pathname is a generated API route. */ +export function isGeneratedApiPath(pathname) { + return GENERATED_API_BASES.some((base) => pathname.startsWith(`/${base}/`)); +} From 4b7442384d2181ee23a79589f3a2e51b0ef2f00f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 11:59:10 +0700 Subject: [PATCH 071/136] docs(evidence): point tutorial readers at evidencectl doctor Both tutorials explain that Evidence names one refused artifact per restart, which is exactly the friction doctor was added to remove. Name it where the reader is already stuck, and say plainly that the missing source bearer token it also reports is expected in a project that only replays fixtures offline. Signed-off-by: Jeremi Joslin --- .../docs/tutorials/author-an-acceptance-definition.mdx | 5 ++++- .../src/content/docs/tutorials/first-evidence-assertion.mdx | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx index d88a349bf..55ae77eaa 100644 --- a/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx +++ b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx @@ -755,7 +755,10 @@ pipeline, offline, with no source and no network. ## When it does not pass To edit the bundle again after freezing it, run `chmod -R u+w bundle` first. Without that, the run -fails with `deployment input is not immutable` before it looks at anything you changed. +fails with `deployment input is not immutable` before it looks at anything you changed. If you have +lost track of which artifacts are writable, `evidencectl doctor --project .` reports all of them in +one read-only pass, along with the two source bearer tokens this offline project deliberately does +not hold. Four refusals account for most of what goes wrong here, and each of them is the runtime working as designed: diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index fbc9a7e4a..e64ab94f9 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -118,6 +118,12 @@ If you skip this step, the next command fails with `deployment input is not immu by the input it refused, such as `the bundle directory has a writable entry`. That refusal is the runtime working as designed, not a broken install. +Each refusal names one artifact, so a project-wide mistake surfaces one restart at a time. +`evidencectl doctor --project .` reports every mode and owner the runtime would refuse in a single +read-only pass instead. It also reports `secrets/source-bearer-token` as missing, which is correct +and expected here: fixtures replay offline, and the source credential is only needed once you make +a live request. + ## Run the fixtures Drive the `evidence` binary across the whole project: From 602deb4e94e55a79f47dd57f697594f8b457acfb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 12:07:05 +0700 Subject: [PATCH 072/136] docs(evidence): teach the real response-schema defect in the source tutorial The tutorial claimed projection drops an intermediate container when its only selected leaf is absent, and built a repair step on that claim. It does not: a key is omitted only when it is absent and its projection node is terminal, and an absent intermediate is a projection violation. The repair step was a no-op and the lesson contradicted the source contract. Teach the defect the contract names instead. Requiring a projected leaf of every record turns an ordinary incomplete record into a source-protocol failure, so the requester is told the institution is unavailable rather than that evidence is not available; the check belongs in the extract script, which this project already does. The step is now load-bearing: without it the fixtures fail. Two other facts had gone stale against the merged source-suggest work: one verbatim line of the drafting report, and the claim that --openapi never fetches. Pin that report line in the gate so the next rewording fails CI instead of leaving the quoted transcript wrong. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 4 ++ .../connect-an-institution-source.mdx | 48 +++++++++++-------- ...tary-retirement-and-evidence-onboarding.md | 23 +++++++++ 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 52869092d..cec73c42f 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -128,6 +128,10 @@ load_spec() { '2 passed, 0 failed (12 cases evaluated)' ) SPEC_OUTPUTS=( + # The page quotes this drafting run verbatim, so pin one line of + # it: a reworded report fails here instead of leaving the quoted + # transcript silently stale. + 'Still needs your input (the source block below):' 'PASS: check' 'PASS: fixtures/cases.yaml (12 cases)' '2 passed, 0 failed (12 cases evaluated)' diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index 30fe8a2f5..255616e3f 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -153,7 +153,7 @@ components: $ref: '#/components/schemas/EventRecord' EventRecord: type: object - required: [recordId, officeCode, registeredAt] + required: [recordId, officeCode, registeredAt, eventDate] properties: recordId: type: string @@ -173,8 +173,10 @@ components: YAML ``` -The document is read from disk and never fetched. `evidencectl` does not resolve remote `$ref` -targets and does not contact the server the document names. +This tutorial passes a file, so nothing is fetched. `--openapi` also accepts the URL a description +is published at, read under the same rule the runtime applies to the source URLs it will itself +call; `products/evidence/README.md` states that rule. Either way `evidencectl` does not resolve +remote `$ref` targets and does not contact the server the document names. ## Draft the source configuration @@ -267,7 +269,7 @@ evidencectl source suggest: draft for source `event-records` (GET /v1/event-reco Derived automatically: - /records (maxItems): derived from a page-size parameter in the spec -Still needs your input (0 schema bound(s), plus the source block below): +Still needs your input (the source block below): - TODO(evidencectl): sources..request selectorInputs, prepareScript, adapterParameters, and adapterParametersSchema in the pasted source block. - TODO(evidencectl): review authentication and baseUrl in the pasted source block. @@ -336,7 +338,7 @@ properties: items: type: object additionalProperties: false - required: [] + required: [eventDate] properties: eventDate: type: string @@ -349,29 +351,35 @@ schema runs and before any script is compiled. ## Narrow the drafted response schema -One line of that draft is wrong, and it is wrong in a way worth understanding. An Evidence response -schema describes the response **after** projection, not the response the API returns. The draft -copied `required: [matchCount, records]` from the document, where both fields are indeed always -present. But projection keeps a key only when it yields at least one selected leaf underneath it, -and the only selected leaf under `records` is `eventDate`, which the document marks optional. A -genuine zero-match response, `{"matchCount": 0, "records": []}`, projects to `{"matchCount": 0}`, -and a schema that requires `records` rejects it. +One line of that draft is wrong, and it is wrong in a way worth understanding. The drafting tool +copied the document's record-level `required` list into the projected record shape, because the +document is the only evidence it has. But a published document states the shape a provider intends +to return, and an Evidence response schema is a boundary check, not a restatement of that intent. +Records do arrive without a field their own document marks required. + +The consequence is not a rejected record. It is the wrong answer to the requester. A response that +fails the schema is a source-protocol failure, so the requester is told `dependency_unavailable`, as +though the institution were down, when the truthful answer is `evidence_not_available`: the source +answered, and this subject's record cannot support the claim. Find this line in `bundle/schemas/event-records-response.schema.yaml`: ```yaml -required: [matchCount, records] + required: [eventDate] ``` Replace it with: ```yaml -required: [matchCount] + required: [] ``` -The rule generalizes: mark a leaf required only when the projection always yields it. `matchCount` -qualifies, because it is a required scalar at the top of the response. Nothing under an optional -leaf does. +The rule has two halves and only one of them is a defect. Requiring a projected leaf of every record +is the defect, and the check belongs in the extract script instead, on the records that have to carry +the field, where you choose which problem the requester sees. Requiring an intermediate container is +merely redundant: an intermediate cannot be absent, because projection rejects the response before +this schema is read. That is why `required: [matchCount, records]` at the top of the draft stays +exactly as drafted. ## Write the request adapter @@ -770,8 +778,10 @@ cases: signed_success: true # One record resolved, but it carries no event date. The operation's own - # schema marks that field optional, so this is a record the source really can - # return. The requester learns only that evidence is not available. + # schema marks that field required, and a source that returns this record + # anyway is exactly why the response schema does not require it: the script + # catches the gap, so the requester learns only that evidence is not + # available rather than that the institution is down. - id: missing-event-date source: matchCount: 1 diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index d33b5504f..d9b89b75f 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -338,3 +338,26 @@ is parallel; B has no upstream dependencies and is the standing priority expectations are load-bearing rather than vacuous. Next in A: none, A is complete, which unblocks D. Next unblocked elsewhere: B5 tutorials E3 to E5 (E2 still waits on the client-assertion tooling call above). +- 2026-08-03: E4 (connect an institution source) corrected against the + `source suggest` work merged into main. A subagent review settled three + stale facts empirically. The page taught that projection drops an + intermediate container whose only selected leaf is absent, so + `{"matchCount": 0, "records": []}` would project to `{"matchCount": 0}`; + it does not. `crates/registry-evidence/src/source.rs` omits a key only + when it is absent and its projection node is terminal, and an absent + intermediate is a projection violation, which is what + `products/evidence/contracts/source-contract.yaml` already states. The + repair step the page taught was therefore a no-op. It now teaches the + defect that is real: requiring a projected leaf of every record turns an + incomplete record into a source-protocol failure, so the requester is + told `dependency_unavailable` instead of `evidence_not_available`, and + the check belongs in the extract script. Also fixed: one verbatim + `source suggest` output line stale since the report wording changed, and + the claim that `--openapi` never fetches, which stopped being true when + the flag gained URL support. The gate now pins that output line so the + next rewording fails CI rather than leaving the quoted transcript stale. + Open for Jeremi: the source contract's `required_rule` reads as + condemning any required selected leaf, including a top-level scalar, + while the reference schemas keep their top-level `required` lists; the + scope is "of every record" and the contract line would be clearer + saying so, but it is a frozen V1 contract so no edit was made. From a0bfc986bfcbb3fa2d5d3af0fbb4deb085a5a9a9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 12:12:08 +0700 Subject: [PATCH 073/136] docs(evidence): cover $ref cycles in the source connection tutorial A schema that refers to itself is common in institution documents: offices nest, so an office names the office above it. evidencectl cuts the repeat at its first repetition and reports it on standard error, and a reader whose needed leaf sits below such a cut has to select members individually rather than the subtree above it. The tutorial's synthetic document had no cycle, so the page never showed this. Adding one costs nothing downstream: the drafted schema, the derived-bounds report and the source block are byte-identical with the cycle present, and the fence count is unchanged. Pin one line of each quoted transcript in the gate so a rewording fails CI rather than leaving the page stale. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 7 +++--- .../connect-an-institution-source.mdx | 23 ++++++++++++++++++- ...tary-retirement-and-evidence-onboarding.md | 8 +++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index cec73c42f..fc5d80485 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -128,10 +128,11 @@ load_spec() { '2 passed, 0 failed (12 cases evaluated)' ) SPEC_OUTPUTS=( - # The page quotes this drafting run verbatim, so pin one line of - # it: a reworded report fails here instead of leaving the quoted - # transcript silently stale. + # The page quotes this drafting run verbatim, so pin the two + # lines it leans on: a reworded report fails here instead of + # leaving the quoted transcript silently stale. 'Still needs your input (the source block below):' + 'the repeat is cut there, so nothing below it can be projected' 'PASS: check' 'PASS: fixtures/cases.yaml (12 cases)' '2 passed, 0 failed (12 cases evaluated)' diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index 255616e3f..14249cdb4 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -170,6 +170,19 @@ components: remarks: type: string maxLength: 4096 + office: + $ref: '#/components/schemas/Office' + # Offices nest: a district office names the regional office above it, and + # so on. The document expresses that as a schema that refers to itself. + Office: + type: object + required: [code] + properties: + code: + type: string + maxLength: 32 + parent: + $ref: '#/components/schemas/Office' YAML ``` @@ -198,12 +211,20 @@ Run without `--operation` and `--select` and the command becomes interactive, li operations it found and the candidate leaves of the response you pick. The flags make the same run reproducible in a script or a review. -One note goes to standard error, and it is the most important line of the run: +Three notes go to standard error, and they are the most important lines of the run: ```text +evidencectl: `/records/*/office/parent` repeats the $ref cycle `#/components/schemas/Office`; the repeat is cut there, so nothing below it can be projected +evidencectl: `/records/*/office/parent` repeats the $ref cycle `#/components/schemas/Office`; a schema with no end cannot be projected, so the repeat is skipped evidencectl: adopting maxItems 25 for `/records`, derived from a page-size parameter in the spec ``` +The first two report the self-referring `Office` schema. A document that describes a hierarchy this +way describes a shape with no end, and Evidence projects only shapes with an end, so the repeat is +cut at the first repetition and the candidate leaves stop there. Nothing is lost here, because this +selection never reaches an office. It matters for your own document: if the leaf you need sits +below such a cut, select the members you need individually rather than the subtree above it. + Expected output, first part: ```text diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index d9b89b75f..64251948e 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -361,3 +361,11 @@ is parallel; B has no upstream dependencies and is the standing priority while the reference schemas keep their top-level `required` lists; the scope is "of every record" and the contract line would be clearer saying so, but it is a frozen V1 contract so no edit was made. +- 2026-08-03: E4's synthetic document gained a self-referring schema, so the + tutorial now covers the construct most likely to make a reader's own + document behave unlike the page's: `evidencectl` cuts a `$ref` cycle at + the first repetition and says so on standard error. Verified against the + real binary; the drafted schema, the derived-bounds report, and the + source block are byte-identical with the cycle present, so nothing + downstream moved. The gate pins one line of each of the two verbatim + transcripts the page quotes. From cc3069331dfbe4a4a416a54f470b8ace1549915a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 12:27:03 +0700 Subject: [PATCH 074/136] docs(site): label every tutorial with whose it is The four deployment roles start/when-to-use.mdx defines were prose only, so nothing tied a tutorial to a reader. They are now a checked vocabulary: a `persona:` key required on every doc_type: tutorial page, validated against src/lib/doc-personas.mjs, rendered under the title as a link back to the definitions. A unit test holds the list and the page in step in both directions, so adding a role to one without the other fails rather than drifting. This is a different axis from `audience`, which names who reads a specification (RS-TERMS Section 6). The two vocabularies stay apart. No tutorial claims consumer or verifier yet: that page is E5, still unwritten. Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-doc-frontmatter.mjs | 17 ++++++ docs/site/scripts/doc-personas.test.mjs | 55 +++++++++++++++++++ .../src/components/RegistryPageTitle.astro | 42 ++++++++++++++ docs/site/src/content.config.ts | 9 +++ .../author-an-acceptance-definition.mdx | 2 + .../tutorials/author-registry-project.mdx | 2 + .../connect-an-institution-source.mdx | 2 + .../tutorials/first-evidence-assertion.mdx | 2 + .../tutorials/first-run-with-solmara-lab.mdx | 2 + .../move-evidence-to-production-signing.mdx | 2 + ...blish-spreadsheet-secured-registry-api.mdx | 2 + .../tutorials/verify-claim-registry-api.mdx | 2 + docs/site/src/lib/doc-personas.mjs | 16 ++++++ ...tary-retirement-and-evidence-onboarding.md | 15 ++++- 14 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 docs/site/scripts/doc-personas.test.mjs create mode 100644 docs/site/src/lib/doc-personas.mjs diff --git a/docs/site/scripts/check-doc-frontmatter.mjs b/docs/site/scripts/check-doc-frontmatter.mjs index fb3165626..e687b2e93 100644 --- a/docs/site/scripts/check-doc-frontmatter.mjs +++ b/docs/site/scripts/check-doc-frontmatter.mjs @@ -2,6 +2,8 @@ import { readdir, readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; import YAML from 'yaml'; +import { DOC_PERSONAS } from '../src/lib/doc-personas.mjs'; + const docsDir = 'src/content/docs'; const required = [ 'title', @@ -26,6 +28,10 @@ const validEvidence = new Set(['aspirational', 'partial', 'verified']); // layers, audience the reader roles. const validLayer = new Set(['metadata', 'consultation', 'evaluation', 'credential', 'federation', 'administration', 'operations']); const validAudience = new Set(['integrator', 'operator', 'maintainer', 'specification editor', 'tooling']); +// Deployment roles, defined for readers in start/when-to-use.mdx. Required on +// tutorials so a reader can tell whose page it is before starting it, optional +// elsewhere. Not the same axis as audience; see src/lib/doc-personas.mjs. +const validPersona = new Set(DOC_PERSONAS); const docIdPattern = /^RS-[A-Z0-9]+(-[A-Z0-9]+)*$/; const seenDocIds = new Map(); const standardsRegister = YAML.parse(await readFile('src/data/standards.yaml', 'utf8')); @@ -99,6 +105,17 @@ for (const file of await files(docsDir)) { } } } + if (data.persona !== undefined) { + if (!Array.isArray(data.persona) || data.persona.length === 0) { + errors.push(`${relative('.', file)} persona must be a non-empty list`); + } else { + for (const value of data.persona) { + if (!validPersona.has(value)) errors.push(`${relative('.', file)} has invalid persona "${value}"`); + } + } + } else if (data.doc_type === 'tutorial') { + errors.push(`${relative('.', file)} (tutorial) missing persona`); + } if (data.doc_type === 'specification') { const rel = relative('.', file); if (data.doc_id === undefined || data.doc_id === null || data.doc_id === '') { diff --git a/docs/site/scripts/doc-personas.test.mjs b/docs/site/scripts/doc-personas.test.mjs new file mode 100644 index 000000000..ed4c32e03 --- /dev/null +++ b/docs/site/scripts/doc-personas.test.mjs @@ -0,0 +1,55 @@ +// Unit tests for src/lib/doc-personas.mjs. +// +// Run with: node --test scripts/doc-personas.test.mjs +// (also picked up by `npm test` via "scripts/**/*.test.mjs") + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +import { DOC_PERSONAS } from '../src/lib/doc-personas.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const whenToUse = resolve(here, '../src/content/docs/start/when-to-use.mdx'); + +/** + * The roles named in the "Who does what" section of start/when-to-use.mdx, + * which is the page a persona label sends a reader to. Scoped to that section + * so an ordinary "- The ... is ..." bullet elsewhere on the page is not read as + * a role definition. + * @returns {string[]} + */ +function definedRoles() { + const source = readFileSync(whenToUse, 'utf8'); + const section = source.match(/^### Who does what$([\s\S]*?)^#{2,3} /m); + assert.ok(section, 'when-to-use.mdx must keep a "Who does what" section'); + return [...section[1].matchAll(/^- The (.+?) is /gm)].map((match) => match[1]); +} + +test('every persona label is a persona the site defines', () => { + // start/when-to-use.mdx is where the four deployment roles are defined for + // readers. A label that names a role the page does not define sends a reader + // looking for an explanation that is not there. + const defined = definedRoles(); + + assert.ok(defined.length > 0, 'when-to-use.mdx must define at least one role'); + for (const persona of DOC_PERSONAS) { + assert.ok( + defined.includes(persona), + `"${persona}" is a valid frontmatter label but when-to-use.mdx defines no such role`, + ); + } +}); + +test('every role the site defines is available as a label', () => { + // The other direction: a role readers are told about, with no tutorial able + // to claim it, is a gap in the onboarding spine rather than a stray label. + for (const role of definedRoles()) { + assert.ok( + DOC_PERSONAS.includes(role), + `when-to-use.mdx defines the role "${role}" but no tutorial can be labeled with it`, + ); + } +}); diff --git a/docs/site/src/components/RegistryPageTitle.astro b/docs/site/src/components/RegistryPageTitle.astro index 454c9291d..e6968a0a7 100644 --- a/docs/site/src/components/RegistryPageTitle.astro +++ b/docs/site/src/components/RegistryPageTitle.astro @@ -42,6 +42,12 @@ const isWide = starlightRoute.entry.data.wide === true; // Compute the .md URL for this page (BASE_URL-aware). The data attribute is // read by the client script so the script doesn't need to recompute it. const mdHref = mdHrefForPath(Astro.url.pathname, import.meta.env.BASE_URL); + +// Whose page this is, in the deployment roles start/when-to-use.mdx defines. +// Required on tutorials by scripts/check-doc-frontmatter.mjs, so every tutorial +// says who it is for before the reader starts it; other pages may omit it. +const personas = starlightRoute.entry.data.persona ?? []; +const personaHref = `${import.meta.env.BASE_URL}start/when-to-use/#who-does-what`; --- {isWide && ( @@ -70,6 +76,17 @@ const mdHref = mdHrefForPath(Astro.url.pathname, import.meta.env.BASE_URL);

{starlightRoute.entry.data.title}

+{personas.length > 0 && ( +

+ For the {personas.map((persona: string, index: number) => ( + + {index > 0 && ' and '} + {persona} + + ))} +

+)} + >C: Access token, authority written
from the registry + + C->>E: POST /v1/evidence
Bearer access token + E->>M: GET the published key set + M-->>E: Public keys, held
between requests + E->>E: Verify the token, read authority
by the configured claim names + E->>S: Read what the
definition needs + S-->>E: Source response + E-->>C: Signed assertion +``` + +Steps 1 to 4 are the whole of Registry Mint's job. It never sees the source, the acceptance +definition, or the assertion. Steps 5 to 11 are Evidence's, and Evidence's only knowledge of +Registry Mint is a URL and a key set: it reads the token by the claim names in its own +configuration, so any issuer writing those claims would serve equally well. + +The exact wire shapes the diagram abbreviates are given in full below: +[Token endpoint contract](#token-endpoint-contract) for the assertion and token at steps 2 and 4, +[Other endpoints](#other-endpoints) for the key set path at step 6, and +[How Evidence verifies these tokens](#how-evidence-verifies-these-tokens) for the claim names at +step 8. + +Two failures collapse deliberately and are worth reading beside the diagram. A failure at step 3 +returns `401 invalid_client` whatever went wrong, so the token endpoint cannot be used to probe +which client ids are registered. A failure at step 6 never opens step 8: with no key set ever +retrieved, every request is rejected until one can be, and with a key set already held, that +held set keeps being accepted only until its allowance runs out. Evidence names the outage in +either case rather than failing silently. + ## How Evidence verifies these tokens Evidence's own `authentication` configuration block, defined in From 8276f0ab7295a9c5f56e75f5ec712c66dadfb9eb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 13:43:13 +0700 Subject: [PATCH 079/136] docs(site): make the Evidence tutorial track traversable No sequence of in-page Next clicks reached all six Evidence tutorials: one page dead-ended with no tutorial link, one pointed backward, and the consumer verification page never mentioned production signing. Align every first Next bullet with the sidebar order, and fix the production signing prerequisite to ask for any scaffolded project rather than one no intervening page tells the reader to keep. Signed-off-by: Jeremi Joslin --- .../author-an-acceptance-definition.mdx | 2 ++ .../tutorials/connect-an-institution-source.mdx | 4 ++-- .../docs/tutorials/first-evidence-assertion.mdx | 2 ++ .../move-evidence-to-production-signing.mdx | 2 +- .../verify-an-assertion-as-a-consumer.mdx | 3 +++ ...notary-retirement-and-evidence-onboarding.md | 17 +++++++++++++++++ 6 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx index 98c4d1ed9..7ebfde3fd 100644 --- a/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx +++ b/docs/site/src/content/docs/tutorials/author-an-acceptance-definition.mdx @@ -785,6 +785,8 @@ rm -rf region-evidence ## Next +- [Connect an institution source](../connect-an-institution-source/) is the next tutorial: it + answers a definition like this one from a real institution API shape instead of a fixture. - [Configure Evidence](../../configure/evidence/) documents every field you touched here. - [Evidence security model](../../security/evidence/) explains the disclosure guard, the existence collapse, and the immutability refusal. diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index 1f1eccc6e..88e426bca 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -942,8 +942,8 @@ rm -rf connect-a-source ## Next -- [Author an acceptance definition](../author-an-acceptance-definition/) adds a second question to - the same project, answered from the same source. +- [Serve assertions over HTTP](../serve-assertions-over-http/) is the next tutorial: it stops + running the project offline and answers a real caller holding a real access token. - [Configure Evidence](../../configure/evidence/) documents every source field this tutorial edited, including the other authentication kinds and postures. - [Evidence security model](../../security/evidence/) explains the projection, the closed response diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index 88973153b..cbee0c7ad 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -160,6 +160,8 @@ rm -rf hello-evidence ## Next +- [Author an acceptance definition](../author-an-acceptance-definition/) is the next tutorial: it + replaces the scaffolded question with one you write yourself, still offline. - [Serve assertions over HTTP](../serve-assertions-over-http/) runs a project like this one as a service and returns a signed assertion to a caller holding a real access token. - [Configure Evidence](../../configure/evidence/) explains every part of the project you built. diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx index b05def4a9..81748740b 100644 --- a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -25,7 +25,7 @@ contract. outcome="A deployment signing with production key material, a published JWKS, and a rehearsed rotation procedure." time="About 20 minutes" level="Production preparation" - prerequisites={['A deployment project from the first-assertion tutorial', 'The Evidence toolset on PATH', 'A Unix host; Evidence Version 1 supports Unix targets only'] } + prerequisites={['Any scaffolded Evidence deployment project; the earlier tutorials each build one', 'The Evidence toolset on PATH', 'A Unix host; Evidence Version 1 supports Unix targets only'] } /> Key generation here produces real private key material. diff --git a/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx b/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx index 19e17844d..fea8571c9 100644 --- a/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx +++ b/docs/site/src/content/docs/tutorials/verify-an-assertion-as-a-consumer.mdx @@ -423,6 +423,9 @@ think about, because it is a description of what your procedure promised to chec ## Next +- [Move Evidence to production signing](../move-evidence-to-production-signing/) is the last + tutorial in the track: it takes a deployment from scaffold key material to keys an operator is + willing to sign with, and rehearses the rotation that makes you rebuild a pinned key set. - [Evidence security model](../../security/evidence/) states the verification invariant behind the single policy class, and the disclosure and binding invariants the payload you read relies on. - [Registry Evidence API](../../reference/apis/registry-evidence/) documents the request and diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index a3fcc71f5..6effb155e 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -424,3 +424,20 @@ is parallel; B has no upstream dependencies and is the standing priority variants and `disclosure` is the eighth, reachable only on the SD-JWT VC path. The released-binary form of the E1 adopter outcome still needs F3, the same caveat B4 carries. +- 2026-08-03: Tutorial track repaired as one path. Auditing E1-E6 end to end + found that no sequence of in-page Next clicks reached all six: + author-an-acceptance-definition offered no tutorial link at all and was a + hard dead end, connect-an-institution-source pointed backward to the page + the reader had just left, and the new E5 never mentioned E6. Every first + Next bullet now follows the sidebar order, first-evidence-assertion to + author-an-acceptance-definition to connect-an-institution-source to + serve-assertions-over-http to verify-an-assertion-as-a-consumer to + move-evidence-to-production-signing. That is the order readers already see + in the sidebar and a working difficulty ramp: stay offline while learning + the shape of a definition and a source, then go over HTTP with a real + token, then verify what you were handed, then take key material to + production. E6's prerequisite also asked for a deployment project from the + first-assertion tutorial, which nothing between them tells the reader to + keep and which E2 replaces with a fresh scaffold anyway; it now asks for + any scaffolded project, which is what its placeholder commands actually + need. From 8d8af8958bf41957692433599de03fbbecb0d4f2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 13:44:56 +0700 Subject: [PATCH 080/136] docs(site): present two doors on when-to-use The decision table still offered three, asking a newcomer to tell a bounded answer from a minimum-disclosure answer before knowing what either meant. Reduce it to reader intent: read specific data goes to Registry Relay, learn only a fact goes to Evidence. Notary keeps one unlinked sentence saying it is being retired; the retirement page is not published yet. Replace the Relay-plus-Notary composition paragraph with the Evidence-over-Relay pattern, and name Registry Mint once as the token issuer for deployments with no identity provider. Signed-off-by: Jeremi Joslin --- .../src/content/docs/start/when-to-use.mdx | 23 +++++++++++-------- ...tary-retirement-and-evidence-onboarding.md | 17 ++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/site/src/content/docs/start/when-to-use.mdx b/docs/site/src/content/docs/start/when-to-use.mdx index d6ef34702..2afcf9779 100644 --- a/docs/site/src/content/docs/start/when-to-use.mdx +++ b/docs/site/src/content/docs/start/when-to-use.mdx @@ -4,9 +4,8 @@ description: Decide whether Registry Stack matches the access problem your insti status: current owner: registry-docs source_repos: - - registry-relay - - registry-notary -last_reviewed: "2026-06-20" + - registry-stack +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] @@ -31,13 +30,18 @@ system that owns the data. | Caller needs | Use | Result | | --- | --- | --- | -| Selected records or fields | Registry Relay | A protected, read-only API response | -| A signed minimum-disclosure answer about one subject | Evidence | A signed assertion carrying the answer, not the source record | -| A bounded answer or status | Registry Notary | A claim result without the source record | +| To read specific data | Registry Relay | A protected, read-only API response | +| To learn only a fact | Evidence | A signed assertion carrying the answer, not the source record | -Registry Relay and Registry Notary can work together: Registry Relay obtains a -limited source result, and Registry Notary evaluates a reviewed claim over -that result. +Registry Notary is being retired and is no longer a recommended starting +point. + +Registry Relay and Evidence can work together: an institution publishes +records through Registry Relay, and Evidence answers a bounded question over +those same records by treating the Relay API as a fixed HTTP source. Registry +Mint issues the short-lived access tokens either service needs when a +deployment has no identity provider; see the +[Registry Mint reference](../../reference/mint/). ### Who does what @@ -78,5 +82,6 @@ approval. - [Start a registry from a spreadsheet](../../tutorials/publish-spreadsheet-secured-registry-api/) - [Connect an existing HTTP registry](../../tutorials/author-registry-project/) +- [Get a first Evidence assertion](../../tutorials/first-evidence-assertion/) - [Read the architecture overview](../../explanation/architecture/) - [Review the security boundaries](../../security/) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 6effb155e..71e99cb80 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -441,3 +441,20 @@ is parallel; B has no upstream dependencies and is the standing priority keep and which E2 replaces with a fresh scaffold anyway; it now asks for any scaffolded project, which is what its placeholder commands actually need. +- 2026-08-03: B8's two-door step is now actually done, and B8 stays open. + An earlier status line claimed `start/when-to-use` already presented two + doors; it did not. That session added the Evidence row but left the Notary + row beside it, so the page still offered three doors and asked a newcomer + to tell a bounded answer from a minimum-disclosure answer before they knew + what either meant. The decision table is now two rows phrased as reader + intent: read specific data goes to Registry Relay, learn only a fact goes + to Evidence. Notary keeps one sentence saying it is being retired and is no + longer a recommended starting point, with no link, because publishing the + retirement page is C7's. The old Relay-plus-Notary composition paragraph + became the Evidence-over-Relay pattern that A2 and A3 proved, and Registry + Mint gets one sentence as the token issuer for deployments with no identity + provider. B8's glossary step was already in place. B8's third step, the + quickstart flip, is still blocked: `start/quickstart` routes the reader + through registryctl and Notary end to end, and ending it in an Evidence + assertion over a Relay-protected API needs the runnable composed path, + which is D. From 956779597dcf1005c77bf31a26087d7b0d1cde72 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 14:00:04 +0700 Subject: [PATCH 081/136] docs(mint): show how to request a token from application code Add a how-to with worked Python and TypeScript examples that build the private_key_jwt assertion and exchange it, for callers integrating a registered client rather than running `mint token` at a terminal. Both examples were run against a real Mint deployment before being written down. Also correct the reference errors table: a missing `grant_type` returns `invalid_request`, not `unsupported_grant_type`, which only covers a `grant_type` that is present and wrong (`server.rs`). Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + docs/site/src/content/docs/configure/mint.mdx | 4 + .../configure/request-an-access-token.mdx | 251 ++++++++++++++++++ docs/site/src/content/docs/reference/mint.mdx | 4 +- 4 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 docs/site/src/content/docs/configure/request-an-access-token.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 0b5dbd8fc..e187b826e 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -334,6 +334,7 @@ export default defineConfig({ { label: 'Verify an assertion as a consumer', slug: 'tutorials/verify-an-assertion-as-a-consumer' }, { label: 'Configure Evidence', slug: 'configure/evidence' }, { label: 'Configure Registry Mint', slug: 'configure/mint' }, + { label: 'Request a token from your own code', slug: 'configure/request-an-access-token' }, { label: 'Move to production signing', slug: 'tutorials/move-evidence-to-production-signing' }, ], }, diff --git a/docs/site/src/content/docs/configure/mint.mdx b/docs/site/src/content/docs/configure/mint.mdx index 7e2b4f720..6a4c51b12 100644 --- a/docs/site/src/content/docs/configure/mint.mdx +++ b/docs/site/src/content/docs/configure/mint.mdx @@ -176,6 +176,10 @@ mint token --url https://mint.example.org/token \ It prints the access token alone on stdout, so `TOKEN=$(mint token ...)` is the whole usage. +To build the same request from an application rather than a terminal, see +[Request an access token from your own code](../request-an-access-token/), which has worked +Python and TypeScript examples. + ## Verify the deployment Request a token and confirm the response shape: diff --git a/docs/site/src/content/docs/configure/request-an-access-token.mdx b/docs/site/src/content/docs/configure/request-an-access-token.mdx new file mode 100644 index 000000000..69b89ccf1 --- /dev/null +++ b/docs/site/src/content/docs/configure/request-an-access-token.mdx @@ -0,0 +1,251 @@ +--- +title: Request an access token from your own code +description: Build and sign the client assertion Registry Mint expects, then exchange it for an access token, with worked Python and TypeScript examples. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: how-to +locale: en +standards_referenced: [] +--- + +Build the signed request Registry Mint expects when the caller is your own application rather +than the `mint token` command. + +## When to use this + +Use this page when you are integrating a registered client into an application and need an access +token to present to a resource server such as Evidence. If you only need a token at a terminal, +`mint token` already does all of this; see +[Obtain a token](../mint/#obtain-a-token). + +## Before you start + +You need four things, all of which come from whoever registered your client: + +- The `clientId` your client is registered under. +- Your client's **private** JWK. Registry Mint holds only the public half; if you do not have the + private half, nobody can issue you a token. +- The exact `clientAssertion.audience` value the deployment is configured with. This is not the + same as the audience of the token you get back, and confusing the two is the most common + first-attempt failure. +- The token endpoint URL. The path is always `/token` and is not configurable. + +Registry Mint serves plain HTTP and expects TLS termination it does not manage, so the URL you +call is the terminator's, not the process's own listener. + +## What you are signing + +One JWT, signed with your own private key, sent once. This is `private_key_jwt` client +authentication (RFC 7523) inside the `client_credentials` grant: there is no shared secret +anywhere in the exchange. + +| Claim | Value | Why it is checked | +| --- | --- | --- | +| `iss` | your `clientId` | Names which registration's keys to verify against. | +| `sub` | your `clientId`, identical to `iss` | Without it a legitimate client key could sign an assertion naming a different subject. | +| `aud` | the configured `clientAssertion.audience` | Stops a request built for one endpoint being replayed at another. | +| `jti` | a fresh unique value **per request** | Accepted exactly once. Reusing one is refused. | +| `iat` | now | Freshness, tolerating a small clock skew. | +| `exp` | within `clientAssertion.maximumLifetimeSeconds` of `iat` (default 300) | A long-lived assertion is a long-lived bearer credential, so the bound is enforced whatever you choose. | + +The header carries `alg` (one of the algorithms the deployment lists under +`clientAssertion.algorithms`), `typ: JWT`, and `kid` naming which of your registered keys signed +it. + +The examples below build the JWT from primitives rather than pulling in a JWT library, so that +every field above is visible in the code. A JWT library is a perfectly good substitute as long as +it lets you set `kid` and does not cache or reuse `jti`. + +## Python + +Needs `cryptography` and `requests`. + +```python +import base64 +import json +import time +import uuid + +import requests +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def unb64url(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def sign_client_assertion( + private_jwk: dict, + client_id: str, + audience: str, + lifetime_seconds: int = 120, +) -> str: + """Build one single-use assertion proving this client holds its own key.""" + now = int(time.time()) + header = {"alg": "EdDSA", "typ": "JWT", "kid": private_jwk["kid"]} + claims = { + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + lifetime_seconds, + "jti": str(uuid.uuid4()), + } + signing_input = ".".join( + b64url(json.dumps(part, separators=(",", ":")).encode()) + for part in (header, claims) + ) + key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"])) + return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" + + +def request_access_token(token_url: str, assertion: str) -> str: + response = requests.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": assertion, + }, + timeout=10, + ) + response.raise_for_status() + return response.json()["access_token"] +``` + +Call it with the client's own private JWK: + +```python +with open("client-signing-private-jwk", encoding="utf-8") as handle: + private_jwk = json.load(handle) + +assertion = sign_client_assertion( + private_jwk, + client_id="health-desk", + audience="https://mint.example.org/token", +) +token = request_access_token("https://mint.example.org/token", assertion) +``` + +## TypeScript + +Needs no dependencies. `node:crypto` reads an Ed25519 JWK directly, and `fetch` is built in. + +```typescript +import { createPrivateKey, randomUUID, sign } from "node:crypto"; + +interface PrivateJwk { + kty: "OKP"; + crv: "Ed25519"; + kid: string; + d: string; + x: string; +} + +const b64url = (raw: Buffer | string): string => + Buffer.from(raw).toString("base64url"); + +/** Build one single-use assertion proving this client holds its own key. */ +export function signClientAssertion( + privateJwk: PrivateJwk, + clientId: string, + audience: string, + lifetimeSeconds = 120, +): string { + const now = Math.floor(Date.now() / 1000); + const header = { alg: "EdDSA", typ: "JWT", kid: privateJwk.kid }; + const claims = { + iss: clientId, + sub: clientId, + aud: audience, + iat: now, + exp: now + lifetimeSeconds, + jti: randomUUID(), + }; + const signingInput = [header, claims] + .map((part) => b64url(JSON.stringify(part))) + .join("."); + const key = createPrivateKey({ key: privateJwk, format: "jwk" }); + // Ed25519 signs the message itself, so the digest argument is null. + const signature = sign(null, Buffer.from(signingInput), key); + return `${signingInput}.${b64url(signature)}`; +} + +export async function requestAccessToken( + tokenUrl: string, + assertion: string, +): Promise { + const response = await fetch(tokenUrl, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_assertion_type: + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: assertion, + }), + }); + if (!response.ok) { + const { error } = (await response.json()) as { error?: string }; + throw new Error(`token request failed: ${response.status} ${error ?? ""}`); + } + const body = (await response.json()) as { access_token: string }; + return body.access_token; +} +``` + +## Use the token + +Present it as a bearer token to the resource server, not back to Registry Mint: + +```sh +curl -sS https://evidence.example.org/v1/evidence \ + -H "Authorization: Bearer ${TOKEN}" \ + -H 'content-type: application/json' \ + --data @request.json +``` + +## Rules that bite + +**Sign a new assertion per request.** The `jti` is spent on first use and remembered past `exp`, +so a cached assertion fails the second time it is sent. What you may cache is the access token, +for the `expires_in` seconds the response reports. + +**The two audiences are different.** The assertion's `aud` is `clientAssertion.audience`, the +value that identifies the token endpoint. The resulting token's own `aud` comes from +`accessTokens.audiences` and identifies the resource server. Signing the assertion with the +resource server's audience is a refusal, not a warning. + +**You cannot ask for more authority than you are registered with.** Principal, requester tags, +evidence audience, and grant pair are all written from the client registry. Nothing you put in +the assertion changes them, so there is no scope parameter to send. + +**Every authentication failure looks identical.** An unknown client id, a bad signature, a +replayed `jti`, a wrong audience, and an expired assertion all return the same +`401 {"error": "invalid_client"}`. This is deliberate: the endpoint must not be usable to +discover which client ids are registered. It also means the response cannot tell you which of +those five things went wrong, so check them in order locally. + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| `401 invalid_client` on the first attempt | The `aud` is the resource server rather than `clientAssertion.audience`, or `kid` names a key the registration does not carry. | +| `401 invalid_client` only on repeat requests | The assertion, rather than the token, is being cached and replayed. Generate a fresh `jti` each time. | +| `401 invalid_client` after a working period | The assertion's `exp` exceeds `clientAssertion.maximumLifetimeSeconds`, or the client clock has drifted beyond the tolerated skew. | +| `400 invalid_request` | A required form field is missing or duplicated, or `client_assertion_type` is not the exact `jwt-bearer` URN. A missing `grant_type` lands here too. | +| `400 unsupported_grant_type` | `grant_type` is present but is not exactly `client_credentials`. | +| The resource server rejects a token Registry Mint issued | The two deployments name different claims for the same authority field. See [How Evidence verifies these tokens](../../reference/mint/#how-evidence-verifies-these-tokens). | + +## Next + +- [How a client, Registry Mint, and Evidence interact](../../reference/mint/#how-a-client-registry-mint-and-evidence-interact) +- [Configure Registry Mint](../mint/) +- [Registry Mint reference](../../reference/mint/) diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx index 5165c18c4..a68fe9b45 100644 --- a/docs/site/src/content/docs/reference/mint.mdx +++ b/docs/site/src/content/docs/reference/mint.mdx @@ -172,8 +172,8 @@ Every response body is `{"error": ""}`, from | Code | Status | When | | --- | --- | --- | -| `invalid_request` | `400` | A required form field is missing, duplicated, or the assertion type is unsupported. | -| `unsupported_grant_type` | `400` | `grant_type` is missing or not `client_credentials`. | +| `invalid_request` | `400` | A required form field is missing or duplicated, or `client_assertion_type` is not the exact `jwt-bearer` URN. A missing `grant_type` is a missing field and lands here, not below. | +| `unsupported_grant_type` | `400` | `grant_type` is present but is not `client_credentials`. | | `invalid_client` | `401` | Every client authentication failure: unknown client id, bad signature, replayed `jti`, expired assertion. Registry Mint collapses these into one code so the endpoint cannot be used to probe which client ids are registered. Carries a `WWW-Authenticate: Bearer error="invalid_client"` header. | | `server_error` | `500` | An internal failure, such as a misconfigured delegation the startup check is meant to catch. | From 5a199061dfa5ed4f1b2d0338284e651811e73a1d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 14:03:18 +0700 Subject: [PATCH 082/136] docs(evidence): say what each generated key is for The keygen step listed three commands and their output without saying what the files do. Name each one against the bundle field that references it, and explain the two symmetric secrets: the audit key both chains the log and derives its pseudonyms, and the subject-binding key is scoped so two audiences cannot correlate assertions about the same person. Signed-off-by: Jeremi Joslin --- .../tutorials/first-evidence-assertion.mdx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index cbee0c7ad..a182a3a0e 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -107,6 +107,34 @@ wrote secrets/subject-binding-hmac-key Every file is owner-only, and private bytes are never printed. +Three commands, three separate jobs. The bundle never names a path: it references each of these +by a logical name that the runtime resolves under the secret root. + +| What you generated | Referenced by | What it is for | +| --- | --- | --- | +| `signing-ed25519-private-jwk` and `signing-ed25519-public.jwk.json` | `signing.activeKeyRef` | The Ed25519 keypair that signs every assertion the deployment issues. The private half stays in the process; the public half is what you publish at `signing.jwksPath`, so a consumer can verify an assertion without asking you anything. | +| `audit-hmac-key` | `audit.hashSecretRef` | 32 random bytes that key the audit log. | +| `subject-binding-hmac-key` | `subjectBinding.secretRef` | 32 random bytes that derive the reference standing in for a subject. | + +The `--kid scaffold-signing-key-1` you passed is the key's public name. It must match +`signing.activeKeyId` in `bundle/evidence.yaml`, which is what the scaffold already wrote there, +and it travels in each assertion's header so a verifier knows which published key to check +against. + +The other two are symmetric secrets rather than keypairs, so there is nothing to publish and +nothing for a verifier to fetch. The audit key does two things: each record's hash is computed +under it and chains onto the previous record's, so nobody without the key can remove or rewrite a +record and re-chain what follows, and it derives the `hmac-sha256:v1:` pseudonyms that stand in +for identifying values in the log, so the trail stays linkable inside the deployment without +carrying the values themselves. The subject-binding key derives its reference over the whole +selector bundle together with the trust domain, audience, purpose, role, and profile, which means +the same person yields a different reference for a different audience, and two audiences holding +assertions about that person cannot correlate them by comparing what they hold. + +Both are versioned in the bundle (`hashKeyVersion` and `keyVersion`, `1` here) because the version +goes into the derivation: replacing either key means everything derived under it changes, and the +version is what says which key produced a given value. + ## Freeze the deployment input Evidence treats the bundle and runtime file as trusted, startup-only artifacts and refuses From 8e52f7bd8d77f625612b52c71cd1767ddb2347f8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 15:49:28 +0700 Subject: [PATCH 083/136] docs(site): give Evidence a front door and a quickstart Both front doors led with the retiring Notary path. Rebalance them on the two-door split when-to-use already uses, and add the Evidence overview route every other section has. The new start/evidence-quickstart maps the six tutorials, their times, and their outcomes, names the four coequal acceptance definitions without privileging one, and states that the track ends at production signing, not production approval. It carries no commands, so it does not compete with the first-assertion tutorial. Three IA assertions guard the result: the overview-route rule now covers Evidence, and each front door must open an Evidence lane ahead of the Notary tutorial. Nothing is deleted; removing a Notary lane belongs to the retirement workstream. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + .../scripts/information-architecture.test.mjs | 18 ++++ docs/site/src/content/docs/index.mdx | 47 ++++++--- .../docs/start/evidence-quickstart.mdx | 96 +++++++++++++++++++ .../src/content/docs/start/quickstart.mdx | 36 +++++-- ...tary-retirement-and-evidence-onboarding.md | 33 +++++++ 6 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 docs/site/src/content/docs/start/evidence-quickstart.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index e187b826e..6f31fdbff 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -327,6 +327,7 @@ export default defineConfig({ { label: 'Answer with Evidence', items: [ + { label: 'Overview', slug: 'start/evidence-quickstart' }, { label: 'Get a first assertion', slug: 'tutorials/first-evidence-assertion' }, { label: 'Author an acceptance definition', slug: 'tutorials/author-an-acceptance-definition' }, { label: 'Connect an institution source', slug: 'tutorials/connect-an-institution-source' }, diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 184b43429..553b4215d 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -68,6 +68,7 @@ test('publishes one overview route for every task-flow section', () => { for (const [label, route] of [ ['Start', "link: '/'"], ['Connect an existing registry', "slug: 'configure'"], + ['Answer with Evidence', "slug: 'start/evidence-quickstart'"], ['Operate', "slug: 'operate'"], ['Security', "slug: 'security'"], ['Reference', "slug: 'reference'"], @@ -120,6 +121,23 @@ test('starts with the spreadsheet registry and keeps HTTP under existing registr assert.match(homepageSource, /\]\(start\/pre-1\.0-cutover\/\)/); }); +test('gives Evidence a lane on both front doors, ahead of the retiring Notary path', () => { + assert.match(homepageSource, /\]\(start\/evidence-quickstart\/\)/); + assert.match(homepageSource, /\]\(tutorials\/first-evidence-assertion\/\)/); + assert.match(quickstartSource, /\]\(\.\.\/evidence-quickstart\/\)/); + assert.match(quickstartSource, /\]\(\.\.\/\.\.\/tutorials\/first-evidence-assertion\/\)/); + assertOrdered( + homepageSource, + ['tutorials/first-evidence-assertion/', 'tutorials/verify-claim-registry-api/'], + 'homepage lane', + ); + assertOrdered( + quickstartSource, + ['tutorials/first-evidence-assertion/', 'tutorials/verify-claim-registry-api/'], + 'quickstart lane', + ); +}); + test('keeps validation on offline test and nested development commands', () => { assertOrdered( validationSource, diff --git a/docs/site/src/content/docs/index.mdx b/docs/site/src/content/docs/index.mdx index 10a501dab..7708ab9af 100644 --- a/docs/site/src/content/docs/index.mdx +++ b/docs/site/src/content/docs/index.mdx @@ -1,21 +1,37 @@ --- title: Registry Stack documentation -description: Start a registry from a spreadsheet, expose bounded evidence, or connect an existing registry. +description: Answer a bounded question with Evidence, start a registry from a spreadsheet, or connect an existing registry. status: current owner: registry-docs source_repos: - registry-stack - registry-relay - registry-notary -last_reviewed: "2026-07-31" +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] --- -Registry Stack helps an institution expose selected registry data through -Registry Relay and bounded evidence through Registry Notary without giving -callers direct access to the source. +Registry Stack helps an institution answer questions about data it already +holds without giving callers direct access to the source. Two doors: Evidence +signs the answer to one bounded question, and Registry Relay exposes selected +records through a protected read-only API. + +## Answer a bounded question with Evidence + +Use Evidence when the caller needs to learn only a fact, not to read a record. +It answers one bounded question about one subject and signs the answer, +releasing the answer rather than the row behind it. Adult status, residence +region, professional licence status, and legal-parent relationship are coequal +acceptance definitions; you author whichever one your institution needs, the +same way. + +[Get a first Evidence assertion](tutorials/first-evidence-assertion/) takes a +scaffolded deployment project from nothing to passing assertion fixtures +offline, in about 15 minutes. +The [Evidence quickstart](start/evidence-quickstart/) maps the six tutorials +that run from there to production signing. ## Start a registry from a spreadsheet @@ -30,7 +46,11 @@ evidence that a local file cannot count itself. takes about 20 minutes and uses released artifacts without a source checkout. After it works, [use your own spreadsheet](tutorials/use-your-spreadsheet/). -## Expose bounded evidence +## Expose Notary evidence claims + +Registry Notary is being retired and is no longer a recommended starting +point. Evidence is the supported way to answer a bounded question. +This path remains documented for deployments that already run it. The spreadsheet starter already includes one evidence service. Registry Relay performs the exact source lookup and sends only the field needed @@ -56,13 +76,18 @@ the source requires authentication or response normalization. ## Keep the product boundaries clear Registry Relay owns source access and protected record surfaces. -Registry Notary owns evidence evaluation and disclosure. +Evidence owns bounded question answering, signing, and minimum disclosure, and +runs independently of Relay; it can read a Relay API as one fixed HTTP source. +Registry Notary owns evidence evaluation and disclosure on the retiring path. The caller receives only the output authorized for that service. -Both paths use the same authoring, offline test, disposable development, and -build commands. The 1.0 project-local workbook path stops there. A governed -deployment starts after an operator-managed HTTP source is bound, then -continues through independent approval and package generation. +Both registryctl paths, spreadsheet and HTTP, use the same authoring, offline +test, disposable development, and build commands. The 1.0 project-local +workbook path stops there. A governed deployment starts after an +operator-managed HTTP source is bound, then continues through independent +approval and package generation. Evidence is not one of those paths: it has +its own toolset and its own deployment project shape, covered by the +[Evidence quickstart](start/evidence-quickstart/). ## Move beyond the first run diff --git a/docs/site/src/content/docs/start/evidence-quickstart.mdx b/docs/site/src/content/docs/start/evidence-quickstart.mdx new file mode 100644 index 000000000..65ecd8e5c --- /dev/null +++ b/docs/site/src/content/docs/start/evidence-quickstart.mdx @@ -0,0 +1,96 @@ +--- +title: Evidence quickstart +description: What Evidence answers, the six tutorials that run from a first offline project to production signing, and where the track stops. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-03" +doc_type: explanation +locale: en +standards_referenced: [] +--- + +Evidence answers one bounded question about one subject and signs the answer, +without releasing the record the answer came from. A relying service asks +whether a person meets a condition and receives a signed assertion carrying +the answer, not the row that settled it. + +This page maps the six tutorials that take a deployment from nothing to +production signing. The page runs nothing itself: every command lives in one +of the six. The five that run offline or locally are executed on every +documentation change, so their commands and their printed output stay true. +The production signing tutorial is reviewed rather than executed, because its +commands stand in for real key material and secret paths that no gate may +hold. + +## Four questions, none of them privileged + +Adult status, residence region, professional licence status, and legal-parent +relationship are coequal acceptance definitions. None is a built-in type, a +special route, or a first phase that the others follow. Whichever one your +institution needs, you author it the same way. The tutorials switch between +them deliberately: the first answers adult status, the source tutorial answers +a legal-parent relationship, and the authoring tutorial covers all four. + +An acceptance definition is a reviewed artifact, not code you ship. Evidence +compiles the definition at startup from an immutable bundle, and refuses to +start if the definition does not compile. + +## The track + +Six tutorials, about two and a half hours end to end. Each one leaves you +somewhere you could stop, and each but the last links on to the next. + +1. [Get a first assertion](../../tutorials/first-evidence-assertion/), about 15 + minutes including installing the toolset. Take a scaffolded deployment + project from nothing to passing assertion fixtures, entirely offline. +2. [Author an acceptance definition](../../tutorials/author-an-acceptance-definition/), + about 30 minutes. Replace the scaffolded question with one you write, and + end with two coequal definitions and 23 fixture cases passing. +3. [Connect an institution source](../../tutorials/connect-an-institution-source/), + about 30 minutes. Swap the placeholder source for a reviewed adapter + against a real institution API shape, proven offline against twelve + sanitized fixture cases. +4. [Serve assertions over HTTP](../../tutorials/serve-assertions-over-http/), + about 30 minutes. Stop running offline: answer a caller holding a real + access token, behind a development certificate. +5. [Verify an assertion as a consumer](../../tutorials/verify-an-assertion-as-a-consumer/), + about 20 minutes. Change seats. Re-verify one stored response offline + against a pinned key set, with five refusals that prove the checks are + real. +6. [Move to production signing](../../tutorials/move-evidence-to-production-signing/), + about 20 minutes. Replace scaffold key material with keys an operator will + sign with, publish the JWKS, and rehearse the rotation. + +The first three stay offline against synthetic fixtures, so you learn the +shape of a definition and a source before any network is involved. None of the +three needs a production endpoint, a source credential, or personal data. + +## Where the track stops + +Finishing all six leaves you with a deployment that signs with production key +material and a rehearsed rotation. Finishing does not give you production +approval. Governance still establishes the provider's authority to act for the +named legal issuer, and a valid signature proves only that the key holder +signed that exact payload: not that the source fact is true, and not that any +credential was issued to a holder. + +## If you are deciding rather than building + +- [When Registry Stack fits](../when-to-use/) settles which door you want: + Registry Relay to read specific data, Evidence to learn only a fact. +- [Evaluate Evidence](../evaluate-evidence/) is the cost accounting: what + Evidence needs to start, what it does not depend on, how it deploys today, + and what operating it demands. +- [Evidence security model](../../security/evidence/) traces the runtime rules + to the invariant matrix and the tests that hold them. + +## Next + +- [Get a first Evidence assertion](../../tutorials/first-evidence-assertion/) + is the tutorial to run next. +- [Configure Evidence](../../configure/evidence/) is the configuration + reference behind every scaffolded project. +- [Registry Mint reference](../../reference/mint/) covers the short-lived + access tokens a caller needs when a deployment has no identity provider. diff --git a/docs/site/src/content/docs/start/quickstart.mdx b/docs/site/src/content/docs/start/quickstart.mdx index 8a9a8072c..b58a11860 100644 --- a/docs/site/src/content/docs/start/quickstart.mdx +++ b/docs/site/src/content/docs/start/quickstart.mdx @@ -1,21 +1,35 @@ --- title: Start with Registry Stack 1.0 -description: Run Registry Relay and Registry Notary from a spreadsheet, then adapt the source or evidence rule. +description: Answer a bounded question with Evidence, or publish protected records with Registry Relay, then adapt the source or the definition. status: current owner: registry-docs source_repos: - registry-stack - registry-relay - registry-notary -last_reviewed: "2026-07-31" +last_reviewed: "2026-08-03" doc_type: explanation locale: en standards_referenced: [] --- -Start with the maintained spreadsheet registry. -It is the shortest complete path through Registry Relay, Registry Notary, -authorization, source access, evidence evaluation, and minimized disclosure. +Pick the door that matches what your caller needs. To learn only a fact about +one subject, start with Evidence. To read specific records or fields, start +with Registry Relay over the maintained spreadsheet registry. Both first runs +use one terminal and synthetic data. Neither needs a source checkout, +production keys, or a deployment package. + +## Answer a bounded question with Evidence + +Evidence signs the answer to one bounded question about one subject without +releasing the record behind it. +[Get a first Evidence assertion](../../tutorials/first-evidence-assertion/) +takes a scaffolded deployment project from nothing to passing assertion +fixtures offline, in about 15 minutes. + +The [Evidence quickstart](../evidence-quickstart/) maps the whole track: six +tutorials running from that first offline project to production signing and a +rehearsed key rotation. ## Start from a spreadsheet @@ -23,11 +37,16 @@ authorization, source access, evidence evaluation, and minimized disclosure. creates the released `spreadsheet` project, checks its evidence rules offline, and runs Relay and Notary locally over maintained synthetic records. -Take this path first when evaluating Registry Stack. +Take this path when the institution has a workbook, or can prepare a reviewed +workbook derivative. Continue with [your own spreadsheet](../../tutorials/use-your-spreadsheet/) after the sample works. -## Expose evidence +## Expose Notary evidence claims + +Registry Notary is being retired and is no longer a recommended starting +point. Evidence is the supported way to answer a bounded question. +This path remains documented for deployments that already run it. The starter already exposes selected records and evaluates two predicate claims. @@ -51,9 +70,6 @@ Continue with: - [The OpenCRVS Events API case study](../../tutorials/verify-opencrvs-claims/) for a synthetic example of that generic integration path -The first run uses one terminal and synthetic data. -You do not need a source checkout, production keys, or a deployment package. - ## Move an older project Pre-1.0 commands and generated runtime files do not have compatibility aliases diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 71e99cb80..d00380e21 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -458,3 +458,36 @@ is parallel; B has no upstream dependencies and is the standing priority through registryctl and Notary end to end, and ending it in an Evidence assertion over a Relay-protected API needs the runnable composed path, which is D. +- 2026-08-03: Front door rebalanced for Evidence, at Jeremi's direction. The + imbalance ran wider than B8 described. The homepage and `start/quickstart` + were both entirely Registry Relay and Registry Notary, with Evidence absent + from each, and the homepage still opened by defining the product as Relay + plus Notary. `Answer with Evidence` was also the only task-flow sidebar + section with no overview route, an omission already visible in + `information-architecture.test.mjs`, which enumerates every other section. + New `start/evidence-quickstart` is that overview: it names the four coequal + acceptance definitions, maps the six tutorials with their times and + outcomes, and states where the track stops, which is production signing and + not production approval. It carries no commands, so it neither competes + with the first-assertion tutorial nor needs a place in the tutorial gate. + Both front doors now open on a two-door choice and give Evidence the first + lane, and each Notary lane carries the same retirement sentence + `when-to-use` uses, unlinked, because publishing the retirement page is + C7's. Nothing was deleted: removing a Notary lane needs test changes and + belongs to C, which is blocked on C1. Three new assertions guard the result + rather than leaving it to drift, one extending the overview-route rule to + Evidence and two pinning an Evidence lane ahead of the Notary tutorial on + each page. Adding Evidence to the homepage exposed a claim that had become + false: `Both paths use the same authoring, offline test, disposable + development, and build commands` is true of the two registryctl paths and + not of Evidence, which has its own toolset and project shape, so it is now + scoped to registryctl. B8 still cannot be ticked. Its third part requires + the quickstart to end in an Evidence assertion over the Relay-protected + API, which is the composed path and needs D. A style-guide pass over the + three touched pages afterwards removed four live violations the automated + checks do not catch: `above` as a directional reference on both front + doors, bold used for keywords inside the track list, `should` where an + obligation was meant, and a Next list mixing fragments with sentences. The + six per-tutorial times, the 23 and 12 fixture counts, and the five + refusals cited on the overview were each read back out of the tutorial + they describe rather than recalled. From a14552c0d3b46551e85fed575830bc16c29500be Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 16:53:50 +0700 Subject: [PATCH 084/136] docs: record Notary retirement decision Signed-off-by: Jeremi Joslin --- ROADMAP.md | 2 +- docs/site/src/content/docs/changelog.mdx | 14 +++- .../notary-retirement-2026-08-03.mdx | 70 +++++++++++++++++++ ...tary-retirement-and-evidence-onboarding.md | 7 +- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 docs/site/src/content/docs/decisions/notary-retirement-2026-08-03.mdx diff --git a/ROADMAP.md b/ROADMAP.md index 03a435898..9f32bcaf8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ Public issues remain the source of truth for work selection: ## October 2026 To March 2027 -- Harden Registry Relay and Registry Notary deployment profiles for pilot +- Harden Registry Relay and Evidence deployment profiles for pilot operators, especially configuration diagnostics, audit posture, and verification commands. - Expand coverage for governed registry reads, evidence issuance, sidecar diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index ef362dc74..40b27ed34 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-docs -last_reviewed: "2026-08-01" +last_reviewed: "2026-08-03" doc_type: reference locale: en standards_referenced: [] @@ -16,6 +16,18 @@ documents. Per-product release notes live in each product repository; the entries below link to the relevant product pages on this site rather than duplicating release notes. +## 2026-08-03 + +Product direction update: + +- Recorded the decision to retire Registry Notary from the current product + surface while preserving historical changelogs, decision records, and + release manifests. +- Reframed the supported product paths around Registry Relay for scoped, + protected reads and Evidence for minimum-disclosure assertions. +- Kept Evidence independent from the retired Notary product model, with + Registry Mint limited to supporting token issuance. + ## 2026-08-01 Stabilization updates for the v0.16.3 beta-26 release candidate: diff --git a/docs/site/src/content/docs/decisions/notary-retirement-2026-08-03.mdx b/docs/site/src/content/docs/decisions/notary-retirement-2026-08-03.mdx new file mode 100644 index 000000000..980933c8b --- /dev/null +++ b/docs/site/src/content/docs/decisions/notary-retirement-2026-08-03.mdx @@ -0,0 +1,70 @@ +--- +title: Registry Notary retirement decision +description: Record of the decision to retire Registry Notary and direct current adoption to Registry Relay and Evidence. +status: draft +owner: registry-docs +source_repos: + - registry-docs +last_reviewed: "2026-08-03" +doc_type: decision +locale: en +standards_referenced: [] +draft: true +--- + +Registry Stack decided on 2026-08-03 to retire Registry Notary and direct +current adopters to Registry Relay and Evidence. + +## Decision + +**Date:** 2026-08-03 + +**Status:** Accepted + +Retire Registry Notary from the current product surface, source workspace, +release train, and continuous integration gates. +Keep Registry Relay as the protected read product and Evidence as the +minimum-disclosure assertion product. +Registry Mint remains a supporting token issuer for deployments without an +identity provider. + +Present two paths to adopters: + +- Use Registry Relay when another system must read specific, authorized data. +- Use Evidence when another system must learn only a fact. + +Evidence can consume a Relay-protected API as a fixed source. +This composition does not make Evidence a Notary mode or a continuation of +the Notary credential lifecycle. + +## Context + +Registry Notary combined claim evaluation, credential issuance, disclosure +policy, replay handling, and audit provenance. +Evidence defines a smaller, stateless assertion boundary with its own frozen +Version 1 contracts. +Keeping both products on the current surface would give adopters overlapping +entry points while preserving two different product models. + +The retirement narrows the supported surface without changing Registry +Relay's role or Evidence's name, contracts, or independence. + +## Consequences + +- Completing the retirement removes Registry Notary from workspace components, + new release manifests, continuous integration gates, and current product + documentation. +- Registry Relay and Evidence remain separate products with separate runtime + and configuration boundaries. +- Evidence Version 1 wire identifiers and production behavior remain + unchanged. +- Historical changelogs, decision records, release manifests, and archived + documentation retain Registry Notary references. +- Publishing this decision redirects current Notary documentation URLs to the + corresponding Evidence guidance or to this decision record. +- Archiving retired Notary source and product material is deferred to a + separate decision. + +## Superseded by + +None. diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index d00380e21..1d2614f24 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -92,7 +92,7 @@ gates for its area (see Verification), and committed. ### C. Notary deletion cascade -- [ ] C1. Decisions on record before deletion: ROADMAP pilot line reframed +- [x] C1. Decisions on record before deletion: ROADMAP pilot line reframed to Relay + Evidence; retirement decision page drafted (published in C7); changelog entry drafted. - [ ] C2. registryctl surgery complete: Notary compiler target, dev @@ -491,3 +491,8 @@ is parallel; B has no upstream dependencies and is the standing priority six per-tutorial times, the 23 and 12 fixture counts, and the five refusals cited on the overview were each read back out of the tutorial they describe rather than recalled. +- 2026-08-03: C1 done after Jeremi approved the framing. The ROADMAP pilot + line now names Registry Relay and Evidence; the docs changelog records the + retirement decision without claiming the deletion is complete; and the + retirement decision page is drafted with `draft: true` for publication in + C7. No runtime, contract, or Relay implementation changed. Next in C: C2. From d1672d6419d97618b2753427c7e4d9416cb2e41a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 18:48:51 +0700 Subject: [PATCH 085/136] docs(plan): record D1, the composed Relay, Mint, and Evidence demo Signed-off-by: Jeremi Joslin --- ...otary-retirement-and-evidence-onboarding.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index d00380e21..7c3e347ae 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -121,7 +121,7 @@ gates for its area (see Verification), and committed. ### D. solmara-lab rebuild (separate repo: registrystack/solmara-lab) -- [ ] D1. Compose runs Relay + Mint + Evidence: spreadsheet source through +- [x] D1. Compose runs Relay + Mint + Evidence: spreadsheet source through Relay, Evidence assertion over Relay's API, smoke suite green. - [ ] D2. `tutorials/first-run-with-solmara-lab` rewritten against the rebuilt demo and passing its gate. @@ -491,3 +491,19 @@ is parallel; B has no upstream dependencies and is the standing priority six per-tutorial times, the 23 and 12 fixture counts, and the five refusals cited on the overview were each read back out of the tutorial they describe rather than recalled. +- 2026-08-03: D1 done in solmara-lab. `just up-evidence` runs a second CRA + Relay serving the civil register as a governed records API, Registry Mint + issuing the caller token, and Registry Evidence answering adult status over + that Relay under `adult-status-verification`. `just smoke-evidence` is green + on four cases: adult 200 true, minor 200 false, unresolved reference 422, + bad token 401, each signed answer verified against the published key set and + checked to carry neither the subject reference nor the date of birth the + Relay disclosed. Two things are honestly incomplete. A1's Mint-as-Relay-IdP + branch stays unexercised: the Relay still trusts its own co-located workload + identity agent, and only Evidence's caller goes through Mint. And no release + ships Evidence or Mint, so the overlay builds those two images from a named + Registry Stack checkout while every other image stays a pinned digest; F3 + would let the lab consume them like the rest. Found on the way: the lab's + workload agent wrote a trailing newline into the token file, which Notary + trims and Evidence does not, since Evidence treats a file secret as opaque + bytes; it now publishes the token alone. From 63c51c3cb762c67d9d1c4046b5b6c8c2ff4e76ab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 19:19:28 +0700 Subject: [PATCH 086/136] refactor(registryctl): remove Notary tooling Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 2 - Cargo.lock | 2 - crates/registry-language-server/src/index.rs | 181 +- .../tests/protocol.rs | 28 +- crates/registryctl/Cargo.toml | 3 - .../SKILL.md | 8 +- .../agents/openai.yaml | 2 +- .../SKILL.md | 4 +- .../bounded-http/environments/local.yaml | 18 +- .../person-record/fixtures/active.yaml | 9 - .../person-record/fixtures/no-match.yaml | 1 - .../bounded-http/registry-stack.yaml | 19 +- .../spreadsheet/environments/local.yaml | 15 +- .../fixtures/match.yaml | 11 - .../fixtures/no-match.yaml | 3 - .../fixtures/planned.yaml | 11 - .../spreadsheet/registry-stack.yaml | 13 +- .../docs/tutorial-standalone-notary.md | 9 - .../documentation-intent.json | 241 +- .../dto-shape-contract.v1.json | 777 +--- .../project-authoring/environment.schema.json | 1049 +++-- .../project-authoring/fixture.schema.json | 295 +- .../project-authoring/parity-coverage.json | 59 +- .../project-authoring/project.schema.json | 1273 ++++-- ...ect.configuration_reference.v1.schema.json | 85 +- ...guration_reference_coverage.v1.schema.json | 15 +- ...untime.configuration_intent.v1.schema.json | 84 +- ...y.project.artifact_manifest.v1.schema.json | 2 - ...roject.capability_inventory.v1.schema.json | 54 +- ...egistry.project.explanation.v1.schema.json | 11 +- ...ry.project.fixture_coverage.v1.schema.json | 202 +- ...try.project.semantic_impact.v1.schema.json | 11 +- ...ctl.fixture_error_reference.v1.schema.json | 5 +- ...tl.operator_error_reference.v1.schema.json | 45 +- ...registryctl.project_command.v1.schema.json | 6 - ...gistryctl.project_preflight.v1.schema.json | 23 +- crates/registryctl/src/approved_set.rs | 129 +- crates/registryctl/src/deployment.rs | 239 +- crates/registryctl/src/dev_credentials.rs | 734 +--- crates/registryctl/src/dev_runtime.rs | 498 +-- crates/registryctl/src/lib.rs | 64 +- crates/registryctl/src/main.rs | 58 +- crates/registryctl/src/project_authoring.rs | 12 +- .../project_authoring/artifact_manifest.rs | 66 +- .../project_authoring/authoring_contract.rs | 319 +- .../project_authoring/capability_inventory.rs | 86 +- .../src/project_authoring/commands.rs | 557 +-- .../project_authoring/compiler/artifacts.rs | 63 +- .../compiler/claim_semantics.rs | 109 + .../project_authoring/compiler/explanation.rs | 285 +- .../src/project_authoring/compiler/notary.rs | 1398 ------ .../src/project_authoring/compiler/relay.rs | 65 +- .../compiler/semantic_impact.rs | 378 +- .../src/project_authoring/development.rs | 988 +---- .../project_authoring/diagnostic_reference.rs | 86 - .../src/project_authoring/diagnostics.rs | 425 +- .../src/project_authoring/documentation.rs | 54 +- .../src/project_authoring/fixture_coverage.rs | 317 +- .../project_authoring/fixture_diagnostics.rs | 37 +- .../src/project_authoring/fixtures.rs | 1024 +---- .../src/project_authoring/knowledge.rs | 6 - .../src/project_authoring/model.rs | 471 +-- .../src/project_authoring/output.rs | 415 +- .../src/project_authoring/preflight.rs | 21 +- .../src/project_authoring/project.rs | 751 +--- .../project_authoring/promotion_projection.rs | 34 +- .../src/project_authoring/report_contract.rs | 12 - .../required_product_action.rs | 4 +- .../src/project_authoring/schema_authority.rs | 48 +- .../src/project_authoring/tests.rs | 969 +---- crates/registryctl/src/release_lock.rs | 129 +- crates/registryctl/src/trust.rs | 209 +- .../tests/anchor_rotation_journey.rs | 77 +- .../tests/approved_set_assembly.rs | 49 +- .../tests/approved_set_rejections.rs | 48 +- .../tests/approved_set_support/mod.rs | 48 +- crates/registryctl/tests/cli_contract.rs | 47 +- crates/registryctl/tests/cli_trust_journey.rs | 4 +- crates/registryctl/tests/deployment_seams.rs | 363 +- crates/registryctl/tests/dev_runtime_core.rs | 1369 +----- .../fixtures/project-authoring-journeys.yaml | 36 +- .../custom-system/environments/local.yaml | 16 +- .../eligibility/fixtures/ambiguous.yaml | 2 +- .../eligibility/fixtures/no-match.yaml | 4 - .../eligibility/fixtures/source-approved.yaml | 12 - .../custom-system/registry-stack.yaml | 21 +- .../dhis2-script/environments/local.yaml | 16 +- .../health-record/fixtures/match.yaml | 23 - .../health-record/fixtures/no-enrollment.yaml | 13 - .../health-record/fixtures/no-match.yaml | 13 - .../health-record/fixtures/partial.yaml | 13 - .../fixtures/source-rejected.yaml | 2 +- .../fixtures/subject-mismatch.yaml | 2 +- .../dhis2-script/registry-stack.yaml | 44 +- .../project-authoring/dhis2-tracker/README.md | 17 +- .../dhis2-tracker/environments/local.yaml | 16 +- .../health-record/fixtures/match.yaml | 23 - .../health-record/fixtures/no-enrollment.yaml | 13 - .../health-record/fixtures/no-match.yaml | 13 - .../health-record/fixtures/partial.yaml | 13 - .../fixtures/source-rejected.yaml | 2 +- .../fixtures/subject-mismatch.yaml | 2 +- .../dhis2-tracker/registry-stack.yaml | 46 +- .../environments/local.yaml | 16 +- .../coverage/fixtures/ambiguous-anchor.yaml | 2 +- .../coverage/fixtures/ambiguous-relation.yaml | 2 +- .../integrations/coverage/fixtures/match.yaml | 12 - .../coverage/fixtures/no-match.yaml | 1 - .../coverage/fixtures/pagination-bounded.yaml | 2 +- .../coverage/fixtures/pagination-match.yaml | 1 - .../coverage/fixtures/subject-mismatch.yaml | 2 +- .../registry-stack.yaml | 20 +- .../environments/local.yaml | 16 +- .../birth-record/fixtures/ambiguous.yaml | 2 +- .../birth-record/fixtures/match.yaml | 18 - .../birth-record/fixtures/no-match.yaml | 1 - .../registry-stack.yaml | 30 +- .../environments/local.yaml | 12 +- .../fixtures/ambiguous.yaml | 1 - .../birth-event-search/fixtures/match.yaml | 12 - .../birth-event-search/fixtures/no-match.yaml | 3 - .../fixtures/oauth-expiry.yaml | 2 +- .../fixtures/oauth-extra-member.yaml | 2 +- .../fixtures/oauth-media-type.yaml | 2 +- .../fixtures/oauth-redirect.yaml | 2 +- .../fixtures/oauth-token-type.yaml | 2 +- .../fixtures/source-malformed.yaml | 2 +- .../fixtures/source-rejected.yaml | 2 +- .../fixtures/source-timeout.yaml | 2 +- .../fixtures/subject-mismatch.yaml | 2 +- .../opencrvs-events-api/registry-stack.yaml | 14 +- .../project-authoring/opencrvs/README.md | 4 +- .../opencrvs/environments/local.yaml | 16 +- .../birth-record/fixtures/ambiguous.yaml | 1 - .../birth-record/fixtures/match.yaml | 21 - .../birth-record/fixtures/no-match.yaml | 1 - .../opencrvs/registry-stack.yaml | 33 +- .../project-authoring/openspp-exact/README.md | 35 +- .../openspp-exact/environments/local.yaml | 16 +- .../individual/fixtures/match.yaml | 9 - .../individual/fixtures/no-match.yaml | 1 - .../openspp-exact/registry-stack.yaml | 14 +- .../snapshot-exact/README.md | 7 +- .../snapshot-exact/environments/local.yaml | 19 +- .../person-snapshot/fixtures/match.yaml | 14 - .../person-snapshot/fixtures/no-match.yaml | 6 - .../snapshot-exact/registry-stack.yaml | 27 +- .../environments/local.yaml | 18 +- .../person-snapshot/fixtures/match.yaml | 14 - .../person-snapshot/fixtures/no-match.yaml | 6 - .../snapshot-with-records/registry-stack.yaml | 25 +- ...istry.project.capability_inventory.v1.json | 63 +- ...project.fixture_coverage.no-target.v1.json | 1 - .../registry.project.fixture_coverage.v1.json | 467 +- ...egistryctl.fixture_error_reference.v1.json | 18 - ...gistryctl.operator_error_reference.v1.json | 324 -- .../registryctl.project_command.v1.json | 3 - .../registryctl.project_preflight.v1.json | 9 +- crates/registryctl/tests/project_authoring.rs | 3762 ++--------------- .../tests/project_authoring_schema_parity.rs | 192 +- .../tests/project_build_baseline_set.rs | 93 +- .../tests/project_capability_inventory.rs | 132 +- .../tests/project_diagnostic_reference.rs | 59 +- .../tests/project_diagnostic_reference_cli.rs | 1 - .../tests/project_documentation_reference.rs | 109 +- .../tests/project_fixture_coverage.rs | 189 +- crates/registryctl/tests/project_preflight.rs | 157 +- .../tests/project_report_contract.rs | 52 + .../tests/project_request_binding.rs | 307 -- ...tary-retirement-and-evidence-onboarding.md | 19 +- release/conformance/adopter-runtime/README.md | 13 +- .../deployment-plan.probe.v1.json | 28 +- .../package/generated/compose.initialize.yaml | 176 - .../package/generated/compose.yaml | 58 - .../operator/secrets/notary-environment | 1 - ...gistry-release-lock-payload.v1.schema.json | 20 +- .../scripts/check_adopter_compose_contract.py | 180 +- release/scripts/registry_release_lock.py | 79 +- release/scripts/runtime_parity_payload.py | 3 - .../test_check_adopter_compose_contract.py | 89 +- .../scripts/test_postgresql_runtime_recipe.py | 13 +- release/scripts/test_registry_release_lock.py | 42 +- 182 files changed, 4706 insertions(+), 20428 deletions(-) delete mode 100644 crates/registryctl/docs/tutorial-standalone-notary.md create mode 100644 crates/registryctl/src/project_authoring/compiler/claim_semantics.rs delete mode 100644 crates/registryctl/src/project_authoring/compiler/notary.rs delete mode 100644 crates/registryctl/tests/project_request_binding.rs delete mode 100644 release/conformance/adopter-runtime/package/operator/secrets/notary-environment diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 6cb26898e..ac9eb59af 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -737,8 +737,6 @@ jobs: "candidate/bundle-root/registryctl-${{ needs.validate.outputs.tag }}-image-lock.json" \ --relay-image-index \ "${RUNNER_TEMP}/registry-relay.index.json" \ - --notary-image-index \ - "${RUNNER_TEMP}/registry-notary.index.json" \ --postgresql-image-index \ "${RUNNER_TEMP}/postgresql.index.json" \ --output "${RUNNER_TEMP}/registry-release-lock.payload.json" diff --git a/Cargo.lock b/Cargo.lock index ce3d4aff6..b0cfa8c3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6097,8 +6097,6 @@ dependencies = [ "regex", "registry-config-report", "registry-language-server", - "registry-notary-core", - "registry-notary-server", "registry-platform-authcommon", "registry-platform-config", "registry-platform-crypto", diff --git a/crates/registry-language-server/src/index.rs b/crates/registry-language-server/src/index.rs index 633ec522d..b36044b0d 100644 --- a/crates/registry-language-server/src/index.rs +++ b/crates/registry-language-server/src/index.rs @@ -20,8 +20,6 @@ pub enum RegistrySymbolKind { Entity, Service, Consultation, - Claim, - CredentialProfile, Fixture, Environment, } @@ -34,8 +32,6 @@ impl RegistrySymbolKind { Self::Entity => "entity", Self::Service => "service", Self::Consultation => "consultation", - Self::Claim => "claim", - Self::CredentialProfile => "credential profile", Self::Fixture => "fixture", Self::Environment => "environment", } @@ -47,8 +43,6 @@ impl RegistrySymbolKind { Self::Integration | Self::Entity => SymbolKind::MODULE, Self::Service => SymbolKind::INTERFACE, Self::Consultation => SymbolKind::FUNCTION, - Self::Claim => SymbolKind::PROPERTY, - Self::CredentialProfile => SymbolKind::OBJECT, Self::Fixture => SymbolKind::EVENT, Self::Environment => SymbolKind::PACKAGE, } @@ -119,14 +113,6 @@ impl SymbolQuery { name: name.into(), } } - - fn scoped(kind: RegistrySymbolKind, scope: impl Into, name: impl Into) -> Self { - Self { - kind, - scope: Some(scope.into()), - name: name.into(), - } - } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -695,68 +681,6 @@ impl IndexBuilder<'_> { } } } - - if let Some(claims) = service.value.get("claims").and_then(YamlValue::as_mapping) { - for claim in claims { - self.add_resolvable_symbol( - SymbolKey::scoped( - RegistrySymbolKind::Claim, - &service_name, - &claim.key.value, - ), - Some(service_name.clone()), - path, - claim.key.range, - ); - if let Some(output) = claim.value.get_scalar("output") { - if let Some((consultation, _)) = output.value.split_once('.') { - self.add_reference( - SymbolQuery::scoped( - RegistrySymbolKind::Consultation, - &service_name, - consultation, - ), - path, - scalar_prefix_range(output, consultation), - ); - } - } - } - } - - if let Some(profiles) = service - .value - .get("credential_profiles") - .and_then(YamlValue::as_mapping) - { - for profile in profiles { - self.add_resolvable_symbol( - SymbolKey::scoped( - RegistrySymbolKind::CredentialProfile, - &service_name, - &profile.key.value, - ), - Some(service_name.clone()), - path, - profile.key.range, - ); - if let Some(claims) = - profile.value.get("claims").and_then(YamlValue::as_sequence) - { - for claim in claims.iter().filter_map(YamlValue::as_scalar) { - self.add_reference( - SymbolQuery::scoped( - RegistrySymbolKind::Claim, - &service_name, - &claim.value, - ), - path, - claim.range, - ); - } - } - } - } } } @@ -843,19 +767,6 @@ impl IndexBuilder<'_> { name.range, ); } - if let Some(claims) = document - .get("expect") - .and_then(|expect| expect.get("claims")) - .and_then(YamlValue::as_mapping) - { - for claim in claims { - self.add_reference( - SymbolQuery::global(RegistrySymbolKind::Claim, &claim.key.value), - path, - claim.key.range, - ); - } - } } fn extract_environment(&mut self, path: &Path, relative: &Path, document: &YamlValue) { @@ -952,6 +863,7 @@ struct YamlPair { enum YamlValue { Scalar(YamlScalar), Mapping(Vec), + #[allow(dead_code)] Sequence(Vec), Other, } @@ -964,6 +876,7 @@ impl YamlValue { } } + #[allow(dead_code)] fn as_sequence(&self) -> Option<&[YamlValue]> { match self { Self::Sequence(entries) => Some(entries), @@ -1094,12 +1007,6 @@ fn scalar_from_node( }) } -fn scalar_prefix_range(scalar: &YamlScalar, prefix: &str) -> Range { - let mut end = scalar.range.start; - end.character += prefix.encode_utf16().count() as u32; - Range::new(scalar.range.start, end) -} - fn meaningful_named_children(node: Node<'_>) -> Vec> { let mut cursor = node.walk(); node.named_children(&mut cursor) @@ -1281,13 +1188,9 @@ entities: residents: { file: entities/residents.yaml } services: person-check: - kind: evidence + kind: consultation_api consultations: person_record: { integration: people } - claims: - active: { output: person_record.active, disclosure: predicate } - credential_profiles: - person-status: { claims: [active] } records: kind: records_api entity: residents @@ -1311,7 +1214,7 @@ services: write( temp.path(), "integrations/people/fixtures/active.yaml", - "name: active-person\nexpect: { claims: { active: true } }\n", + "name: active-person\n", ); temp } @@ -1332,28 +1235,18 @@ services: && symbol.name == "residents" && symbol.location.path.ends_with("residents.yaml") })); + assert!(index.symbols().iter().any(|symbol| { + symbol.kind == RegistrySymbolKind::Consultation && symbol.name == "person_record" + })); let manifest = temp.path().join(PROJECT_FILE); let locations = index.definitions_at(&manifest, Position::new(10, 38)); assert_eq!(locations.len(), 1); assert!(locations[0].path.ends_with("integration.yaml")); - - let fixture = temp.path().join("integrations/people/fixtures/active.yaml"); - let claim_locations = index.definitions_at(&fixture, Position::new(1, 21)); - assert_eq!(claim_locations.len(), 1); - assert_eq!(claim_locations[0].path, normalize_lookup_path(&manifest)); - - let consultation_locations = index.definitions_at(&manifest, Position::new(12, 28)); - assert_eq!(consultation_locations.len(), 1); - assert_eq!( - consultation_locations[0].path, - normalize_lookup_path(&manifest) - ); - assert_eq!(consultation_locations[0].range.start, Position::new(10, 6)); } #[test] - fn reports_missing_duplicate_and_ambiguous_references() { + fn reports_missing_and_duplicate_references() { let temp = fixture_project(); write( temp.path(), @@ -1366,22 +1259,9 @@ services: first: consultations: lookup: { integration: missing } - claims: - shared: { cel: true } - shared: { cel: false } - broken: { output: absent.value } - credential_profiles: - broken: { claims: [absent-claim] } - second: - claims: - shared: { cel: true } + lookup: { integration: people } "#, ); - write( - temp.path(), - "integrations/people/fixtures/active.yaml", - "name: active-person\nexpect: { claims: { shared: true, absent-fixture-claim: true } }\n", - ); let index = ProjectIndex::load(temp.path()).unwrap(); let messages = index .diagnostics() @@ -1394,19 +1274,7 @@ services: .any(|message| message.contains("Unknown integration"))); assert!(messages .iter() - .any(|message| message.contains("Duplicate claim"))); - assert!(messages - .iter() - .any(|message| message.contains("Ambiguous claim"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown consultation"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown claim reference 'absent-claim'"))); - assert!(messages - .iter() - .any(|message| message.contains("Unknown claim reference 'absent-fixture-claim'"))); + .any(|message| message.contains("Duplicate consultation"))); } #[test] @@ -1581,7 +1449,7 @@ services: (RegistrySymbolKind::Registry, "fictional-citizen-registry"), (RegistrySymbolKind::Integration, "person-record"), (RegistrySymbolKind::Service, "person-verification"), - (RegistrySymbolKind::Claim, "person-active"), + (RegistrySymbolKind::Consultation, "person_record"), (RegistrySymbolKind::Fixture, "active-person"), (RegistrySymbolKind::Environment, "local"), ] { @@ -1595,6 +1463,33 @@ services: } } + #[test] + fn retired_notary_authoring_fields_are_not_indexed() { + let temp = TempDir::new().unwrap(); + write( + temp.path(), + PROJECT_FILE, + r#"version: 1 +registry: { id: demo } +services: + retired: + claims: + active: { cel: true } + credential_profiles: + status: { claims: [active] } +"#, + ); + + let index = ProjectIndex::load(temp.path()).unwrap(); + assert!( + index + .symbols() + .iter() + .all(|symbol| symbol.name != "active" && symbol.name != "status"), + "retired Notary authoring fields must not remain in the current editor surface" + ); + } + #[test] fn maintained_authoring_catalog_workspaces_have_no_reference_diagnostics() { let repository_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/crates/registry-language-server/tests/protocol.rs b/crates/registry-language-server/tests/protocol.rs index 170b8fc04..4e594041b 100644 --- a/crates/registry-language-server/tests/protocol.rs +++ b/crates/registry-language-server/tests/protocol.rs @@ -21,12 +21,9 @@ integrations: people: { file: integrations/people/integration.yaml } services: check: + kind: consultation_api consultations: lookup: { integration: people } - claims: - active: { output: lookup.active } - credential_profiles: - status: { claims: [active, missing] } "#, ) .unwrap(); @@ -162,7 +159,7 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "result": null }), ); - let mut published_missing_reference = false; + let mut published_manifest_diagnostics = false; for _ in 0..3 { let notification = receive(&mut stdout); if notification.get("method").and_then(Value::as_str) @@ -170,22 +167,13 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { && notification.pointer("/params/uri").and_then(Value::as_str) == Some(manifest_uri.as_str()) { - published_missing_reference = notification + published_manifest_diagnostics = notification .pointer("/params/diagnostics") .and_then(Value::as_array) - .is_some_and(|diagnostics| { - diagnostics.iter().any(|diagnostic| { - diagnostic - .get("message") - .and_then(Value::as_str) - .is_some_and(|message| { - message.contains("Unknown claim reference 'missing'") - }) - }) - }); + .is_some_and(Vec::is_empty); } } - assert!(published_missing_reference); + assert!(published_manifest_diagnostics); send( &mut stdin, @@ -195,7 +183,7 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "method": "textDocument/definition", "params": { "textDocument": { "uri": manifest_uri }, - "position": { "line": 7, "character": 31 } + "position": { "line": 8, "character": 31 } } }), ); @@ -233,13 +221,13 @@ fn serves_definition_references_and_workspace_symbols_over_stdio() { "jsonrpc": "2.0", "id": 4, "method": "workspace/symbol", - "params": { "query": "active" } + "params": { "query": "lookup" } }), ); let symbols = receive_response(&mut stdout, 4); assert_eq!( symbols.pointer("/result/0/name").and_then(Value::as_str), - Some("active") + Some("lookup") ); let changed_manifest = fs::read_to_string(&manifest_path) diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index 017e63e7c..996458c71 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -10,7 +10,6 @@ workspace = true [features] default = [] -relay-contract-test-support = ["registry-notary-server/relay-contract-test-support"] [dependencies] anyhow = "1" @@ -27,8 +26,6 @@ ipnet.workspace = true jsonschema.workspace = true registry-config-report = { workspace = true } registry-language-server.workspace = true -registry-notary-core.workspace = true -registry-notary-server = { workspace = true, features = ["registry-notary-cel"] } registry-platform-authcommon = { workspace = true } registry-platform-config = { workspace = true } registry-platform-crypto = { workspace = true } diff --git a/crates/registryctl/agent-skills/registry-stack-single-node-deploy/SKILL.md b/crates/registryctl/agent-skills/registry-stack-single-node-deploy/SKILL.md index a005f9bda..49195f4e2 100644 --- a/crates/registryctl/agent-skills/registry-stack-single-node-deploy/SKILL.md +++ b/crates/registryctl/agent-skills/registry-stack-single-node-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: registry-stack-single-node-deploy -description: Use when a user wants a self-hosted single-node Registry Relay, Registry Notary, or Relay plus Notary deployment using registryctl-generated project layout or equivalent local Compose wiring. +description: Use when a user wants a self-hosted single-node Registry Relay deployment using registryctl-generated project layout or equivalent local Compose wiring. --- # Registry Stack Single-Node Deploy @@ -9,7 +9,7 @@ Use this skill to help a user create, validate, and troubleshoot a single-node R ## Workflow -1. Identify the mode: Relay only, Notary only, or Relay plus Notary. +1. Identify whether the project exposes the public Relay lane, the consultation Relay lane, or both. 2. Establish the deployment profile: `local`, `hosted_lab`, `production`, or `evidence_grade`. Prefer product configs declaring `deployment.profile`; use `registryctl doctor --profile` only as a temporary review override. 3. Generate or edit the smallest necessary project files. 4. Run product-owned validation through registryctl: @@ -24,12 +24,12 @@ Use this skill to help a user create, validate, and troubleshoot a single-node R registryctl doctor --profile local --format json ``` -5. Treat the merged JSON report as orchestration evidence only. Relay findings belong to `registry-relay`; Notary findings belong to `registry-notary`. +5. Treat the merged JSON report as orchestration evidence only. Relay findings belong to `registry-relay`. 6. Start containers and run smoke checks only when the user asks for a runnable deployment or provides controlled test targets. ## Redaction Rules -Never print raw env-file values, API keys, bearer tokens, source tokens, Redis URLs, private JWKs, SD-JWT disclosures, source rows, claim values, or full environment dumps. +Never print raw env-file values, API keys, bearer tokens, source tokens, Redis URLs, private JWKs, source rows, or full environment dumps. ## Output diff --git a/crates/registryctl/agent-skills/registry-stack-single-node-deploy/agents/openai.yaml b/crates/registryctl/agent-skills/registry-stack-single-node-deploy/agents/openai.yaml index bf84217cf..31c99d864 100644 --- a/crates/registryctl/agent-skills/registry-stack-single-node-deploy/agents/openai.yaml +++ b/crates/registryctl/agent-skills/registry-stack-single-node-deploy/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Registry Stack Single-Node Deploy" - short_description: "Deploy and validate a local Relay/Notary stack" + short_description: "Deploy and validate a local Relay stack" default_prompt: "Use $registry-stack-single-node-deploy to set up and validate this single-node Registry stack." policy: diff --git a/crates/registryctl/agent-skills/registryctl-local-project-troubleshoot/SKILL.md b/crates/registryctl/agent-skills/registryctl-local-project-troubleshoot/SKILL.md index 37c025d99..79063f00d 100644 --- a/crates/registryctl/agent-skills/registryctl-local-project-troubleshoot/SKILL.md +++ b/crates/registryctl/agent-skills/registryctl-local-project-troubleshoot/SKILL.md @@ -1,6 +1,6 @@ --- name: registryctl-local-project-troubleshoot -description: Use when a user has a registryctl-generated local project and doctor, dev smoke, dev status, dev logs, Relay, or Notary checks fail. +description: Use when a user has a registryctl-generated local project and doctor, dev smoke, dev status, dev logs, or Relay checks fail. --- # registryctl Local Project Troubleshoot @@ -28,7 +28,7 @@ Use this skill to troubleshoot generated local Registry projects without duplica ## Redaction Rules -Do not print raw env-file values, API keys, source tokens, Redis URLs, private JWKs, request bodies, source rows, claim values, or SD-JWT disclosures. Summarize redacted stdout/stderr only. +Do not print raw env-file values, API keys, source tokens, Redis URLs, private JWKs, request bodies, or source rows. Summarize redacted stdout/stderr only. ## Output diff --git a/crates/registryctl/assets/project-starters/bounded-http/environments/local.yaml b/crates/registryctl/assets/project-starters/bounded-http/environments/local.yaml index a1896cee6..58376d930 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/environments/local.yaml +++ b/crates/registryctl/assets/project-starters/bounded-http/environments/local.yaml @@ -13,30 +13,14 @@ integrations: token: { secret: FICTIONAL_REGISTRY_TOKEN } generation: 1 -issuance: - issuer: did:web:notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 - -callers: - evidence-client: - api_key_fingerprint: { secret: EVIDENCE_CLIENT_TOKEN_HASH } - scopes: ["evidence:person:read"] - relay: origin: https://relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [fictional-relay-client] - -notary_relay: - base_url: http://registry-relay-consultation:8080 - workload_client_id: fictional-registry-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: fictional-consultation-client, principal_id: fictional-consultation-principal } deployment: profile: local relay: { service: fictional-registry-relay } - notary: { service: fictional-registry-notary } diff --git a/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/active.yaml b/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/active.yaml index cfb842ce4..a3b6ff675 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/active.yaml +++ b/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/active.yaml @@ -1,13 +1,5 @@ name: active-person classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: registry_person_id, value: AB-123456 }] - claims: [person-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: public-service-person-verification input: { person_id: AB-123456 } interactions: - expect: @@ -18,4 +10,3 @@ interactions: expect: outcome: match outputs: { active: true } - claims: { person-record-exists: true, person-active: true } diff --git a/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/no-match.yaml b/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/no-match.yaml index b0d12f9a0..70327997c 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/no-match.yaml +++ b/crates/registryctl/assets/project-starters/bounded-http/integrations/person-record/fixtures/no-match.yaml @@ -10,4 +10,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: { person-record-exists: false, person-active: null } diff --git a/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml b/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml index 6b3812ba1..92bb91f2f 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml +++ b/crates/registryctl/assets/project-starters/bounded-http/registry-stack.yaml @@ -2,7 +2,7 @@ version: 1 starter: id: http release: 0.16.3 - content_digest: sha256:775d32e45c461bbe6e597434401918188832cd411dec6959517c633670087293 + content_digest: sha256:3366a122be5007cda57217e219a7eaf62da345536251d4fad2a07dd36c39da63 registry: id: fictional-citizen-registry @@ -13,28 +13,13 @@ integrations: services: person-verification: - kind: evidence + kind: consultation_api version: 1 purpose: public-service-person-verification legal_basis: public-service-delivery consent: not_required - access: - scopes: ["evidence:person:read"] consultations: person_record: integration: person-record input: person_id: request.target.identifiers.registry_person_id - claims: - person-record-exists: - cel: person_record.matched - disclosure: predicate - person-active: - output: person_record.active - disclosure: value - credential_profiles: - person-status: - format: dc+sd-jwt - type: https://credentials.invalid/person-status/v1 - validity: 10m - claims: [person-record-exists, person-active] diff --git a/crates/registryctl/assets/project-starters/spreadsheet/environments/local.yaml b/crates/registryctl/assets/project-starters/spreadsheet/environments/local.yaml index 88a41bf5e..577d7cbca 100644 --- a/crates/registryctl/assets/project-starters/spreadsheet/environments/local.yaml +++ b/crates/registryctl/assets/project-starters/spreadsheet/environments/local.yaml @@ -21,31 +21,18 @@ entities: source_revision: synthetic-public-works-v1 generation: "1" -callers: - public-works-service: - api_key_fingerprint: { secret: REGISTRYCTL_LOCAL_NOTARY_CALLER_TOKEN_HASH } - scopes: ["evidence:projects:read"] - public-works-under-scoped: - api_key_fingerprint: { secret: REGISTRYCTL_LOCAL_NOTARY_UNDER_SCOPED_TOKEN_HASH } - scopes: ["evidence:projects:metadata"] - relay: origin: https://records-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [public-works-casework] + consultation: { client_id: public-works-consultation-client, principal_id: public-works-consultation-principal } local_api_keys: match_principal: pw_001 no_match_principal: registryctl_local_no_match scopes: [projects:metadata, projects:rows] -notary_relay: - base_url: http://registry-relay-consultation:8080 - workload_client_id: registryctl-local-notary - token_file: /run/secrets/relay-workload-token - deployment: profile: local relay: { service: records-relay } - notary: { service: registryctl-local-notary } diff --git a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/match.yaml b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/match.yaml index b110f5a23..ee2465ee0 100644 --- a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/match.yaml +++ b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/match.yaml @@ -1,13 +1,5 @@ name: match classification: synthetic -request: - target: - type: Project - identifiers: [{ scheme: project_id, value: pw_001 }] - claims: [project-record-exists, project-status-accepted] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: public-works-case-management input: { project_id: pw_001 } interactions: - expect: { method: GET, path: /snapshot } @@ -15,6 +7,3 @@ interactions: expect: outcome: match outputs: { status: active } - claims: - project-record-exists: true - project-status-accepted: true diff --git a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/no-match.yaml b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/no-match.yaml index 63da026cf..891ba57ac 100644 --- a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/no-match.yaml +++ b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/no-match.yaml @@ -7,6 +7,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - project-record-exists: false - project-status-accepted: false diff --git a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/planned.yaml b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/planned.yaml index 90236fbcf..9d5d560fc 100644 --- a/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/planned.yaml +++ b/crates/registryctl/assets/project-starters/spreadsheet/integrations/project-record-snapshot/fixtures/planned.yaml @@ -1,13 +1,5 @@ name: planned classification: synthetic -request: - target: - type: Project - identifiers: [{ scheme: project_id, value: PW-002 }] - claims: [project-record-exists, project-status-accepted] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: public-works-case-management input: { project_id: PW-002 } interactions: - expect: { method: GET, path: /snapshot } @@ -15,6 +7,3 @@ interactions: expect: outcome: match outputs: { status: planned } - claims: - project-record-exists: true - project-status-accepted: false diff --git a/crates/registryctl/assets/project-starters/spreadsheet/registry-stack.yaml b/crates/registryctl/assets/project-starters/spreadsheet/registry-stack.yaml index 9e9dfa47f..1fc6d876b 100644 --- a/crates/registryctl/assets/project-starters/spreadsheet/registry-stack.yaml +++ b/crates/registryctl/assets/project-starters/spreadsheet/registry-stack.yaml @@ -2,7 +2,7 @@ version: 1 starter: id: spreadsheet release: 0.16.3 - content_digest: sha256:4b9bb158081c28aecf41fb8edc4b354834b316da4ebfaec5aa61173753195b9b + content_digest: sha256:da9145bf0efd5918e06af3def65b0c62daf976d57e3b9d6baf0089e26c3758a0 registry: id: fictional-public-works-registry @@ -39,21 +39,12 @@ services: standards: { ogc_features: false, sp_dci: false } public-works-verification: - kind: evidence + kind: consultation_api version: 1 - subject_type: project purpose: public-works-case-management legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:projects:read"] } consultations: project: integration: project-record-snapshot input: { project_id: request.target.identifiers.project_id } - claims: - project-record-exists: - cel: project.matched - disclosure: predicate - project-status-accepted: - cel: 'project.matched && project.status == "active"' - disclosure: predicate diff --git a/crates/registryctl/docs/tutorial-standalone-notary.md b/crates/registryctl/docs/tutorial-standalone-notary.md deleted file mode 100644 index 78cb507df..000000000 --- a/crates/registryctl/docs/tutorial-standalone-notary.md +++ /dev/null @@ -1,9 +0,0 @@ -# Verify a claim from your own API - -This tutorial is published in Registry Docs: - - - -`registry-registryctl` keeps the CLI source, installer, generated templates, and command reference. -Registry Docs owns the beginner tutorials so the onboarding path is not split across product -repositories. diff --git a/crates/registryctl/schemas/project-authoring/documentation-intent.json b/crates/registryctl/schemas/project-authoring/documentation-intent.json index 91b5a2ccf..2bb9b6188 100644 --- a/crates/registryctl/schemas/project-authoring/documentation-intent.json +++ b/crates/registryctl/schemas/project-authoring/documentation-intent.json @@ -76,20 +76,9 @@ { "schema": "project", "pointer": "/$defs/recordSpatialGeometry/oneOf/1", "path_kind": "branch" }, { "schema": "project", "pointer": "/$defs/recordFieldMap/propertyNames", "path_kind": "map_key" }, { "schema": "project", "pointer": "/$defs/recordFieldMap/additionalProperties", "path_kind": "map_value" }, - { "schema": "project", "pointer": "/$defs/disclosure/oneOf/0", "path_kind": "branch" }, - { "schema": "project", "pointer": "/$defs/disclosure/oneOf/1", "path_kind": "branch" }, - { "schema": "project", "pointer": "/$defs/disclosure/oneOf/1/properties/allowed/items", "path_kind": "array_item" }, { "schema": "project", "pointer": "/$defs/service/oneOf/0", "path_kind": "branch" }, { "schema": "project", "pointer": "/$defs/service/oneOf/1", "path_kind": "branch" }, { "schema": "project", "pointer": "/$defs/recordsService/properties/conforms_to/items", "path_kind": "array_item" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/claims/propertyNames", "path_kind": "map_key" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/claims/additionalProperties", "path_kind": "map_value" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/claims/additionalProperties/oneOf/0", "path_kind": "branch" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/claims/additionalProperties/oneOf/1", "path_kind": "branch" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/credential_profiles/propertyNames", "path_kind": "map_key" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/credential_profiles/additionalProperties", "path_kind": "map_value" }, - { "schema": "project", "pointer": "/$defs/evidenceService/properties/credential_profiles/additionalProperties/properties/claims/items", "path_kind": "array_item" }, - { "schema": "project", "pointer": "/$defs/access/properties/scopes/items", "path_kind": "array_item" }, { "schema": "project", "pointer": "/$defs/variables/propertyNames", "path_kind": "map_key" }, { "schema": "project", "pointer": "/$defs/variables/additionalProperties", "path_kind": "map_value" }, { "schema": "project", "pointer": "/$defs/consultations/propertyNames", "path_kind": "map_key" }, @@ -100,13 +89,8 @@ { "schema": "environment", "pointer": "/properties/integrations/additionalProperties", "path_kind": "map_value" }, { "schema": "environment", "pointer": "/properties/entities/propertyNames", "path_kind": "map_key" }, { "schema": "environment", "pointer": "/properties/entities/additionalProperties", "path_kind": "map_value" }, - { "schema": "environment", "pointer": "/properties/callers/propertyNames", "path_kind": "map_key" }, - { "schema": "environment", "pointer": "/properties/callers/additionalProperties", "path_kind": "map_value" }, - { "schema": "environment", "pointer": "/properties/callers/additionalProperties/properties/scopes/items", "path_kind": "array_item" }, { "schema": "environment", "pointer": "/properties/relay/properties/allowed_clients/items", "path_kind": "array_item" }, { "schema": "environment", "pointer": "/properties/relay/properties/local_api_keys/properties/scopes/items", "path_kind": "array_item" }, - { "schema": "environment", "pointer": "/properties/deployment/anyOf/0", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/properties/deployment/anyOf/1", "path_kind": "branch" }, { "schema": "environment", "pointer": "/allOf/0", "path_kind": "branch" }, { "schema": "environment", "pointer": "/allOf/0/if", "path_kind": "branch" }, { "schema": "environment", "pointer": "/allOf/0/then", "path_kind": "branch" }, @@ -117,30 +101,12 @@ { "schema": "environment", "pointer": "/allOf/1/then", "path_kind": "branch" }, { "schema": "environment", "pointer": "/allOf/2", "path_kind": "branch" }, { "schema": "environment", "pointer": "/allOf/2/if", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/2/then", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/3", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/3/if", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/3/then", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/4", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/4/if", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/4/then", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/5", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/5/if", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/5/then", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/5/else", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/6", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/6/if", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/6/else", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/allOf/6/else/properties/relay/not", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/$defs/internalOrigin/anyOf/0", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/$defs/internalOrigin/anyOf/1", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/$defs/internalOrigin/anyOf/2", "path_kind": "branch" }, + { "schema": "environment", "pointer": "/allOf/2/else", "path_kind": "branch" }, + { "schema": "environment", "pointer": "/allOf/2/else/properties/relay/not", "path_kind": "branch" }, { "schema": "environment", "pointer": "/$defs/relayOrigin/anyOf/0", "path_kind": "branch" }, { "schema": "environment", "pointer": "/$defs/relayOrigin/anyOf/1", "path_kind": "branch" }, { "schema": "environment", "pointer": "/$defs/relayResource/anyOf/0", "path_kind": "branch" }, { "schema": "environment", "pointer": "/$defs/relayResource/anyOf/1", "path_kind": "branch" }, - { "schema": "environment", "pointer": "/$defs/oid4vci/properties/allowed_wallet_origins/items", "path_kind": "array_item" }, - { "schema": "environment", "pointer": "/$defs/oid4vci/properties/registrar_clients/items", "path_kind": "array_item" }, { "schema": "environment", "pointer": "/$defs/privateCidrs/items", "path_kind": "array_item" }, { "schema": "environment", "pointer": "/$defs/credential/oneOf/0", "path_kind": "branch" }, { "schema": "environment", "pointer": "/$defs/credential/oneOf/1", "path_kind": "branch" }, @@ -214,13 +180,6 @@ { "schema": "fixture", "pointer": "/properties/variables/propertyNames", "path_kind": "map_key" }, { "schema": "fixture", "pointer": "/properties/variables/additionalProperties", "path_kind": "map_value" }, { "schema": "fixture", "pointer": "/properties/interactions/items", "path_kind": "array_item" }, - { "schema": "fixture", "pointer": "/$defs/governedRequest/properties/variables/propertyNames", "path_kind": "map_key" }, - { "schema": "fixture", "pointer": "/$defs/governedRequest/properties/variables/additionalProperties", "path_kind": "map_value" }, - { "schema": "fixture", "pointer": "/$defs/governedRequest/properties/claims/items", "path_kind": "array_item" }, - { "schema": "fixture", "pointer": "/$defs/governedTarget/properties/identifiers/items", "path_kind": "array_item" }, - { "schema": "fixture", "pointer": "/$defs/governedTarget/properties/attributes/additionalProperties", "path_kind": "map_value" }, - { "schema": "fixture", "pointer": "/$defs/governedClaimRef/oneOf/0", "path_kind": "branch" }, - { "schema": "fixture", "pointer": "/$defs/governedClaimRef/oneOf/1", "path_kind": "branch" }, { "schema": "fixture", "pointer": "/$defs/request/properties/query/additionalProperties", "path_kind": "map_value" }, { "schema": "fixture", "pointer": "/$defs/request/properties/query/additionalProperties/oneOf/0", "path_kind": "branch" }, { "schema": "fixture", "pointer": "/$defs/request/properties/query/additionalProperties/oneOf/1", "path_kind": "branch" }, @@ -246,7 +205,7 @@ "domains": [ { "schema": "project", - "scope": "The product-neutral project graph and the Relay or Notary services that consume its authored contracts.", + "scope": "The product-neutral project graph and the Relay services that consume its authored contracts.", "state": "authored", "environment_behavior": "narrows_reviewed_authority", "validation_stages": [ @@ -303,7 +262,7 @@ ], "diagnostic": "registryctl.authoring.fixture.invalid", "migration_note": "Update the fixture when the reviewed integration contract changes, while preserving synthetic and non-sensitive test data.", - "example_guidance": "Use unmistakably synthetic inputs, requests, responses, claims, and errors; fixtures must not contain copied personal or source data." + "example_guidance": "Use unmistakably synthetic inputs, requests, responses, outputs, and errors; fixtures must not contain copied personal or source data." }, { "schema": "entity", @@ -347,41 +306,6 @@ "pointer": "/properties/development/properties/relay_port", "purpose": "Overrides the loopback port exposed by the disposable local Relay runtime without changing a deployment endpoint." }, - { - "schema": "environment", - "pointer": "/properties/development/properties/notary_port", - "purpose": "Overrides the loopback port exposed by the disposable local Notary runtime without changing a deployment endpoint." - }, - { - "schema": "environment", - "pointer": "/properties/issuance/properties/issuer", - "purpose": "Binds the operator-approved credential issuer identifier without granting claim or disclosure authority." - }, - { - "schema": "environment", - "pointer": "/properties/issuance/properties/signing_key", - "purpose": "Names the operator-managed secret reference for credential signing; the signing key value is never authored or reported." - }, - { - "schema": "environment", - "pointer": "/properties/issuance/properties/signing_kid", - "purpose": "Selects the reviewed signing-key identifier exposed in issued credential metadata without containing private key material." - }, - { - "schema": "environment", - "pointer": "/properties/issuance/properties/generation", - "purpose": "Records the operator-controlled issuance-key generation used for explicit rotation and rollback checks." - }, - { - "schema": "environment", - "pointer": "/properties/callers/additionalProperties/properties/api_key_fingerprint", - "purpose": "Names the secret reference used to verify one caller API key; neither the key nor fingerprint value is authored here." - }, - { - "schema": "environment", - "pointer": "/properties/callers/additionalProperties/properties/scopes", - "purpose": "Narrows one caller to the explicitly reviewed service scopes available in this environment." - }, { "schema": "environment", "pointer": "/properties/relay/properties/origin", @@ -427,31 +351,11 @@ "pointer": "/properties/relay/properties/local_api_keys/properties/scopes", "purpose": "Limits both generated local API-key callers to the explicitly reviewed service scopes needed by the maintained checks." }, - { - "schema": "environment", - "pointer": "/properties/notary_relay/properties/base_url", - "purpose": "Binds Notary to the operator-reviewed Relay origin used for governed evidence consultations." - }, - { - "schema": "environment", - "pointer": "/properties/notary_relay/properties/workload_client_id", - "purpose": "Identifies the Notary workload client authorized to request reviewed Relay consultations." - }, - { - "schema": "environment", - "pointer": "/properties/notary_relay/properties/token_file", - "purpose": "Selects the operator-managed token-file binding for Notary-to-Relay authentication without exposing its contents." - }, { "schema": "environment", "pointer": "/properties/relay_state/properties/postgresql/properties/root_certificate_path", "purpose": "Binds Relay state storage to an operator-managed PostgreSQL trust root at a deployment-local path." }, - { - "schema": "environment", - "pointer": "/properties/notary_state/properties/postgresql/properties/root_certificate_path", - "purpose": "Binds Notary state storage to an operator-managed PostgreSQL trust root at a deployment-local path." - }, { "schema": "integration", "pointer": "/$defs/input/properties/role", @@ -507,11 +411,6 @@ "pointer": "/$defs/response/oneOf/0/properties/body", "purpose": "Defines the synthetic response body returned by the offline fixture harness to the integration." }, - { - "schema": "fixture", - "pointer": "/$defs/expectation/properties/claims", - "purpose": "States the synthetic Notary claim outcomes expected after the reviewed Relay consultation behavior." - }, { "schema": "entity", "pointer": "/$defs/fieldSchema/properties/type", @@ -787,11 +686,6 @@ "pointer": "/$defs/recordSpdci/properties/response_fields", "purpose": "Maps SP-DCI response names to the entity fields returned for an authorized record." }, - { - "schema": "project", - "pointer": "/$defs/disclosure/oneOf/1/properties/allowed", - "purpose": "Lists the disclosure modes that may be selected in addition to the policy's declared default." - }, { "schema": "project", "pointer": "/$defs/recordsService/properties/kind", @@ -819,84 +713,34 @@ }, { "schema": "project", - "pointer": "/$defs/evidenceService/properties/kind", - "purpose": "Identifies this service declaration as a Notary evidence policy." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/version", - "purpose": "Assigns the positive authored version used to track this evidence-service policy contract." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/consent", - "purpose": "States whether evaluation of this evidence service requires consent." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/claims", - "purpose": "Maps evidence claim identifiers to an output or CEL expression, value contract, and disclosure policy." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/claims/additionalProperties/properties/output", - "purpose": "Selects the exact Relay consultation output that backs this evidence claim." + "pointer": "/$defs/consultationService/properties/kind", + "purpose": "Identifies this service declaration as a Relay consultation API backed by reviewed integrations." }, { "schema": "project", - "pointer": "/$defs/evidenceService/properties/claims/additionalProperties/properties/cel", - "purpose": "Provides the CEL expression used to derive a claim from declared Relay consultation outputs." + "pointer": "/$defs/consultationService/properties/version", + "purpose": "Assigns the positive authored version used to track this Relay consultation service contract." }, { "schema": "project", - "pointer": "/$defs/evidenceService/properties/credential_profiles/additionalProperties/properties/type", - "purpose": "Declares the credential type identifier emitted for this reviewed credential profile." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/credential_profiles/additionalProperties/properties/validity", - "purpose": "Bounds the lifetime of credentials issued from this profile using a positive duration." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/credential_profiles/additionalProperties/properties/claims", - "purpose": "Lists the registry-backed evidence claims selected into this credential profile." - }, - { - "schema": "project", - "pointer": "/$defs/access/properties/scopes", - "purpose": "Lists the scopes a caller must hold to use the enclosing evidence service." + "pointer": "/$defs/consultationService/properties/consent", + "purpose": "States whether a caller must confirm consent before the Relay consultation is evaluated." }, { "schema": "project", "pointer": "/$defs/variables/additionalProperties/properties/from", - "purpose": "Binds one evidence variable to its caller-supplied request variable path." + "purpose": "Binds one consultation variable to its caller-supplied request variable path." }, { "schema": "project", "pointer": "/$defs/variables/additionalProperties/properties/type", - "purpose": "Declares the request variable as a date value for typed evidence evaluation." + "purpose": "Declares the request variable as a date value for typed consultation evaluation." }, { "schema": "project", "pointer": "/$defs/consultations/additionalProperties/properties/input", "purpose": "Maps integration input names to reviewed target-request identifier or attribute bindings." }, - { - "schema": "project", - "pointer": "/$defs/claimValue/properties/type", - "purpose": "Declares the boolean, integer, string, or date type of a claim value." - }, - { - "schema": "project", - "pointer": "/$defs/claimValue/properties/nullable", - "purpose": "States whether the claim value may be null." - }, - { - "schema": "project", - "pointer": "/$defs/claimValue/properties/max_bytes", - "purpose": "Bounds the encoded size of a claim value." - }, { "schema": "project", "pointer": "/anyOf/0/properties/integrations", @@ -917,11 +761,6 @@ "pointer": "/$defs/secret/properties/secret", "purpose": "Names the operator-managed environment secret reference without containing the secret value." }, - { - "schema": "environment", - "pointer": "/$defs/oid4vci/properties/tx_code/properties/required", - "purpose": "States whether OID4VCI authorization requires a transaction code before credential issuance." - }, { "schema": "environment", "pointer": "/$defs/ca/properties/generation", @@ -1062,16 +901,6 @@ "pointer": "/properties/relay_state/properties/postgresql", "purpose": "Selects the PostgreSQL trust binding used by Relay state storage." }, - { - "schema": "environment", - "pointer": "/properties/notary_state/properties/postgresql", - "purpose": "Selects the PostgreSQL trust binding used by Notary state storage." - }, - { - "schema": "environment", - "pointer": "/properties/notary_cel/properties/worker_memory_bytes", - "purpose": "Caps the memory available to one isolated Notary CEL worker." - }, { "schema": "environment", "pointer": "/properties/deployment/properties/profile", @@ -1085,61 +914,21 @@ { "schema": "environment", "pointer": "/allOf/1/then/properties/deployment", - "purpose": "Requires both Relay and Notary deployment services when a Notary-to-Relay binding is present." - }, - { - "schema": "environment", - "pointer": "/allOf/2/then/properties/deployment", "purpose": "Requires the Relay deployment service when Relay state storage is configured." }, { "schema": "environment", - "pointer": "/allOf/3/then/properties/deployment", - "purpose": "Requires the Notary deployment service when Notary state storage is configured." - }, - { - "schema": "environment", - "pointer": "/allOf/4/then/properties/deployment", - "purpose": "Requires the Notary deployment service when a Notary CEL worker memory bound is configured." - }, - { - "schema": "environment", - "pointer": "/allOf/5/else/properties/callers", - "purpose": "When callers are authored without OID4VCI, requires the caller map to contain at least one binding; cross-file validation separately requires callers for Notary environments and rejects them for Relay-only topology." - }, - { - "schema": "environment", - "pointer": "/allOf/5/then/properties/deployment", - "purpose": "Requires the Notary deployment service when OID4VCI issuance is configured." - }, - { - "schema": "environment", - "pointer": "/allOf/6/else/properties/relay", + "pointer": "/allOf/2/else/properties/relay", "purpose": "Applies non-local HTTPS constraints to the Relay origin, issuer, and key-set bindings." }, { "schema": "environment", - "pointer": "/allOf/6/else/properties/oid4vci", - "purpose": "Applies non-local HTTPS constraints to public OID4VCI endpoints." - }, - { - "schema": "environment", - "pointer": "/allOf/6/else/properties/oid4vci/properties/representative_issuance", - "purpose": "Applies non-local HTTPS constraints to representative issuance." - }, - { - "schema": "environment", - "pointer": "/allOf/6/else/properties/oid4vci/properties/authorization_server", - "purpose": "Applies non-local HTTPS constraints to all configured OID4VCI authorization-server endpoints." - }, - { - "schema": "environment", - "pointer": "/allOf/6/if/properties/deployment", + "pointer": "/allOf/2/if/properties/deployment", "purpose": "Matches the local deployment profile before relaxing public endpoint transport constraints." }, { "schema": "environment", - "pointer": "/allOf/6/if/properties/deployment/properties/profile", + "pointer": "/allOf/2/if/properties/deployment/properties/profile", "purpose": "Selects the local-profile condition used by the environment transport-validation branch." }, { diff --git a/crates/registryctl/schemas/project-authoring/dto-shape-contract.v1.json b/crates/registryctl/schemas/project-authoring/dto-shape-contract.v1.json index fe8ccb96d..6f0b37d41 100644 --- a/crates/registryctl/schemas/project-authoring/dto-shape-contract.v1.json +++ b/crates/registryctl/schemas/project-authoring/dto-shape-contract.v1.json @@ -11,81 +11,6 @@ "rust_type": "RegistryProject", "schema": { "$defs": { - "AccessDeclaration": { - "additionalProperties": false, - "properties": { - "scopes": { - "default": [], - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "ClaimDeclaration": { - "additionalProperties": false, - "properties": { - "cel": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "disclosure": { - "$ref": "#/$defs/DisclosureDeclaration" - }, - "output": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "value": { - "anyOf": [ - { - "$ref": "#/$defs/ClaimValueDeclaration" - }, - { - "type": "null" - } - ], - "default": null - } - }, - "required": [ - "disclosure" - ], - "type": "object" - }, - "ClaimValueDeclaration": { - "additionalProperties": false, - "properties": { - "max_bytes": { - "default": null, - "format": "uint32", - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, - "nullable": { - "default": false, - "type": "boolean" - }, - "type": { - "$ref": "#/$defs/OutputType" - } - }, - "required": [ - "type" - ], - "type": "object" - }, "ConsentDeclaration": { "enum": [ "not_required", @@ -112,66 +37,6 @@ ], "type": "object" }, - "CredentialProfileDeclaration": { - "additionalProperties": false, - "properties": { - "claims": { - "items": { - "type": "string" - }, - "type": "array" - }, - "format": { - "type": "string" - }, - "type": { - "type": "string" - }, - "validity": { - "type": "string" - } - }, - "required": [ - "format", - "type", - "validity", - "claims" - ], - "type": "object" - }, - "DisclosureDeclaration": { - "anyOf": [ - { - "$ref": "#/$defs/DisclosureMode" - }, - { - "properties": { - "allowed": { - "items": { - "$ref": "#/$defs/DisclosureMode" - }, - "type": "array" - }, - "default": { - "$ref": "#/$defs/DisclosureMode" - } - }, - "required": [ - "default", - "allowed" - ], - "type": "object" - } - ] - }, - "DisclosureMode": { - "enum": [ - "value", - "predicate", - "redacted" - ], - "type": "string" - }, "EntityReference": { "additionalProperties": false, "properties": { @@ -184,13 +49,6 @@ ], "type": "object" }, - "EvidenceSubjectType": { - "enum": [ - "person", - "project" - ], - "type": "string" - }, "IntegrationReference": { "additionalProperties": false, "properties": { @@ -1169,12 +1027,6 @@ "ServiceDeclaration": { "additionalProperties": false, "properties": { - "access": { - "$ref": "#/$defs/AccessDeclaration", - "default": { - "scopes": [] - } - }, "access_rights": { "anyOf": [ { @@ -1197,13 +1049,6 @@ ], "default": null }, - "claims": { - "additionalProperties": { - "$ref": "#/$defs/ClaimDeclaration" - }, - "default": {}, - "type": "object" - }, "conforms_to": { "default": [], "items": { @@ -1222,13 +1067,6 @@ "default": {}, "type": "object" }, - "credential_profiles": { - "additionalProperties": { - "$ref": "#/$defs/CredentialProfileDeclaration" - }, - "default": {}, - "type": "object" - }, "description": { "default": null, "type": [ @@ -1272,17 +1110,6 @@ ], "default": null }, - "subject_type": { - "anyOf": [ - { - "$ref": "#/$defs/EvidenceSubjectType" - }, - { - "type": "null" - } - ], - "description": "Subject category evaluated by an evidence service. Omission is\nnormalized to `person` without erasing whether the field was authored,\nso records services cannot silently accept it." - }, "title": { "default": null, "type": [ @@ -1322,7 +1149,7 @@ }, "ServiceKind": { "enum": [ - "evidence", + "consultation_api", "records_api" ], "type": "string" @@ -1406,25 +1233,6 @@ "rust_type": "EnvironmentDocument", "schema": { "$defs": { - "CallerBinding": { - "additionalProperties": false, - "properties": { - "api_key_fingerprint": { - "$ref": "#/$defs/SecretReference" - }, - "scopes": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "api_key_fingerprint", - "scopes" - ], - "type": "object" - }, "CertificateAuthorityBinding": { "additionalProperties": false, "properties": { @@ -1446,17 +1254,6 @@ "DeploymentBinding": { "additionalProperties": false, "properties": { - "notary": { - "anyOf": [ - { - "$ref": "#/$defs/ServiceBinding" - }, - { - "type": "null" - } - ], - "default": null - }, "profile": { "$ref": "#/$defs/DeploymentProfile" }, @@ -1481,8 +1278,7 @@ "enum": [ "local", "hosted_lab", - "production", - "evidence_grade" + "production" ], "type": "string" }, @@ -1495,16 +1291,6 @@ "default_integration": { "type": "string" }, - "notary_port": { - "default": null, - "format": "uint16", - "maximum": 65535, - "minimum": 0, - "type": [ - "integer", - "null" - ] - }, "relay_port": { "default": null, "format": "uint16", @@ -1742,339 +1528,41 @@ ], "default": null }, - "timeout": { - "default": null, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "origin" - ], - "type": "object" - }, - "IssuanceBinding": { - "additionalProperties": false, - "properties": { - "algorithm": { - "$ref": "#/$defs/IssuanceSigningAlgorithm", - "default": "EdDSA" - }, - "generation": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "issuer": { - "type": "string" - }, - "signing_key": { - "$ref": "#/$defs/SecretReference" - }, - "signing_kid": { - "type": "string" - } - }, - "required": [ - "issuer", - "signing_key", - "signing_kid", - "generation" - ], - "type": "object" - }, - "IssuanceSigningAlgorithm": { - "enum": [ - "EdDSA", - "ES256" - ], - "type": "string" - }, - "MutualTlsBinding": { - "additionalProperties": false, - "properties": { - "certificate_file": { - "type": "string" - }, - "generation": { - "format": "uint64", - "minimum": 0, - "type": "integer" - }, - "private_key": { - "$ref": "#/$defs/SecretReference" - } - }, - "required": [ - "certificate_file", - "private_key", - "generation" - ], - "type": "object" - }, - "NotaryCelBinding": { - "additionalProperties": false, - "properties": { - "worker_memory_bytes": { - "format": "uint64", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "worker_memory_bytes" - ], - "type": "object" - }, - "NotaryPostgresqlBinding": { - "additionalProperties": false, - "properties": { - "root_certificate_path": { - "type": "string" - } - }, - "required": [ - "root_certificate_path" - ], - "type": "object" - }, - "NotaryRelayBinding": { - "additionalProperties": false, - "properties": { - "base_url": { - "type": "string" - }, - "token_file": { - "type": "string" - }, - "workload_client_id": { - "type": "string" - } - }, - "required": [ - "base_url", - "workload_client_id", - "token_file" - ], - "type": "object" - }, - "NotaryStateBinding": { - "additionalProperties": false, - "properties": { - "postgresql": { - "$ref": "#/$defs/NotaryPostgresqlBinding" - } - }, - "required": [ - "postgresql" - ], - "type": "object" - }, - "Oid4vciAuthorizationServerBinding": { - "additionalProperties": false, - "properties": { - "authorize_url": { - "type": "string" - }, - "issuer": { - "type": "string" - }, - "jwks_url": { - "type": "string" - }, - "token_url": { - "type": "string" - }, - "userinfo_url": { - "type": "string" - } - }, - "required": [ - "issuer", - "jwks_url", - "userinfo_url", - "authorize_url", - "token_url" - ], - "type": "object" - }, - "Oid4vciBinding": { - "additionalProperties": false, - "properties": { - "access_token": { - "$ref": "#/$defs/Oid4vciSigningKeyBinding" - }, - "allowed_wallet_origins": { - "items": { - "type": "string" - }, - "type": "array" - }, - "authorization_server": { - "$ref": "#/$defs/Oid4vciAuthorizationServerBinding" - }, - "client": { - "$ref": "#/$defs/Oid4vciClientBinding" - }, - "credential": { - "$ref": "#/$defs/Oid4vciCredentialBinding" - }, - "public_base_url": { - "type": "string" - }, - "redirect_uri": { - "type": "string" - }, - "registrar_clients": { - "default": [], - "description": "Machine OIDC clients admitted to create registrar-initiated offers.\n\nThese clients use the pinned authorization server and the Notary public\nbase URL as their resource audience. They are deliberately separate\nfrom the citizen client so generated subject-access classification\nremains closed.", - "items": { - "type": "string" - }, - "type": "array" - }, - "representative_issuance": { - "anyOf": [ - { - "$ref": "#/$defs/Oid4vciRepresentativeIssuanceBinding" - }, - { - "type": "null" - } - ] - }, - "sensitive_state_key": { - "$ref": "#/$defs/SecretReference" - }, - "subject": { - "$ref": "#/$defs/Oid4vciSubjectBinding" - }, - "tx_code": { - "$ref": "#/$defs/Oid4vciTxCodeBinding", - "default": { - "required": true - } - } - }, - "required": [ - "public_base_url", - "credential", - "authorization_server", - "client", - "access_token", - "sensitive_state_key", - "subject", - "redirect_uri", - "allowed_wallet_origins" - ], - "type": "object" - }, - "Oid4vciClientBinding": { - "additionalProperties": false, - "properties": { - "id": { - "type": "string" - }, - "signing_key": { - "$ref": "#/$defs/SecretReference" - }, - "signing_kid": { - "type": "string" + "timeout": { + "default": null, + "type": [ + "string", + "null" + ] } }, "required": [ - "id", - "signing_key", - "signing_kid" + "origin" ], "type": "object" }, - "Oid4vciCredentialBinding": { + "MutualTlsBinding": { "additionalProperties": false, "properties": { - "profile": { + "certificate_file": { "type": "string" }, - "service": { - "type": "string" - } - }, - "required": [ - "service", - "profile" - ], - "type": "object" - }, - "Oid4vciRepresentativeIssuanceBinding": { - "additionalProperties": false, - "properties": { - "max_proof_age_seconds": { - "default": 300, + "generation": { "format": "uint64", "minimum": 0, "type": "integer" }, - "proof_claim": { - "type": "string" - }, - "relationship": { - "type": "string" - }, - "target_id_type": { - "type": "string" - } - }, - "required": [ - "relationship", - "proof_claim", - "target_id_type" - ], - "type": "object" - }, - "Oid4vciSigningKeyBinding": { - "additionalProperties": false, - "properties": { - "signing_key": { + "private_key": { "$ref": "#/$defs/SecretReference" - }, - "signing_kid": { - "type": "string" - } - }, - "required": [ - "signing_key", - "signing_kid" - ], - "type": "object" - }, - "Oid4vciSubjectBinding": { - "additionalProperties": false, - "properties": { - "id_type": { - "type": "string" - }, - "token_claim": { - "type": "string" } }, "required": [ - "token_claim", - "id_type" + "certificate_file", + "private_key", + "generation" ], "type": "object" }, - "Oid4vciTxCodeBinding": { - "additionalProperties": false, - "properties": { - "required": { - "default": true, - "type": "boolean" - } - }, - "type": "object" - }, "PrivateEndpointBinding": { "additionalProperties": false, "properties": { @@ -2271,6 +1759,17 @@ "audience": { "type": "string" }, + "consultation": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConsultationBinding" + }, + { + "type": "null" + } + ], + "default": null + }, "issuer": { "type": "string" }, @@ -2301,6 +1800,22 @@ ], "type": "object" }, + "RelayConsultationBinding": { + "additionalProperties": false, + "properties": { + "client_id": { + "type": "string" + }, + "principal_id": { + "type": "string" + } + }, + "required": [ + "client_id", + "principal_id" + ], + "type": "object" + }, "RelayLocalApiKeyBinding": { "additionalProperties": false, "properties": { @@ -2397,13 +1912,6 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { - "callers": { - "additionalProperties": { - "$ref": "#/$defs/CallerBinding" - }, - "default": {}, - "type": "object" - }, "deployment": { "$ref": "#/$defs/DeploymentBinding" }, @@ -2432,61 +1940,6 @@ "default": {}, "type": "object" }, - "issuance": { - "anyOf": [ - { - "$ref": "#/$defs/IssuanceBinding" - }, - { - "type": "null" - } - ], - "default": null - }, - "notary_cel": { - "anyOf": [ - { - "$ref": "#/$defs/NotaryCelBinding" - }, - { - "type": "null" - } - ], - "default": null - }, - "notary_relay": { - "anyOf": [ - { - "$ref": "#/$defs/NotaryRelayBinding" - }, - { - "type": "null" - } - ], - "default": null - }, - "notary_state": { - "anyOf": [ - { - "$ref": "#/$defs/NotaryStateBinding" - }, - { - "type": "null" - } - ], - "default": null - }, - "oid4vci": { - "anyOf": [ - { - "$ref": "#/$defs/Oid4vciBinding" - }, - { - "type": "null" - } - ], - "default": null - }, "relay": { "anyOf": [ { @@ -3787,36 +3240,9 @@ ], "type": "object" }, - "ClaimRef": { - "$ref": "#/$defs/WireClaimRef" - }, - "ClaimRefObject": { - "additionalProperties": false, - "properties": { - "id": { - "type": "string" - }, - "version": { - "default": null, - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "id" - ], - "type": "object" - }, "FixtureExpectation": { "additionalProperties": false, "properties": { - "claims": { - "additionalProperties": true, - "default": {}, - "type": "object" - }, "error": { "default": null, "type": [ @@ -3839,118 +3265,12 @@ }, "type": "object" }, - "GovernedFixtureIdentifier": { - "additionalProperties": false, - "properties": { - "scheme": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "scheme", - "value" - ], - "type": "object" - }, - "GovernedFixtureRequest": { - "additionalProperties": false, - "description": "The closed governed request accepted by an independently authored synthetic\nfixture witness.", - "properties": { - "claims": { - "items": { - "$ref": "#/$defs/ClaimRef" - }, - "type": "array" - }, - "disclosure": { - "type": [ - "string", - "null" - ] - }, - "format": { - "type": [ - "string", - "null" - ] - }, - "purpose": { - "type": "string" - }, - "requester": { - "anyOf": [ - { - "$ref": "#/$defs/GovernedFixtureTarget" - }, - { - "type": "null" - } - ] - }, - "target": { - "$ref": "#/$defs/GovernedFixtureTarget" - }, - "variables": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - } - }, - "required": [ - "target", - "claims", - "purpose" - ], - "type": "object" - }, - "GovernedFixtureTarget": { - "additionalProperties": false, - "properties": { - "attributes": { - "additionalProperties": true, - "type": "object" - }, - "id": { - "type": [ - "string", - "null" - ] - }, - "identifiers": { - "items": { - "$ref": "#/$defs/GovernedFixtureIdentifier" - }, - "type": "array" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type" - ], - "type": "object" - }, "ReadMethod": { "enum": [ "GET", "POST" ], "type": "string" - }, - "WireClaimRef": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/$defs/ClaimRefObject" - } - ] } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -3976,17 +3296,6 @@ "name": { "type": "string" }, - "request": { - "anyOf": [ - { - "$ref": "#/$defs/GovernedFixtureRequest" - }, - { - "type": "null" - } - ], - "default": null - }, "variables": { "additionalProperties": true, "default": {}, diff --git a/crates/registryctl/schemas/project-authoring/environment.schema.json b/crates/registryctl/schemas/project-authoring/environment.schema.json index 4e9c3a1e6..6c39474c9 100644 --- a/crates/registryctl/schemas/project-authoring/environment.schema.json +++ b/crates/registryctl/schemas/project-authoring/environment.schema.json @@ -3,172 +3,329 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://registrystack.example/schemas/project-authoring/environment.v1.json", "title": "Registry Stack project environment v1", - "description": "Binds a Registry Stack project to environment-specific services, sources, credentials, and deployment topology.", + "description": "Binds a Registry Stack project to environment-specific Relay services, sources, credentials, and deployment topology.", "type": "object", "additionalProperties": false, - "required": ["version", "deployment"], + "required": [ + "version", + "deployment" + ], "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Environment authoring format version." }, + "version": { + "x-registry-field": "public_property", + "const": 1, + "description": "Environment authoring format version." + }, "development": { "x-registry-field": "property", "description": "Closed local-development selection. Registryctl uses the exact authored default and never prompts or guesses.", "type": "object", "additionalProperties": false, - "required": ["source_mode", "default_integration", "default_fixture"], + "required": [ + "source_mode", + "default_integration", + "default_fixture" + ], "properties": { - "source_mode": {"x-registry-field": "property", "enum": ["synthetic", "local_snapshot", "operator_bound"]}, - "default_integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId"}, - "default_fixture": {"x-registry-field": "property", "$ref": "#/$defs/stableId"}, - "relay_port": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65535}, - "notary_port": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65535} + "source_mode": { + "x-registry-field": "property", + "enum": [ + "synthetic", + "local_snapshot", + "operator_bound" + ] + }, + "default_integration": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "default_fixture": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "relay_port": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 65535 + } } }, - "integrations": {"x-registry-field": "property", "type": "object", "maxProperties": 16, "description": "Environment bindings keyed by authored integration identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/integration" } }, - "entities": {"x-registry-field": "property", "type": "object", "maxProperties": 32, "description": "Environment-specific source bindings keyed by entity identifier.", "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/entity" } }, - "issuance": { + "integrations": { "x-registry-field": "property", - "description": "Notary issuer and signing-key bindings for credential issuance.", - "type": "object", "additionalProperties": false, "required": ["issuer", "signing_key", "signing_kid", "generation"], - "properties": { "issuer": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1 }, "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" }, "algorithm": {"x-registry-field": "property", "enum": ["EdDSA", "ES256"], "default": "EdDSA", "description": "Credential issuer signing algorithm. Holder proof remains EdDSA with did:jwk." }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } + "type": "object", + "maxProperties": 16, + "description": "Environment bindings keyed by authored integration identifier.", + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/integration" + } }, - "callers": { + "entities": { "x-registry-field": "property", - "description": "Notary API-key caller identities and the scopes each identity receives.", - "type": "object", "maxProperties": 64, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, + "type": "object", + "maxProperties": 32, + "description": "Environment-specific source bindings keyed by entity identifier.", + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, "additionalProperties": { "x-registry-field": "map_value", - "type": "object", "additionalProperties": false, "required": ["api_key_fingerprint", "scopes"], - "properties": { "api_key_fingerprint": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } + "$ref": "#/$defs/entity" } }, "relay": { "x-registry-field": "property", "description": "Public Relay identity and token-validation settings for a Relay deployment.", - "type": "object", "additionalProperties": false, - "required": ["origin", "issuer", "jwks_url", "audience", "allowed_clients"], - "properties": { "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, "audience": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "allowed_clients": {"x-registry-field": "sensitive_property", "type": "array", "maxItems": 64, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 256 } }, "local_api_keys": {"x-registry-field": "property", "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", "type": "object", "additionalProperties": false, "required": ["match_principal", "no_match_principal", "scopes"], "properties": { "match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "no_match_principal": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "type": "string", "minLength": 1, "maxLength": 128 } } } } } - }, - "notary_relay": { - "x-registry-field": "property", - "description": "Deployment-internal Relay connection, Notary workload identity, and token file used only when Notary consults Relay.", - "type": "object", "additionalProperties": false, "required": ["base_url", "workload_client_id", "token_file"], - "properties": { "base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/internalOrigin" }, "workload_client_id": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "token_file": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$" } } - }, - "relay_state": { - "x-registry-field": "property", - "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], + "type": "object", + "additionalProperties": false, + "required": [ + "origin", + "issuer", + "jwks_url", + "audience", + "allowed_clients" + ], "properties": { - "postgresql": { + "origin": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relayOrigin" + }, + "issuer": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relayOrigin" + }, + "jwks_url": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relayResource" + }, + "audience": { + "x-registry-field": "sensitive_property", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "allowed_clients": { + "x-registry-field": "sensitive_property", + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "x-registry-field": "array_item", + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "consultation": { "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } + "description": "Explicit Relay consultation workload identity. The client must also appear in allowed_clients; the principal is the stable authorization identity bound to that client.", + "type": "object", + "additionalProperties": false, + "required": [ + "client_id", + "principal_id" + ], + "properties": { + "client_id": { + "x-registry-field": "sensitive_property", + "description": "Token client identifier dedicated to the Relay consultation lane; it must also appear in the environment allowed-client list.", + "$ref": "#/$defs/token256" + }, + "principal_id": { + "x-registry-field": "sensitive_property", + "description": "Stable authorization principal Relay binds to the dedicated consultation client.", + "$ref": "#/$defs/token256" + } + } + }, + "local_api_keys": { + "x-registry-field": "property", + "description": "Local-profile-only synthetic principals and scopes. Registryctl generates the raw keys and supplies only their fingerprints to Relay.", + "type": "object", + "additionalProperties": false, + "required": [ + "match_principal", + "no_match_principal", + "scopes" + ], + "properties": { + "match_principal": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/token256" + }, + "no_match_principal": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/token256" + }, + "scopes": { + "x-registry-field": "property", + "type": "array", + "minItems": 1, + "maxItems": 16, + "uniqueItems": true, + "items": { + "x-registry-field": "array_item", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } } } }, - "notary_state": { + "relay_state": { "x-registry-field": "property", - "description": "Optional deployment binding for Notary-owned PostgreSQL state transport trust.", - "type": "object", "additionalProperties": false, "required": ["postgresql"], + "description": "Optional deployment binding for Relay-owned consultation PostgreSQL state transport trust.", + "type": "object", + "additionalProperties": false, + "required": [ + "postgresql" + ], "properties": { "postgresql": { "x-registry-field": "property", - "type": "object", "additionalProperties": false, "required": ["root_certificate_path"], - "properties": { "root_certificate_path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } + "type": "object", + "additionalProperties": false, + "required": [ + "root_certificate_path" + ], + "properties": { + "root_certificate_path": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + } + } } } }, - "notary_cel": { + "deployment": { "x-registry-field": "property", - "description": "Optional per-worker data/address-space ceiling for dedicated Notary CEL processes. The Notary default is 134217728 bytes; 1073741824 is the maximum emulation-compatible exception and remains a limit, not an allocation.", - "type": "object", "additionalProperties": false, "required": ["worker_memory_bytes"], + "description": "Relay deployed in this environment and the operational profile it uses.", + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "relay" + ], "properties": { - "worker_memory_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 33554432, "maximum": 1073741824 } + "profile": { + "x-registry-field": "property", + "enum": [ + "local", + "hosted_lab", + "production" + ] + }, + "relay": { + "x-registry-field": "property", + "$ref": "#/$defs/service" + } } - }, - "oid4vci": { - "x-registry-field": "property", - "description": "Explicit registry-backed holder-wallet OID4VCI binding for one authored Notary credential profile.", - "$ref": "#/$defs/oid4vci" - }, - "deployment": { - "x-registry-field": "property", - "description": "Products deployed in this environment and the operational profile they use.", - "type": "object", "additionalProperties": false, "required": ["profile"], - "properties": { "profile": {"x-registry-field": "property", "enum": ["local", "hosted_lab", "production", "evidence_grade"] }, "relay": {"x-registry-field": "property", "$ref": "#/$defs/service" }, "notary": {"x-registry-field": "property", "$ref": "#/$defs/service" } }, - "anyOf": [{"x-registry-field": "branch", "required": ["relay"] }, {"x-registry-field": "branch", "required": ["notary"] }] } }, "allOf": [ { "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["deployment"], "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } }, - "then": {"x-registry-field": "branch", "required": ["relay"] }, - "else": {"x-registry-field": "branch", "not": {"x-registry-field": "branch", "required": ["relay"] } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_relay"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay", "notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["relay_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["relay"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_state"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } - }, - { - "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["notary_cel"] }, - "then": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["notary"] } } } + "if": { + "x-registry-field": "branch", + "required": [ + "deployment" + ], + "properties": { + "deployment": { + "x-registry-field": "property", + "required": [ + "relay" + ] + } + } + }, + "then": { + "x-registry-field": "branch", + "required": [ + "relay" + ] + }, + "else": { + "x-registry-field": "branch", + "not": { + "x-registry-field": "branch", + "required": [ + "relay" + ] + } + } }, { "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "required": ["oid4vci"] }, + "if": { + "x-registry-field": "branch", + "required": [ + "relay_state" + ] + }, "then": { "x-registry-field": "branch", - "required": ["notary_state"], "properties": { - "deployment": {"x-registry-field": "property", "required": ["notary"] } + "deployment": { + "x-registry-field": "property", + "required": [ + "relay" + ] + } } - }, - "else": {"x-registry-field": "branch", "properties": { "callers": {"x-registry-field": "property", "minProperties": 1 } } } + } }, { "x-registry-field": "branch", - "if": {"x-registry-field": "branch", "properties": { "deployment": {"x-registry-field": "property", "required": ["profile"], "properties": { "profile": {"x-registry-field": "property", "const": "local" } } } } }, - "else": { + "if": { "x-registry-field": "branch", "properties": { - "relay": { + "deployment": { "x-registry-field": "property", - "not": {"x-registry-field": "branch", "required": ["local_api_keys"] }, + "required": [ + "profile" + ], "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } + "profile": { + "x-registry-field": "property", + "const": "local" + } } - }, - "oid4vci": { + } + } + }, + "else": { + "x-registry-field": "branch", + "properties": { + "relay": { "x-registry-field": "property", + "not": { + "x-registry-field": "branch", + "required": [ + "local_api_keys" + ] + }, "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorization_server": { - "x-registry-field": "property", - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/httpsResource" } - } + "origin": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/origin" + }, + "issuer": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/origin" }, - "representative_issuance": {"x-registry-field": "property" } + "jwks_url": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/httpsResource" + } } } } @@ -178,185 +335,533 @@ "examples": [ { "version": 1, + "relay": { + "origin": "http://127.0.0.1:8080", + "issuer": "http://127.0.0.1:9090", + "jwks_url": "http://127.0.0.1:9090/.well-known/jwks.json", + "audience": "registry-relay", + "allowed_clients": [ + "relay-client" + ] + }, "deployment": { "profile": "local", - "notary": { "service": "notary" } + "relay": { + "service": "relay" + } } } ], "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token2048": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII token, including full verification-method identifiers." }, - "secret": { "type": "object", "additionalProperties": false, "description": "Reference to a process-environment variable; secret values are never authored here.", "required": ["secret"], "properties": { "secret": {"x-registry-field": "secret_reference_property", "type": "string", "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" } } }, - "origin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", "description": "HTTPS origin without path, query, or fragment." }, - "httpsResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", "description": "Exact HTTPS resource without query or fragment." }, - "localLoopbackOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", "description": "HTTP IP-loopback origin; public Relay and issuer fields restrict it to the local profile, while internal Notary-to-Relay connections allow it in any profile." }, - "privateServiceOrigin": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://[a-z][a-z0-9.-]{0,252}(?::[0-9]+)?/?$", "description": "Signed internal HTTP service origin. Runtime DNS pinning admits only eligible private addresses." }, - "localLoopbackResource": { "type": "string", "format": "uri", "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", "description": "HTTP IP-loopback resource accepted only with the local deployment profile." }, - "internalOrigin": { "description": "Deployment-internal HTTPS, private-service HTTP, or HTTP IP-loopback origin.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/privateServiceOrigin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayOrigin": { "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/origin" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackOrigin" }] }, - "relayResource": { "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", "anyOf": [{"x-registry-field": "branch", "$ref": "#/$defs/httpsResource" }, {"x-registry-field": "branch", "$ref": "#/$defs/localLoopbackResource" }] }, - "oid4vci": { - "description": "Closed OID4VCI trust, key, subject, wallet, and credential-profile binding.", + "stableId": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,95}$", + "description": "Lowercase stable identifier used in project references." + }, + "secret": { "type": "object", "additionalProperties": false, - "required": ["public_base_url", "credential", "authorization_server", "client", "access_token", "sensitive_state_key", "subject", "redirect_uri", "allowed_wallet_origins"], + "description": "Reference to a process-environment variable; secret values are never authored here.", + "required": [ + "secret" + ], "properties": { - "public_base_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "credential": { - "x-registry-field": "property", - "description": "Existing project service and credential profile exposed through OID4VCI.", - "type": "object", "additionalProperties": false, "required": ["service", "profile"], - "properties": { - "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "profile": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } - } - }, - "authorization_server": { - "x-registry-field": "property", - "description": "Pinned eSignet issuer and exact endpoints used for login and token validation.", - "type": "object", "additionalProperties": false, "required": ["issuer", "jwks_url", "userinfo_url", "authorize_url", "token_url"], - "properties": { - "issuer": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayOrigin" }, - "jwks_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "userinfo_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "authorize_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "token_url": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" } - } - }, - "client": { - "x-registry-field": "property", - "description": "eSignet relying-party identity and dedicated private-key reference.", - "type": "object", "additionalProperties": false, "required": ["id", "signing_key", "signing_kid"], - "properties": { - "id": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token256" }, - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "registrar_clients": { - "x-registry-field": "sensitive_property", - "description": "Closed OIDC machine-client allow-list for registrar-created credential offers. Each client uses this authorization server and the Notary public base URL as its resource audience; citizen client classification remains separate.", - "type": "array", "maxItems": 64, "uniqueItems": true, "default": [], - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/token256" } - }, - "access_token": { - "x-registry-field": "property", - "description": "Dedicated Notary access-token signing key and published key identifier.", - "type": "object", "additionalProperties": false, "required": ["signing_key", "signing_kid"], - "properties": { - "signing_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "signing_kid": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/token2048" } - } - }, - "sensitive_state_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, - "subject": { - "x-registry-field": "property", - "description": "Verified eSignet userinfo claim bound exactly to the credential subject identifier.", - "type": "object", "additionalProperties": false, "required": ["token_claim", "id_type"], - "properties": { - "token_claim": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" } - } - }, - "redirect_uri": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relayResource" }, - "allowed_wallet_origins": { - "x-registry-field": "sensitive_property", - "description": "Exact HTTPS browser origins admitted to the wallet-facing Notary routes.", - "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "$ref": "#/$defs/origin" } + "secret": { + "x-registry-field": "secret_reference_property", + "type": "string", + "pattern": "^[A-Z_][A-Z0-9_]{0,127}$" + } + } + }, + "origin": { + "type": "string", + "format": "uri", + "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/?$", + "description": "HTTPS origin without path, query, or fragment." + }, + "httpsResource": { + "type": "string", + "format": "uri", + "pattern": "^[hH][tT][tT][pP][sS]://[^/?#]+/[^?#]+$", + "description": "Exact HTTPS resource without query or fragment." + }, + "localLoopbackOrigin": { + "type": "string", + "format": "uri", + "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/?$", + "description": "HTTP IP-loopback origin accepted only under the local profile." + }, + "localLoopbackResource": { + "type": "string", + "format": "uri", + "pattern": "^[hH][tT][tT][pP]://(?:127(?:\\.[0-9]{1,3}){3}|\\[::1\\])(?::[0-9]+)?/[^?#]+$", + "description": "HTTP IP-loopback resource accepted only with the local deployment profile." + }, + "relayOrigin": { + "description": "Relay or issuer origin using HTTPS, or HTTP IP-loopback under the local profile.", + "anyOf": [ + { + "x-registry-field": "branch", + "$ref": "#/$defs/origin" }, - "tx_code": { - "x-registry-field": "property", - "description": "Transaction-code policy. Omit for the secure required-PIN default. Set required=false only for a bounded bearer-offer interoperability profile; the compiler fixes the offer lifetime at 300 seconds.", - "type": "object", "additionalProperties": false, - "properties": { "required": {"x-registry-field": "property", "type": "boolean", "default": true } } + { + "x-registry-field": "branch", + "$ref": "#/$defs/localLoopbackOrigin" + } + ] + }, + "relayResource": { + "description": "Relay authentication resource using HTTPS, or HTTP IP-loopback under the local profile.", + "anyOf": [ + { + "x-registry-field": "branch", + "$ref": "#/$defs/httpsResource" }, - "representative_issuance": { - "x-registry-field": "property", - "description": "One digitally authenticated representative ceremony bound to an exact relationship proof. Registryctl derives the credential dependency, status endpoint, and secure proof-freshness default.", - "type": "object", - "additionalProperties": false, - "required": ["relationship", "proof_claim", "target_id_type"], - "properties": { - "relationship": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "proof_claim": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "target_id_type": {"x-registry-field": "property", "$ref": "#/$defs/token256" }, - "max_proof_age_seconds": { - "x-registry-field": "property", - "description": "Maximum age of the relationship proof. Omit for the secure 300-second default.", - "type": "integer", - "minimum": 1, - "maximum": 600, - "default": 300 - } - } + { + "x-registry-field": "branch", + "$ref": "#/$defs/localLoopbackResource" } - } + ] }, "privateCidrs": { "description": "Explicit private network ranges this source may resolve to.", - "type": "array", "maxItems": 16, "uniqueItems": true, - "items": {"x-registry-field": "sensitive_array_item", "type": "string", "minLength": 3, "maxLength": 64 } + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "items": { + "x-registry-field": "sensitive_array_item", + "type": "string", + "minLength": 3, + "maxLength": 64 + } }, "ca": { "description": "Pinned certificate-authority file and its rotation generation.", - "type": "object", "additionalProperties": false, "required": ["file", "generation"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } + "type": "object", + "additionalProperties": false, + "required": [ + "file", + "generation" + ], + "properties": { + "file": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } }, "mtls": { "description": "Client certificate and private-key reference for mutual TLS.", - "type": "object", "additionalProperties": false, "required": ["certificate_file", "private_key", "generation"], - "properties": { "certificate_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "private_key": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } + "type": "object", + "additionalProperties": false, + "required": [ + "certificate_file", + "private_key", + "generation" + ], + "properties": { + "certificate_file": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + }, + "private_key": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } }, "endpoint": { "description": "Security-bound HTTPS endpoint with optional private-network, CA, and mTLS policy.", - "type": "object", "additionalProperties": false, "required": ["origin", "path", "generation"], + "type": "object", + "additionalProperties": false, + "required": [ + "origin", + "path", + "generation" + ], "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "path": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } + "origin": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/origin" + }, + "path": { + "x-registry-field": "sensitive_property", + "type": "string", + "minLength": 2, + "maxLength": 4096, + "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//)(?!.*[?#]).+$" + }, + "allowed_private_cidrs": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/privateCidrs" + }, + "ca": { + "x-registry-field": "property", + "$ref": "#/$defs/ca" + }, + "mtls": { + "x-registry-field": "property", + "$ref": "#/$defs/mtls" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } } }, "credential": { "description": "One supported source credential shape, containing references rather than values.", "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["username", "password", "generation"], "properties": { "username": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "password": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["token", "generation"], "properties": { "token": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["client_id", "client_secret", "generation"], "properties": { "client_id": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "client_secret": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["value", "generation"], "properties": { "value": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "generation": {"x-registry-field": "sensitive_property", "type": "integer", "minimum": 1, "maximum": 18446744073709551615 } } } + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password", + "generation" + ], + "properties": { + "username": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "password": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "generation" + ], + "properties": { + "token": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "client_id", + "client_secret", + "generation" + ], + "properties": { + "client_id": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "client_secret": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "generation" + ], + "properties": { + "value": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "integer", + "minimum": 1, + "maximum": 18446744073709551615 + } + } + } ] }, "provider": { "description": "Environment-specific physical provider for a materialized entity.", "oneOf": [ - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "csv" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "delimiter": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 }, "quote": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 255 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "project_file", "path", "sheet"], "properties": { "type": {"x-registry-field": "property", "const": "xlsx" }, "project_file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" }, "sheet": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 }, "header_row": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "data_range": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 256 } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "path"], "properties": { "type": {"x-registry-field": "property", "const": "parquet" }, "path": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/absolutePath" } } }, - {"x-registry-field": "branch", "type": "object", "additionalProperties": false, "required": ["type", "connection", "schema", "table"], "properties": { "type": {"x-registry-field": "property", "const": "postgres" }, "connection": {"x-registry-field": "secret_reference_property", "$ref": "#/$defs/secret" }, "schema": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" }, "table": {"x-registry-field": "property", "$ref": "#/$defs/postgresIdentifier" } } } + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "x-registry-field": "property", + "const": "csv" + }, + "path": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + }, + "header_row": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "delimiter": { + "x-registry-field": "property", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "quote": { + "x-registry-field": "property", + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "project_file", + "path", + "sheet" + ], + "properties": { + "type": { + "x-registry-field": "property", + "const": "xlsx" + }, + "project_file": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relativePath" + }, + "path": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + }, + "sheet": { + "x-registry-field": "property", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "header_row": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "data_range": { + "x-registry-field": "property", + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "x-registry-field": "property", + "const": "parquet" + }, + "path": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/absolutePath" + } + } + }, + { + "x-registry-field": "branch", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "connection", + "schema", + "table" + ], + "properties": { + "type": { + "x-registry-field": "property", + "const": "postgres" + }, + "connection": { + "x-registry-field": "secret_reference_property", + "$ref": "#/$defs/secret" + }, + "schema": { + "x-registry-field": "property", + "$ref": "#/$defs/postgresIdentifier" + }, + "table": { + "x-registry-field": "property", + "$ref": "#/$defs/postgresIdentifier" + } + } + } ] }, "entity": { "description": "Maps an authored entity to provider columns and immutable source-generation identifiers.", - "type": "object", "additionalProperties": false, "required": ["provider", "columns", "source_revision", "generation"], - "properties": { "provider": {"x-registry-field": "property", "$ref": "#/$defs/provider" }, "columns": {"x-registry-field": "property", "type": "object", "minProperties": 1, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } }, "source_revision": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 }, "generation": {"x-registry-field": "sensitive_property", "type": "string", "minLength": 1, "maxLength": 256 } } + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "columns", + "source_revision", + "generation" + ], + "properties": { + "provider": { + "x-registry-field": "property", + "$ref": "#/$defs/provider" + }, + "columns": { + "x-registry-field": "property", + "type": "object", + "minProperties": 1, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/stableId" + } + }, + "source_revision": { + "x-registry-field": "sensitive_property", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "generation": { + "x-registry-field": "sensitive_property", + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } }, "source": { "description": "Runtime source connection policy for one authored integration.", - "type": "object", "additionalProperties": false, "required": ["origin"], + "type": "object", + "additionalProperties": false, + "required": [ + "origin" + ], "properties": { - "origin": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/origin" }, - "allowed_private_cidrs": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/privateCidrs" }, - "ca": {"x-registry-field": "property", "$ref": "#/$defs/ca" }, - "mtls": {"x-registry-field": "property", "$ref": "#/$defs/mtls" }, - "credential": {"x-registry-field": "property", "$ref": "#/$defs/credential" }, - "oauth": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "jwks": {"x-registry-field": "property", "$ref": "#/$defs/endpoint" }, - "rate": {"x-registry-field": "property", "type": "object", "additionalProperties": false, "required": ["per_minute", "burst"], "properties": { "per_minute": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 60000 }, "burst": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 1024 } } }, - "concurrency": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 64 }, + "origin": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/origin" + }, + "allowed_private_cidrs": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/privateCidrs" + }, + "ca": { + "x-registry-field": "property", + "$ref": "#/$defs/ca" + }, + "mtls": { + "x-registry-field": "property", + "$ref": "#/$defs/mtls" + }, + "credential": { + "x-registry-field": "property", + "$ref": "#/$defs/credential" + }, + "oauth": { + "x-registry-field": "property", + "$ref": "#/$defs/endpoint" + }, + "jwks": { + "x-registry-field": "property", + "$ref": "#/$defs/endpoint" + }, + "rate": { + "x-registry-field": "property", + "type": "object", + "additionalProperties": false, + "required": [ + "per_minute", + "burst" + ], + "properties": { + "per_minute": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 60000 + }, + "burst": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 1024 + } + } + }, + "concurrency": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 64 + }, "timeout": { "x-registry-field": "property", "description": "Optional request timeout no greater than 20 seconds; generated bindings narrow it to the integration deadline.", @@ -367,13 +872,57 @@ }, "integration": { "description": "Environment binding for an authored source integration.", - "type": "object", "additionalProperties": false, "required": ["source"], - "properties": { "source": {"x-registry-field": "property", "$ref": "#/$defs/source" } } + "type": "object", + "additionalProperties": false, + "required": [ + "source" + ], + "properties": { + "source": { + "x-registry-field": "property", + "$ref": "#/$defs/source" + } + } }, - "service": { "type": "object", "additionalProperties": false, "description": "Binds an authored product role to a deployed service identifier.", "required": ["service"], "properties": { "service": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } }, - "token256": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[!-~]+$", "description": "Bounded visible-ASCII protocol token." }, - "postgresIdentifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$", "description": "Portable unquoted PostgreSQL schema or table identifier." }, - "absolutePath": { "type": "string", "minLength": 2, "maxLength": 4096, "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized absolute path without dot segments or duplicate separators." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Normalized project-relative path without dot segments or duplicate separators." } + "service": { + "type": "object", + "additionalProperties": false, + "description": "Binds an authored product role to a deployed service identifier.", + "required": [ + "service" + ], + "properties": { + "service": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + } + } + }, + "token256": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[!-~]+$", + "description": "Bounded visible-ASCII protocol token." + }, + "postgresIdentifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,62}$", + "description": "Portable unquoted PostgreSQL schema or table identifier." + }, + "absolutePath": { + "type": "string", + "minLength": 2, + "maxLength": 4096, + "pattern": "^/(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", + "description": "Normalized absolute path without dot segments or duplicate separators." + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", + "description": "Normalized project-relative path without dot segments or duplicate separators." + } } } diff --git a/crates/registryctl/schemas/project-authoring/fixture.schema.json b/crates/registryctl/schemas/project-authoring/fixture.schema.json index 79b5b7c34..067e8196e 100644 --- a/crates/registryctl/schemas/project-authoring/fixture.schema.json +++ b/crates/registryctl/schemas/project-authoring/fixture.schema.json @@ -6,14 +6,28 @@ "description": "Defines one synthetic, deterministic integration scenario for offline adapter verification.", "type": "object", "additionalProperties": false, - "required": ["name", "classification", "input", "interactions", "expect"], + "required": [ + "name", + "classification", + "input", + "interactions", + "expect" + ], "properties": { - "name": {"x-registry-field": "public_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Human-readable scenario name shown in test output.", "examples": ["existing household matches"] }, - "classification": {"x-registry-field": "public_property", "const": "synthetic", "description": "Declares that fixture data is synthetic and safe for offline testing." }, - "request": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedRequest", - "description": "Optional independently authored synthetic Notary request used to prove the request-to-consultation binding before Relay access." + "name": { + "x-registry-field": "public_property", + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Human-readable scenario name shown in test output.", + "examples": [ + "existing household matches" + ] + }, + "classification": { + "x-registry-field": "public_property", + "const": "synthetic", + "description": "Declares that fixture data is synthetic and safe for offline testing." }, "input": { "x-registry-field": "redacted_fixture_property", @@ -21,10 +35,18 @@ "type": "object", "minProperties": 1, "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, + "propertyNames": { + "x-registry-field": "redacted_fixture_map_key", + "pattern": "^[a-z][a-z0-9_]{0,63}$" + }, "additionalProperties": { "x-registry-field": "redacted_fixture_map_value", - "type": ["string", "boolean", "integer", "null"] + "type": [ + "string", + "boolean", + "integer", + "null" + ] } }, "variables": { @@ -32,8 +54,15 @@ "description": "Named date values available to deterministic fixture interpolation.", "type": "object", "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } + "propertyNames": { + "x-registry-field": "redacted_fixture_map_key", + "pattern": "^[a-z][a-z0-9._-]{0,95}$" + }, + "additionalProperties": { + "x-registry-field": "redacted_fixture_map_value", + "type": "string", + "format": "date" + } }, "interactions": { "x-registry-field": "redacted_fixture_property", @@ -41,126 +70,89 @@ "type": "array", "minItems": 1, "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/interaction" } + "items": { + "x-registry-field": "redacted_fixture_array_item", + "$ref": "#/$defs/interaction" + } }, - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/expectation", "description": "Observable adapter result required for the scenario to pass." } + "expect": { + "x-registry-field": "redacted_fixture_property", + "$ref": "#/$defs/expectation", + "description": "Observable adapter result required for the scenario to pass." + } }, "examples": [ { "name": "existing household matches", "classification": "synthetic", - "input": { "household_id": "HH-1001" }, + "input": { + "household_id": "HH-1001" + }, "interactions": [ { - "expect": { "method": "GET", "path": "/households/HH-1001" }, - "respond": { "status": 200, "body": { "active": true } } + "expect": { + "method": "GET", + "path": "/households/HH-1001" + }, + "respond": { + "status": 200, + "body": { + "active": true + } + } } ], - "expect": { "outcome": "match", "outputs": { "active": true } } + "expect": { + "outcome": "match", + "outputs": { + "active": true + } + } } ], "$defs": { - "governedRequest": { - "description": "The closed governed request shape for an independently authored synthetic fixture witness.", - "type": "object", - "additionalProperties": false, - "required": ["target", "claims", "purpose"], - "properties": { - "requester": { - "x-registry-field": "redacted_fixture_property", - "$ref": "#/$defs/governedTarget", - "description": "Supplies a synthetic authenticated requester when selected consultation mappings read requester identifiers." - }, - "target": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/governedTarget" }, - "variables": { - "x-registry-field": "redacted_fixture_property", - "description": "Supplies synthetic date variables to the governed evaluation request.", - "type": "object", - "maxProperties": 16, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[a-z][a-z0-9._-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "format": "date" } - }, - "claims": { - "x-registry-field": "redacted_fixture_property", - "description": "Lists the authored claims evaluated by this synthetic request witness.", - "type": "array", - "minItems": 1, - "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedClaimRef" } - }, - "disclosure": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the disclosure mode requested from the authored claim policy." }, - "format": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Selects the claim-result media type requested from the governed Notary path." }, - "purpose": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256, "description": "Names the authored service purpose exercised by this synthetic request witness." } - } - }, - "governedTarget": { - "description": "The synthetic subject target presented to the governed offline fixture boundary.", + "interaction": { + "description": "One expected upstream request paired with its synthetic response.", "type": "object", "additionalProperties": false, - "required": ["type"], + "required": [ + "expect", + "respond" + ], "properties": { - "type": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 64, "description": "Names the authored target entity type used for claim evaluation." }, - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies an optional synthetic direct target identifier." }, - "identifiers": { + "expect": { "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored synthetic identifiers for consultation input mapping.", - "type": "array", - "maxItems": 16, - "items": {"x-registry-field": "redacted_fixture_array_item", "$ref": "#/$defs/governedIdentifier" } + "$ref": "#/$defs/request" }, - "attributes": { + "respond": { "x-registry-field": "redacted_fixture_property", - "description": "Supplies independently authored typed target attributes for consultation input mapping.", - "type": "object", - "maxProperties": 16, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": ["string", "boolean", "integer", "null"] } - } - } - }, - "governedIdentifier": { - "description": "One synthetic target identifier with an authored scheme and string value.", - "type": "object", - "additionalProperties": false, - "required": ["scheme", "value"], - "properties": { - "scheme": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 96, "description": "Names the authored identifier scheme selected by a consultation input mapping." }, - "value": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Supplies the synthetic string value bound to this identifier scheme." } - } - }, - "governedClaimRef": { - "description": "A requested claim ID, optionally pinned to one authored claim version.", - "oneOf": [ - {"x-registry-field": "branch", "type": "string"}, - { - "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { - "id": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Names one authored claim requested by this fixture witness."}, - "version": {"x-registry-field": "redacted_fixture_property", "type": "string", "description": "Pins the requested claim to one authored claim-policy version."} - } + "$ref": "#/$defs/response" } - ] - }, - "interaction": { - "description": "One expected upstream request paired with its synthetic response.", - "type": "object", - "additionalProperties": false, - "required": ["expect", "respond"], - "properties": { - "expect": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/request" }, - "respond": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/response" } } }, "request": { "description": "Exact HTTP request shape the adapter must produce.", "type": "object", "additionalProperties": false, - "required": ["method", "path"], + "required": [ + "method", + "path" + ], "properties": { - "method": {"x-registry-field": "redacted_fixture_property", "enum": ["GET", "POST"] }, - "path": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^/[^?#]*$" }, + "method": { + "x-registry-field": "redacted_fixture_property", + "enum": [ + "GET", + "POST" + ] + }, + "path": { + "x-registry-field": "redacted_fixture_property", + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^/[^?#]*$" + }, "query": { "x-registry-field": "redacted_fixture_property", "type": "object", @@ -168,12 +160,28 @@ "additionalProperties": { "x-registry-field": "redacted_fixture_map_value", "oneOf": [ - {"x-registry-field": "branch", "type": ["string", "boolean", "integer", "null"] }, + { + "x-registry-field": "branch", + "type": [ + "string", + "boolean", + "integer", + "null" + ] + }, { "x-registry-field": "branch", "type": "array", "maxItems": 64, - "items": {"x-registry-field": "redacted_fixture_array_item", "type": ["string", "boolean", "integer", "null"] } + "items": { + "x-registry-field": "redacted_fixture_array_item", + "type": [ + "string", + "boolean", + "integer", + "null" + ] + } } ] } @@ -182,10 +190,20 @@ "x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 32, - "propertyNames": {"x-registry-field": "redacted_fixture_map_key", "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" }, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } + "propertyNames": { + "x-registry-field": "redacted_fixture_map_key", + "pattern": "^[A-Za-z][A-Za-z0-9-]{0,63}$" + }, + "additionalProperties": { + "x-registry-field": "redacted_fixture_map_value", + "type": "string", + "maxLength": 8192 + } }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } + "body": { + "x-registry-field": "redacted_fixture_property", + "$ref": "#/$defs/fixtureBody" + } } }, "response": { @@ -195,23 +213,39 @@ "x-registry-field": "branch", "type": "object", "additionalProperties": false, - "required": ["status"], + "required": [ + "status" + ], "properties": { - "status": {"x-registry-field": "redacted_fixture_property", "type": "integer", "minimum": 100, "maximum": 599 }, + "status": { + "x-registry-field": "redacted_fixture_property", + "type": "integer", + "minimum": 100, + "maximum": 599 + }, "headers": { "x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 32, - "additionalProperties": {"x-registry-field": "redacted_fixture_map_value", "type": "string", "maxLength": 8192 } + "additionalProperties": { + "x-registry-field": "redacted_fixture_map_value", + "type": "string", + "maxLength": 8192 + } }, - "body": {"x-registry-field": "redacted_fixture_property", "$ref": "#/$defs/fixtureBody" } + "body": { + "x-registry-field": "redacted_fixture_property", + "$ref": "#/$defs/fixtureBody" + } } }, { "x-registry-field": "branch", "type": "object", "additionalProperties": false, - "required": ["timeout"], + "required": [ + "timeout" + ], "properties": { "timeout": { "x-registry-field": "redacted_fixture_property", @@ -230,7 +264,9 @@ "x-registry-field": "branch", "type": "object", "additionalProperties": false, - "required": ["file"], + "required": [ + "file" + ], "properties": { "file": { "x-registry-field": "redacted_fixture_property", @@ -246,21 +282,38 @@ "not": { "x-registry-field": "branch", "type": "object", - "required": ["file"] + "required": [ + "file" + ] } } ] }, "expectation": { - "description": "Expected normalized outcome, output values, claims, or error.", + "description": "Expected normalized Relay outcome, output values, or error.", "type": "object", "additionalProperties": false, "minProperties": 1, "properties": { - "outcome": {"x-registry-field": "redacted_fixture_property", "enum": ["match", "no_match", "ambiguous"] }, - "outputs": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "claims": {"x-registry-field": "redacted_fixture_property", "type": "object", "maxProperties": 64 }, - "error": {"x-registry-field": "redacted_fixture_property", "type": "string", "minLength": 1, "maxLength": 256 } + "outcome": { + "x-registry-field": "redacted_fixture_property", + "enum": [ + "match", + "no_match", + "ambiguous" + ] + }, + "outputs": { + "x-registry-field": "redacted_fixture_property", + "type": "object", + "maxProperties": 64 + }, + "error": { + "x-registry-field": "redacted_fixture_property", + "type": "string", + "minLength": 1, + "maxLength": 256 + } } } } diff --git a/crates/registryctl/schemas/project-authoring/parity-coverage.json b/crates/registryctl/schemas/project-authoring/parity-coverage.json index dd0f36d5f..9daf7900d 100644 --- a/crates/registryctl/schemas/project-authoring/parity-coverage.json +++ b/crates/registryctl/schemas/project-authoring/parity-coverage.json @@ -38,12 +38,11 @@ "schema": "project", "semantic_owner": "authoring_contract", "human_owner": "registry_maintainers", - "products": ["registryctl", "relay", "notary", "editor", "docs"], + "products": ["registryctl", "relay", "editor", "docs"], "migration": "rebuild_project", "consumers": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator" ], @@ -51,7 +50,6 @@ "editor_schemas", "project_build", "relay_config", - "notary_config", "field_reference" ], "review_classes": [ @@ -59,7 +57,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation" ] @@ -68,12 +65,11 @@ "schema": "environment", "semantic_owner": "deployment_security", "human_owner": "security_maintainers", - "products": ["registryctl", "relay", "notary", "editor", "docs"], + "products": ["registryctl", "relay", "editor", "docs"], "migration": "coordinate_deployment", "consumers": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator" ], @@ -81,7 +77,6 @@ "editor_schemas", "project_build", "relay_config", - "notary_config", "field_reference" ], "review_classes": [ @@ -89,7 +84,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation" ] @@ -98,12 +92,11 @@ "schema": "integration", "semantic_owner": "integration_contract", "human_owner": "integration_maintainers", - "products": ["registryctl", "relay", "notary", "editor", "docs"], + "products": ["registryctl", "relay", "editor", "docs"], "migration": "rebuild_project", "consumers": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator" ], @@ -111,7 +104,6 @@ "editor_schemas", "project_build", "relay_config", - "notary_config", "fixture_report", "field_reference" ], @@ -120,7 +112,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation", "testing" @@ -309,12 +300,6 @@ "kind": "typed_map", "rationale": "Entity identifiers are project-defined and every value uses the closed entity binding." }, - { - "schema": "environment", - "pointer": "/properties/callers", - "kind": "typed_map", - "rationale": "Caller identifiers are deployment-defined and every value uses a closed caller binding." - }, { "schema": "environment", "pointer": "/$defs/entity/properties/columns", @@ -333,18 +318,6 @@ "kind": "typed_map", "rationale": "Fixture variable names are authored and values are date strings." }, - { - "schema": "fixture", - "pointer": "/$defs/governedRequest/properties/variables", - "kind": "typed_map", - "rationale": "Governed request variable names are project-defined and values use the closed bounded string contract." - }, - { - "schema": "fixture", - "pointer": "/$defs/governedTarget/properties/attributes", - "kind": "typed_map", - "rationale": "Governed target attribute names are project-defined and values use the closed bounded scalar contract." - }, { "schema": "fixture", "pointer": "/$defs/request/properties/query", @@ -369,12 +342,6 @@ "kind": "extension_map", "rationale": "Expected adapter outputs are integration-defined JSON values checked against the integration contract at runtime." }, - { - "schema": "fixture", - "pointer": "/$defs/expectation/properties/claims", - "kind": "extension_map", - "rationale": "Expected claims are service-defined JSON values checked against the project claim contract at runtime." - }, { "schema": "fixture", "pointer": "/$defs/fixtureBody/oneOf/1/not", @@ -483,18 +450,6 @@ "kind": "typed_map", "rationale": "Aggregate filter field names map to bounded filter operator lists." }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/claims", - "kind": "typed_map", - "rationale": "Claim identifiers are authored and every value uses a closed claim declaration." - }, - { - "schema": "project", - "pointer": "/$defs/evidenceService/properties/credential_profiles", - "kind": "typed_map", - "rationale": "Credential profile identifiers are authored and every value uses a closed profile declaration." - }, { "schema": "project", "pointer": "/$defs/variables", @@ -719,16 +674,16 @@ } }, { - "id": "environment-empty-caller-scopes", + "id": "environment-empty-consultation-client", "dimension": "boundary", - "expected_failing_keywords": ["minItems"], + "expected_failing_keywords": ["minLength", "pattern"], "schema": "environment", "source": "crates/registryctl/tests/fixtures/project-authoring/custom-system", "document": "environments/local.yaml", "mutation": { "operation": "set", - "pointer": "/callers/benefits-service/scopes", - "value": [] + "pointer": "/relay/consultation/client_id", + "value": "" } }, { diff --git a/crates/registryctl/schemas/project-authoring/project.schema.json b/crates/registryctl/schemas/project-authoring/project.schema.json index 0147c4f35..c2d001b47 100644 --- a/crates/registryctl/schemas/project-authoring/project.schema.json +++ b/crates/registryctl/schemas/project-authoring/project.schema.json @@ -3,22 +3,47 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://registrystack.example/schemas/project-authoring/project.v1.json", "title": "Registry Stack project v1", - "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay or Notary services.", + "description": "Declares the product-neutral Registry Stack project graph: integrations, materialized entities, and Relay services.", "type": "object", "additionalProperties": false, - "required": ["version", "registry", "services"], + "required": [ + "version", + "registry", + "services" + ], "properties": { - "version": {"x-registry-field": "public_property", "const": 1, "description": "Project authoring format version." }, + "version": { + "x-registry-field": "public_property", + "const": 1, + "description": "Project authoring format version." + }, "starter": { "x-registry-field": "property", "description": "Immutable provenance for a workspace initialized from a Registry Stack starter. The digest excludes this content_digest field and covers the starter's authored project files.", "type": "object", "additionalProperties": false, - "required": ["id", "release", "content_digest"], + "required": [ + "id", + "release", + "content_digest" + ], "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId", "description": "Registry Stack starter identifier." }, - "release": {"x-registry-field": "property", "$ref": "#/$defs/token", "description": "Registry Stack release that supplied the starter." }, - "content_digest": {"x-registry-field": "sensitive_property", "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." } + "id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId", + "description": "Registry Stack starter identifier." + }, + "release": { + "x-registry-field": "property", + "$ref": "#/$defs/token", + "description": "Registry Stack release that supplied the starter." + }, + "content_digest": { + "x-registry-field": "sensitive_property", + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "Digest of the initialized starter authoring content, used to report later workspace divergence." + } } }, "registry": { @@ -26,21 +51,38 @@ "description": "Stable identity of the Registry Stack project.", "type": "object", "additionalProperties": false, - "required": ["id"], - "properties": { "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } } + "required": [ + "id" + ], + "properties": { + "id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + } + } }, "integrations": { "x-registry-field": "property", "description": "Source adaptation definitions keyed by project-local integration identifier.", "type": "object", "maxProperties": 16, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, "additionalProperties": { "x-registry-field": "map_value", "type": "object", "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } + "required": [ + "file" + ], + "properties": { + "file": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relativePath" + } + } } }, "entities": { @@ -48,98 +90,262 @@ "description": "Materialized entity definitions keyed by project-local entity identifier.", "type": "object", "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, "additionalProperties": { "x-registry-field": "map_value", "type": "object", "additionalProperties": false, - "required": ["file"], - "properties": { "file": {"x-registry-field": "sensitive_property", "$ref": "#/$defs/relativePath" } } + "required": [ + "file" + ], + "properties": { + "file": { + "x-registry-field": "sensitive_property", + "$ref": "#/$defs/relativePath" + } + } } }, "services": { "x-registry-field": "property", - "description": "Relay records APIs and Notary evidence services exposed by the project.", + "description": "Relay records and consultation APIs exposed by the project.", "type": "object", "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/service" } + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/service" + } } }, "anyOf": [ - {"x-registry-field": "branch", "required": ["integrations"], "properties": { "integrations": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["entities"], "properties": { "entities": {"x-registry-field": "property", "minProperties": 1 } } }, - {"x-registry-field": "branch", "required": ["services"], "properties": { "services": {"x-registry-field": "property", "minProperties": 1 } } } + { + "x-registry-field": "branch", + "required": [ + "integrations" + ], + "properties": { + "integrations": { + "x-registry-field": "property", + "minProperties": 1 + } + } + }, + { + "x-registry-field": "branch", + "required": [ + "entities" + ], + "properties": { + "entities": { + "x-registry-field": "property", + "minProperties": 1 + } + } + }, + { + "x-registry-field": "branch", + "required": [ + "services" + ], + "properties": { + "services": { + "x-registry-field": "property", + "minProperties": 1 + } + } + } ], "examples": [ { "version": 1, - "registry": { "id": "benefits-registry" }, + "registry": { + "id": "benefits-registry" + }, "integrations": { - "household_lookup": { "file": "integrations/household-lookup/integration.yaml" } + "household_lookup": { + "file": "integrations/household-lookup/integration.yaml" + } }, "services": {} } ], "$defs": { - "stableId": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,95}$", "description": "Lowercase stable identifier used in project references." }, - "token": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Non-empty token without commas, whitespace, or control characters." }, - "relativePath": { "type": "string", "minLength": 1, "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", "description": "Project-relative path without traversal or duplicate separators." }, - "text": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Bounded human-readable project metadata." }, - "scope": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", "description": "Authorization scope token." }, + "stableId": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,95}$", + "description": "Lowercase stable identifier used in project references." + }, + "token": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", + "description": "Non-empty token without commas, whitespace, or control characters." + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.?/)(?!.*//).+$", + "description": "Project-relative path without traversal or duplicate separators." + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "Bounded human-readable project metadata." + }, + "scope": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$", + "description": "Authorization scope token." + }, "recordsApi": { "description": "Relay records API behavior, authorization, query bounds, and standards profiles.", "type": "object", "additionalProperties": false, - "required": ["scopes", "projection", "pagination", "standards"], + "required": [ + "scopes", + "projection", + "pagination", + "standards" + ], "properties": { "scopes": { "x-registry-field": "property", "type": "object", "additionalProperties": false, - "required": ["metadata", "rows"], + "required": [ + "metadata", + "rows" + ], "properties": { - "metadata": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "rows": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "evidence_verification": {"x-registry-field": "property", "$ref": "#/$defs/scope" } + "metadata": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, + "rows": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, + "aggregate": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, + "evidence_verification": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + } + } + }, + "purposes": { + "x-registry-field": "property", + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/token" + } + }, + "projection": { + "x-registry-field": "property", + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/stableId" } }, - "purposes": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } }, - "projection": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 256, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, "pagination": { "x-registry-field": "property", "type": "object", "additionalProperties": false, - "required": ["default_limit", "max_limit"], + "required": [ + "default_limit", + "max_limit" + ], "properties": { - "default_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 }, - "max_limit": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 10000 } + "default_limit": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "max_limit": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 10000 + } } }, "filters": { "x-registry-field": "property", "type": "object", "maxProperties": 256, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/filterOperators" + } + }, + "required_principal_filters": { + "x-registry-field": "property", + "$ref": "#/$defs/fieldList" }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, "relationships": { "x-registry-field": "property", "type": "object", "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, "additionalProperties": { "x-registry-field": "map_value", "type": "object", "additionalProperties": false, - "required": ["kind", "target", "foreign_key"], + "required": [ + "kind", + "target", + "foreign_key" + ], "properties": { - "kind": {"x-registry-field": "property", "enum": ["belongs_to", "has_many", "has_one"] }, - "target": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "foreign_key": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "concept_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } + "kind": { + "x-registry-field": "property", + "enum": [ + "belongs_to", + "has_many", + "has_one" + ] + }, + "target": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "foreign_key": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "concept_uri": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + } } } }, @@ -147,18 +353,54 @@ "x-registry-field": "property", "type": "object", "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAggregate" } + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/recordAggregate" + } + }, + "attribute_release_profiles": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAttributeReleaseProfiles" }, - "attribute_release_profiles": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseProfiles" }, "standards": { "x-registry-field": "property", "type": "object", "additionalProperties": false, - "required": ["ogc_features", "sp_dci"], + "required": [ + "ogc_features", + "sp_dci" + ], "properties": { - "ogc_features": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpatial" }] }, - "sp_dci": {"x-registry-field": "property", "oneOf": [{"x-registry-field": "branch", "const": false }, {"x-registry-field": "branch", "$ref": "#/$defs/recordSpdci" }] } + "ogc_features": { + "x-registry-field": "property", + "oneOf": [ + { + "x-registry-field": "branch", + "const": false + }, + { + "x-registry-field": "branch", + "$ref": "#/$defs/recordSpatial" + } + ] + }, + "sp_dci": { + "x-registry-field": "property", + "oneOf": [ + { + "x-registry-field": "branch", + "const": false + }, + { + "x-registry-field": "branch", + "$ref": "#/$defs/recordSpdci" + } + ] + } } } } @@ -167,57 +409,142 @@ "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": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/recordAttributeReleaseProfile" } + "propertyNames": { + "x-registry-field": "map_key", + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,95}$" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/recordAttributeReleaseProfile" + } }, "recordAttributeReleaseProfile": { "description": "One purpose-bound, minimized identity release compiled into the owning Relay entity.", "type": "object", "additionalProperties": false, - "required": ["version", "purpose", "release_scope", "subject", "release_conditions", "claims"], + "required": [ + "version", + "purpose", + "release_scope", + "subject", + "release_conditions", + "claims" + ], "properties": { - "version": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "release_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, + "version": { + "x-registry-field": "property", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" + }, + "title": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "description": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "purpose": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + }, + "release_scope": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, "subject": { "x-registry-field": "property", "type": "object", "additionalProperties": false, - "required": ["source_field", "id_type"], + "required": [ + "source_field", + "id_type" + ], "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "id_type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } + "source_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "id_type": { + "x-registry-field": "property", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" + } } }, "release_conditions": { "x-registry-field": "property", "type": "object", "additionalProperties": false, - "required": ["expression"], - "properties": { "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" } } + "required": [ + "expression" + ], + "properties": { + "expression": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAttributeReleaseExpression" + } + } }, "claims": { "x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, + "propertyNames": { + "x-registry-field": "map_key", + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,63}$" + }, "additionalProperties": { "x-registry-field": "map_value", "type": "object", "additionalProperties": false, - "required": ["required", "sensitivity"], + "required": [ + "required", + "sensitivity" + ], "properties": { - "source_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "expression": {"x-registry-field": "property", "$ref": "#/$defs/recordAttributeReleaseExpression" }, - "required": {"x-registry-field": "property", "type": "boolean" }, - "sensitivity": {"x-registry-field": "property", "enum": ["direct_identifier", "personal", "public", "pseudonymous"] } + "source_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "expression": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAttributeReleaseExpression" + }, + "required": { + "x-registry-field": "property", + "type": "boolean" + }, + "sensitivity": { + "x-registry-field": "property", + "enum": [ + "direct_identifier", + "personal", + "public", + "pseudonymous" + ] + } }, "oneOf": [ - {"x-registry-field": "branch", "required": ["source_field"] }, - {"x-registry-field": "branch", "required": ["expression"] } + { + "x-registry-field": "branch", + "required": [ + "source_field" + ] + }, + { + "x-registry-field": "branch", + "required": [ + "expression" + ] + } ] } } @@ -227,82 +554,269 @@ "description": "Bounded CEL evaluated only against the projected source object.", "type": "object", "additionalProperties": false, - "required": ["cel"], - "properties": { "cel": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 4096 } } + "required": [ + "cel" + ], + "properties": { + "cel": { + "x-registry-field": "property", + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "filterOperators": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "Allowed operators for one queryable field.", + "items": { + "x-registry-field": "array_item", + "enum": [ + "eq", + "in", + "gte", + "lte", + "between" + ] + } + }, + "fieldList": { + "type": "array", + "maxItems": 16, + "uniqueItems": true, + "description": "Bounded unique list of entity field identifiers.", + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/stableId" + } }, - "filterOperators": { "type": "array", "minItems": 1, "uniqueItems": true, "description": "Allowed operators for one queryable field.", "items": {"x-registry-field": "array_item", "enum": ["eq", "in", "gte", "lte", "between"] } }, - "fieldList": { "type": "array", "maxItems": 16, "uniqueItems": true, "description": "Bounded unique list of entity field identifiers.", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } }, "recordAggregate": { "description": "Governed aggregate definition with disclosure controls.", "type": "object", "additionalProperties": false, - "required": ["description", "disclosure_control"], + "required": [ + "description", + "disclosure_control" + ], "properties": { - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "default_group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "dimensions": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateDimension" } }, - "indicators": {"x-registry-field": "property", "type": "array", "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateIndicator" } }, - "allowed_filters": {"x-registry-field": "property", "type": "object", "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/filterOperators" } }, - "required_principal_filters": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "temporal_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateAccess" }, - "spatial": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateSpatial" }, - "joins": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "group_by": {"x-registry-field": "property", "$ref": "#/$defs/fieldList" }, - "measures": {"x-registry-field": "property", "type": "array", "minItems": 1, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/recordAggregateMeasure" } }, + "title": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "description": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "default_group_by": { + "x-registry-field": "property", + "$ref": "#/$defs/fieldList" + }, + "dimensions": { + "x-registry-field": "property", + "type": "array", + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/recordAggregateDimension" + } + }, + "indicators": { + "x-registry-field": "property", + "type": "array", + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/recordAggregateIndicator" + } + }, + "allowed_filters": { + "x-registry-field": "property", + "type": "object", + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/filterOperators" + } + }, + "required_principal_filters": { + "x-registry-field": "property", + "$ref": "#/$defs/fieldList" + }, + "temporal_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "access": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAggregateAccess" + }, + "spatial": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAggregateSpatial" + }, + "joins": { + "x-registry-field": "property", + "$ref": "#/$defs/fieldList" + }, + "group_by": { + "x-registry-field": "property", + "$ref": "#/$defs/fieldList" + }, + "measures": { + "x-registry-field": "property", + "type": "array", + "minItems": 1, + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/recordAggregateMeasure" + } + }, "disclosure_control": { "x-registry-field": "property", "type": "object", "additionalProperties": false, "properties": { - "min_group_size": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "suppression": {"x-registry-field": "property", "enum": ["omit", "mask", "null"] } + "min_group_size": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "suppression": { + "x-registry-field": "property", + "enum": [ + "omit", + "mask", + "null" + ] + } } } }, - "anyOf": [{"x-registry-field": "branch", "required": ["measures"] }, {"x-registry-field": "branch", "required": ["indicators"] }] + "anyOf": [ + { + "x-registry-field": "branch", + "required": [ + "measures" + ] + }, + { + "x-registry-field": "branch", + "required": [ + "indicators" + ] + } + ] }, "recordAggregateAccess": { "description": "Authorization scopes and execution policy for one governed aggregate.", "type": "object", "additionalProperties": false, "properties": { - "metadata_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_scope": {"x-registry-field": "property", "$ref": "#/$defs/scope" }, - "aggregate_only_execution": {"x-registry-field": "property", "type": "boolean" } + "metadata_scope": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, + "aggregate_scope": { + "x-registry-field": "property", + "$ref": "#/$defs/scope" + }, + "aggregate_only_execution": { + "x-registry-field": "property", + "type": "boolean" + } } }, "recordAggregateSpatial": { "description": "Administrative-area geometry join used to spatially group aggregate results.", "type": "object", "additionalProperties": false, - "required": ["mode", "dimension", "geometry_entity", "geometry_id_field", "geometry_field"], + "required": [ + "mode", + "dimension", + "geometry_entity", + "geometry_id_field", + "geometry_field" + ], "properties": { - "mode": {"x-registry-field": "property", "const": "admin_area" }, - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "dimension": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_id_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "geometry_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } + "mode": { + "x-registry-field": "property", + "const": "admin_area" + }, + "collection_id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "dimension": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "geometry_entity": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "geometry_id_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "geometry_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "bbox_fields": { + "x-registry-field": "property", + "$ref": "#/$defs/recordSpatialBbox" + }, + "max_geometry_vertices": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + } } }, "recordSpatial": { "description": "OGC API Features collection metadata and bounded geometry mapping.", "type": "object", "additionalProperties": false, - "required": ["geometry"], + "required": [ + "geometry" + ], "properties": { - "collection_id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "geometry": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialGeometry" }, - "bbox_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordSpatialBbox" }, - "datetime_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_bbox_degrees": {"x-registry-field": "property", "type": "number", "exclusiveMinimum": 0 }, - "max_geometry_vertices": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 } + "collection_id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "title": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "description": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "geometry": { + "x-registry-field": "property", + "$ref": "#/$defs/recordSpatialGeometry" + }, + "bbox_fields": { + "x-registry-field": "property", + "$ref": "#/$defs/recordSpatialBbox" + }, + "datetime_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "max_bbox_degrees": { + "x-registry-field": "property", + "type": "number", + "exclusiveMinimum": 0 + }, + "max_geometry_vertices": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + } } }, "recordSpatialGeometry": { @@ -312,23 +826,57 @@ "x-registry-field": "branch", "type": "object", "additionalProperties": false, - "required": ["kind", "longitude_field", "latitude_field", "crs"], + "required": [ + "kind", + "longitude_field", + "latitude_field", + "crs" + ], "properties": { - "kind": {"x-registry-field": "property", "const": "point" }, - "longitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "latitude_field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } + "kind": { + "x-registry-field": "property", + "const": "point" + }, + "longitude_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "latitude_field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "crs": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + } } }, { "x-registry-field": "branch", "type": "object", "additionalProperties": false, - "required": ["kind", "field", "crs"], + "required": [ + "kind", + "field", + "crs" + ], "properties": { - "kind": {"x-registry-field": "property", "enum": ["geojson", "wkt", "wkb"] }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "crs": {"x-registry-field": "property", "$ref": "#/$defs/token" } + "kind": { + "x-registry-field": "property", + "enum": [ + "geojson", + "wkt", + "wkb" + ] + }, + "field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "crs": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + } } } ] @@ -337,195 +885,360 @@ "description": "Record fields carrying the four coordinates of a bounding box.", "type": "object", "additionalProperties": false, - "required": ["min_x", "min_y", "max_x", "max_y"], + "required": [ + "min_x", + "min_y", + "max_x", + "max_y" + ], "properties": { - "min_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "min_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_x": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "max_y": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } + "min_x": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "min_y": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "max_x": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "max_y": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + } } }, "recordSpdci": { "description": "SP-DCI registry identity and field mappings for a records service.", "type": "object", "additionalProperties": false, - "required": ["registry", "registry_type", "record_type", "identifiers", "expression_fields"], + "required": [ + "registry", + "registry_type", + "record_type", + "identifiers", + "expression_fields" + ], "properties": { - "registry": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "registry_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "record_type": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "identifiers": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "expression_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" }, - "response_fields": {"x-registry-field": "property", "$ref": "#/$defs/recordFieldMap" } + "registry": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "registry_type": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + }, + "record_type": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + }, + "identifiers": { + "x-registry-field": "property", + "$ref": "#/$defs/recordFieldMap" + }, + "expression_fields": { + "x-registry-field": "property", + "$ref": "#/$defs/recordFieldMap" + }, + "response_fields": { + "x-registry-field": "property", + "$ref": "#/$defs/recordFieldMap" + } } }, "recordFieldMap": { "description": "Bounded mapping from standard field names to entity field identifiers.", "type": "object", "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/stableId" } + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/stableId" + } }, "recordAggregateDimension": { "description": "Named grouping dimension backed by one entity field.", "type": "object", "additionalProperties": false, - "required": ["id", "label", "field"], + "required": [ + "id", + "label", + "field" + ], "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "field": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "codelist": {"x-registry-field": "property", "$ref": "#/$defs/text" } + "id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "label": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "field": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "codelist": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + } } }, "recordAggregateIndicator": { "description": "SDMX-style aggregate indicator with units and display metadata.", "type": "object", "additionalProperties": false, - "required": ["id", "label", "function", "column", "unit_measure"], + "required": [ + "id", + "label", + "function", + "column", + "unit_measure" + ], "properties": { - "id": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "label": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "unit_measure": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "unit_mult": {"x-registry-field": "property", "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, - "decimals": {"x-registry-field": "property", "type": "integer", "minimum": 0, "maximum": 4294967295 }, - "frequency": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "definition_uri": {"x-registry-field": "property", "$ref": "#/$defs/text" } + "id": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "label": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "function": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAggregateFunction" + }, + "column": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "unit_measure": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "unit_mult": { + "x-registry-field": "property", + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "decimals": { + "x-registry-field": "property", + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "frequency": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "definition_uri": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + } } }, "recordAggregateMeasure": { "description": "Named aggregate calculation over one entity column.", "type": "object", "additionalProperties": false, - "required": ["name", "function", "column"], + "required": [ + "name", + "function", + "column" + ], "properties": { - "name": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "function": {"x-registry-field": "property", "$ref": "#/$defs/recordAggregateFunction" }, - "column": {"x-registry-field": "property", "$ref": "#/$defs/stableId" } + "name": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "function": { + "x-registry-field": "property", + "$ref": "#/$defs/recordAggregateFunction" + }, + "column": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + } } }, "recordAggregateFunction": { "description": "Supported aggregate calculation function.", - "enum": ["count", "sum", "avg", "min", "max", "median", "count_distinct", "stddev"] + "enum": [ + "count", + "sum", + "avg", + "min", + "max", + "median", + "count_distinct", + "stddev" + ] }, - "disclosureMode": { "enum": ["value", "predicate", "redacted"], "description": "Maximum detail a claim may disclose." }, - "disclosure": { - "description": "Fixed disclosure mode or a default constrained by explicitly allowed alternatives.", + "service": { + "description": "Closed choice between a Relay consultation service and a Relay records service.", "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/disclosureMode" }, { "x-registry-field": "branch", - "type": "object", - "additionalProperties": false, - "required": ["default", "allowed"], - "properties": { - "default": {"x-registry-field": "property", "$ref": "#/$defs/disclosureMode" }, - "allowed": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/disclosureMode" } } - } + "$ref": "#/$defs/consultationService" + }, + { + "x-registry-field": "branch", + "$ref": "#/$defs/recordsService" } ] }, - "service": { - "description": "Closed choice between a Notary evidence service and a Relay records service.", - "oneOf": [ - {"x-registry-field": "branch", "$ref": "#/$defs/evidenceService" }, - {"x-registry-field": "branch", "$ref": "#/$defs/recordsService" } - ] - }, "recordsService": { "description": "Relay records API backed by one authored materialized entity.", "type": "object", "additionalProperties": false, - "required": ["kind", "entity", "api"], + "required": [ + "kind", + "entity", + "api" + ], "properties": { - "kind": {"x-registry-field": "property", "const": "records_api" }, - "entity": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, - "title": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "description": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "owner": {"x-registry-field": "property", "$ref": "#/$defs/text" }, - "sensitivity": {"x-registry-field": "property", "enum": ["public", "internal", "personal", "confidential", "secret"] }, - "access_rights": {"x-registry-field": "property", "enum": ["public", "restricted", "non_public"] }, - "update_frequency": {"x-registry-field": "property", "enum": ["continuous", "daily", "weekly", "termly", "monthly", "quarterly", "annual", "irregular", "as_needed", "unknown"] }, - "conforms_to": {"x-registry-field": "property", "type": "array", "maxItems": 32, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/text" } }, - "api": {"x-registry-field": "property", "$ref": "#/$defs/recordsApi" } - } - }, - "evidenceService": { - "description": "Notary evidence policy, consultations, claims, and registry-backed credential profiles. Every claim derives from exactly one declared Relay consultation.", - "type": "object", - "additionalProperties": false, - "required": ["kind", "version", "purpose", "legal_basis", "consent", "access", "claims"], - "properties": { - "kind": {"x-registry-field": "property", "const": "evidence" }, - "version": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "subject_type": { - "x-registry-field": "property", - "description": "Subject category evaluated by this evidence service. Omission is normalized to person.", - "enum": ["person", "project"] - }, - "purpose": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "legal_basis": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "consent": {"x-registry-field": "property", "enum": ["not_required", "required"] }, - "access": {"x-registry-field": "property", "$ref": "#/$defs/access" }, - "variables": {"x-registry-field": "property", "$ref": "#/$defs/variables" }, - "consultations": {"x-registry-field": "property", "$ref": "#/$defs/consultations" }, - "claims": { + "kind": { "x-registry-field": "property", - "type": "object", - "minProperties": 1, - "maxProperties": 64, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["disclosure"], - "properties": { - "output": {"x-registry-field": "property", "type": "string" }, - "cel": {"x-registry-field": "property", "type": "string", "minLength": 1 }, - "value": {"x-registry-field": "property", "$ref": "#/$defs/claimValue" }, - "disclosure": {"x-registry-field": "property", "$ref": "#/$defs/disclosure" } - }, - "oneOf": [{"x-registry-field": "branch", "required": ["output"] }, {"x-registry-field": "branch", "required": ["cel"] }] - } + "const": "records_api" }, - "credential_profiles": { + "entity": { "x-registry-field": "property", - "description": "Credential profiles may select only claims backed by an exact Relay consultation.", - "type": "object", - "maxProperties": 32, - "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": { - "x-registry-field": "map_value", - "type": "object", - "additionalProperties": false, - "required": ["format", "type", "validity", "claims"], - "properties": { - "format": {"x-registry-field": "property", "$ref": "#/$defs/token" }, - "type": {"x-registry-field": "property", "type": "string", "minLength": 1, "maxLength": 2048 }, - "validity": {"x-registry-field": "property", "type": "string", "pattern": "^[1-9][0-9]*(?:s|m|h)$" }, - "claims": {"x-registry-field": "property", "type": "array", "minItems": 1, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/stableId" } } - } + "$ref": "#/$defs/stableId" + }, + "title": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "description": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "owner": { + "x-registry-field": "property", + "$ref": "#/$defs/text" + }, + "sensitivity": { + "x-registry-field": "property", + "enum": [ + "public", + "internal", + "personal", + "confidential", + "secret" + ] + }, + "access_rights": { + "x-registry-field": "property", + "enum": [ + "public", + "restricted", + "non_public" + ] + }, + "update_frequency": { + "x-registry-field": "property", + "enum": [ + "continuous", + "daily", + "weekly", + "termly", + "monthly", + "quarterly", + "annual", + "irregular", + "as_needed", + "unknown" + ] + }, + "conforms_to": { + "x-registry-field": "property", + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { + "x-registry-field": "array_item", + "$ref": "#/$defs/text" } + }, + "api": { + "x-registry-field": "property", + "$ref": "#/$defs/recordsApi" } } }, - "access": { - "description": "Scopes a caller must hold to use an evidence service.", - "type": "object", "additionalProperties": false, "required": ["scopes"], - "properties": { "scopes": {"x-registry-field": "property", "type": "array", "minItems": 1, "maxItems": 16, "uniqueItems": true, "items": {"x-registry-field": "array_item", "$ref": "#/$defs/token" } } } - }, "variables": { - "description": "Typed request variables exposed to evidence evaluation.", - "type": "object", "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["from", "type"], "properties": { "from": {"x-registry-field": "property", "type": "string", "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" }, "type": {"x-registry-field": "property", "const": "date" } } } + "description": "Typed request variables exposed to Relay consultation bindings.", + "type": "object", + "maxProperties": 16, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "type" + ], + "properties": { + "from": { + "x-registry-field": "property", + "type": "string", + "pattern": "^request\\.variables\\.[a-z][a-z0-9._-]{0,95}$" + }, + "type": { + "x-registry-field": "property", + "const": "date" + } + } + } }, "consultations": { "description": "Relay consultations and their closed bindings to target request identifiers or caller-supplied target attributes.", - "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, - "additionalProperties": {"x-registry-field": "map_value", "type": "object", "additionalProperties": false, "required": ["integration", "input"], "properties": { "integration": {"x-registry-field": "property", "$ref": "#/$defs/stableId" }, "input": {"x-registry-field": "property", "type": "object", "minProperties": 1, "maxProperties": 16, "propertyNames": {"x-registry-field": "map_key", "$ref": "#/$defs/stableId" }, "additionalProperties": {"x-registry-field": "map_value", "$ref": "#/$defs/requestMapping" } } } } + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "type": "object", + "additionalProperties": false, + "required": [ + "integration", + "input" + ], + "properties": { + "integration": { + "x-registry-field": "property", + "$ref": "#/$defs/stableId" + }, + "input": { + "x-registry-field": "property", + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "propertyNames": { + "x-registry-field": "map_key", + "$ref": "#/$defs/stableId" + }, + "additionalProperties": { + "x-registry-field": "map_value", + "$ref": "#/$defs/requestMapping" + } + } + } + } }, "requestMapping": { "description": "Closed request binding. Ordinary consultations use the target; representative relationship proofs may additionally bind an authenticated requester identifier.", @@ -533,10 +1246,54 @@ "maxLength": 128, "pattern": "^request\\.(?:target\\.(?:id|identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95}|attributes\\.[a-z][a-z0-9_]{0,63})|requester\\.identifiers\\.[A-Za-z][A-Za-z0-9._-]{0,95})$" }, - "claimValue": { - "description": "Explicit claim value type, nullability, and encoded-size bound for a claim derived from declared Relay consultation outputs.", - "type": "object", "additionalProperties": false, "required": ["type"], - "properties": { "type": {"x-registry-field": "property", "enum": ["boolean", "integer", "string", "date"] }, "nullable": {"x-registry-field": "property", "type": "boolean" }, "max_bytes": {"x-registry-field": "property", "type": "integer", "minimum": 1, "maximum": 65536 } } + "consultationService": { + "description": "Relay consultation policy and closed bindings to authored integration packs.", + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "version", + "purpose", + "legal_basis", + "consent", + "consultations" + ], + "properties": { + "kind": { + "x-registry-field": "property", + "const": "consultation_api" + }, + "version": { + "x-registry-field": "property", + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "purpose": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + }, + "legal_basis": { + "x-registry-field": "property", + "$ref": "#/$defs/token" + }, + "consent": { + "x-registry-field": "property", + "enum": [ + "not_required", + "required" + ] + }, + "variables": { + "x-registry-field": "property", + "$ref": "#/$defs/variables", + "description": "Typed request variables available to Relay consultation bindings." + }, + "consultations": { + "x-registry-field": "property", + "$ref": "#/$defs/consultations" + } + } } } } diff --git a/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference.v1.schema.json b/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference.v1.schema.json index a018c70e4..ce68a5c05 100644 --- a/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference.v1.schema.json +++ b/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference.v1.schema.json @@ -44,8 +44,7 @@ "integration", "fixture", "entity", - "relay", - "notary" + "relay" ] }, "pathKind": { @@ -85,10 +84,7 @@ "if": { "properties": { "schema": { - "enum": [ - "relay", - "notary" - ] + "const": "relay" } } }, @@ -199,13 +195,10 @@ "runtime_intent": { "type": "array", "minItems": 0, - "maxItems": 2, + "maxItems": 1, "uniqueItems": true, "items": { - "enum": [ - "crates/registry-relay/config/documentation-intent.json", - "crates/registry-notary-core/config/documentation-intent.json" - ] + "const": "crates/registry-relay/config/documentation-intent.json" } }, "reads_country_workspaces": { @@ -365,8 +358,7 @@ "integration_contract", "fixture_harness", "entity_contract", - "relay_runtime", - "notary_runtime" + "relay_runtime" ] }, "human_owner": { @@ -376,8 +368,7 @@ "integration_maintainers", "test_maintainers", "data_model_maintainers", - "relay_maintainers", - "notary_maintainers" + "relay_maintainers" ] }, "scope": { @@ -508,7 +499,7 @@ "anyOf": [ { "type": "string", - "pattern": "^(registryctl\\.authoring|registry\\.(relay|notary)\\.config|config)\\.[a-z0-9_.]+$" + "pattern": "^(registryctl\\.authoring|registry\\.relay\\.config|config)\\.[a-z0-9_.]+$" }, { "$ref": "#/$defs/prose" @@ -614,10 +605,7 @@ "type": "object", "properties": { "schema": { - "enum": [ - "relay", - "notary" - ] + "const": "relay" } } } @@ -717,59 +705,6 @@ } } } - }, - { - "if": { - "properties": { - "address": { - "type": "object", - "properties": { - "schema": { - "const": "notary" - } - } - } - } - }, - "then": { - "properties": { - "semantic_owner": { - "const": "notary_runtime" - }, - "human_owner": { - "const": "notary_maintainers" - }, - "products": { - "const": [ - "notary", - "docs" - ] - }, - "consumers": { - "const": [ - "registry_notary", - "docs_generator" - ] - }, - "generated_artifacts": { - "const": [ - "notary_config", - "field_reference" - ] - }, - "diagnostic": { - "anyOf": [ - { - "type": "string", - "pattern": "^(registry\\.notary\\.config|config)\\.[a-z0-9_.]+$" - }, - { - "$ref": "#/$defs/prose" - } - ] - } - } - } } ] }, @@ -795,7 +730,6 @@ "enum": [ "registryctl", "relay", - "notary", "editor", "docs" ] @@ -824,7 +758,6 @@ "enum": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator" ] @@ -839,7 +772,6 @@ "editor_schemas", "project_build", "relay_config", - "notary_config", "fixture_report", "field_reference" ] @@ -855,7 +787,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation", "testing" diff --git a/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference_coverage.v1.schema.json b/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference_coverage.v1.schema.json index 97d013638..c7c6b322f 100644 --- a/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference_coverage.v1.schema.json +++ b/crates/registryctl/schemas/project-documentation/registry.project.configuration_reference_coverage.v1.schema.json @@ -75,8 +75,7 @@ "integration", "fixture", "entity", - "relay", - "notary" + "relay" ] }, "pathKind": { @@ -157,13 +156,10 @@ "runtime_intent": { "type": "array", "minItems": 0, - "maxItems": 2, + "maxItems": 1, "uniqueItems": true, "items": { - "enum": [ - "crates/registry-relay/config/documentation-intent.json", - "crates/registry-notary-core/config/documentation-intent.json" - ] + "const": "crates/registry-relay/config/documentation-intent.json" } }, "reads_country_workspaces": { @@ -289,10 +285,7 @@ "if": { "properties": { "schema": { - "enum": [ - "relay", - "notary" - ] + "const": "relay" } } }, diff --git a/crates/registryctl/schemas/project-documentation/registry.runtime.configuration_intent.v1.schema.json b/crates/registryctl/schemas/project-documentation/registry.runtime.configuration_intent.v1.schema.json index 111c2df50..4ff26d472 100644 --- a/crates/registryctl/schemas/project-documentation/registry.runtime.configuration_intent.v1.schema.json +++ b/crates/registryctl/schemas/project-documentation/registry.runtime.configuration_intent.v1.schema.json @@ -118,77 +118,11 @@ } } } - }, - { - "if": { - "properties": { - "runtime_schema": { - "const": "notary" - } - } - }, - "then": { - "properties": { - "profiles": { - "type": "array", - "items": { - "allOf": [ - { - "$ref": "#/$defs/profile" - }, - { - "type": "object", - "properties": { - "semantic_owner": { - "const": "notary_runtime" - }, - "human_owner": { - "const": "notary_maintainers" - }, - "products": { - "const": [ - "notary", - "docs" - ] - }, - "consumers": { - "const": [ - "registry_notary", - "docs_generator" - ] - }, - "generated_artifacts": { - "const": [ - "notary_config", - "field_reference" - ] - }, - "diagnostic": { - "anyOf": [ - { - "type": "string", - "pattern": "^(registry\\.notary\\.config|config)\\.[a-z0-9_.]+$" - }, - { - "$ref": "#/$defs/prose" - } - ] - } - } - } - ] - } - } - } - } } ], "$defs": { "runtimeSchema": { - "enum": [ - "relay", - "notary" - ] + "const": "relay" }, "pathKind": { "enum": [ @@ -238,16 +172,10 @@ "$ref": "#/$defs/prose" }, "semantic_owner": { - "enum": [ - "relay_runtime", - "notary_runtime" - ] + "const": "relay_runtime" }, "human_owner": { - "enum": [ - "relay_maintainers", - "notary_maintainers" - ] + "const": "relay_maintainers" }, "scope": { "$ref": "#/$defs/prose" @@ -277,7 +205,6 @@ "items": { "enum": [ "relay", - "notary", "docs" ] } @@ -307,7 +234,7 @@ "anyOf": [ { "type": "string", - "pattern": "^(registry\\.(relay|notary)\\.config|config)\\.[a-z0-9_.]+$" + "pattern": "^(registry\\.relay\\.config|config)\\.[a-z0-9_.]+$" }, { "$ref": "#/$defs/prose" @@ -334,7 +261,6 @@ "items": { "enum": [ "registry_relay", - "registry_notary", "docs_generator" ] } @@ -346,7 +272,6 @@ "items": { "enum": [ "relay_config", - "notary_config", "field_reference" ] } @@ -361,7 +286,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation" ] diff --git a/crates/registryctl/schemas/project-reports/registry.project.artifact_manifest.v1.schema.json b/crates/registryctl/schemas/project-reports/registry.project.artifact_manifest.v1.schema.json index efd0b2555..fc47705b0 100644 --- a/crates/registryctl/schemas/project-reports/registry.project.artifact_manifest.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registry.project.artifact_manifest.v1.schema.json @@ -126,7 +126,6 @@ "runtime_config", "consultation_contract", "source_plan", - "claim_configuration", "deployment_input", "review_record", "documentation" @@ -179,7 +178,6 @@ "items": { "enum": [ "registry_relay", - "registry_notary", "bundle_signer", "deployment_tooling", "project_documentation", diff --git a/crates/registryctl/schemas/project-reports/registry.project.capability_inventory.v1.schema.json b/crates/registryctl/schemas/project-reports/registry.project.capability_inventory.v1.schema.json index 1b7a422cd..a29d8fd18 100644 --- a/crates/registryctl/schemas/project-reports/registry.project.capability_inventory.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registry.project.capability_inventory.v1.schema.json @@ -26,8 +26,8 @@ }, "capabilities": { "type": "array", - "minItems": 12, - "maxItems": 12, + "minItems": 9, + "maxItems": 9, "items": { "$ref": "#/$defs/capability_record" }, @@ -38,18 +38,15 @@ {"contains": {"properties": {"capability": {"const": "rhai_runtime"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"capability": {"const": "rhai_abi"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"capability": {"const": "registry_relay_product"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"capability": {"const": "registry_notary_product"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"capability": {"const": "registry_relay_validator"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"capability": {"const": "registry_notary_validator"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"capability": {"const": "project_authoring_schemas"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"capability": {"const": "registry_relay_config_schema"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"capability": {"const": "registry_notary_config_schema"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1} + {"contains": {"properties": {"capability": {"const": "registry_relay_config_schema"}}, "required": ["capability"]}, "minContains": 1, "maxContains": 1} ] }, "support": { "type": "array", - "minItems": 14, - "maxItems": 14, + "minItems": 10, + "maxItems": 10, "items": { "$ref": "#/$defs/support_assessment" }, @@ -59,20 +56,16 @@ {"contains": {"properties": {"component": {"const": "snapshot_materialization_worker"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "rhai_xw_protocol_helper"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "registry_relay_product"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"component": {"const": "registry_notary_product"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "registry_relay_validator"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"component": {"const": "registry_notary_validator"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "project_authoring_schema"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "registry_relay_config_schema"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"component": {"const": "registry_notary_config_schema"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, {"contains": {"properties": {"component": {"const": "registryctl_distribution"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"component": {"const": "registry_relay_image"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1}, - {"contains": {"properties": {"component": {"const": "registry_notary_image"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1} + {"contains": {"properties": {"component": {"const": "registry_relay_image"}}, "required": ["component"]}, "minContains": 1, "maxContains": 1} ] }, "missing_support": { "type": "array", - "maxItems": 14, + "maxItems": 10, "uniqueItems": true, "items": { "$ref": "#/$defs/missing_support" @@ -80,7 +73,7 @@ }, "inactive_or_unused": { "type": "array", - "maxItems": 5, + "maxItems": 4, "uniqueItems": true, "items": { "$ref": "#/$defs/inactive_or_unused" @@ -96,12 +89,9 @@ "rhai_runtime", "rhai_abi", "registry_relay_product", - "registry_notary_product", "registry_relay_validator", - "registry_notary_validator", "project_authoring_schemas", - "registry_relay_config_schema", - "registry_notary_config_schema" + "registry_relay_config_schema" ] }, "capability_version": { @@ -110,27 +100,24 @@ "relay_integration_pack_v1", "rhai_language_v1", "rhai_xw_v1", - "registry_relay_config_v1", - "registry_notary_config_v1" + "registry_relay_config_v1" ] }, "owner": { "enum": [ "registryctl", "registry_relay", - "registry_notary", "release_engineering" ] }, "usage": { - "description": "Value-free usage counts. total is the bounded aggregate; strict typed ingress requires it to equal services + consultations + claims.", + "description": "Value-free usage counts. total is the bounded aggregate; strict typed ingress requires it to equal services + consultations.", "x-registry-aggregateMaximum": 1000000, "type": "object", "additionalProperties": false, "required": [ "services", "consultations", - "claims", "total" ], "properties": { @@ -144,11 +131,6 @@ "minimum": 0, "maximum": 1000000 }, - "claims": { - "type": "integer", - "minimum": 0, - "maximum": 1000000 - }, "total": { "type": "integer", "minimum": 0, @@ -333,15 +315,11 @@ "snapshot_materialization_worker", "rhai_xw_protocol_helper", "registry_relay_product", - "registry_notary_product", "registry_relay_validator", - "registry_notary_validator", "project_authoring_schema", "registry_relay_config_schema", - "registry_notary_config_schema", "registryctl_distribution", - "registry_relay_image", - "registry_notary_image" + "registry_relay_image" ] }, "support_kind": { @@ -413,10 +391,7 @@ "if": { "properties": { "component": { - "enum": [ - "registry_relay_image", - "registry_notary_image" - ] + "const": "registry_relay_image" } }, "required": [ @@ -558,8 +533,7 @@ "source_http", "source_script", "source_snapshot", - "registry_relay_product", - "registry_notary_product" + "registry_relay_product" ] }, "reason": { diff --git a/crates/registryctl/schemas/project-reports/registry.project.explanation.v1.schema.json b/crates/registryctl/schemas/project-reports/registry.project.explanation.v1.schema.json index 9e9b603df..5d38c45b7 100644 --- a/crates/registryctl/schemas/project-reports/registry.project.explanation.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registry.project.explanation.v1.schema.json @@ -269,7 +269,6 @@ "enum": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator" ] @@ -280,7 +279,6 @@ "security", "privacy", "relay", - "notary", "compatibility", "documentation", "testing" @@ -314,7 +312,8 @@ "deployment_security", "integration_contract", "fixture_harness", - "entity_contract" + "entity_contract", + "relay_runtime" ] }, "human_owner": { @@ -323,7 +322,8 @@ "security_maintainers", "integration_maintainers", "test_maintainers", - "data_model_maintainers" + "data_model_maintainers", + "relay_maintainers" ] }, "sensitivity": { @@ -340,7 +340,7 @@ "products": { "type": "array", "items": { - "enum": ["registryctl", "relay", "notary", "editor", "docs"] + "enum": ["registryctl", "relay", "editor", "docs"] } }, "introduced_in": { @@ -374,7 +374,6 @@ "editor_schemas", "project_build", "relay_config", - "notary_config", "fixture_report", "field_reference" ] diff --git a/crates/registryctl/schemas/project-reports/registry.project.fixture_coverage.v1.schema.json b/crates/registryctl/schemas/project-reports/registry.project.fixture_coverage.v1.schema.json index 919f67f31..9296c1934 100644 --- a/crates/registryctl/schemas/project-reports/registry.project.fixture_coverage.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registry.project.fixture_coverage.v1.schema.json @@ -12,7 +12,6 @@ "evidence_scope", "compatibility_claim", "live_compatibility", - "governed_request_evidence", "targets", "summary" ], @@ -42,9 +41,6 @@ "live_compatibility": { "const": "not_evaluated" }, - "governed_request_evidence": { - "const": "per_consultation_authored_request_witness_evaluation" - }, "targets": { "type": "array", "maxItems": 256, @@ -81,30 +77,8 @@ "$ref": "#/$defs/identifier" } }, - "consultation_identity": { - "type": "object", - "additionalProperties": false, - "required": ["service_id", "consultation_id"], - "properties": { - "service_id": { - "$ref": "#/$defs/identifier" - }, - "consultation_id": { - "$ref": "#/$defs/identifier" - } - } - }, - "consultation_identity_list": { - "type": "array", - "maxItems": 512, - "uniqueItems": true, - "items": { - "$ref": "#/$defs/consultation_identity" - } - }, "safe_code": { "enum": [ - "authorization.denied", "failure.subject_mismatch", "fixture.execution_contract_invalid", "fixture.profile_not_found", @@ -218,11 +192,9 @@ "interaction_count", "input_ids", "output_ids", - "claim_ids", "exercised_status_mappings", "classification", - "pass_state", - "request_to_consultation_binding" + "pass_state" ], "properties": { "evidence": { @@ -251,9 +223,6 @@ "output_ids": { "$ref": "#/$defs/identifier_list" }, - "claim_ids": { - "$ref": "#/$defs/identifier_list" - }, "exercised_status_mappings": { "type": "array", "maxItems": 2, @@ -267,9 +236,6 @@ }, "pass_state": { "$ref": "#/$defs/pass_state" - }, - "request_to_consultation_binding": { - "$ref": "#/$defs/request_binding" } } }, @@ -306,7 +272,6 @@ "byte_ceiling", "timeout", "protocol_verification", - "authorization_before_source", "output_minimization" ] }, @@ -349,7 +314,6 @@ "no_distinguishable_request_pair", "no_generated_request_matcher", "final_response_is_not_json_object", - "integration_has_no_product_claims", "snapshot_uses_closed_materialization", "protocol_matcher_owns_response_mutation" ] @@ -362,7 +326,6 @@ "order_mutation_requires_distinguishable_source_interactions", "protocol_mutation_requires_generated_request_matcher", "mutation_requires_final_json_object_response", - "authorization_check_requires_product_claim_evaluation", "snapshot_output_uses_closed_materialization_projection", "protocol_matcher_fixture_uses_protocol_verification_instead" ] @@ -371,45 +334,6 @@ } ] }, - "source_access_assertion": { - "type": "object", - "additionalProperties": false, - "required": [ - "expected_source_calls", - "actual_source_calls", - "passed" - ], - "properties": { - "expected_source_calls": { - "const": "zero" - }, - "actual_source_calls": { - "oneOf": [ - { - "type": "null" - }, - { - "type": "integer", - "minimum": 0, - "maximum": 16 - } - ] - }, - "passed": { - "type": "boolean" - } - } - }, - "nullable_source_access_assertion": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/$defs/source_access_assertion" - } - ] - }, "generated_case": { "type": "object", "additionalProperties": false, @@ -421,8 +345,7 @@ "mutation_target_class", "expected_safe_code", "actual_safe_code", - "pass_state", - "source_access_assertion" + "pass_state" ], "properties": { "evidence": { @@ -446,7 +369,6 @@ "declared_response_byte_count", "source_deadline", "protocol_response_envelope", - "authorization_gate", "unselected_response_member" ] }, @@ -456,11 +378,8 @@ "actual_safe_code": { "$ref": "#/$defs/nullable_safe_code" }, - "pass_state": { + "pass_state": { "$ref": "#/$defs/pass_state" - }, - "source_access_assertion": { - "$ref": "#/$defs/nullable_source_access_assertion" } } }, @@ -537,8 +456,6 @@ "required": [ "input_ids", "output_ids", - "claim_ids", - "disclosure_modes", "status_mappings", "protocol_helpers", "limits", @@ -551,21 +468,6 @@ "output_ids": { "$ref": "#/$defs/identifier_list" }, - "claim_ids": { - "$ref": "#/$defs/identifier_list" - }, - "disclosure_modes": { - "type": "array", - "maxItems": 3, - "uniqueItems": true, - "items": { - "enum": [ - "predicate", - "redacted", - "value" - ] - } - }, "status_mappings": { "type": "array", "maxItems": 2, @@ -621,7 +523,6 @@ "enum": [ "changed_input", "changed_output", - "changed_claim", "changed_source_contract" ] }, @@ -653,8 +554,8 @@ }, "impacts": { "type": "array", - "minItems": 4, - "maxItems": 4, + "minItems": 3, + "maxItems": 3, "items": { "$ref": "#/$defs/change_impact" } @@ -671,61 +572,6 @@ } ] }, - "request_binding": { - "type": "object", - "additionalProperties": false, - "required": ["state", "consultations", "actual_relay_consultations", "safe_error_code"], - "properties": { - "state": { - "enum": ["not_authored", "not_executed", "passed", "failed"] - }, - "consultations": { - "$ref": "#/$defs/consultation_identity_list" - }, - "actual_relay_consultations": { - "type": ["integer", "null"], - "minimum": 0, - "maximum": 4294967295 - }, - "safe_error_code": { - "$ref": "#/$defs/nullable_safe_code" - } - }, - "oneOf": [ - { - "properties": { - "state": { "enum": ["not_authored", "not_executed"] }, - "consultations": { "maxItems": 0 }, - "actual_relay_consultations": { "type": "null" }, - "safe_error_code": { "type": "null" } - } - }, - { - "properties": { - "state": { "const": "passed" }, - "consultations": { "minItems": 1 }, - "actual_relay_consultations": { - "type": "integer", - "minimum": 1, - "maximum": 4294967295 - }, - "safe_error_code": { "type": "null" } - } - }, - { - "properties": { - "state": { "const": "failed" }, - "consultations": { "maxItems": 0 }, - "actual_relay_consultations": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - }, - "safe_error_code": { "$ref": "#/$defs/safe_code" } - } - } - ] - }, "requirement_name": { "enum": [ "semantic_match", @@ -733,22 +579,16 @@ "semantic_ambiguity", "subject_mismatch", "semantic_null", - "authorization_denial", "source_failure", "request_rendering", - "request_to_consultation_binding", "expected_source_interactions", "source_interaction_order", "output_fields", - "claims", - "declared_disclosure_modes", - "exercised_disclosure_modes", "script_branches", "pagination_and_continuation", "status_mappings", "protocol_helpers", "protocol_verification", - "authorization_before_source", "malformed_decoding", "structural_limits", "request_bytes", @@ -761,7 +601,6 @@ "output_minimization", "changed_input_affected_fixtures", "changed_output_affected_fixtures", - "changed_claim_affected_fixtures", "changed_source_contract_affected_fixtures" ] }, @@ -822,7 +661,6 @@ "enum": [ "required_evidence_missing", "target_has_no_fixtures", - "runtime_dimension_not_observed", "numeric_boundary_not_exercised", "script_branch_contract_not_declared" ] @@ -850,7 +688,6 @@ }, "reason": { "enum": [ - "no_product_claims_declared", "no_protocol_helpers_declared", "no_verification_protocol_declared", "no_continuation_protocol_declared", @@ -901,8 +738,8 @@ }, "requirements": { "type": "array", - "minItems": 35, - "maxItems": 35, + "minItems": 28, + "maxItems": 28, "uniqueItems": true, "prefixItems": [ { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "semantic_match" } } }, @@ -910,22 +747,16 @@ { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "semantic_ambiguity" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "subject_mismatch" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "semantic_null" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "authorization_denial" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "source_failure" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "request_rendering" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "request_to_consultation_binding" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "expected_source_interactions" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "source_interaction_order" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "output_fields" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "claims" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "declared_disclosure_modes" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "exercised_disclosure_modes" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "script_branches" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "pagination_and_continuation" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "status_mappings" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "protocol_helpers" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "protocol_verification" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "authorization_before_source" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "malformed_decoding" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "structural_limits" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "request_bytes" } } }, @@ -938,7 +769,6 @@ { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "output_minimization" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "changed_input_affected_fixtures" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "changed_output_affected_fixtures" } } }, - { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "changed_claim_affected_fixtures" } } }, { "$ref": "#/$defs/requirement_coverage", "properties": { "requirement": { "const": "changed_source_contract_affected_fixtures" } } } ], "items": false @@ -985,8 +815,7 @@ "additionalProperties": false, "required": [ "source_operation_count", - "reviewed_not_applicable", - "registry_backed_consultations" + "reviewed_not_applicable" ], "properties": { "source_operation_count": { @@ -1011,9 +840,6 @@ "subject_mismatch" ] } - }, - "registry_backed_consultations": { - "$ref": "#/$defs/consultation_identity_list" } } }, @@ -1035,7 +861,7 @@ }, "generated_cases": { "type": "array", - "maxItems": 9216, + "maxItems": 8192, "items": { "$ref": "#/$defs/generated_case" } @@ -1107,27 +933,27 @@ "covered": { "type": "integer", "minimum": 0, - "maximum": 8704 + "maximum": 7168 }, "missing": { "type": "integer", "minimum": 0, - "maximum": 8704 + "maximum": 7168 }, "not_applicable": { "type": "integer", "minimum": 0, - "maximum": 8704 + "maximum": 7168 }, "not_evaluated": { "type": "integer", "minimum": 0, - "maximum": 8704 + "maximum": 7168 }, "total": { "type": "integer", "minimum": 0, - "maximum": 8704 + "maximum": 7168 } } } diff --git a/crates/registryctl/schemas/project-reports/registry.project.semantic_impact.v1.schema.json b/crates/registryctl/schemas/project-reports/registry.project.semantic_impact.v1.schema.json index 032f8148f..87211811b 100644 --- a/crates/registryctl/schemas/project-reports/registry.project.semantic_impact.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registry.project.semantic_impact.v1.schema.json @@ -33,11 +33,9 @@ }, "dimension": { "enum": [ - "claim", "integration", "service_policy", "operator_security", - "disclosure", "compiler" ] }, @@ -56,7 +54,6 @@ "enum": [ "registryctl_authoring", "registry_relay", - "registry_notary", "editor_tooling", "docs_generator", "bundle_signer", @@ -73,7 +70,6 @@ "privacy", "security", "relay", - "notary", "compatibility", "documentation", "testing", @@ -176,8 +172,6 @@ "fixture", "service_policy", "consultation", - "claim", - "disclosure", "product_input", "generated_artifact" ] @@ -196,7 +190,6 @@ "enum": [ "registryctl", "relay", - "notary", "editor", "docs" ] @@ -224,10 +217,10 @@ }, "product_actions": { "type": "array", - "maxItems": 3, + "maxItems": 2, "uniqueItems": true, "items": { - "enum": ["relay-public", "relay-consultation", "notary"] + "enum": ["relay-public", "relay-consultation"] } }, "semantic_impact": { diff --git a/crates/registryctl/schemas/project-reports/registryctl.fixture_error_reference.v1.schema.json b/crates/registryctl/schemas/project-reports/registryctl.fixture_error_reference.v1.schema.json index 64499435c..697b05818 100644 --- a/crates/registryctl/schemas/project-reports/registryctl.fixture_error_reference.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registryctl.fixture_error_reference.v1.schema.json @@ -11,8 +11,8 @@ }, "entries": { "type": "array", - "minItems": 16, - "maxItems": 16, + "minItems": 15, + "maxItems": 15, "uniqueItems": true, "items": { "$ref": "#/$defs/entry" @@ -22,7 +22,6 @@ "$defs": { "code": { "enum": [ - "authorization.denied", "failure.subject_mismatch", "fixture.execution_contract_invalid", "fixture.profile_not_found", diff --git a/crates/registryctl/schemas/project-reports/registryctl.operator_error_reference.v1.schema.json b/crates/registryctl/schemas/project-reports/registryctl.operator_error_reference.v1.schema.json index ef6a249c9..35ddf4e19 100644 --- a/crates/registryctl/schemas/project-reports/registryctl.operator_error_reference.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registryctl.operator_error_reference.v1.schema.json @@ -11,8 +11,8 @@ }, "entries": { "type": "array", - "minItems": 60, - "maxItems": 60, + "minItems": 42, + "maxItems": 42, "uniqueItems": true, "items": { "allOf": [ @@ -56,7 +56,6 @@ "family": { "enum": [ "bundle_verification", - "notary_activation", "operator_preflight", "relay_activation", "relay_process_startup" @@ -69,7 +68,6 @@ }, "owner": { "enum": [ - "registry_notary", "registry_platform_ops", "registry_relay", "registryctl" @@ -77,7 +75,6 @@ }, "product": { "enum": [ - "registry_notary", "registry_platform_ops", "registry_relay", "registryctl" @@ -156,44 +153,6 @@ } } }, - { - "properties": { - "family": { - "const": "notary_activation" - }, - "code": { - "enum": [ - "notary.cel.worker_unavailable", - "notary.configuration.invalid", - "notary.deployment.gate_failed", - "notary.relay.activation_failed", - "notary.relay.configuration_invalid", - "notary.relay.credential_unavailable", - "notary.relay.credentials_rejected", - "notary.relay.profile_mismatch", - "notary.relay.profile_not_found", - "notary.relay.unavailable", - "notary.runtime.activation_failed", - "notary.runtime.activation_required", - "notary.state.postgresql.database_read_only", - "notary.state.postgresql.database_unavailable", - "notary.state.postgresql.database_unsupported", - "notary.state.postgresql.durability_unsafe", - "notary.state.postgresql.role_incompatible", - "notary.state.postgresql.schema_incompatible" - ] - }, - "owner": { - "const": "registry_notary" - }, - "product": { - "const": "registry_notary" - }, - "docs_anchor": { - "pattern": "^/reference/diagnostics/operator/#registry_notary--[a-z0-9]+(?:-[a-z0-9]+)*$" - } - } - }, { "properties": { "family": { diff --git a/crates/registryctl/schemas/project-reports/registryctl.project_command.v1.schema.json b/crates/registryctl/schemas/project-reports/registryctl.project_command.v1.schema.json index 261ebbf4d..42b744fda 100644 --- a/crates/registryctl/schemas/project-reports/registryctl.project_command.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registryctl.project_command.v1.schema.json @@ -89,7 +89,6 @@ "inputs", "calls", "outputs", - "claims", "passed" ], "additionalProperties": false, @@ -109,9 +108,6 @@ "outputs": { "$ref": "#/$defs/string_list" }, - "claims": { - "$ref": "#/$defs/string_list" - }, "outcome": { "$ref": "#/$defs/non_empty_string" }, @@ -136,11 +132,9 @@ "properties": { "dimension": { "enum": [ - "claim", "integration", "service_policy", "operator_security", - "disclosure", "compiler" ] } diff --git a/crates/registryctl/schemas/project-reports/registryctl.project_preflight.v1.schema.json b/crates/registryctl/schemas/project-reports/registryctl.project_preflight.v1.schema.json index c3c00dd2d..8e4835db7 100644 --- a/crates/registryctl/schemas/project-reports/registryctl.project_preflight.v1.schema.json +++ b/crates/registryctl/schemas/project-reports/registryctl.project_preflight.v1.schema.json @@ -47,7 +47,7 @@ }, "product_validators": { "type": "array", - "maxItems": 2, + "maxItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/product_validator" @@ -210,7 +210,7 @@ "additionalProperties": false, "properties": { "product": { - "enum": ["registry_relay", "registry_notary"] + "const": "registry_relay" }, "capability": { "const": "configuration_validation" @@ -231,12 +231,7 @@ "source_mtls_private_key", "source_oauth_mtls_private_key", "source_jwks_mtls_private_key", - "entity_postgres_connection", - "issuance_signing_key", - "caller_api_key_fingerprint", - "oid4vci_client_signing_key", - "oid4vci_access_token_signing_key", - "oid4vci_sensitive_state_key" + "entity_postgres_connection" ] }, "secret_check": { @@ -247,7 +242,7 @@ "consumers": { "type": "array", "minItems": 1, - "maxItems": 15, + "maxItems": 10, "uniqueItems": true, "items": { "$ref": "#/$defs/secret_consumer" @@ -272,9 +267,7 @@ "entity_csv", "entity_xlsx", "entity_parquet", - "relay_state_root_certificate", - "notary_state_root_certificate", - "notary_to_relay_token" + "relay_state_root_certificate" ] }, "runtime_file": { @@ -308,11 +301,7 @@ "if": { "properties": { "kind": { - "enum": [ - "relay_state_root_certificate", - "notary_state_root_certificate", - "notary_to_relay_token" - ] + "const": "relay_state_root_certificate" } } }, diff --git a/crates/registryctl/src/approved_set.rs b/crates/registryctl/src/approved_set.rs index 5637c5e1e..5d078f51c 100644 --- a/crates/registryctl/src/approved_set.rs +++ b/crates/registryctl/src/approved_set.rs @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -//! Fixed three-lane approved baseline set assembly. +//! Fixed two-lane Relay approved baseline set assembly. //! //! This module deliberately does not make an approved set a signature or an //! activation authority. Product-specific verification remains responsible //! for constructing [`VerifiedApprovedLaneV1`]. Assembly then proves that all -//! three independently verified lanes form one compatible governed set. +//! two independently verified lanes form one compatible governed set. use std::collections::BTreeSet; use std::fmt; @@ -35,18 +35,15 @@ pub enum ApprovedLaneV1 { RelayPublic, #[serde(rename = "relay-consultation")] RelayConsultation, - #[serde(rename = "notary")] - Notary, } impl ApprovedLaneV1 { - pub const ALL: [Self; 3] = [Self::RelayPublic, Self::RelayConsultation, Self::Notary]; + pub const ALL: [Self; 2] = [Self::RelayPublic, Self::RelayConsultation]; pub const fn acceptance_lane(self) -> ProductAcceptanceLaneV1 { match self { Self::RelayPublic => ProductAcceptanceLaneV1::RelayPublic, Self::RelayConsultation => ProductAcceptanceLaneV1::RelayConsultation, - Self::Notary => ProductAcceptanceLaneV1::Notary, } } @@ -55,15 +52,14 @@ impl ApprovedLaneV1 { Self::RelayPublic | Self::RelayConsultation => { ProductAcceptanceProductV1::RegistryRelay } - Self::Notary => ProductAcceptanceProductV1::RegistryNotary, } } - pub const fn from_acceptance_lane(lane: ProductAcceptanceLaneV1) -> Self { + pub fn try_from_acceptance_lane(lane: ProductAcceptanceLaneV1) -> Result { match lane { - ProductAcceptanceLaneV1::RelayPublic => Self::RelayPublic, - ProductAcceptanceLaneV1::RelayConsultation => Self::RelayConsultation, - ProductAcceptanceLaneV1::Notary => Self::Notary, + ProductAcceptanceLaneV1::RelayPublic => Ok(Self::RelayPublic), + ProductAcceptanceLaneV1::RelayConsultation => Ok(Self::RelayConsultation), + _ => bail!("acceptance lane is not supported by registryctl"), } } @@ -71,7 +67,6 @@ impl ApprovedLaneV1 { match self { Self::RelayPublic => "relay", Self::RelayConsultation => "relay_consultation", - Self::Notary => "notary", } } } @@ -81,7 +76,6 @@ impl fmt::Display for ApprovedLaneV1 { formatter.write_str(match self { Self::RelayPublic => "relay-public", Self::RelayConsultation => "relay-consultation", - Self::Notary => "notary", }) } } @@ -189,40 +183,15 @@ impl ApprovedLaneLocatorsV1 { } } -/// The only cross-lane product contract in the 1.0 governed topology. -/// -/// The value is the canonical digest of the complete consultation contract -/// closure exposed by consultation Relay and pinned by Notary. Projects with -/// no consultations use `None` in both lanes. -#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct CrossLaneInterfaceDigestsV1 { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consultation_relay_notary: Option, -} - -impl CrossLaneInterfaceDigestsV1 { - fn validate_for_lane(&self, lane: ApprovedLaneV1) -> Result<()> { - if let Some(digest) = &self.consultation_relay_notary { - validate_sha256_digest(digest, "cross-lane interface digest")?; - if lane == ApprovedLaneV1::RelayPublic { - bail!("relay-public cannot bind the consultation Relay and Notary interface"); - } - } - Ok(()) - } -} - #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ReviewedLaneBindingV1 { pub lane_scoped_reviewed_input_digest: String, pub signing_input_closure_digest: String, - pub interfaces: CrossLaneInterfaceDigestsV1, } impl ReviewedLaneBindingV1 { - fn validate_for_lane(&self, lane: ApprovedLaneV1) -> Result<()> { + fn validate_for_lane(&self, _lane: ApprovedLaneV1) -> Result<()> { validate_sha256_digest( &self.lane_scoped_reviewed_input_digest, "lane-scoped reviewed-input digest", @@ -231,7 +200,7 @@ impl ReviewedLaneBindingV1 { &self.signing_input_closure_digest, "signing-input closure digest", )?; - self.interfaces.validate_for_lane(lane) + Ok(()) } } @@ -244,7 +213,6 @@ pub struct ApprovedLaneEntryV1 { pub anchor_digest: String, pub lane_scoped_reviewed_input_digest: String, pub signing_input_closure_digest: String, - pub interfaces: CrossLaneInterfaceDigestsV1, } impl ApprovedLaneEntryV1 { @@ -252,7 +220,6 @@ impl ApprovedLaneEntryV1 { ReviewedLaneBindingV1 { lane_scoped_reviewed_input_digest: self.lane_scoped_reviewed_input_digest.clone(), signing_input_closure_digest: self.signing_input_closure_digest.clone(), - interfaces: self.interfaces.clone(), } } @@ -272,7 +239,6 @@ pub struct ApprovedBaselineLanesV1 { pub relay_public: ApprovedLaneEntryV1, #[serde(rename = "relay-consultation")] pub relay_consultation: ApprovedLaneEntryV1, - pub notary: ApprovedLaneEntryV1, } impl ApprovedBaselineLanesV1 { @@ -280,7 +246,6 @@ impl ApprovedBaselineLanesV1 { match lane { ApprovedLaneV1::RelayPublic => &self.relay_public, ApprovedLaneV1::RelayConsultation => &self.relay_consultation, - ApprovedLaneV1::Notary => &self.notary, } } @@ -289,29 +254,19 @@ impl ApprovedBaselineLanesV1 { self.get(lane).validate_for_lane(lane)?; } - let consultation = self - .relay_consultation - .interfaces - .consultation_relay_notary - .as_deref(); - let notary = self.notary.interfaces.consultation_relay_notary.as_deref(); - if consultation != notary { - bail!("consultation Relay and Notary interface digests do not match"); - } - let bundle_locators = ApprovedLaneV1::ALL.map(|lane| self.get(lane).locators.bundle.as_str()); - if bundle_locators.into_iter().collect::>().len() != 3 { + if bundle_locators.into_iter().collect::>().len() != 2 { bail!("approved set contains a duplicated lane bundle locator"); } let manifest_locators = ApprovedLaneV1::ALL.map(|lane| self.get(lane).locators.signed_manifest.as_str()); - if manifest_locators.into_iter().collect::>().len() != 3 { + if manifest_locators.into_iter().collect::>().len() != 2 { bail!("approved set contains a duplicated lane manifest locator"); } let anchor_locators = ApprovedLaneV1::ALL.map(|lane| self.get(lane).locators.anchor.as_str()); - if anchor_locators.into_iter().collect::>().len() != 3 { + if anchor_locators.into_iter().collect::>().len() != 2 { bail!("approved set contains a duplicated lane anchor locator"); } Ok(()) @@ -479,7 +434,6 @@ impl VerifiedApprovedLaneV1 { pub struct InitialApprovedSetInputs { pub relay_public: PathBuf, pub relay_consultation: PathBuf, - pub notary: PathBuf, } impl InitialApprovedSetInputs { @@ -487,7 +441,6 @@ impl InitialApprovedSetInputs { match lane { ApprovedLaneV1::RelayPublic => &self.relay_public, ApprovedLaneV1::RelayConsultation => &self.relay_consultation, - ApprovedLaneV1::Notary => &self.notary, } } } @@ -496,7 +449,6 @@ impl InitialApprovedSetInputs { pub struct AffectedLaneReplacements { pub relay_public: Option, pub relay_consultation: Option, - pub notary: Option, } impl AffectedLaneReplacements { @@ -504,7 +456,6 @@ impl AffectedLaneReplacements { match lane { ApprovedLaneV1::RelayPublic => self.relay_public.as_deref(), ApprovedLaneV1::RelayConsultation => self.relay_consultation.as_deref(), - ApprovedLaneV1::Notary => self.notary.as_deref(), } } } @@ -519,7 +470,6 @@ impl AffectedLaneReplacements { pub struct ReviewedBuildUpdateV1 { pub relay_public: Option, pub relay_consultation: Option, - pub notary: Option, } impl ReviewedBuildUpdateV1 { @@ -534,7 +484,6 @@ impl ReviewedBuildUpdateV1 { match lane { ApprovedLaneV1::RelayPublic => self.relay_public.as_ref(), ApprovedLaneV1::RelayConsultation => self.relay_consultation.as_ref(), - ApprovedLaneV1::Notary => self.notary.as_ref(), } } @@ -586,7 +535,6 @@ pub struct ApprovedSetAssembleOptions { pub preceding_set: Option, pub relay_public: Option, pub relay_consultation: Option, - pub notary: Option, pub output_file: PathBuf, } @@ -595,7 +543,6 @@ impl ApprovedSetAssembleOptions { match lane { ApprovedLaneV1::RelayPublic => self.relay_public.as_deref(), ApprovedLaneV1::RelayConsultation => self.relay_consultation.as_deref(), - ApprovedLaneV1::Notary => self.notary.as_deref(), } } } @@ -640,7 +587,6 @@ pub fn assemble_approved_set( let replacements = AffectedLaneReplacements { relay_public: options.relay_public.clone(), relay_consultation: options.relay_consultation.clone(), - notary: options.notary.clone(), }; return assemble_updated_approved_set( preceding_file, @@ -679,7 +625,6 @@ pub fn assemble_approved_set( .relay_consultation .clone() .expect("all lanes checked"), - notary: options.notary.clone().expect("all lanes checked"), }; assemble_initial_approved_set(&inputs, &options.output_file, |request| { let lane = request.lane; @@ -697,7 +642,7 @@ pub fn assemble_initial_approved_set( mut verify: impl FnMut(LaneVerificationRequestV1) -> Result, ) -> Result { validate_absent_output_file(output_file)?; - let mut verified = Vec::with_capacity(3); + let mut verified = Vec::with_capacity(2); for lane in ApprovedLaneV1::ALL { let request = LaneVerificationRequestV1 { lane, @@ -745,7 +690,7 @@ pub fn assemble_updated_approved_set( } let preceding = load_approved_baseline_set_document(preceding_set_file)?; - let mut verified_preceding = Vec::with_capacity(3); + let mut verified_preceding = Vec::with_capacity(2); for lane in ApprovedLaneV1::ALL { let entry = preceding.lanes.get(lane).clone(); let request = LaneVerificationRequestV1 { @@ -765,7 +710,7 @@ pub fn assemble_updated_approved_set( } validate_identity_set(&verified_preceding)?; - let mut final_lanes = Vec::with_capacity(3); + let mut final_lanes = Vec::with_capacity(2); for lane in ApprovedLaneV1::ALL { let preceding_lane = verified_for(&verified_preceding, lane); if let (Some(expected), Some(directory)) = @@ -842,7 +787,7 @@ pub(crate) fn load_approved_baseline_set_with_root( let approved_set = load_approved_baseline_set_document(path)?; let canonical_root = fs::canonicalize(closure_root).context("failed to resolve approved-set closure root")?; - let mut verified = Vec::with_capacity(3); + let mut verified = Vec::with_capacity(2); for lane in ApprovedLaneV1::ALL { verified.push(verify_lane_request( LaneVerificationRequestV1 { @@ -1046,7 +991,6 @@ pub(crate) fn verify_lane_request( anchor_digest, lane_scoped_reviewed_input_digest: reviewed.lane_scoped_reviewed_input_digest, signing_input_closure_digest: reviewed.signing_input_closure_digest, - interfaces: reviewed.interfaces, }; if expected_entry .as_ref() @@ -1210,31 +1154,9 @@ fn reviewed_binding_from_verified_bundle( bail!("signed lane manifest bundle_id does not match its exact input closure"); } - let review = read_manifest_payload(bundle, manifest, "approval/review.json")?; - let review = - parse_json_strict(&review).context("signed lane review record is not strict JSON")?; - let consultations = review - .get("consultations") - .and_then(serde_json::Value::as_object) - .ok_or_else(|| anyhow!("signed lane review record lacks consultations"))?; - let interface = if consultations.is_empty() { - None - } else { - Some(sha256_uri( - &canonicalize_json(&serde_json::Value::Object(consultations.clone())) - .context("failed to canonicalize consultation interface")?, - )) - }; - let interfaces = match lane { - ApprovedLaneV1::RelayPublic => CrossLaneInterfaceDigestsV1::default(), - ApprovedLaneV1::RelayConsultation | ApprovedLaneV1::Notary => CrossLaneInterfaceDigestsV1 { - consultation_relay_notary: interface, - }, - }; Ok(ReviewedLaneBindingV1 { lane_scoped_reviewed_input_digest: lane_digest, signing_input_closure_digest, - interfaces, }) } @@ -1311,7 +1233,6 @@ fn set_from_verified(verified: &[VerifiedApprovedLaneV1]) -> Result Result<()> { - if verified.len() != 3 { - bail!("approved set requires exactly three independently verified lanes"); + if verified.len() != 2 { + bail!("approved set requires exactly two independently verified Relay lanes"); } let lanes = verified .iter() @@ -1472,6 +1393,16 @@ mod tests { use super::*; use std::os::unix::fs::symlink; + #[test] + fn non_relay_acceptance_lane_fails_without_panicking() { + let error = ApprovedLaneV1::try_from_acceptance_lane(ProductAcceptanceLaneV1::Notary) + .expect_err("the retired acceptance lane must fail closed"); + assert_eq!( + error.to_string(), + "acceptance lane is not supported by registryctl" + ); + } + #[test] fn portable_artifact_resolution_rejects_intermediate_symlink_escape() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -1492,11 +1423,11 @@ mod tests { fn portable_artifact_resolution_uses_the_verifier_selected_closure_root() { let temporary = tempfile::tempdir().expect("temporary directory"); let generated = temporary.path().join("generated"); - let artifact = generated.join("bundles/notary/manifest-digest"); + let artifact = generated.join("bundles/relay-public/manifest-digest"); fs::create_dir_all(&artifact).expect("package artifact"); fs::create_dir_all(generated.join("inputs")).expect("nested set directory"); - let locator = PortableArtifactLocator::new("bundles/notary/manifest-digest") + let locator = PortableArtifactLocator::new("bundles/relay-public/manifest-digest") .expect("normalized package locator"); assert_eq!( resolve_portable_artifact(&generated, &locator).expect("selected root resolves"), diff --git a/crates/registryctl/src/deployment.rs b/crates/registryctl/src/deployment.rs index 3f613bb97..140f06e31 100644 --- a/crates/registryctl/src/deployment.rs +++ b/crates/registryctl/src/deployment.rs @@ -35,23 +35,17 @@ const SECRET_CONSUMERS_SCHEMA: &str = "registry.project.secret-consumers.v1"; const RELAY_PUBLIC: &str = "relay-public"; const RELAY_CONSULTATION: &str = "relay-consultation"; -const NOTARY: &str = "notary"; const POSTGRESQL: &str = "postgresql-state-plane"; const RELAY_PUBLIC_ACTIONS: &str = "relay-public-actions"; const RELAY_CONSULTATION_ACTIONS: &str = "relay-consultation-actions"; -const NOTARY_ACTIONS: &str = "notary-actions"; const POSTGRESQL_ACTIONS: &str = "postgresql-actions"; const SERVICE_RELAY_PUBLIC: &str = "registry-relay-public"; const SERVICE_RELAY_CONSULTATION: &str = "registry-relay-consultation"; -const SERVICE_NOTARY: &str = "registry-notary"; const SERVICE_POSTGRESQL: &str = "registry-postgres"; const SECRET_STAGER_SUFFIX: &str = "-stage-secrets"; const NETWORK_RUNTIME: &str = "registry-runtime"; -pub(crate) const OPERATOR_FILE_IDS: [&str; 9] = [ - "notary-environment", - "notary-relay-workload-credential", - "notary-signing-key", +pub(crate) const OPERATOR_FILE_IDS: [&str; 6] = [ "postgresql-admin-password", "postgresql-bootstrap-environment", "postgresql-tls-certificate", @@ -66,23 +60,18 @@ const BOUNDED_LOG_DRIVER: &str = "local"; const BOUNDED_LOG_MAX_SIZE: &str = "10m"; const BOUNDED_LOG_MAX_FILES: &str = "3"; -const INITIALIZATION_SERVICES: [&str; 16] = [ +const INITIALIZATION_SERVICES: [&str; 11] = [ SERVICE_POSTGRESQL_BOOTSTRAP, "registry-relay-public-prepare-state", "registry-relay-consultation-prepare-state", - "registry-notary-prepare-state", "registry-relay-public-initialize", "registry-relay-consultation-initialize", - "registry-notary-initialize", "registry-relay-public-preview-state", "registry-relay-consultation-preview-state", - "registry-notary-preview-state", "registry-relay-public-accept-state", "registry-relay-consultation-accept-state", - "registry-notary-accept-state", "registry-relay-public-verify-state", "registry-relay-consultation-verify-state", - "registry-notary-verify-state", ]; #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -126,8 +115,6 @@ impl<'de> Deserialize<'de> for ImageIdentityV1 { pub struct ManagedTopologyImagesV1 { pub relay: ImageIdentityV1, pub relay_platform: OciPlatformV1, - pub notary: ImageIdentityV1, - pub notary_platform: OciPlatformV1, pub postgresql_state_plane: ImageIdentityV1, pub postgresql_state_plane_platform: OciPlatformV1, } @@ -137,7 +124,6 @@ pub struct ManagedTopologyImagesV1 { pub enum ProductLaneV1 { RelayPublic, RelayConsultation, - Notary, } impl ProductLaneV1 { @@ -145,7 +131,6 @@ impl ProductLaneV1 { match lane { ApprovedLaneV1::RelayPublic => Self::RelayPublic, ApprovedLaneV1::RelayConsultation => Self::RelayConsultation, - ApprovedLaneV1::Notary => Self::Notary, } } @@ -153,7 +138,6 @@ impl ProductLaneV1 { match self { Self::RelayPublic => RELAY_PUBLIC, Self::RelayConsultation => RELAY_CONSULTATION, - Self::Notary => NOTARY, } } @@ -161,7 +145,6 @@ impl ProductLaneV1 { match self { Self::RelayPublic => SERVICE_RELAY_PUBLIC, Self::RelayConsultation => SERVICE_RELAY_CONSULTATION, - Self::Notary => SERVICE_NOTARY, } } } @@ -398,28 +381,6 @@ impl DeploymentPlanV1 { vec![POSTGRESQL], "relay-consultation-health", ), - product( - ProductLaneV1::Notary, - images.notary.clone(), - images.notary_platform, - vec![ - MountRoleV1::Bundle, - MountRoleV1::Anchor, - MountRoleV1::AntiRollbackState, - MountRoleV1::Secret, - MountRoleV1::Audit, - ], - vec!["notary-relay-workload-credential", "notary-signing-key"], - vec!["notary-anti-rollback", "notary-audit"], - vec![ - EndpointClassV1::PublicApplication, - EndpointClassV1::Administration, - EndpointClassV1::Posture, - ], - vec!["runtime"], - vec![RELAY_CONSULTATION, POSTGRESQL], - "notary-health", - ), DeploymentWorkloadV1::Supporting(SupportingWorkloadV1 { id: POSTGRESQL.to_string(), recipe: SupportingWorkloadRecipeV1::PostgresqlStatePlane, @@ -451,11 +412,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::PrepareStateStore, ), - initialization( - "prepare-notary-state", - NOTARY, - RuntimeActionV1::PrepareStateStore, - ), initialization( "initialize-relay-public", RELAY_PUBLIC, @@ -466,11 +422,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::InitializeState, ), - initialization( - "initialize-notary", - NOTARY, - RuntimeActionV1::InitializeState, - ), initialization( "preview-relay-public-state", RELAY_PUBLIC, @@ -481,11 +432,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::PreviewState, ), - initialization( - "preview-notary-state", - NOTARY, - RuntimeActionV1::PreviewState, - ), initialization( "accept-relay-public-state", RELAY_PUBLIC, @@ -496,7 +442,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::AcceptState, ), - initialization("accept-notary-state", NOTARY, RuntimeActionV1::AcceptState), initialization( "verify-relay-public-state", RELAY_PUBLIC, @@ -507,12 +452,11 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::VerifyState, ), - initialization("verify-notary-state", NOTARY, RuntimeActionV1::VerifyState), ], recovery_consistency_groups: vec![ WorkloadGroupV1 { id: "consultation-state".to_string(), - members: strings(vec![RELAY_CONSULTATION, NOTARY, POSTGRESQL]), + members: strings(vec![RELAY_CONSULTATION, POSTGRESQL]), }, WorkloadGroupV1 { id: "relay-public-state".to_string(), @@ -528,10 +472,6 @@ impl DeploymentPlanV1 { EndpointClassV1::PrivateApplication, EndpointExposureV1::PrivateNetworkOnly, ), - exposure( - EndpointClassV1::Administration, - EndpointExposureV1::PrivateNetworkOnly, - ), exposure( EndpointClassV1::Posture, EndpointExposureV1::PrivateNetworkOnly, @@ -555,9 +495,9 @@ impl DeploymentPlanV1 { .iter() .map(DeploymentWorkloadV1::id) .collect(); - let expected_ids = BTreeSet::from([RELAY_PUBLIC, RELAY_CONSULTATION, NOTARY, POSTGRESQL]); + let expected_ids = BTreeSet::from([RELAY_PUBLIC, RELAY_CONSULTATION, POSTGRESQL]); if ids != expected_ids || ids.len() != self.workloads.len() { - bail!("DeploymentPlanV1 must contain the complete closed four-workload topology"); + bail!("DeploymentPlanV1 must contain the complete closed three-workload topology"); } for workload in &self.workloads { if workload @@ -606,11 +546,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::PrepareStateStore, ), - ( - "prepare-notary-state", - NOTARY, - RuntimeActionV1::PrepareStateStore, - ), ( "initialize-relay-public", RELAY_PUBLIC, @@ -621,11 +556,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::InitializeState, ), - ( - "initialize-notary", - NOTARY, - RuntimeActionV1::InitializeState, - ), ( "preview-relay-public-state", RELAY_PUBLIC, @@ -636,11 +566,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::PreviewState, ), - ( - "preview-notary-state", - NOTARY, - RuntimeActionV1::PreviewState, - ), ( "accept-relay-public-state", RELAY_PUBLIC, @@ -651,7 +576,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::AcceptState, ), - ("accept-notary-state", NOTARY, RuntimeActionV1::AcceptState), ( "verify-relay-public-state", RELAY_PUBLIC, @@ -662,7 +586,6 @@ impl DeploymentPlanV1 { RELAY_CONSULTATION, RuntimeActionV1::VerifyState, ), - ("verify-notary-state", NOTARY, RuntimeActionV1::VerifyState), ]; if self.initialization_actions.len() != expected_initialization.len() || !self @@ -679,7 +602,7 @@ impl DeploymentPlanV1 { != [ WorkloadGroupV1 { id: "consultation-state".to_string(), - members: strings(vec![RELAY_CONSULTATION, NOTARY, POSTGRESQL]), + members: strings(vec![RELAY_CONSULTATION, POSTGRESQL]), }, WorkloadGroupV1 { id: "relay-public-state".to_string(), @@ -703,10 +626,6 @@ impl DeploymentPlanV1 { EndpointClassV1::PrivateApplication, EndpointExposureV1::PrivateNetworkOnly, ), - ( - EndpointClassV1::Administration, - EndpointExposureV1::PrivateNetworkOnly, - ), ( EndpointClassV1::Posture, EndpointExposureV1::PrivateNetworkOnly, @@ -723,8 +642,6 @@ impl DeploymentPlanV1 { let expected = Self::managed_single_node(&ManagedTopologyImagesV1 { relay: relay_public.image_identity.clone(), relay_platform: relay_public.image_platform, - notary: self.product(ProductLaneV1::Notary)?.image_identity.clone(), - notary_platform: self.product(ProductLaneV1::Notary)?.image_platform, postgresql_state_plane: self .supporting(SupportingWorkloadRecipeV1::PostgresqlStatePlane)? .image_identity @@ -768,7 +685,6 @@ impl DeploymentPlanV1 { #[serde(deny_unknown_fields)] pub struct LoopbackPortsV1 { pub relay_public: u16, - pub notary: u16, } #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] @@ -795,10 +711,7 @@ impl DeploymentBindingV1 { package_id: package_id.into(), environment: environment.into(), loopback_address: "127.0.0.1".to_string(), - ports: LoopbackPortsV1 { - relay_public: 4242, - notary: 4255, - }, + ports: LoopbackPortsV1 { relay_public: 4242 }, secret_files: OPERATOR_FILE_IDS .into_iter() .map(|id| (id.to_string(), format!("operator/secrets/{id}"))) @@ -823,9 +736,8 @@ impl DeploymentBindingV1 { if self.loopback_address != "127.0.0.1" && self.loopback_address != "::1" { bail!("managed host publishing must use an explicit loopback address"); } - let ports = [self.ports.relay_public, self.ports.notary]; - if ports.contains(&0) || ports.iter().collect::>().len() != ports.len() { - bail!("managed loopback ports must be non-zero and distinct"); + if self.ports.relay_public == 0 { + bail!("managed loopback port must be non-zero"); } if self.restart_policy != "unless-stopped" || self.logging_policy != "local-bounded" { bail!("deployment binding selects an unsupported managed policy"); @@ -882,7 +794,6 @@ pub struct LockedPostgresqlRuntimeV1 { pub struct LockedRuntimeMappingV1 { pub relay_public: LockedProductRuntimeV1, pub relay_consultation: LockedProductRuntimeV1, - pub notary: LockedProductRuntimeV1, pub postgresql_state_plane: LockedPostgresqlRuntimeV1, pub operator_files: Vec, } @@ -940,19 +851,6 @@ impl LockedRuntimeMappingV1 { "consultation Relay health", &self.relay_consultation.health_probe, ), - ("Notary serve", &self.notary.serve.command), - ( - "Notary prepare_state_store", - &self.notary.prepare_state_store.command, - ), - ( - "Notary initialize_state", - &self.notary.initialize_state.command, - ), - ("Notary preview_state", &self.notary.preview_state.command), - ("Notary accept_state", &self.notary.accept_state.command), - ("Notary verify_state", &self.notary.verify_state.command), - ("Notary health", &self.notary.health_probe), ( "PostgreSQL state-plane recipe", &self.postgresql_state_plane.serve.command, @@ -977,7 +875,6 @@ impl LockedRuntimeMappingV1 { match lane { ProductLaneV1::RelayPublic => &self.relay_public, ProductLaneV1::RelayConsultation => &self.relay_consultation, - ProductLaneV1::Notary => &self.notary, } } @@ -985,7 +882,6 @@ impl LockedRuntimeMappingV1 { Self { relay_public: LockedProductRuntimeV1::from_verified(value.relay_public()), relay_consultation: LockedProductRuntimeV1::from_verified(value.relay_consultation()), - notary: LockedProductRuntimeV1::from_verified(value.notary()), postgresql_state_plane: LockedPostgresqlRuntimeV1::from_verified( value.postgresql_state_plane(), ), @@ -1080,7 +976,6 @@ fn operator_file_inventory( for (lane, product) in [ ("relay-public", &runtime.relay_public), ("relay-consultation", &runtime.relay_consultation), - ("notary", &runtime.notary), ] { for (action_name, action) in [ ("serve", &product.serve), @@ -1156,8 +1051,8 @@ fn operator_file_inventory( fn signed_environment_keys( lanes: &[VerifiedLanePackageSourceV1], ) -> Result>> { - if lanes.len() != 3 { - bail!("deployment requires exactly three signed product lanes"); + if lanes.len() != 2 { + bail!("deployment requires exactly two signed product lanes"); } let mut keys = BTreeMap::new(); for lane in lanes { @@ -1166,10 +1061,7 @@ fn signed_environment_keys( .context("failed to read signed secret-consumer descriptor")?; let descriptor: SecretConsumerDescriptorV1 = serde_json::from_slice(&bytes) .context("signed secret-consumer descriptor is not strict JSON")?; - let expected_product = match lane.lane { - ProductLaneV1::RelayPublic | ProductLaneV1::RelayConsultation => "registry-relay", - ProductLaneV1::Notary => "registry-notary", - }; + let expected_product = "registry-relay"; if descriptor.schema != SECRET_CONSUMERS_SCHEMA || descriptor.product != expected_product || descriptor.consumers.len() > 256 @@ -1289,31 +1181,6 @@ fn validate_managed_bundle_compatibility(lanes: &[VerifiedLanePackageSourceV1]) ); } } - ProductLaneV1::Notary => { - let path = lane.bundle_dir.join("config/notary.yaml"); - let config: Value = - serde_norway::from_slice(&read_bounded(&path, MAX_PORTABLE_DOCUMENT_BYTES)?) - .context("failed to parse managed Notary config")?; - let Some(base_url) = config - .pointer("/evidence/relay/base_url") - .and_then(Value::as_str) - else { - continue; - }; - let origin = url::Url::parse(base_url) - .context("managed Notary Relay base URL is invalid")?; - if origin.scheme() == "http" - && (!matches!(origin.host(), Some(url::Host::Domain(_))) - || config - .pointer("/evidence/relay/allow_insecure_private_network") - .and_then(Value::as_bool) - != Some(true)) - { - bail!( - "managed deployment requires HTTPS or an explicitly enabled private-service HTTP Notary-to-Relay origin; loopback is not shared across workloads" - ); - } - } } } Ok(()) @@ -1642,8 +1509,6 @@ fn deployment_authority( let plan = DeploymentPlanV1::managed_single_node(&ManagedTopologyImagesV1 { relay: ImageIdentityV1::parse(images.relay())?, relay_platform: images.relay_platform(), - notary: ImageIdentityV1::parse(images.notary())?, - notary_platform: images.notary_platform(), postgresql_state_plane: ImageIdentityV1::parse(images.postgresql_state_plane())?, postgresql_state_plane_platform: images.postgresql_state_plane_platform(), }); @@ -2622,7 +2487,6 @@ fn render_ordinary_model( ) -> Result { let relay_public = plan.product(ProductLaneV1::RelayPublic)?; let relay_consultation = plan.product(ProductLaneV1::RelayConsultation)?; - let notary = plan.product(ProductLaneV1::Notary)?; let postgresql = plan.supporting(SupportingWorkloadRecipeV1::PostgresqlStatePlane)?; let mut services = Map::new(); @@ -2688,33 +2552,12 @@ fn render_ordinary_model( ), )?, ); - services.insert( - SERVICE_NOTARY.to_string(), - product_service( - notary, - runtime.product(ProductLaneV1::Notary), - binding, - bundle_source(lanes, ProductLaneV1::Notary)?, - json!({NETWORK_RUNTIME: {}}), - action_dependency_map( - &[ - (SERVICE_POSTGRESQL, "service_healthy"), - (SERVICE_RELAY_CONSULTATION, "service_healthy"), - ], - &runtime.notary.serve, - NOTARY, - ), - )?, - ); - let mut volumes = Map::from_iter([ durable_volume(binding, "postgresql-data"), durable_volume(binding, "relay-public-state"), durable_volume(binding, "relay-public-audit"), durable_volume(binding, "relay-consultation-state"), durable_volume(binding, "relay-consultation-audit"), - durable_volume(binding, "notary-state"), - durable_volume(binding, "notary-audit"), ]); volumes.extend(secret_stage_volumes( serving_secret_stage_groups(runtime), @@ -2817,7 +2660,6 @@ fn render_initialization_model( let lane = match action.workload.as_str() { RELAY_PUBLIC => ProductLaneV1::RelayPublic, RELAY_CONSULTATION => ProductLaneV1::RelayConsultation, - NOTARY => ProductLaneV1::Notary, _ => bail!("initialization action targets an unknown product workload"), }; let product = plan.product(lane)?; @@ -2853,7 +2695,7 @@ fn render_initialization_model( ( ProductLaneV1::RelayConsultation, RuntimeActionV1::PrepareStateStore | RuntimeActionV1::InitializeState - ) | (ProductLaneV1::Notary, RuntimeActionV1::PrepareStateStore) + ) ); let dependencies = action_dependency_map( if requires_postgresql { @@ -2949,7 +2791,6 @@ fn product_service( )?; let published_port = match lane { ProductLaneV1::RelayPublic => Some((binding.ports.relay_public, 8080)), - ProductLaneV1::Notary => Some((binding.ports.notary, 8081)), ProductLaneV1::RelayConsultation => None, }; if let Some((host, container)) = published_port { @@ -3083,7 +2924,6 @@ fn serving_secret_stage_groups( &runtime.relay_consultation.serve, )], ), - (NOTARY, vec![("notary-serve", &runtime.notary.serve)]), ( "postgresql", vec![("postgresql-serve", &runtime.postgresql_state_plane.serve)], @@ -3136,16 +2976,6 @@ fn action_secret_stage_groups( ), ], ), - ( - NOTARY_ACTIONS, - vec![ - ("notary-prepare", &runtime.notary.prepare_state_store), - ("notary-initialize", &runtime.notary.initialize_state), - ("notary-preview", &runtime.notary.preview_state), - ("notary-accept", &runtime.notary.accept_state), - ("notary-verify", &runtime.notary.verify_state), - ], - ), ( POSTGRESQL_ACTIONS, vec![( @@ -3160,7 +2990,6 @@ fn action_secret_stage_owner(lane: ProductLaneV1) -> &'static str { match lane { ProductLaneV1::RelayPublic => RELAY_PUBLIC_ACTIONS, ProductLaneV1::RelayConsultation => RELAY_CONSULTATION_ACTIONS, - ProductLaneV1::Notary => NOTARY_ACTIONS, } } @@ -3477,7 +3306,6 @@ fn validate_hard_effective_model( for (lane, service_name) in [ (ProductLaneV1::RelayPublic, SERVICE_RELAY_PUBLIC), (ProductLaneV1::RelayConsultation, SERVICE_RELAY_CONSULTATION), - (ProductLaneV1::Notary, SERVICE_NOTARY), ] { let Some(service) = actual_services.get(service_name) else { violations.push(format!("ordinary effective model omits {service_name}")); @@ -3639,7 +3467,6 @@ fn validate_hard_effective_model( for (service_name, expected_networks) in [ (SERVICE_RELAY_PUBLIC, json!({NETWORK_RUNTIME: {}})), (SERVICE_RELAY_CONSULTATION, json!({NETWORK_RUNTIME: {}})), - (SERVICE_NOTARY, json!({NETWORK_RUNTIME: {}})), (SERVICE_POSTGRESQL, json!({NETWORK_RUNTIME: {}})), ] { if let Some(service) = actual_services.get(service_name) { @@ -3711,11 +3538,7 @@ fn initialization_with_effective_ordinary( let mut expected = canonical_initialization.clone(); let expected_services = expected.get_mut("services")?.as_object_mut()?; let ordinary_services = effective_ordinary.get("services")?.as_object()?; - for service_name in [ - SERVICE_RELAY_PUBLIC, - SERVICE_RELAY_CONSULTATION, - SERVICE_NOTARY, - ] { + for service_name in [SERVICE_RELAY_PUBLIC, SERVICE_RELAY_CONSULTATION] { expected_services.insert( service_name.to_string(), ordinary_services.get(service_name)?.clone(), @@ -4117,7 +3940,6 @@ fn normalized_lane_mut( match lane { ApprovedLaneV1::RelayPublic => &mut approved_set.lanes.relay_public, ApprovedLaneV1::RelayConsultation => &mut approved_set.lanes.relay_consultation, - ApprovedLaneV1::Notary => &mut approved_set.lanes.notary, } } @@ -4472,17 +4294,11 @@ fn runbook(package_name: &str, inventory: &DeploymentOperatorFileInventoryV1) -> .collect::>() .join("\n") }; - let serving_secret_staging_commands = staging_commands( - compose_ordinary, - &[RELAY_CONSULTATION, NOTARY, "postgresql"], - ); + let serving_secret_staging_commands = + staging_commands(compose_ordinary, &[RELAY_CONSULTATION, "postgresql"]); let initialization_secret_staging_commands = staging_commands( &compose_actions, - &[ - RELAY_CONSULTATION_ACTIONS, - NOTARY_ACTIONS, - POSTGRESQL_ACTIONS, - ], + &[RELAY_CONSULTATION_ACTIONS, POSTGRESQL_ACTIONS], ); let operator_files = inventory .files @@ -4517,11 +4333,11 @@ fn runbook(package_name: &str, inventory: &DeploymentOperatorFileInventoryV1) -> Package: `{package_name}`\n\n\ Record the approved-set digest and generated closure root printed by `registryctl deploy generate` outside this package. After transfer, run `registryctl deploy verify --package . --expected-closure-sha256 ` and compare both externally recorded values before any initialization.\n\n\ ## Required operator files\n\n\ -The signed inventory is also recorded at `generated/operator-files.v1.json`. Environment requirements below come directly from the hash-covered product `descriptors/secret-consumers.json`; Registryctl does not infer product semantics. Before any first-install command, create every owner-only regular file below, then run `registryctl deploy verify --package . --check-operator-files`. Registryctl checks only structural isolation, mode, owner, and consumer assignment; Relay and Notary remain the semantic authorities for their environment and secret values. Do not create placeholders or print file values.\n\n\ +The signed inventory is also recorded at `generated/operator-files.v1.json`. Environment requirements below come directly from the hash-covered product `descriptors/secret-consumers.json`; Registryctl does not infer product semantics. Before any first-install command, create every owner-only regular file below, then run `registryctl deploy verify --package . --check-operator-files`. Registryctl checks only structural isolation, mode, owner, and consumer assignment; Relay remains the semantic authority for its environment and secret values. Do not create placeholders or print file values.\n\n\ | Path | Consumers and targets | Format | Required environment keys | Mode | Allowed owners |\n\ |---|---|---|---|---|---|\n\ {operator_files}\n\n\ -Obtain environment values from the owning secret manager and identity provider. Obtain private keys from the owning key custodian. Certificates and keys must be matching PEM material for their named service; the PostgreSQL certificate chain must validate `registry-postgres`, because bootstrap uses TLS `verify-full`. The Notary signing file must be a private JWK accepted by the signing provider in the signed Notary config. The Notary-to-Relay file must be a compact JWT issued under the signed consultation Relay workload contract, including its exact issuer, audience, client/principal binding, scope, and expiry. Inspect those value-free requirements in each copied signed bundle's `config/` and `descriptors/secret-consumers.json`; never edit them. Product startup or preparation performs the semantic checks and fails closed before its protected action.\n\n\ +Obtain environment values from the owning secret manager and identity provider. Obtain private keys from the owning key custodian. Certificates and keys must be matching PEM material for their named service; the PostgreSQL certificate chain must validate `registry-postgres`, because bootstrap uses TLS `verify-full`. Inspect those value-free requirements in each copied signed bundle's `config/` and `descriptors/secret-consumers.json`; never edit them. Product startup or preparation performs the semantic checks and fails closed before its protected action.\n\n\ ## Compose project context\n\n\ The generated package is standalone by default. Set `REGISTRY_STACK_COMPOSE_PROJECT` once and use the same value for every stage, preview, stop, accept, verify, and start command:\n\n\ ```sh\n\ @@ -4535,13 +4351,10 @@ When a parent Compose application includes `generated/compose.yaml`, set this va {compose_actions} run --rm registry-postgres-bootstrap\n\ {compose_actions} run --rm registry-relay-public-prepare-state\n\ {compose_actions} run --rm registry-relay-consultation-prepare-state\n\ -{compose_actions} run --rm registry-notary-prepare-state\n\ {compose_actions} run --rm registry-relay-public-initialize\n\ {compose_actions} run --rm registry-relay-consultation-initialize\n\ -{compose_actions} run --rm registry-notary-initialize\n\ {compose_actions} run --rm --no-deps registry-relay-public-verify-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-verify-state\n\ -{compose_actions} run --rm --no-deps registry-notary-verify-state\n\ {serving_secret_staging_commands}\n\ {compose_ordinary} up --detach --wait --wait-timeout 120\n\ {compose_ordinary} ps\n\ @@ -4552,14 +4365,13 @@ Selecting `compose.initialize.yaml` is the only supported way to initialize an e {compose_ordinary} config --no-interpolate --no-env-resolution --quiet\n\ {compose_actions} run --rm --no-deps registry-relay-public-verify-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-verify-state\n\ -{compose_actions} run --rm --no-deps registry-notary-verify-state\n\ {serving_secret_staging_commands}\n\ {compose_ordinary} up --detach --wait --wait-timeout 120\n\ {compose_ordinary} ps\n\ {compose_ordinary} down\n\ ```\n\n\ ## Product or image update\n\n\ -Copy the intact current package to the candidate path and regenerate that copy in place. The candidate must retain the exact current `generated/` closure as `generated.previous/` and preserve every operator-owned file. Before shutdown, verify both packages against their externally recorded closure digests and approved sets, validate both effective Compose models, and run all three current read-only state checks:\n\n\ +Copy the intact current package to the candidate path and regenerate that copy in place. The candidate must retain the exact current `generated/` closure as `generated.previous/` and preserve every operator-owned file. Before shutdown, verify both packages against their externally recorded closure digests and approved sets, validate both effective Compose models, and run both current read-only state checks:\n\n\ ```sh\n\ CURRENT_PACKAGE=\"\"\n\ CURRENT_APPROVED_SET=\"\"\n\ @@ -4572,31 +4384,26 @@ registryctl deploy verify --package \"$CANDIDATE_PACKAGE\" --approved-set \"$CAN (cd \"$CURRENT_PACKAGE\" && {compose_ordinary} config --no-interpolate --no-env-resolution --quiet)\n\ (cd \"$CURRENT_PACKAGE\" && {compose_actions} run --rm --no-deps registry-relay-public-verify-state)\n\ (cd \"$CURRENT_PACKAGE\" && {compose_actions} run --rm --no-deps registry-relay-consultation-verify-state)\n\ -(cd \"$CURRENT_PACKAGE\" && {compose_actions} run --rm --no-deps registry-notary-verify-state)\n\ (cd \"$CANDIDATE_PACKAGE\" && {compose_actions} config --no-interpolate --no-env-resolution --quiet)\n\ ```\n\n\ These package checks verify every locked bundle, anchor, deployment file, and operator-file boundary. Keep all current services running while previewing every candidate lane. Do not stop any service or accept any lane unless every preview succeeds. Run the following block from the candidate package root:\n\n\ ```sh\n\ {compose_actions} run --rm --no-deps registry-relay-public-preview-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-preview-state\n\ -{compose_actions} run --rm --no-deps registry-notary-preview-state\n\ {compose_ordinary} stop\n\ {compose_actions} run --rm --no-deps registry-relay-public-accept-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-accept-state\n\ -{compose_actions} run --rm --no-deps registry-notary-accept-state\n\ {compose_actions} run --rm --no-deps registry-relay-public-verify-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-verify-state\n\ -{compose_actions} run --rm --no-deps registry-notary-verify-state\n\ {serving_secret_staging_commands}\n\ {compose_ordinary} up --detach --wait --wait-timeout 120\n\ {compose_actions} run --rm --no-deps registry-relay-public-verify-state\n\ {compose_actions} run --rm --no-deps registry-relay-consultation-verify-state\n\ -{compose_actions} run --rm --no-deps registry-notary-verify-state\n\ {compose_ordinary} ps\n\ ```\n\n\ -Each `accept_state` action uses the locked audit-before-mutation path. The manual abort boundary is the first successful acceptance that advances durable anti-rollback state. Before that boundary, restore the intact `generated.previous/` closure and restart it. After that boundary, only complete the forward update, restore a coherent snapshot at the same or newer accepted sequence, or replace the affected instance identity. Never start an older closure or restore a pre-update sequence. Start and verify PostgreSQL and the consultation Relay before starting Notary or any externally reachable dependant. Remove `generated.previous/` only after all affected lanes report the new accepted sequence.\n\n\ +Each `accept_state` action uses the locked audit-before-mutation path. The manual abort boundary is the first successful acceptance that advances durable anti-rollback state. Before that boundary, restore the intact `generated.previous/` closure and restart it. After that boundary, only complete the forward update, restore a coherent snapshot at the same or newer accepted sequence, or replace the affected instance identity. Never start an older closure or restore a pre-update sequence. Start and verify PostgreSQL before starting the consultation Relay. Remove `generated.previous/` only after all affected lanes report the new accepted sequence.\n\n\ ## State recovery\n\n\ -Quiesce the complete `relay-public-state` or `consultation-state` recovery consistency group before snapshot or restore. A coherent backup includes the lane anti-rollback and audit state, PostgreSQL data where declared, this package, approved set, bundle, anchor, instance, stream, and accepted sequence identities. After restoring the exact lane, instance, and stream, run the three read-only `verify_state` commands above before ordinary startup. Partial or older recovery must be manually aborted.\n\n\ +Quiesce the complete `relay-public-state` or `consultation-state` recovery consistency group before snapshot or restore. A coherent backup includes the lane anti-rollback and audit state, PostgreSQL data where declared, this package, approved set, bundle, anchor, instance, stream, and accepted sequence identities. After restoring the exact lane, instance, and stream, run both read-only `verify_state` commands above before ordinary startup. Partial or older recovery must be manually aborted.\n\n\ If no coherent backup exists, provision a new instance identity, review and sign every affected lane, generate a new package, and follow first installation. Reinitializing the same identity is not recovery and is unsupported. Rollback is unsupported.\n\n\ ## Operations\n\n\ Use `docker compose ... ps` for value-free health and `docker compose ... logs ` for product-separated logs. Metrics, administration, and posture are not host-published. Resolve a documented readiness latch before using the product-owned clear action. Preserve signed audit retention policy, and treat storage exhaustion as a fail-closed incident requiring a coherent recovery-group snapshot or restore.\n\ @@ -4609,8 +4416,6 @@ fn operator_file_format(format: LockedOperatorFileFormatV1) -> &'static str { LockedOperatorFileFormatV1::Dotenv => "dotenv", LockedOperatorFileFormatV1::PemCertificate => "pem_certificate", LockedOperatorFileFormatV1::PemPrivateKey => "pem_private_key", - LockedOperatorFileFormatV1::JsonWebKey => "json_web_key", - LockedOperatorFileFormatV1::CompactJwt => "compact_jwt", LockedOperatorFileFormatV1::Opaque => "opaque", } } diff --git a/crates/registryctl/src/dev_credentials.rs b/crates/registryctl/src/dev_credentials.rs index 9c3175dc8..1e14cc2a8 100644 --- a/crates/registryctl/src/dev_credentials.rs +++ b/crates/registryctl/src/dev_credentials.rs @@ -9,11 +9,10 @@ use std::collections::BTreeSet; use std::fs::{self, File, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, bail, Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use ed25519_dalek::{Signer as _, SigningKey}; +use ed25519_dalek::SigningKey; use registry_platform_authcommon::{fingerprint_api_key, validate_api_key_entropy}; use registry_platform_config::ProductAcceptanceLaneV1; use registry_platform_crypto::PublicJwk; @@ -22,14 +21,7 @@ use zeroize::Zeroizing; const DEV_SECRET_ROOT: &str = "/run/registry/dev-secrets"; const DEV_PUBLIC_ROOT: &str = "/run/registry/dev-public"; const SYNTHETIC_SECRET_ROOT: &str = "/run/registry/synthetic-source-secrets"; -const DEV_WORKLOAD_ISSUER: &str = "https://registryctl-local-notary.invalid"; -const DEV_WORKLOAD_KID: &str = "registryctl-local-workload"; -const DEV_NOTARY_RUNTIME_SIGNING_KID: &str = "registryctl-local-notary-runtime-issuer"; -const DEV_WORKLOAD_CLIENT: &str = "registryctl-local-notary"; -const DEV_WORKLOAD_AUDIENCE: &str = "registry-relay"; -const DEV_WORKLOAD_LIFETIME_SECONDS: u64 = 10 * 365 * 24 * 60 * 60; const DEV_SYNTHETIC_SOURCE_PRIVATE_CIDR: &str = "10.89.0.3/32"; -const DEV_RELAY_CONSULTATION_PRIVATE_CIDR: &str = "10.89.0.4/32"; const DEV_SYNTHETIC_SOURCE_CA_PATH: &str = "/run/registry/dev-public/synthetic-source-tls.crt"; const RELAY_PUBLIC_AUDIT_ENV: &str = "REGISTRY_RELAY_AUDIT_HASH_SECRET"; @@ -39,13 +31,6 @@ const RELAY_DATABASE_ENV: &str = "REGISTRY_RELAY_CONSULTATION_DATABASE_URL"; const RELAY_MIGRATION_DATABASE_ENV: &str = "REGISTRY_RELAY_CONSULTATION_MIGRATION_DATABASE_URL"; const RELAY_MAINTENANCE_DATABASE_ENV: &str = "REGISTRY_RELAY_CONSULTATION_MAINTENANCE_DATABASE_URL"; const RELAY_READER_DATABASE_ENV: &str = "REGISTRY_RELAY_CONSULTATION_READER_DATABASE_URL"; -const NOTARY_AUDIT_ENV: &str = "REGISTRY_NOTARY_AUDIT_HASH_SECRET"; -const NOTARY_MIGRATION_DATABASE_ENV: &str = "REGISTRY_NOTARY_POSTGRES_MIGRATOR_URL"; -const NOTARY_DATABASE_ENV: &str = "REGISTRY_NOTARY_POSTGRES_RUNTIME_URL"; -const NOTARY_MAINTENANCE_DATABASE_ENV: &str = "REGISTRY_NOTARY_POSTGRES_MAINTENANCE_URL"; -const NOTARY_READER_DATABASE_ENV: &str = "REGISTRY_NOTARY_POSTGRES_READER_URL"; -const NOTARY_WORKLOAD_PUBLIC_JWK_ENV: &str = "REGISTRY_NOTARY_WORKLOAD_PUBLIC_JWK"; - const POSTGRES_HOST: &str = "registry-postgres"; const POSTGRES_PORT: u16 = 5432; const POSTGRES_ADMIN_ROLE: &str = "registry_stack_bootstrap"; @@ -55,22 +40,10 @@ const RELAY_MIGRATOR_ROLE: &str = "registry_relay_migrator"; const RELAY_RUNTIME_ROLE: &str = "registry_relay_runtime"; const RELAY_MAINTENANCE_ROLE: &str = "registry_relay_maintenance"; const RELAY_READER_ROLE: &str = "registry_relay_reader"; -const NOTARY_DATABASE: &str = "registry_notary"; -const NOTARY_OWNER_ROLE: &str = "registry_notary_owner"; -const NOTARY_MIGRATOR_ROLE: &str = "registry_notary_migrator"; -const NOTARY_RUNTIME_ROLE: &str = "registry_notary_runtime"; -const NOTARY_MAINTENANCE_ROLE: &str = "registry_notary_maintenance"; -const NOTARY_READER_ROLE: &str = "registry_notary_reader"; - -const CALLER_TOKEN_FILE: &str = "caller-token"; const RELAY_MATCH_TOKEN_FILE: &str = "relay-match-token"; const RELAY_NO_MATCH_TOKEN_FILE: &str = "relay-no-match-token"; -const WORKLOAD_TOKEN_FILE: &str = "notary-relay-token"; -const WORKLOAD_PUBLIC_JWK_FILE: &str = "notary-workload-public.jwk"; -const WORKLOAD_JWKS_FILE: &str = "notary-workload-jwks.json"; const POSTGRES_TLS_CERTIFICATE_FILE: &str = "postgres-tls.crt"; const POSTGRES_TLS_PRIVATE_KEY_FILE: &str = "postgres-tls.key"; -const NOTARY_SIGNING_KEY_FILE: &str = "notary-signing-key.jwk"; const POSTGRES_ADMIN_PASSWORD_FILE: &str = "postgres-admin-password"; const SYNTHETIC_CONTROL_TOKEN_FILE: &str = "control-token"; const SYNTHETIC_TLS_CERTIFICATE_FILE: &str = "tls.crt"; @@ -85,9 +58,6 @@ const RELAY_PUBLIC_SERVE_ENV_FILE: &str = "relay-public-serve.env"; const RELAY_CONSULTATION_PREPARE_ENV_FILE: &str = "relay-consultation-prepare.env"; const RELAY_CONSULTATION_INITIALIZE_ENV_FILE: &str = "relay-consultation-initialize.env"; const RELAY_CONSULTATION_SERVE_ENV_FILE: &str = "relay-consultation-serve.env"; -const NOTARY_PREPARE_ENV_FILE: &str = "notary-prepare.env"; -const NOTARY_INITIALIZE_ENV_FILE: &str = "notary-initialize.env"; -const NOTARY_SERVE_ENV_FILE: &str = "notary-serve.env"; const POSTGRES_BOOTSTRAP_ENV_FILE: &str = "postgres-bootstrap.env"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -110,23 +80,12 @@ pub(crate) enum DevSourceCredentialProfile { }, } -#[derive(Clone, Eq, PartialEq)] -pub(crate) struct DevIssuanceCredentialRequirement { - pub(crate) issuer: String, - pub(crate) signing_kid: String, - pub(crate) private_jwk_env: String, -} - #[derive(Clone, Eq, PartialEq)] pub(crate) struct DevCredentialRequirements { pub(crate) project_id: String, pub(crate) environment_id: String, - pub(crate) service_id: String, - pub(crate) caller_id: String, - pub(crate) caller_fingerprint_env: String, pub(crate) relay_api_keys: Option, pub(crate) source: DevSourceCredentialProfile, - pub(crate) issuance: Option, } #[derive(Clone, Eq, PartialEq)] @@ -136,13 +95,6 @@ pub(crate) struct DevRelayApiKeyRequirements { pub(crate) scopes: Vec, } -#[derive(Clone, Eq, PartialEq)] -pub(crate) struct DevCallerCredentialProjection { - pub(crate) id: String, - pub(crate) fingerprint_env: String, - pub(crate) token_file: String, -} - #[derive(Clone, Eq, PartialEq)] pub(crate) struct DevRelayApiKeyProjection { pub(crate) match_principal: String, @@ -154,23 +106,6 @@ pub(crate) struct DevRelayApiKeyProjection { pub(crate) no_match_token_file: String, } -#[derive(Clone, Eq, PartialEq)] -pub(crate) struct DevRelayOidcProjection { - pub(crate) issuer: String, - pub(crate) jwks_file: String, - pub(crate) public_jwk: String, - pub(crate) audience: String, - pub(crate) client_id: String, -} - -#[derive(Clone, Eq, PartialEq)] -pub(crate) struct DevNotaryRelayProjection { - pub(crate) base_url: String, - pub(crate) workload_client_id: String, - pub(crate) token_file: String, - pub(crate) allowed_private_cidr: String, -} - #[derive(Clone, Eq, PartialEq)] pub(crate) struct DevSyntheticSourceTransportProjection { pub(crate) root_certificate_path: String, @@ -186,19 +121,10 @@ pub(crate) struct DevDatabaseCredentialProjection { pub(crate) relay_runtime_role: &'static str, pub(crate) relay_maintenance_role: &'static str, pub(crate) relay_reader_role: &'static str, - pub(crate) notary_owner_role: &'static str, - pub(crate) notary_migrator_role: &'static str, - pub(crate) notary_runtime_role: &'static str, - pub(crate) notary_maintenance_role: &'static str, - pub(crate) notary_reader_role: &'static str, pub(crate) relay_database_env: &'static str, pub(crate) relay_migration_database_env: &'static str, pub(crate) relay_maintenance_database_env: &'static str, pub(crate) relay_reader_database_env: &'static str, - pub(crate) notary_database_env: &'static str, - pub(crate) notary_migration_database_env: &'static str, - pub(crate) notary_maintenance_database_env: &'static str, - pub(crate) notary_reader_database_env: &'static str, } #[derive(Clone, Eq, PartialEq)] @@ -228,15 +154,6 @@ pub(crate) enum DevSourceCredentialProjection { }, } -#[derive(Clone, Eq, PartialEq)] -pub(crate) struct DevIssuanceCredentialProjection { - pub(crate) issuer: String, - pub(crate) signing_kid: String, - pub(crate) private_jwk_env: String, - pub(crate) public_jwk: String, - pub(crate) public_jwk_file: String, -} - #[derive(Clone, Eq, PartialEq)] pub(crate) struct DevLaneSignerProjection { pub(crate) lane: ProductAcceptanceLaneV1, @@ -259,23 +176,16 @@ pub(crate) struct DevActionCredentialProjection { pub(crate) relay_consultation_prepare: DevActionCredentialLocator, pub(crate) relay_consultation_initialize: DevActionCredentialLocator, pub(crate) relay_consultation_serve: DevActionCredentialLocator, - pub(crate) notary_prepare: DevActionCredentialLocator, - pub(crate) notary_initialize: DevActionCredentialLocator, - pub(crate) notary_serve: DevActionCredentialLocator, pub(crate) postgres_bootstrap: DevActionCredentialLocator, } #[derive(Clone, Eq, PartialEq)] pub(crate) struct DevCredentialPublicProjection { - pub(crate) caller: DevCallerCredentialProjection, pub(crate) relay_api_keys: Option, - pub(crate) relay_oidc: DevRelayOidcProjection, - pub(crate) notary_relay: DevNotaryRelayProjection, pub(crate) databases: DevDatabaseCredentialProjection, pub(crate) source: DevSourceCredentialProjection, pub(crate) synthetic_source_transport: Option, - pub(crate) issuance: Option, - pub(crate) lane_signers: [DevLaneSignerProjection; 3], + pub(crate) lane_signers: [DevLaneSignerProjection; 2], pub(crate) actions: DevActionCredentialProjection, } @@ -285,20 +195,6 @@ struct LaneSigningCredential { public_jwk: String, } -struct IssuanceCredential { - projection: DevIssuanceCredentialProjection, - private_jwk: Zeroizing, - public_jwk: String, -} - -/// Runtime-only fallback signing material for a Notary configuration that does -/// not declare issuance. It exists for one disposable dev runtime and is never -/// reused as product-lane trust material or exported as a public trust input. -struct NotaryRuntimeSigningCredential { - private_jwk: Zeroizing, - public_jwk: String, -} - struct DatabaseCredentialSet { postgres_admin: Zeroizing, relay_owner: Zeroizing, @@ -306,11 +202,6 @@ struct DatabaseCredentialSet { relay_runtime: Zeroizing, relay_maintenance: Zeroizing, relay_reader: Zeroizing, - notary_owner: Zeroizing, - notary_migrator: Zeroizing, - notary_runtime: Zeroizing, - notary_maintenance: Zeroizing, - notary_reader: Zeroizing, } struct TlsCredential { @@ -344,23 +235,15 @@ enum SourceCredential { /// private material. pub(crate) struct PreparedDevCredentialClosure { projection: DevCredentialPublicProjection, - caller_token: Zeroizing, relay_match_token: Option>, relay_no_match_token: Option>, - workload_private_jwk: Zeroizing, - workload_public_jwk: String, - workload_jwks: String, - workload_token: Zeroizing, relay_public_audit: Zeroizing, relay_consultation_audit: Zeroizing, - notary_audit: Zeroizing, relay_pseudonym: Zeroizing, databases: DatabaseCredentialSet, postgres_tls: TlsCredential, source: SourceCredential, - issuance: Option, - notary_runtime_signing: Option, - lane_signers: [LaneSigningCredential; 3], + lane_signers: [LaneSigningCredential; 2], } #[derive(Clone, Debug, Eq, PartialEq)] @@ -383,38 +266,25 @@ pub(crate) struct PreparedDevSourceCredentialFiles { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PreparedDevCredentialFiles { pub(crate) root: PathBuf, - pub(crate) caller_token: PathBuf, pub(crate) relay_match_token: Option, pub(crate) relay_no_match_token: Option, - pub(crate) workload_token: PathBuf, - pub(crate) workload_public_jwk: PathBuf, - pub(crate) workload_jwks: PathBuf, pub(crate) relay_public_prepare: PreparedDevActionCredentialFile, pub(crate) relay_public_initialize: PreparedDevActionCredentialFile, pub(crate) relay_public_serve: PreparedDevActionCredentialFile, pub(crate) relay_consultation_prepare: PreparedDevActionCredentialFile, pub(crate) relay_consultation_initialize: PreparedDevActionCredentialFile, pub(crate) relay_consultation_serve: PreparedDevActionCredentialFile, - pub(crate) notary_prepare: PreparedDevActionCredentialFile, - pub(crate) notary_initialize: PreparedDevActionCredentialFile, - pub(crate) notary_serve: PreparedDevActionCredentialFile, pub(crate) postgres_bootstrap: PreparedDevActionCredentialFile, pub(crate) postgres_admin_password: PathBuf, - pub(crate) notary_signing_key: PathBuf, pub(crate) postgres_tls_certificate: PathBuf, pub(crate) postgres_tls_private_key: PathBuf, pub(crate) source: Option, - pub(crate) issuance_public_jwk: Option, - pub(crate) lane_public_jwks: [PathBuf; 3], + pub(crate) lane_public_jwks: [PathBuf; 2], } impl PreparedDevCredentialClosure { pub(crate) fn generate(requirements: DevCredentialRequirements) -> Result { validate_requirements(&requirements)?; - let caller_token = secret_token(32)?; - validate_api_key_entropy(&caller_token) - .map_err(|_| anyhow!("generated development caller credential failed validation"))?; - let caller_fingerprint = fingerprint_api_key(&caller_token); let (relay_match_token, relay_no_match_token) = if requirements.relay_api_keys.is_some() { let match_token = secret_token(32)?; let no_match_token = secret_token(32)?; @@ -429,13 +299,6 @@ impl PreparedDevCredentialClosure { (None, None) }; - let (workload_private_jwk, workload_public_jwk) = generate_ed25519_jwk(DEV_WORKLOAD_KID) - .context("failed to generate the development workload identity")?; - let workload_jwks = workload_jwks_from_private(&workload_private_jwk) - .context("failed to project the development workload identity")?; - let workload_token = - sign_dev_workload_jwt(&workload_private_jwk, &requirements.service_id)?; - let lane_signers = [ generate_lane_signer( ProductAcceptanceLaneV1::RelayPublic, @@ -447,37 +310,16 @@ impl PreparedDevCredentialClosure { &requirements.project_id, &requirements.environment_id, )?, - generate_lane_signer( - ProductAcceptanceLaneV1::Notary, - &requirements.project_id, - &requirements.environment_id, - )?, ]; - let issuance = requirements - .issuance - .as_ref() - .map(generate_issuance_credential) - .transpose()?; - let notary_runtime_signing = if issuance.is_none() { - Some(generate_notary_runtime_signing_credential()?) - } else { - None - }; let source = generate_source_credential(&requirements.source)?; let postgres_tls = generate_tls_credential(POSTGRES_HOST)?; let databases = DatabaseCredentialSet::generate()?; let relay_public_audit = secret_token(48)?; let relay_consultation_audit = secret_token(48)?; - let notary_audit = secret_token(48)?; let relay_pseudonym = secret_token(48)?; let source_projection = source_projection(&requirements.source); let projection = DevCredentialPublicProjection { - caller: DevCallerCredentialProjection { - id: requirements.caller_id, - fingerprint_env: requirements.caller_fingerprint_env, - token_file: secret_container_path(CALLER_TOKEN_FILE), - }, relay_api_keys: requirements .relay_api_keys .map(|keys| DevRelayApiKeyProjection { @@ -490,19 +332,6 @@ impl PreparedDevCredentialClosure { match_token_file: secret_container_path(RELAY_MATCH_TOKEN_FILE), no_match_token_file: secret_container_path(RELAY_NO_MATCH_TOKEN_FILE), }), - relay_oidc: DevRelayOidcProjection { - issuer: DEV_WORKLOAD_ISSUER.to_string(), - jwks_file: public_container_path(WORKLOAD_JWKS_FILE), - public_jwk: workload_public_jwk.clone(), - audience: DEV_WORKLOAD_AUDIENCE.to_string(), - client_id: DEV_WORKLOAD_CLIENT.to_string(), - }, - notary_relay: DevNotaryRelayProjection { - base_url: "http://10.89.0.4:8080".to_string(), - workload_client_id: DEV_WORKLOAD_CLIENT.to_string(), - token_file: "/run/secrets/relay-workload-token".to_string(), - allowed_private_cidr: DEV_RELAY_CONSULTATION_PRIVATE_CIDR.to_string(), - }, databases: DevDatabaseCredentialProjection { root_certificate_path: "/run/secrets/postgresql-ca.pem".to_string(), postgres_admin_role: POSTGRES_ADMIN_ROLE, @@ -511,19 +340,10 @@ impl PreparedDevCredentialClosure { relay_runtime_role: RELAY_RUNTIME_ROLE, relay_maintenance_role: RELAY_MAINTENANCE_ROLE, relay_reader_role: RELAY_READER_ROLE, - notary_owner_role: NOTARY_OWNER_ROLE, - notary_migrator_role: NOTARY_MIGRATOR_ROLE, - notary_runtime_role: NOTARY_RUNTIME_ROLE, - notary_maintenance_role: NOTARY_MAINTENANCE_ROLE, - notary_reader_role: NOTARY_READER_ROLE, relay_database_env: RELAY_DATABASE_ENV, relay_migration_database_env: RELAY_MIGRATION_DATABASE_ENV, relay_maintenance_database_env: RELAY_MAINTENANCE_DATABASE_ENV, relay_reader_database_env: RELAY_READER_DATABASE_ENV, - notary_database_env: NOTARY_DATABASE_ENV, - notary_migration_database_env: NOTARY_MIGRATION_DATABASE_ENV, - notary_maintenance_database_env: NOTARY_MAINTENANCE_DATABASE_ENV, - notary_reader_database_env: NOTARY_READER_DATABASE_ENV, }, source: source_projection, synthetic_source_transport: (!matches!( @@ -534,9 +354,6 @@ impl PreparedDevCredentialClosure { root_certificate_path: DEV_SYNTHETIC_SOURCE_CA_PATH.to_string(), allowed_private_cidr: DEV_SYNTHETIC_SOURCE_PRIVATE_CIDR.to_string(), }), - issuance: issuance - .as_ref() - .map(|credential| credential.projection.clone()), lane_signers: lane_signers .each_ref() .map(|credential| credential.projection.clone()), @@ -544,26 +361,17 @@ impl PreparedDevCredentialClosure { }; let closure = Self { projection, - caller_token, relay_match_token, relay_no_match_token, - workload_private_jwk: Zeroizing::new(workload_private_jwk), - workload_public_jwk, - workload_jwks, - workload_token: Zeroizing::new(workload_token), relay_public_audit, relay_consultation_audit, - notary_audit, relay_pseudonym, databases, postgres_tls, source, - issuance, - notary_runtime_signing, lane_signers, }; - closure.validate_distinct_secrets(&caller_fingerprint)?; - closure.validate_notary_runtime_signing_separation()?; + closure.validate_distinct_secrets()?; Ok(closure) } @@ -595,7 +403,6 @@ impl PreparedDevCredentialClosure { fn materialize_into(&self, root: &Path) -> Result { let files = PreparedDevCredentialFiles::for_root(root, &self.projection); - write_new_owner_only(&files.caller_token, self.caller_token.as_bytes())?; if let (Some(path), Some(token)) = (&files.relay_match_token, &self.relay_match_token) { write_new_owner_only(path, token.as_bytes())?; } @@ -603,13 +410,6 @@ impl PreparedDevCredentialClosure { { write_new_owner_only(path, token.as_bytes())?; } - write_new_owner_only(&files.workload_token, self.workload_token.as_bytes())?; - write_new_owner_only( - &files.workload_public_jwk, - self.workload_public_jwk.as_bytes(), - )?; - write_new_owner_only(&files.workload_jwks, self.workload_jwks.as_bytes())?; - write_new_owner_only( &files.relay_public_prepare.host_path, env_file(&[(RELAY_PUBLIC_AUDIT_ENV, &self.relay_public_audit)]).as_bytes(), @@ -707,71 +507,6 @@ impl PreparedDevCredentialClosure { env_file(&relay_serve).as_bytes(), )?; - let notary_migration_url = database_url( - NOTARY_MIGRATOR_ROLE, - &self.databases.notary_migrator, - NOTARY_DATABASE, - ); - let notary_runtime_url = database_url( - NOTARY_RUNTIME_ROLE, - &self.databases.notary_runtime, - NOTARY_DATABASE, - ); - let notary_maintenance_url = database_url( - NOTARY_MAINTENANCE_ROLE, - &self.databases.notary_maintenance, - NOTARY_DATABASE, - ); - let notary_reader_url = database_url( - NOTARY_READER_ROLE, - &self.databases.notary_reader, - NOTARY_DATABASE, - ); - write_new_owner_only( - &files.notary_prepare.host_path, - env_file(&[ - (NOTARY_AUDIT_ENV, &self.notary_audit), - (NOTARY_MIGRATION_DATABASE_ENV, ¬ary_migration_url), - ]) - .as_bytes(), - )?; - write_new_owner_only( - &files.notary_initialize.host_path, - env_file(&[ - (NOTARY_AUDIT_ENV, &self.notary_audit), - (NOTARY_DATABASE_ENV, ¬ary_runtime_url), - ]) - .as_bytes(), - )?; - let caller_fingerprint = fingerprint_api_key(&self.caller_token); - let mut notary_serve = vec![ - (NOTARY_AUDIT_ENV, self.notary_audit.as_str()), - (NOTARY_DATABASE_ENV, notary_runtime_url.as_str()), - ( - NOTARY_MAINTENANCE_DATABASE_ENV, - notary_maintenance_url.as_str(), - ), - (NOTARY_READER_DATABASE_ENV, notary_reader_url.as_str()), - ( - NOTARY_WORKLOAD_PUBLIC_JWK_ENV, - self.workload_public_jwk.as_str(), - ), - ( - self.projection.caller.fingerprint_env.as_str(), - caller_fingerprint.as_str(), - ), - ]; - if let Some(issuance) = &self.issuance { - notary_serve.push(( - issuance.projection.private_jwk_env.as_str(), - issuance.private_jwk.as_str(), - )); - } - write_new_owner_only( - &files.notary_serve.host_path, - env_file(¬ary_serve).as_bytes(), - )?; - let postgres_bootstrap = [ ( "REGISTRY_RELAY_MIGRATOR_PASSWORD", @@ -789,22 +524,6 @@ impl PreparedDevCredentialClosure { "REGISTRY_RELAY_READER_PASSWORD", self.databases.relay_reader.as_str(), ), - ( - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - self.databases.notary_migrator.as_str(), - ), - ( - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - self.databases.notary_runtime.as_str(), - ), - ( - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - self.databases.notary_maintenance.as_str(), - ), - ( - "REGISTRY_NOTARY_READER_PASSWORD", - self.databases.notary_reader.as_str(), - ), ]; write_new_owner_only( &files.postgres_bootstrap.host_path, @@ -814,13 +533,6 @@ impl PreparedDevCredentialClosure { &files.postgres_admin_password, self.databases.postgres_admin.as_bytes(), )?; - let notary_signing_key = match (&self.issuance, &self.notary_runtime_signing) { - (Some(credential), None) => credential.private_jwk.as_str(), - (None, Some(credential)) => credential.private_jwk.as_str(), - _ => bail!("development Notary runtime signing authority is not closed"), - }; - write_new_owner_only(&files.notary_signing_key, notary_signing_key.as_bytes())?; - write_tls_files( &files.postgres_tls_certificate, &files.postgres_tls_private_key, @@ -830,24 +542,17 @@ impl PreparedDevCredentialClosure { if let Some(source_files) = &files.source { materialize_source(source_files, &self.source)?; } - if let (Some(path), Some(issuance)) = (&files.issuance_public_jwk, &self.issuance) { - write_new_owner_only(path, issuance.public_jwk.as_bytes())?; - } for (path, signer) in files.lane_public_jwks.iter().zip(&self.lane_signers) { write_new_owner_only(path, signer.public_jwk.as_bytes())?; } Ok(files) } - fn validate_distinct_secrets(&self, caller_fingerprint: &str) -> Result<()> { + fn validate_distinct_secrets(&self) -> Result<()> { let mut values = Vec::new(); values.extend([ - self.caller_token.as_str(), - self.workload_private_jwk.as_str(), - self.workload_token.as_str(), self.relay_public_audit.as_str(), self.relay_consultation_audit.as_str(), - self.notary_audit.as_str(), self.relay_pseudonym.as_str(), self.postgres_tls.private_key.as_str(), ]); @@ -859,12 +564,6 @@ impl PreparedDevCredentialClosure { .iter() .map(|credential| credential.private_jwk.as_str()), ); - if let Some(issuance) = &self.issuance { - values.push(issuance.private_jwk.as_str()); - } - if let Some(runtime) = &self.notary_runtime_signing { - values.push(runtime.private_jwk.as_str()); - } match &self.source { SourceCredential::OperatorBound => {} SourceCredential::SyntheticUnauthenticated { control_token, tls } => { @@ -892,7 +591,6 @@ impl PreparedDevCredentialClosure { ]), } if values.iter().any(|value| value.is_empty()) - || values.contains(&caller_fingerprint) || self .relay_match_token .iter() @@ -905,25 +603,6 @@ impl PreparedDevCredentialClosure { } Ok(()) } - - fn validate_notary_runtime_signing_separation(&self) -> Result<()> { - let Some(runtime) = &self.notary_runtime_signing else { - return Ok(()); - }; - let runtime_kid = PublicJwk::parse(&runtime.public_jwk) - .and_then(|jwk| jwk.jkt()) - .context("failed to identify the development Notary runtime signing identity")?; - let lane_kids = self - .lane_signers - .iter() - .map(|lane| PublicJwk::parse(&lane.public_jwk).and_then(|jwk| jwk.jkt())) - .collect::, _>>() - .context("failed to identify the development lane signing identities")?; - if self.issuance.is_some() || lane_kids.contains(&runtime_kid) { - bail!("development Notary runtime signing identity is not isolated"); - } - Ok(()) - } } impl DatabaseCredentialSet { @@ -935,15 +614,10 @@ impl DatabaseCredentialSet { relay_runtime: secret_token(32)?, relay_maintenance: secret_token(32)?, relay_reader: secret_token(32)?, - notary_owner: secret_token(32)?, - notary_migrator: secret_token(32)?, - notary_runtime: secret_token(32)?, - notary_maintenance: secret_token(32)?, - notary_reader: secret_token(32)?, }) } - fn values(&self) -> [&str; 11] { + fn values(&self) -> [&str; 6] { [ &self.postgres_admin, &self.relay_owner, @@ -951,11 +625,6 @@ impl DatabaseCredentialSet { &self.relay_runtime, &self.relay_maintenance, &self.relay_reader, - &self.notary_owner, - &self.notary_migrator, - &self.notary_runtime, - &self.notary_maintenance, - &self.notary_reader, ] } } @@ -994,7 +663,6 @@ impl PreparedDevCredentialFiles { }); Self { root: root.to_path_buf(), - caller_token: root.join(CALLER_TOKEN_FILE), relay_match_token: projection .relay_api_keys .as_ref() @@ -1003,9 +671,6 @@ impl PreparedDevCredentialFiles { .relay_api_keys .as_ref() .map(|_| root.join(RELAY_NO_MATCH_TOKEN_FILE)), - workload_token: root.join(WORKLOAD_TOKEN_FILE), - workload_public_jwk: root.join(WORKLOAD_PUBLIC_JWK_FILE), - workload_jwks: root.join(WORKLOAD_JWKS_FILE), relay_public_prepare: action( RELAY_PUBLIC_PREPARE_ENV_FILE, &projection.actions.relay_public_prepare, @@ -1030,29 +695,17 @@ impl PreparedDevCredentialFiles { RELAY_CONSULTATION_SERVE_ENV_FILE, &projection.actions.relay_consultation_serve, ), - notary_prepare: action(NOTARY_PREPARE_ENV_FILE, &projection.actions.notary_prepare), - notary_initialize: action( - NOTARY_INITIALIZE_ENV_FILE, - &projection.actions.notary_initialize, - ), - notary_serve: action(NOTARY_SERVE_ENV_FILE, &projection.actions.notary_serve), postgres_bootstrap: action( POSTGRES_BOOTSTRAP_ENV_FILE, &projection.actions.postgres_bootstrap, ), postgres_admin_password: root.join(POSTGRES_ADMIN_PASSWORD_FILE), - notary_signing_key: root.join(NOTARY_SIGNING_KEY_FILE), postgres_tls_certificate: root.join(POSTGRES_TLS_CERTIFICATE_FILE), postgres_tls_private_key: root.join(POSTGRES_TLS_PRIVATE_KEY_FILE), source, - issuance_public_jwk: projection - .issuance - .as_ref() - .map(|_| root.join("issuance-public.jwk")), lane_public_jwks: [ root.join("relay-public-signing-public.jwk"), root.join("relay-consultation-signing-public.jwk"), - root.join("notary-signing-public.jwk"), ], } } @@ -1062,8 +715,6 @@ fn validate_requirements(requirements: &DevCredentialRequirements) -> Result<()> for (name, value) in [ ("project id", requirements.project_id.as_str()), ("environment id", requirements.environment_id.as_str()), - ("service id", requirements.service_id.as_str()), - ("caller id", requirements.caller_id.as_str()), ] { if value.is_empty() || value.len() > 128 @@ -1074,7 +725,6 @@ fn validate_requirements(requirements: &DevCredentialRequirements) -> Result<()> bail!("development credential {name} is outside the closed identifier grammar"); } } - validate_env_name(&requirements.caller_fingerprint_env)?; if let Some(keys) = &requirements.relay_api_keys { for (name, value) in [ ("Relay match principal", keys.match_principal.as_str()), @@ -1111,18 +761,6 @@ fn validate_requirements(requirements: &DevCredentialRequirements) -> Result<()> } } } - if let Some(issuance) = &requirements.issuance { - validate_env_name(&issuance.private_jwk_env)?; - if issuance.issuer.is_empty() - || issuance.issuer.len() > 2048 - || issuance.issuer.chars().any(char::is_control) - { - bail!("development issuance issuer is outside the closed locator grammar"); - } - if issuance.signing_kid.is_empty() || issuance.signing_kid.len() > 128 { - bail!("development issuance signing kid is outside the closed identifier grammar"); - } - } Ok(()) } @@ -1144,7 +782,7 @@ fn generate_lane_signer( project: &str, environment: &str, ) -> Result { - let lane_name = lane_name(lane); + let lane_name = lane_name(lane)?; let kid = format!("registryctl-dev-{project}-{environment}-{lane_name}"); let (private_jwk, public_jwk) = generate_ed25519_jwk(&kid) .context("failed to generate a disposable development lane signer")?; @@ -1162,7 +800,7 @@ fn generate_lane_signer( ProductAcceptanceLaneV1::RelayConsultation => { "relay-consultation-signing-public.jwk" } - ProductAcceptanceLaneV1::Notary => "notary-signing-public.jwk", + _ => bail!("development signing lane is not in the Relay lane set"), }), }, private_jwk: Zeroizing::new(private_jwk), @@ -1170,33 +808,6 @@ fn generate_lane_signer( }) } -fn generate_issuance_credential( - requirement: &DevIssuanceCredentialRequirement, -) -> Result { - let (private_jwk, public_jwk) = generate_ed25519_jwk(&requirement.signing_kid) - .context("failed to generate disposable development issuance material")?; - Ok(IssuanceCredential { - projection: DevIssuanceCredentialProjection { - issuer: requirement.issuer.clone(), - signing_kid: requirement.signing_kid.clone(), - private_jwk_env: requirement.private_jwk_env.clone(), - public_jwk: public_jwk.clone(), - public_jwk_file: public_container_path("issuance-public.jwk"), - }, - private_jwk: Zeroizing::new(private_jwk), - public_jwk, - }) -} - -fn generate_notary_runtime_signing_credential() -> Result { - let (private_jwk, public_jwk) = generate_ed25519_jwk(DEV_NOTARY_RUNTIME_SIGNING_KID) - .context("failed to generate a disposable development Notary runtime signing identity")?; - Ok(NotaryRuntimeSigningCredential { - private_jwk: Zeroizing::new(private_jwk), - public_jwk, - }) -} - fn generate_source_credential(profile: &DevSourceCredentialProfile) -> Result { Ok(match profile { DevSourceCredentialProfile::OperatorBound => SourceCredential::OperatorBound, @@ -1355,9 +966,6 @@ fn action_projection() -> DevActionCredentialProjection { relay_consultation_prepare: locator(RELAY_CONSULTATION_PREPARE_ENV_FILE), relay_consultation_initialize: locator(RELAY_CONSULTATION_INITIALIZE_ENV_FILE), relay_consultation_serve: locator(RELAY_CONSULTATION_SERVE_ENV_FILE), - notary_prepare: locator(NOTARY_PREPARE_ENV_FILE), - notary_initialize: locator(NOTARY_INITIALIZE_ENV_FILE), - notary_serve: locator(NOTARY_SERVE_ENV_FILE), postgres_bootstrap: locator(POSTGRES_BOOTSTRAP_ENV_FILE), } } @@ -1402,58 +1010,6 @@ fn generate_ed25519_jwk(kid: &str) -> Result<(String, String)> { )) } -fn workload_jwks_from_private(private_jwk: &str) -> Result { - let private: serde_json::Value = - serde_json::from_str(private_jwk).context("failed to parse workload private JWK")?; - let x = private["x"] - .as_str() - .ok_or_else(|| anyhow!("workload private JWK is missing its public member"))?; - serde_json::to_string_pretty(&serde_json::json!({ - "keys": [{ - "kty": "OKP", - "crv": "Ed25519", - "x": x, - "alg": "EdDSA", - "kid": DEV_WORKLOAD_KID, - "use": "sig", - }] - })) - .context("failed to render workload JWKS") -} - -fn sign_ed25519_compact_jwt( - private_jwk: &str, - header: &serde_json::Value, - claims: &serde_json::Value, -) -> Result { - let private: serde_json::Value = - serde_json::from_str(private_jwk).context("failed to parse private signing JWK")?; - let encoded_secret = private["d"] - .as_str() - .ok_or_else(|| anyhow!("private signing JWK is missing its private member"))?; - let secret = Zeroizing::new( - URL_SAFE_NO_PAD - .decode(encoded_secret) - .context("private signing JWK contains an invalid private member")?, - ); - let secret: [u8; 32] = secret - .as_slice() - .try_into() - .map_err(|_| anyhow!("private signing JWK has the wrong private member length"))?; - let secret = Zeroizing::new(secret); - let signing_key = SigningKey::from_bytes(&secret); - let header = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(header).context("failed to render JWT header")?); - let claims = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).context("failed to render JWT claims")?); - let signing_input = format!("{header}.{claims}"); - let signature = signing_key.sign(signing_input.as_bytes()); - Ok(format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - )) -} - fn generate_self_signed_tls_identity(subject_alt_names: Vec) -> Result<(String, String)> { let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(subject_alt_names) @@ -1477,33 +1033,6 @@ fn pem_block(label: &str, der: &[u8]) -> String { format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----\n") } -fn sign_dev_workload_jwt(private_jwk: &str, service_id: &str) -> Result { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| anyhow!("system clock cannot issue a development workload credential"))? - .as_secs(); - let header = serde_json::json!({ - "alg": "EdDSA", - "kid": DEV_WORKLOAD_KID, - "typ": "at+jwt", - }); - let scope = format!("registry:consult:{service_id}"); - let claims = serde_json::json!({ - "iss": DEV_WORKLOAD_ISSUER, - "sub": DEV_WORKLOAD_CLIENT, - "aud": DEV_WORKLOAD_AUDIENCE, - "client_id": DEV_WORKLOAD_CLIENT, - "azp": DEV_WORKLOAD_CLIENT, - "scope": scope, - "iat": now, - "nbf": now.saturating_sub(1), - "exp": now + DEV_WORKLOAD_LIFETIME_SECONDS, - "jti": secret_token(16)?.as_str(), - }); - sign_ed25519_compact_jwt(private_jwk, &header, &claims) - .context("failed to issue the development workload credential") -} - fn generate_tls_credential(service_name: &str) -> Result { let mut subject_alt_names = vec![service_name.to_string()]; if service_name == "registry-synthetic-source" { @@ -1608,11 +1137,11 @@ fn synthetic_container_path(file: &str) -> String { format!("{SYNTHETIC_SECRET_ROOT}/{file}") } -fn lane_name(lane: ProductAcceptanceLaneV1) -> &'static str { +fn lane_name(lane: ProductAcceptanceLaneV1) -> Result<&'static str> { match lane { - ProductAcceptanceLaneV1::RelayPublic => "relay-public", - ProductAcceptanceLaneV1::RelayConsultation => "relay-consultation", - ProductAcceptanceLaneV1::Notary => "notary", + ProductAcceptanceLaneV1::RelayPublic => Ok("relay-public"), + ProductAcceptanceLaneV1::RelayConsultation => Ok("relay-consultation"), + _ => bail!("development signing lane is not in the Relay lane set"), } } @@ -1620,11 +1149,7 @@ fn lane_name(lane: ProductAcceptanceLaneV1) -> &'static str { mod tests { use std::collections::BTreeMap; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine as _; - use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; use registry_platform_crypto::{PrivateJwk, PublicJwk}; - use serde_json::Value; use tempfile::tempdir; use super::*; @@ -1633,16 +1158,8 @@ mod tests { DevCredentialRequirements { project_id: "example-project".to_string(), environment_id: "local".to_string(), - service_id: "example-service".to_string(), - caller_id: "local-developer".to_string(), - caller_fingerprint_env: "REGISTRY_DEV_CALLER_FINGERPRINT".to_string(), relay_api_keys: None, source, - issuance: Some(DevIssuanceCredentialRequirement { - issuer: "http://127.0.0.1:4243".to_string(), - signing_kid: "registry-dev-issuer".to_string(), - private_jwk_env: "REGISTRY_NOTARY_ISSUER_JWK".to_string(), - }), } } @@ -1684,28 +1201,6 @@ mod tests { ); } } - assert_eq!( - projection.caller.token_file, - secret_container_path(files.caller_token.file_name().unwrap().to_str().unwrap()) - ); - assert_eq!( - projection.relay_oidc.jwks_file, - public_container_path(files.workload_jwks.file_name().unwrap().to_str().unwrap()) - ); - assert_eq!( - projection.relay_oidc.issuer, - "https://registryctl-local-notary.invalid" - ); - assert_eq!( - projection.relay_oidc.jwks_file, - "/run/registry/dev-public/notary-workload-jwks.json" - ); - assert_eq!( - projection.notary_relay.token_file, - "/run/secrets/relay-workload-token" - ); - assert_eq!(projection.notary_relay.base_url, "http://10.89.0.4:8080"); - assert_eq!(projection.notary_relay.allowed_private_cidr, "10.89.0.4/32"); let source_transport = projection.synthetic_source_transport.as_ref().unwrap(); assert_eq!( source_transport.root_certificate_path, @@ -1726,9 +1221,6 @@ mod tests { &projection.actions.relay_consultation_prepare, &projection.actions.relay_consultation_initialize, &projection.actions.relay_consultation_serve, - &projection.actions.notary_prepare, - &projection.actions.notary_initialize, - &projection.actions.notary_serve, &projection.actions.postgres_bootstrap, ]; let prepared_actions = [ @@ -1738,9 +1230,6 @@ mod tests { &files.relay_consultation_prepare, &files.relay_consultation_initialize, &files.relay_consultation_serve, - &files.notary_prepare, - &files.notary_initialize, - &files.notary_serve, &files.postgres_bootstrap, ]; for (projected, prepared) in projected_actions.into_iter().zip(prepared_actions) { @@ -1760,19 +1249,6 @@ mod tests { public_container_path(prepared.file_name().unwrap().to_str().unwrap()) ); } - assert_eq!( - projection.issuance.as_ref().unwrap().public_jwk_file, - public_container_path( - files - .issuance_public_jwk - .as_ref() - .unwrap() - .file_name() - .unwrap() - .to_str() - .unwrap() - ) - ); let DevSourceCredentialProjection::SyntheticOAuthClientCredentials { source_client_id_file, source_client_secret_file, @@ -1881,54 +1357,6 @@ mod tests { } } - #[test] - fn caller_workload_and_jwks_are_internally_consistent() { - let closure = PreparedDevCredentialClosure::generate(requirements( - DevSourceCredentialProfile::SyntheticUnauthenticated, - )) - .unwrap(); - let parent = tempdir().unwrap(); - let files = closure - .materialize_owner_only(&parent.path().join("credentials")) - .unwrap(); - let serve_env = fs::read_to_string(files.notary_serve.host_path).unwrap(); - assert!(serve_env.contains(&format!( - "{}={}", - closure.projection.caller.fingerprint_env, - fingerprint_api_key(&closure.caller_token) - ))); - let public: Value = serde_json::from_str(&closure.workload_public_jwk).unwrap(); - let jwks: Value = serde_json::from_str(&closure.workload_jwks).unwrap(); - assert_eq!(jwks["keys"][0], public); - - let segments = closure.workload_token.split('.').collect::>(); - assert_eq!(segments.len(), 3); - let header: Value = - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[0]).unwrap()).unwrap(); - let claims: Value = - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1]).unwrap()).unwrap(); - assert_eq!(header["kid"], DEV_WORKLOAD_KID); - assert_eq!(claims["iss"], closure.projection.relay_oidc.issuer); - assert_eq!(claims["aud"], closure.projection.relay_oidc.audience); - assert_eq!(claims["azp"], closure.projection.relay_oidc.client_id); - assert_eq!(claims["scope"], "registry:consult:example-service"); - - let x: [u8; 32] = URL_SAFE_NO_PAD - .decode(public["x"].as_str().unwrap()) - .unwrap() - .try_into() - .unwrap(); - let signature = - Signature::from_slice(&URL_SAFE_NO_PAD.decode(segments[2]).unwrap()).unwrap(); - VerifyingKey::from_bytes(&x) - .unwrap() - .verify( - format!("{}.{}", segments[0], segments[1]).as_bytes(), - &signature, - ) - .unwrap(); - } - #[test] fn source_profiles_are_closed_and_operator_bound_gets_no_source_secret() { let profiles = [ @@ -2047,29 +1475,6 @@ mod tests { ]) ); - let notary_prepare = parse(&files.notary_prepare.host_path); - let notary_initialize = parse(&files.notary_initialize.host_path); - let notary_serve = parse(&files.notary_serve.host_path); - assert_eq!( - keys(¬ary_prepare), - expected(&[NOTARY_AUDIT_ENV, NOTARY_MIGRATION_DATABASE_ENV]) - ); - assert_eq!( - keys(¬ary_initialize), - expected(&[NOTARY_AUDIT_ENV, NOTARY_DATABASE_ENV]) - ); - assert_eq!( - keys(¬ary_serve), - expected(&[ - NOTARY_AUDIT_ENV, - NOTARY_DATABASE_ENV, - NOTARY_MAINTENANCE_DATABASE_ENV, - NOTARY_READER_DATABASE_ENV, - NOTARY_WORKLOAD_PUBLIC_JWK_ENV, - "REGISTRY_DEV_CALLER_FINGERPRINT", - "REGISTRY_NOTARY_ISSUER_JWK", - ]) - ); assert_eq!( keys(&parse(&files.postgres_bootstrap.host_path)), expected(&[ @@ -2077,10 +1482,6 @@ mod tests { "REGISTRY_RELAY_RUNTIME_PASSWORD", "REGISTRY_RELAY_MAINTENANCE_PASSWORD", "REGISTRY_RELAY_READER_PASSWORD", - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - "REGISTRY_NOTARY_READER_PASSWORD", ]) ); } @@ -2102,17 +1503,12 @@ mod tests { let no_match_path = files.relay_no_match_token.unwrap(); let match_token = fs::read_to_string(&match_path).unwrap(); let no_match_token = fs::read_to_string(&no_match_path).unwrap(); - let caller_token = fs::read_to_string(&files.caller_token).unwrap(); assert_eq!( - [ - match_token.as_str(), - no_match_token.as_str(), - caller_token.as_str() - ] - .into_iter() - .collect::>() - .len(), - 3 + [match_token.as_str(), no_match_token.as_str()] + .into_iter() + .collect::>() + .len(), + 2 ); let serve_env = fs::read_to_string(&files.relay_public_serve.host_path).unwrap(); assert!(serve_env.contains(&format!( @@ -2125,7 +1521,7 @@ mod tests { crate::project_authoring::LOCAL_RELAY_NO_MATCH_KEY_HASH_ENV, fingerprint_api_key(&no_match_token) ))); - for raw in [&match_token, &no_match_token, &caller_token] { + for raw in [&match_token, &no_match_token] { assert!(!serve_env.contains(raw)); } @@ -2152,7 +1548,6 @@ mod tests { for lane in [ ProductAcceptanceLaneV1::RelayPublic, ProductAcceptanceLaneV1::RelayConsultation, - ProductAcceptanceLaneV1::Notary, ] { closure .with_lane_private_jwk(lane, |text| { @@ -2164,8 +1559,8 @@ mod tests { }) .unwrap(); } - assert_eq!(private_values.len(), 3); - assert_eq!(public_thumbprints.len(), 3); + assert_eq!(private_values.len(), 2); + assert_eq!(public_thumbprints.len(), 2); assert_eq!( closure .projection @@ -2174,7 +1569,7 @@ mod tests { .map(|signer| signer.kid.as_str()) .collect::>() .len(), - 3 + 2 ); for signer in &closure.projection.lane_signers { assert_eq!( @@ -2184,87 +1579,13 @@ mod tests { } } - #[test] - fn absent_issuance_gets_an_isolated_runtime_signer_for_one_credential_closure() { - let mut first_requirements = requirements(DevSourceCredentialProfile::OperatorBound); - first_requirements.issuance = None; - let closure = PreparedDevCredentialClosure::generate(first_requirements).unwrap(); - assert!(closure.issuance.is_none()); - let runtime = closure.notary_runtime_signing.as_ref().unwrap(); - let runtime_private: Value = serde_json::from_str(&runtime.private_jwk).unwrap(); - assert_eq!(runtime_private["kid"], DEV_NOTARY_RUNTIME_SIGNING_KID); - let runtime_kid = PublicJwk::parse(&runtime.public_jwk) - .unwrap() - .jkt() - .unwrap(); - let mut lane_private_values = BTreeSet::new(); - let mut lane_kids = BTreeSet::new(); - for lane in [ - ProductAcceptanceLaneV1::RelayPublic, - ProductAcceptanceLaneV1::RelayConsultation, - ProductAcceptanceLaneV1::Notary, - ] { - closure - .with_lane_private_jwk(lane, |text| { - let private = PrivateJwk::parse(text)?; - lane_private_values.insert(text.to_string()); - lane_kids.insert(private.public().jkt()?); - Ok(()) - }) - .unwrap(); - } - assert!(!lane_private_values.contains(runtime.private_jwk.as_str())); - assert!(!lane_kids.contains(&runtime_kid)); - - let mut second_requirements = requirements(DevSourceCredentialProfile::OperatorBound); - second_requirements.issuance = None; - let second = PreparedDevCredentialClosure::generate(second_requirements).unwrap(); - let second_runtime = second.notary_runtime_signing.as_ref().unwrap(); - assert_ne!( - runtime_kid, - PublicJwk::parse(&second_runtime.public_jwk) - .unwrap() - .jkt() - .unwrap() - ); - - let parent = tempdir().unwrap(); - let files = closure - .materialize_owner_only(&parent.path().join("credentials")) - .unwrap(); - assert!(files.issuance_public_jwk.is_none()); - assert!( - fs::read_to_string(&files.notary_signing_key).unwrap() == runtime.private_jwk.as_str() - ); - let runtime_private_member = runtime_private["d"].as_str().unwrap(); - for path in files - .lane_public_jwks - .iter() - .chain([&files.workload_public_jwk]) - { - let text = fs::read_to_string(path).unwrap(); - assert!(!text.contains(runtime_private_member)); - assert_ne!(PublicJwk::parse(&text).unwrap().jkt().unwrap(), runtime_kid); - } - assert_eq!( - fs::read_dir(&files.root) - .unwrap() - .filter_map(|entry| entry.ok()) - .filter_map(|entry| fs::read_to_string(entry.path()).ok()) - .filter(|text| text.contains(runtime_private_member)) - .count(), - 1 - ); - } - #[test] fn all_generated_secret_values_are_distinct() { let closure = PreparedDevCredentialClosure::generate(requirements(oauth_profile( DevOAuthCredentialProfile::Oauth2Bearer, ))) .unwrap(); - let fingerprint = fingerprint_api_key(&closure.caller_token); - closure.validate_distinct_secrets(&fingerprint).unwrap(); + closure.validate_distinct_secrets().unwrap(); } #[test] @@ -2276,7 +1597,7 @@ mod tests { }, )) .unwrap(); - let sentinel = closure.caller_token.to_string(); + let sentinel = closure.relay_public_audit.to_string(); let files = closure .materialize_owner_only(&parent.path().join("credentials")) .unwrap(); @@ -2299,12 +1620,7 @@ mod tests { let files = closure .materialize_owner_only(&parent.path().join("credentials")) .unwrap(); - for path in files - .lane_public_jwks - .iter() - .chain(files.issuance_public_jwk.iter()) - .chain([&files.workload_public_jwk]) - { + for path in &files.lane_public_jwks { PublicJwk::parse(&fs::read_to_string(path).unwrap()).unwrap(); assert!(!fs::read_to_string(path).unwrap().contains("\"d\"")); } diff --git a/crates/registryctl/src/dev_runtime.rs b/crates/registryctl/src/dev_runtime.rs index 8a41cd21b..8928eec4b 100644 --- a/crates/registryctl/src/dev_runtime.rs +++ b/crates/registryctl/src/dev_runtime.rs @@ -48,15 +48,11 @@ pub const DEV_SYNTHETIC_SOURCE_ORIGIN: &str = "https://10.89.0.3:8099"; const DEV_PRIVATE_SUBNET: &str = "10.89.0.0/24"; const DEV_SYNTHETIC_SOURCE_CA_CONTAINER_PATH: &str = "/run/registry/dev-public/synthetic-source-tls.crt"; -const DEV_WORKLOAD_JWKS_CONTAINER_PATH: &str = "/run/registry/dev-public/notary-workload-jwks.json"; const DEV_ROOT: &str = ".registry-stack/dev"; const RUNTIME_STATE_FILE: &str = "runtime-state.json"; -const REQUEST_CONFIG_FILE: &str = "request.curl"; -const REQUEST_BODY_FILE: &str = "request.json"; const RECORDS_REQUEST_CONFIG_FILE: &str = "records-request.curl"; const RECORDS_DENIED_CONFIG_FILE: &str = "records-denied.curl"; -const CALLER_TOKEN_FILE: &str = "caller-token"; const SYNTHETIC_SOURCE_PLAN_FILE: &str = "synthetic-source-plan.json"; const SYNTHETIC_SOURCE_PLAN_SCHEMA_V1: &str = "registry.relay.synthetic-source-plan.v1"; const SYNTHETIC_SOURCE_PLAN_CONTAINER_PATH: &str = "/run/registry/synthetic-source-plan.json"; @@ -72,7 +68,6 @@ const MAX_RUNTIME_PLAN_BYTES: u64 = 2 * 1024 * 1024; pub(crate) const MAX_LOCAL_SNAPSHOT_BYTES: u64 = 256 * 1024 * 1024; const MAX_ID_BYTES: usize = 128; const DEFAULT_RELAY_PORT: u16 = 4242; -const DEFAULT_NOTARY_PORT: u16 = 4243; const DEFAULT_SHUTDOWN_SECONDS: u16 = 15; const MAX_LOG_LINES_PER_PRODUCT: u16 = 500; @@ -311,7 +306,6 @@ pub struct AuthoredDevelopment { /// projection. pub operator_source_binding_present: bool, pub relay_port: Option, - pub notary_port: Option, } /// One validated project-owned snapshot exposed read-only to Relay serve @@ -340,8 +334,6 @@ pub struct DevRuntimeArtifactInputs { pub relay_public_anchor: PathBuf, pub relay_consultation_bundle: PathBuf, pub relay_consultation_anchor: PathBuf, - pub notary_bundle: PathBuf, - pub notary_anchor: PathBuf, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -349,7 +341,6 @@ pub(crate) struct VerifiedDevReleaseProjection { release_id: String, release_tag: String, registry_relay_image: String, - registry_notary_image: String, postgresql_image: String, minimum_compose_version: String, relay_public_prepare: DevRuntimeActionProjection, @@ -358,9 +349,6 @@ pub(crate) struct VerifiedDevReleaseProjection { relay_consultation_prepare: DevRuntimeActionProjection, relay_consultation_initialize: DevRuntimeActionProjection, relay_consultation_serve: DevRuntimeActionProjection, - notary_prepare: DevRuntimeActionProjection, - notary_initialize: DevRuntimeActionProjection, - notary_serve: DevRuntimeActionProjection, postgresql_serve: DevRuntimeActionProjection, postgresql_bootstrap: DevRuntimeActionProjection, postgresql_server_environment: Vec, @@ -368,7 +356,6 @@ pub(crate) struct VerifiedDevReleaseProjection { postgresql_operator_files: Vec, relay_public_health_probe: Vec, relay_consultation_health_probe: Vec, - notary_health_probe: Vec, postgresql_health_probe: Vec, } @@ -484,7 +471,6 @@ impl VerifiedDevReleaseProjection { release_id: lock.signed_payload_sha256().to_string(), release_tag: lock.release_tag().to_string(), registry_relay_image: images.relay().to_string(), - registry_notary_image: images.notary().to_string(), postgresql_image: images.postgresql_state_plane().to_string(), minimum_compose_version: lock.minimum_compose_version().to_string(), relay_public_prepare: runtime @@ -508,15 +494,6 @@ impl VerifiedDevReleaseProjection { .relay_consultation() .development_serve_action() .into(), - notary_prepare: runtime - .notary() - .development_prepare_state_store_action() - .into(), - notary_initialize: runtime - .notary() - .development_initialize_state_action() - .into(), - notary_serve: runtime.notary().development_serve_action().into(), postgresql_serve: runtime.postgresql_state_plane().serve().into(), postgresql_bootstrap: runtime.postgresql_state_plane().bootstrap().into(), postgresql_server_environment: runtime @@ -537,7 +514,6 @@ impl VerifiedDevReleaseProjection { .collect(), relay_public_health_probe: runtime.relay_public().health_probe().to_vec(), relay_consultation_health_probe: runtime.relay_consultation().health_probe().to_vec(), - notary_health_probe: runtime.notary().health_probe().to_vec(), postgresql_health_probe: runtime.postgresql_state_plane().health_probe().to_vec(), } } @@ -551,7 +527,6 @@ impl VerifiedDevReleaseProjection { release_id: String, release_tag: String, registry_relay_image: String, - registry_notary_image: String, postgresql_image: String, minimum_compose_version: String, ) -> DevRuntimeResult { @@ -566,16 +541,11 @@ impl VerifiedDevReleaseProjection { ®istry_relay_image, "ghcr.io/registrystack/registry-relay", )?; - validate_image_ref( - ®istry_notary_image, - "ghcr.io/registrystack/registry-notary", - )?; validate_image_ref(&postgresql_image, "docker.io/library/postgres")?; Ok(Self { release_id, release_tag, registry_relay_image, - registry_notary_image, postgresql_image, minimum_compose_version, relay_public_prepare: relay_development_action("relay-public", "prepare_state_store"), @@ -590,9 +560,6 @@ impl VerifiedDevReleaseProjection { "initialize_state", ), relay_consultation_serve: relay_development_action("relay-consultation", "serve"), - notary_prepare: notary_development_action("prepare_state_store"), - notary_initialize: notary_development_action("initialize_state"), - notary_serve: notary_development_action("serve"), postgresql_serve: DevRuntimeActionProjection { command: vec![ "postgres".into(), @@ -664,16 +631,11 @@ impl VerifiedDevReleaseProjection { "REGISTRY_RELAY_RUNTIME_PASSWORD", "REGISTRY_RELAY_MAINTENANCE_PASSWORD", "REGISTRY_RELAY_READER_PASSWORD", - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - "REGISTRY_NOTARY_READER_PASSWORD", ], ), ], relay_public_health_probe: vec!["registry-relay".into(), "health".into()], relay_consultation_health_probe: vec!["registry-relay".into(), "health".into()], - notary_health_probe: vec!["registry-notary".into(), "health".into()], postgresql_health_probe: vec!["pg_isready".into()], }) } @@ -751,7 +713,7 @@ pub fn prepare_dev_runtime_plan( // Credentials are generated before the compiler sees any disposable // binding. The compiler receives only their nonsecret public projection - // and signs all three closed lanes before Compose is rendered. + // and signs both Relay lanes before Compose is rendered. let credentials = PreparedDevCredentialClosure::generate(authoring.credential_requirements()) .map_err(|_| DevRuntimeError::invalid_credentials())?; let signed_root = generated_root.join("signed-lanes"); @@ -781,8 +743,6 @@ pub fn prepare_dev_runtime_plan( relay_public_anchor: signed.relay_public_anchor, relay_consultation_bundle: signed.relay_consultation_bundle, relay_consultation_anchor: signed.relay_consultation_anchor, - notary_bundle: signed.notary_bundle, - notary_anchor: signed.notary_anchor, }; let paths = runtime_paths(&canonical_root, environment_id, &runtime_id); @@ -799,10 +759,6 @@ pub fn prepare_dev_runtime_plan( .development .relay_port .unwrap_or(DEFAULT_RELAY_PORT), - notary_port: authoring - .development - .notary_port - .unwrap_or(DEFAULT_NOTARY_PORT), synthetic: authoring.development.source_mode == DevSourceMode::Synthetic, local_snapshot: authoring.local_snapshot.as_ref(), operator_source_secret_env: &authoring.operator_source_secret_env, @@ -870,7 +826,7 @@ pub fn load_bound_dev_runtime_plan( let plan_file = runtime_roots[0].join("runtime-plan.json"); let bytes = read_owner_only_regular_file(&plan_file, MAX_RUNTIME_PLAN_BYTES) .map_err(|_| DevRuntimeError::project_binding())?; - let mut plan: DevRuntimePlan = + let plan: DevRuntimePlan = parse_json_strict(&bytes).map_err(|_| DevRuntimeError::project_binding())?; let expected_digest = sha256_uri(canonical_root.to_string_lossy().as_bytes()); if plan.binding.environment != environment_id @@ -882,15 +838,6 @@ pub fn load_bound_dev_runtime_plan( { return Err(DevRuntimeError::project_binding()); } - let request_json = - read_owner_only_regular_file(&plan.paths.request_body, MAX_REQUEST_BODY_BYTES as u64) - .map_err(|_| DevRuntimeError::project_binding())?; - let _: serde_json::Value = - parse_json_strict(&request_json).map_err(|_| DevRuntimeError::project_binding())?; - if request_json.is_empty() || sha256_uri(&request_json) != plan.request_digest { - return Err(DevRuntimeError::project_binding()); - } - plan.request_json = request_json; load_bound_state(&plan)?; Ok(plan) } @@ -901,8 +848,6 @@ fn runtime_paths(root: &Path, environment_id: &str, runtime_id: &str) -> DevRunt state_file: runtime_root.join(RUNTIME_STATE_FILE), plan_file: runtime_root.join("runtime-plan.json"), credentials: runtime_root.join("credentials"), - request_config: runtime_root.join("credentials").join(REQUEST_CONFIG_FILE), - request_body: runtime_root.join("credentials").join(REQUEST_BODY_FILE), records_request_config: runtime_root .join("credentials") .join(RECORDS_REQUEST_CONFIG_FILE), @@ -1026,7 +971,6 @@ pub(crate) fn dev_claim_results_commitment( pub enum DevWorkloadId { RelayPublic, RelayConsultation, - Notary, Postgresql, SyntheticSource, } @@ -1036,7 +980,6 @@ impl DevWorkloadId { match self { Self::RelayPublic => "registry-relay-public", Self::RelayConsultation => "registry-relay-consultation", - Self::Notary => "registry-notary", Self::Postgresql => "registry-postgres", Self::SyntheticSource => "registry-synthetic-source", } @@ -1129,8 +1072,6 @@ pub struct DevRuntimePaths { pub state_file: PathBuf, pub plan_file: PathBuf, pub credentials: PathBuf, - pub request_config: PathBuf, - pub request_body: PathBuf, pub records_request_config: PathBuf, pub records_denied_config: PathBuf, pub synthetic_source_plan: PathBuf, @@ -1145,7 +1086,6 @@ pub struct DevRuntimePlan { pub release_tag: String, pub minimum_compose_version: String, pub compose_digest: String, - pub request_digest: String, pub records_request_digest: Option, pub local_snapshot_digest: Option, pub source_mode: DevSourceMode, @@ -1155,8 +1095,6 @@ pub struct DevRuntimePlan { pub paths: DevRuntimePaths, pub artifacts: DevRuntimeArtifactInputs, #[serde(skip)] - request_json: Vec, - #[serde(skip)] records_request: Option, #[serde(skip)] synthetic_source_plan: Option, @@ -1176,7 +1114,6 @@ impl fmt::Debug for DevRuntimePlan { .field("release_tag", &self.release_tag) .field("minimum_compose_version", &self.minimum_compose_version) .field("compose_digest", &self.compose_digest) - .field("request_digest", &self.request_digest) .field("records_request_digest", &self.records_request_digest) .field("local_snapshot_digest", &self.local_snapshot_digest) .field("source_mode", &self.source_mode) @@ -1185,7 +1122,6 @@ impl fmt::Debug for DevRuntimePlan { .field("lifecycle", &self.lifecycle) .field("paths", &self.paths) .field("artifacts", &self.artifacts) - .field("request_json", &"") .field( "records_request", &self.records_request.as_ref().map(|_| ""), @@ -1372,7 +1308,6 @@ impl DevRuntimePlan { validate_authored_local_snapshot(&canonical_root, snapshot)?; } let scenario = select_authored_scenario(&input.development, &input.scenarios)?; - let request_digest = sha256_uri(&scenario.request_json); let records_request_digest = input.records_request.as_ref().map(|request| { sha256_uri( &[ @@ -1397,12 +1332,11 @@ impl DevRuntimePlan { )); } let relay_port = input.development.relay_port.unwrap_or(DEFAULT_RELAY_PORT); - let notary_port = input.development.notary_port.unwrap_or(DEFAULT_NOTARY_PORT); - if relay_port == 0 || notary_port == 0 || relay_port == notary_port { + if relay_port == 0 { return Err(DevRuntimeError::new( DevFailureCategory::InvalidPlan, - "development loopback ports must be non-zero and distinct", - "author distinct development.relay_port and development.notary_port values", + "development Relay loopback port must be non-zero", + "author a non-zero development.relay_port value", )); } @@ -1425,8 +1359,6 @@ impl DevRuntimePlan { state_file: runtime_root.join(RUNTIME_STATE_FILE), plan_file: runtime_root.join("runtime-plan.json"), credentials: runtime_root.join("credentials"), - request_config: runtime_root.join("credentials").join(REQUEST_CONFIG_FILE), - request_body: runtime_root.join("credentials").join(REQUEST_BODY_FILE), records_request_config: runtime_root .join("credentials") .join(RECORDS_REQUEST_CONFIG_FILE), @@ -1478,7 +1410,6 @@ impl DevRuntimePlan { &credential_files, DevWorkloadBuildOptions { relay_port, - notary_port, synthetic: input.development.source_mode == DevSourceMode::Synthetic, local_snapshot: input.local_snapshot.as_ref(), operator_source_secret_env: &input.operator_source_secret_env, @@ -1492,7 +1423,6 @@ impl DevRuntimePlan { log_services: vec![ DevWorkloadId::RelayPublic, DevWorkloadId::RelayConsultation, - DevWorkloadId::Notary, DevWorkloadId::SyntheticSource, ] .into_iter() @@ -1529,7 +1459,6 @@ impl DevRuntimePlan { lifecycle: &lifecycle, artifacts: &artifacts, compose_digest: &compose_digest, - request_digest: &request_digest, records_request_digest: records_request_digest.as_deref(), local_snapshot_digest: local_snapshot_digest.as_deref(), synthetic_source_plan: synthetic_source_plan.as_ref(), @@ -1542,7 +1471,6 @@ impl DevRuntimePlan { release_tag: input.release.release_tag, minimum_compose_version: input.release.minimum_compose_version, compose_digest, - request_digest, records_request_digest, local_snapshot_digest, source_mode: input.development.source_mode, @@ -1551,7 +1479,6 @@ impl DevRuntimePlan { lifecycle, paths, artifacts, - request_json: scenario.request_json.clone(), records_request: input.records_request, synthetic_source_plan, credentials: Some(input.credentials), @@ -1573,13 +1500,6 @@ impl DevRuntimePlan { .collect() } - pub fn evidence_request_command(&self) -> String { - format!( - "curl --config {}", - shell_quote_path(&self.paths.request_config) - ) - } - pub fn records_request_command(&self) -> Option { self.records_request_digest.as_ref().map(|_| { format!( @@ -1618,20 +1538,6 @@ impl DevRuntimePlan { refs.into_iter().collect() } - fn caller_endpoint(&self) -> DevRuntimeResult { - self.workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::Notary) - .and_then(|workload| workload.host_endpoint) - .ok_or_else(|| { - DevRuntimeError::new( - DevFailureCategory::InvalidPlan, - "development plan has no public Notary endpoint", - "rebuild the development runtime plan", - ) - }) - } - fn relay_public_endpoint(&self) -> DevRuntimeResult { self.workloads .iter() @@ -1782,7 +1688,11 @@ fn validate_credential_projection( }; if !source_matches || !file_shape_matches - || files.root != files.caller_token.parent().unwrap_or(Path::new("")) + || files.root + != files + .postgres_admin_password + .parent() + .unwrap_or(Path::new("")) { return Err(DevRuntimeError::invalid_credentials()); } @@ -2199,7 +2109,6 @@ fn build_workloads( ) -> DevRuntimeResult> { let DevWorkloadBuildOptions { relay_port, - notary_port, synthetic, local_snapshot, operator_source_secret_env, @@ -2288,46 +2197,6 @@ fn build_workloads( )?, hardening: None, }, - DevWorkloadPlan { - id: DevWorkloadId::Notary, - image: release.registry_notary_image.clone(), - acceptance_identity: Some(identity( - ProductAcceptanceLaneV1::Notary, - ProductAcceptanceProductV1::RegistryNotary, - "notary", - )), - host_endpoint: Some(SocketAddr::new( - IpAddr::V4(Ipv4Addr::LOCALHOST), - notary_port, - )), - prepare_state_store: Some(product_action( - DevWorkloadId::Notary, - "prepare-state-store", - &release.notary_prepare, - artifacts, - paths, - credential_files, - )?), - initialize_state: Some(product_action( - DevWorkloadId::Notary, - "initialize-state", - &release.notary_initialize, - artifacts, - paths, - credential_files, - )?), - command: release.notary_serve.command.clone(), - health_probe: release.notary_health_probe.clone(), - environment_passthrough: Vec::new(), - mounts: product_action_mounts( - DevWorkloadId::Notary, - &release.notary_serve, - artifacts, - paths, - credential_files, - )?, - hardening: None, - }, DevWorkloadPlan { id: DevWorkloadId::Postgresql, image: release.postgresql_image.clone(), @@ -2342,14 +2211,6 @@ fn build_workloads( hardening: Some(release.postgresql_hardening.clone()), }, ]; - let consultation = workloads - .iter_mut() - .find(|workload| workload.id == DevWorkloadId::RelayConsultation) - .ok_or_else(DevRuntimeError::image_lock)?; - consultation.mounts.push(read_only_mount( - &credential_files.workload_jwks, - DEV_WORKLOAD_JWKS_CONTAINER_PATH, - )); if let Some(source) = &credential_files.source { let consultation = workloads .iter_mut() @@ -2411,7 +2272,6 @@ fn build_workloads( struct DevWorkloadBuildOptions<'a> { relay_port: u16, - notary_port: u16, synthetic: bool, local_snapshot: Option<&'a AuthoredLocalSnapshot>, operator_source_secret_env: &'a [String], @@ -2434,7 +2294,6 @@ fn product_environment_file<'a>( let expected_environment = match workload { DevWorkloadId::RelayPublic => "relay-public-environment", DevWorkloadId::RelayConsultation => "relay-consultation-environment", - DevWorkloadId::Notary => "notary-environment", DevWorkloadId::Postgresql | DevWorkloadId::SyntheticSource => { return Err(DevRuntimeError::image_lock()); } @@ -2461,9 +2320,6 @@ fn product_environment_file<'a>( (DevWorkloadId::RelayConsultation, Some("serve" | "verify_state")) => { Ok(&files.relay_consultation_serve) } - (DevWorkloadId::Notary, Some("prepare_state_store")) => Ok(&files.notary_prepare), - (DevWorkloadId::Notary, Some("initialize_state")) => Ok(&files.notary_initialize), - (DevWorkloadId::Notary, Some("serve" | "verify_state")) => Ok(&files.notary_serve), _ => Err(DevRuntimeError::image_lock()), } } @@ -2474,8 +2330,6 @@ fn product_secret_path<'a>( ) -> DevRuntimeResult<&'a Path> { match file_id { "postgresql-tls-certificate" => Ok(&files.postgres_tls_certificate), - "notary-relay-workload-credential" => Ok(&files.workload_token), - "notary-signing-key" => Ok(&files.notary_signing_key), _ => Err(DevRuntimeError::image_lock()), } } @@ -2491,7 +2345,6 @@ fn product_artifact_mount( let path = match workload { DevWorkloadId::RelayPublic => &artifacts.relay_public_bundle, DevWorkloadId::RelayConsultation => &artifacts.relay_consultation_bundle, - DevWorkloadId::Notary => &artifacts.notary_bundle, DevWorkloadId::Postgresql | DevWorkloadId::SyntheticSource => { return Err(DevRuntimeError::image_lock()); } @@ -2502,7 +2355,6 @@ fn product_artifact_mount( let path = match workload { DevWorkloadId::RelayPublic => &artifacts.relay_public_anchor, DevWorkloadId::RelayConsultation => &artifacts.relay_consultation_anchor, - DevWorkloadId::Notary => &artifacts.notary_anchor, DevWorkloadId::Postgresql | DevWorkloadId::SyntheticSource => { return Err(DevRuntimeError::image_lock()); } @@ -2888,7 +2740,6 @@ fn compose_service( } let container_port = match workload { DevWorkloadId::RelayPublic => 8080, - DevWorkloadId::Notary => 8081, DevWorkloadId::RelayConsultation | DevWorkloadId::Postgresql | DevWorkloadId::SyntheticSource => { @@ -2936,7 +2787,6 @@ const fn private_ipv4_address(workload: DevWorkloadId) -> &'static str { DevWorkloadId::SyntheticSource => "10.89.0.3", DevWorkloadId::RelayConsultation => "10.89.0.4", DevWorkloadId::RelayPublic => "10.89.0.5", - DevWorkloadId::Notary => "10.89.0.6", } } @@ -2998,15 +2848,6 @@ fn product_action( }) } -#[cfg(test)] -#[cfg_attr( - test, - allow(dead_code, reason = "used by direct-module integration tests") -)] -fn notary_development_action(action: &str) -> DevRuntimeActionProjection { - test_development_action("registry-notary", "notary", action) -} - #[cfg(test)] #[cfg_attr( test, @@ -3046,19 +2887,6 @@ fn test_development_action(binary: &str, lane: &str, action: &str) -> DevRuntime "/run/secrets/postgresql-ca.pem", )); } - if action == "serve" { - match lane { - "relay-public" | "relay-consultation" => {} - "notary" => secret_files.extend([ - secret( - "notary-relay-workload-credential", - "/run/secrets/relay-workload-token", - ), - secret("notary-signing-key", "/run/secrets/notary-signing-key.jwk"), - ]), - _ => unreachable!(), - } - } DevRuntimeActionProjection { command, mounts, @@ -3134,11 +2962,6 @@ fn validate_development_trust_material( &artifacts.relay_consultation_bundle, &artifacts.relay_consultation_anchor, ), - ( - DevWorkloadId::Notary, - &artifacts.notary_bundle, - &artifacts.notary_anchor, - ), ]; for (workload_id, bundle, anchor_path) in lanes { let expected = workloads @@ -3200,11 +3023,9 @@ pub struct DevRuntimeStateV1 { pub compose_project: String, pub compose_file: PathBuf, pub compose_digest: String, - pub request_digest: String, pub generated_artifact_root: PathBuf, pub plan_file: PathBuf, pub source_mode: DevSourceMode, - pub request_config: PathBuf, pub workloads: Vec, } @@ -3217,7 +3038,6 @@ impl DevRuntimeStateV1 { compose_project: plan.lifecycle.compose_project.clone(), compose_file: plan.lifecycle.compose_file.clone(), compose_digest: plan.compose_digest.clone(), - request_digest: plan.request_digest.clone(), generated_artifact_root: plan .artifacts .compose_file @@ -3226,7 +3046,6 @@ impl DevRuntimeStateV1 { .to_path_buf(), plan_file: plan.paths.plan_file.clone(), source_mode: plan.source_mode, - request_config: plan.paths.request_config.clone(), workloads: plan.lifecycle.status_services.clone(), } } @@ -3237,10 +3056,8 @@ impl DevRuntimeStateV1 { || self.compose_project != plan.lifecycle.compose_project || self.compose_file != plan.lifecycle.compose_file || self.compose_digest != plan.compose_digest - || self.request_digest != plan.request_digest || self.plan_file != plan.paths.plan_file || self.source_mode != plan.source_mode - || self.request_config != plan.paths.request_config || self.workloads != plan.lifecycle.status_services { return Err(DevRuntimeError::project_binding()); @@ -3329,10 +3146,8 @@ pub struct DevStatusReport { pub workloads: Vec, pub source_mode: DevSourceMode, pub relay_api_url: String, - pub evidence_api_url: String, pub records_denied_command: Option, pub records_request_command: Option, - pub evidence_request_command: String, } #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -3387,10 +3202,17 @@ impl DevSmokeReportV1 { if self.schema_version != DEV_SMOKE_REPORT_SCHEMA_V1 || self.project != plan.binding.project || self.environment != plan.binding.environment - || self.results.len() != 2 { return Err(DevRuntimeError::smoke()); } + if plan.records_request.is_none() { + return (self.results.is_empty() && self.passed) + .then_some(()) + .ok_or_else(DevRuntimeError::smoke); + } + if self.results.len() != 2 { + return Err(DevRuntimeError::smoke()); + } let denial = self.results.iter().find(|result| { result.scenario_id == plan.lifecycle.smoke_denial_scenario && result.status == DevSmokeStatus::Denied @@ -3405,28 +3227,13 @@ impl DevSmokeReportV1 { let Some(authorized) = authorized else { return Err(DevRuntimeError::smoke()); }; - let expected_token_delta = match plan.scenario.oauth_profile { - DevOAuthProfile::None => 0, - DevOAuthProfile::Oauth2Bearer | DevOAuthProfile::Oauth2BearerNoExpiry => 1, - }; - let counters_match = match plan.source_mode { - DevSourceMode::Synthetic => { - denial.token_counter_delta == Some(0) - && denial.source_counter_delta == Some(0) - && authorized.token_counter_delta == Some(expected_token_delta) - && authorized.source_counter_delta == Some(1) - } - DevSourceMode::OperatorBound | DevSourceMode::LocalSnapshot => { - denial.token_counter_delta.is_none() - && denial.source_counter_delta.is_none() - && authorized.token_counter_delta.is_none() - && authorized.source_counter_delta.is_none() - } - }; - if !counters_match || !denial.minimized_claim_ids.is_empty() || !denial.passed { - return Err(DevRuntimeError::smoke()); - } - if authorized.minimized_claim_ids != plan.scenario.minimized_claim_ids + if denial.token_counter_delta.is_some() + || denial.source_counter_delta.is_some() + || authorized.token_counter_delta.is_some() + || authorized.source_counter_delta.is_some() + || !denial.minimized_claim_ids.is_empty() + || !authorized.minimized_claim_ids.is_empty() + || !denial.passed || !authorized.passed || !self.passed { @@ -3440,11 +3247,9 @@ impl DevSmokeReportV1 { pub struct DevStartupReport { pub endpoints: Vec, pub relay_api_url: String, - pub evidence_api_url: String, pub source_mode: DevSourceMode, pub records_denied_command: Option, pub records_request_command: Option, - pub evidence_request_command: String, pub smoke_command: String, pub logs_command: String, pub down_command: String, @@ -3595,53 +3400,37 @@ impl DockerComposeBackend { .filter(|workload| workload.acceptance_identity.is_some()) } - fn synthetic_counters(state: &DevRuntimeStateV1) -> DevRuntimeResult { - let output = Self::compose_success( - state, - [ - "exec".to_string(), - "-T".to_string(), - DevWorkloadId::SyntheticSource.compose_service().to_string(), - "registry-relay".to_string(), - "synthetic-source".to_string(), - "probe".to_string(), - "--plan".to_string(), - SYNTHETIC_SOURCE_PLAN_CONTAINER_PATH.to_string(), - ], - )?; - parse_json_strict(&output.stdout).map_err(|_| DevRuntimeError::backend_contract()) - } - - fn evaluate( + fn request_records( plan: &DevRuntimePlan, authorized: bool, ) -> DevRuntimeResult<(DevSmokeStatus, Vec)> { - let endpoint = plan.caller_endpoint()?; - let url = format!("http://{endpoint}/v1/evaluations"); + let records = plan + .records_request + .as_ref() + .ok_or_else(DevRuntimeError::smoke)?; + let endpoint = plan.relay_public_endpoint()?; + let url = format!( + "http://{endpoint}/v1/datasets/{}/entities/{}/records/{}", + encode_path_segment(&records.dataset_id), + encode_path_segment(&records.entity_id), + encode_path_segment(&records.record_id), + ); let agent = ureq::AgentBuilder::new().build(); - let mut request = agent.post(&url).set("Content-Type", "application/json"); + let mut request = agent.get(&url).set("Data-Purpose", &records.purpose); if authorized { - let token = read_owner_only_regular_file( - &plan.paths.credentials.join(CALLER_TOKEN_FILE), - 16 * 1024, - ) - .map_err(|_| DevRuntimeError::smoke())?; + let token_path = plan + .prepared_credential_files()? + .relay_match_token + .as_ref() + .ok_or_else(DevRuntimeError::smoke)?; + let token = read_owner_only_regular_file(token_path, 16 * 1024) + .map_err(|_| DevRuntimeError::smoke())?; let token = std::str::from_utf8(&token).map_err(|_| DevRuntimeError::smoke())?; request = request.set("Authorization", &format!("Bearer {token}")); } - match request.send_bytes(&plan.request_json) { + match request.call() { Ok(response) if authorized && (200..300).contains(&response.status()) => { - let mut body = Vec::new(); - response - .into_reader() - .take((MAX_REQUEST_BODY_BYTES + 1) as u64) - .read_to_end(&mut body) - .map_err(|_| DevRuntimeError::smoke())?; - if body.len() > MAX_REQUEST_BODY_BYTES { - return Err(DevRuntimeError::smoke()); - } - let claim_ids = validate_authorized_evaluation_response(plan, &body)?; - Ok((DevSmokeStatus::Authorized, claim_ids)) + Ok((DevSmokeStatus::Authorized, Vec::new())) } Err(ureq::Error::Status(status, _)) if !authorized && matches!(status, 401 | 403) => { Ok((DevSmokeStatus::Denied, Vec::new())) @@ -3651,64 +3440,6 @@ impl DockerComposeBackend { } } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct DevEvaluationResponse { - results: Vec, -} - -pub(crate) fn validate_authorized_evaluation_response( - plan: &DevRuntimePlan, - body: &[u8], -) -> DevRuntimeResult> { - let response: DevEvaluationResponse = - parse_json_strict(body).map_err(|_| DevRuntimeError::smoke())?; - // The typed response intentionally represents JSON null as None, but the - // wire contract requires these fields to be present even when they are null. - let wire: serde_json::Value = parse_json_strict(body).map_err(|_| DevRuntimeError::smoke())?; - let wire_results = wire - .get("results") - .and_then(serde_json::Value::as_array) - .ok_or_else(DevRuntimeError::smoke)?; - if wire_results.len() != response.results.len() - || wire_results.iter().any(|result| { - result.as_object().is_none_or(|object| { - !object.contains_key("value") - || !object.contains_key("satisfied") - || !object.contains_key("disclosure") - }) - }) - { - return Err(DevRuntimeError::smoke()); - } - let mut claim_ids = response - .results - .iter() - .map(|result| result.claim_id.clone()) - .collect::>(); - claim_ids.sort(); - if claim_ids != plan.scenario.minimized_claim_ids { - return Err(DevRuntimeError::smoke()); - } - let observed_commitment = dev_claim_results_commitment( - response - .results - .into_iter() - .map(|result| DevClaimResultExpectation { - claim_id: result.claim_id, - value: result.value.unwrap_or(serde_json::Value::Null), - satisfied: result.satisfied, - disclosure: result.disclosure, - }) - .collect(), - ) - .map_err(|_| DevRuntimeError::smoke())?; - if observed_commitment != plan.scenario.expected_claim_results_sha256 { - return Err(DevRuntimeError::smoke()); - } - Ok(claim_ids) -} - pub(crate) fn supporting_up_operation(services: Vec) -> Vec { let mut operation = vec![ "up".to_string(), @@ -3721,13 +3452,6 @@ pub(crate) fn supporting_up_operation(services: Vec) -> Vec { operation } -#[derive(Clone, Copy, Deserialize)] -#[serde(deny_unknown_fields)] -struct SyntheticSourceCounters { - token_requests: u64, - source_requests: u64, -} - #[derive(Deserialize)] struct ComposePsEntry { #[serde(rename = "Service")] @@ -3998,77 +3722,37 @@ impl DevRuntimeBackend for DockerComposeBackend { fn smoke( &mut self, plan: &DevRuntimePlan, - state: &DevRuntimeStateV1, + _state: &DevRuntimeStateV1, ) -> DevRuntimeResult { - if plan.source_mode != DevSourceMode::Synthetic { - let (denial_status, denial_claims) = Self::evaluate(plan, false)?; - let (authorized_status, authorized_claims) = Self::evaluate(plan, true)?; - return Ok(DevSmokeReportV1 { - schema_version: DEV_SMOKE_REPORT_SCHEMA_V1.to_string(), - project: plan.binding.project.clone(), - environment: plan.binding.environment.clone(), - results: vec![ - DevSmokeScenarioResult { - scenario_id: plan.lifecycle.smoke_denial_scenario.clone(), - status: denial_status, - token_counter_delta: None, - source_counter_delta: None, - minimized_claim_ids: denial_claims, - passed: true, - }, - DevSmokeScenarioResult { - scenario_id: plan.lifecycle.smoke_authorized_scenario.clone(), - status: authorized_status, - token_counter_delta: None, - source_counter_delta: None, - minimized_claim_ids: authorized_claims, - passed: true, - }, - ], - passed: true, - }); - } - let before = Self::synthetic_counters(state)?; - let (denial_status, denial_claims) = Self::evaluate(plan, false)?; - let after_denial = Self::synthetic_counters(state)?; - let (authorized_status, authorized_claims) = Self::evaluate(plan, true)?; - let after_authorized = Self::synthetic_counters(state)?; - let delta = - |after: u64, before: u64| after.checked_sub(before).ok_or_else(DevRuntimeError::smoke); - Ok(DevSmokeReportV1 { - schema_version: DEV_SMOKE_REPORT_SCHEMA_V1.to_string(), - project: plan.binding.project.clone(), - environment: plan.binding.environment.clone(), - results: vec![ + let results = if plan.records_request.is_some() { + let (denial_status, denial_claims) = Self::request_records(plan, false)?; + let (authorized_status, authorized_claims) = Self::request_records(plan, true)?; + vec![ DevSmokeScenarioResult { scenario_id: plan.lifecycle.smoke_denial_scenario.clone(), status: denial_status, - token_counter_delta: Some(delta( - after_denial.token_requests, - before.token_requests, - )?), - source_counter_delta: Some(delta( - after_denial.source_requests, - before.source_requests, - )?), + token_counter_delta: None, + source_counter_delta: None, minimized_claim_ids: denial_claims, passed: true, }, DevSmokeScenarioResult { scenario_id: plan.lifecycle.smoke_authorized_scenario.clone(), status: authorized_status, - token_counter_delta: Some(delta( - after_authorized.token_requests, - after_denial.token_requests, - )?), - source_counter_delta: Some(delta( - after_authorized.source_requests, - after_denial.source_requests, - )?), + token_counter_delta: None, + source_counter_delta: None, minimized_claim_ids: authorized_claims, passed: true, }, - ], + ] + } else { + Vec::new() + }; + Ok(DevSmokeReportV1 { + schema_version: DEV_SMOKE_REPORT_SCHEMA_V1.to_string(), + project: plan.binding.project.clone(), + environment: plan.binding.environment.clone(), + results, passed: true, }) } @@ -4222,10 +3906,8 @@ impl DevRuntimeController { workloads, source_mode: plan.source_mode, relay_api_url: format!("http://{}", plan.relay_public_endpoint()?), - evidence_api_url: format!("http://{}", plan.caller_endpoint()?), records_denied_command: plan.records_denied_command(), records_request_command: plan.records_request_command(), - evidence_request_command: plan.evidence_request_command(), }) } @@ -4262,7 +3944,6 @@ fn same_runtime_semantics(existing: &DevRuntimePlan, candidate: &DevRuntimePlan) && existing.local_snapshot_digest == candidate.local_snapshot_digest && existing.release_tag == candidate.release_tag && existing.minimum_compose_version == candidate.minimum_compose_version - && existing.request_digest == candidate.request_digest && existing.source_mode == candidate.source_mode && existing.scenario == candidate.scenario && existing @@ -4411,26 +4092,6 @@ fn materialize_runtime(plan: &DevRuntimePlan) -> DevRuntimeResult DevStartupReport { plan.relay_public_endpoint() .expect("validated development plan has a public Relay endpoint") ), - evidence_api_url: format!( - "http://{}", - plan.caller_endpoint() - .expect("validated development plan has a public Notary endpoint") - ), source_mode: plan.source_mode, records_denied_command: plan.records_denied_command(), records_request_command: plan.records_request_command(), - evidence_request_command: plan.evidence_request_command(), smoke_command: plan.smoke_command(), logs_command: plan.logs_command(), down_command: plan.down_command(), @@ -4887,8 +4542,6 @@ fn validate_artifact_inputs( &artifacts.relay_public_anchor, &artifacts.relay_consultation_bundle, &artifacts.relay_consultation_anchor, - &artifacts.notary_bundle, - &artifacts.notary_anchor, ] { let canonical = fs::canonicalize(path).map_err(|_| { DevRuntimeError::new( @@ -4945,7 +4598,6 @@ struct DevPlanDigestInput<'a> { lifecycle: &'a DevLifecycleBindings, artifacts: &'a DevRuntimeArtifactInputs, compose_digest: &'a str, - request_digest: &'a str, records_request_digest: Option<&'a str>, local_snapshot_digest: Option<&'a str>, synthetic_source_plan: Option<&'a SyntheticSourcePlanV1>, @@ -4963,7 +4615,6 @@ fn plan_digest(input: DevPlanDigestInput<'_>) -> DevRuntimeResult { lifecycle: &'a DevLifecycleBindings, artifacts: &'a DevRuntimeArtifactInputs, compose_digest: &'a str, - request_digest: &'a str, records_request_digest: Option<&'a str>, local_snapshot_digest: Option<&'a str>, synthetic_source_plan_digest: Option, @@ -4986,7 +4637,6 @@ fn plan_digest(input: DevPlanDigestInput<'_>) -> DevRuntimeResult { lifecycle: input.lifecycle, artifacts: input.artifacts, compose_digest: input.compose_digest, - request_digest: input.request_digest, records_request_digest: input.records_request_digest, local_snapshot_digest: input.local_snapshot_digest, synthetic_source_plan_digest, @@ -5214,24 +4864,6 @@ fn write_owner_only(path: &Path, bytes: &[u8]) -> DevRuntimeResult<()> { Ok(()) } -fn curl_config_path(path: &Path) -> DevRuntimeResult { - let text = path.to_str().ok_or_else(|| { - DevRuntimeError::new( - DevFailureCategory::InvalidPlan, - "development request path is not UTF-8", - "move the project to a UTF-8 path and retry", - ) - })?; - if text.contains(['\n', '\r', '"', '\\']) { - return Err(DevRuntimeError::new( - DevFailureCategory::InvalidPlan, - "development request path cannot be represented safely", - "move the project to a path without quotes or control characters", - )); - } - Ok(text.to_string()) -} - fn validate_curl_literal(value: &str) -> DevRuntimeResult<()> { if value.is_empty() || value diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 8f891e8a3..2d8d62ce2 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -38,8 +38,8 @@ pub use approved_set::{ assemble_approved_set, load_approved_baseline_set, ApprovedAnchorTransitionLinkV1, ApprovedBaselineLanesV1, ApprovedBaselineSetV1, ApprovedLaneEntryV1, ApprovedLaneLocatorsV1, ApprovedLaneV1, ApprovedSetAssembleOptions, ApprovedSetAssemblyReportV1, - CrossLaneInterfaceDigestsV1, PortableArtifactLocator, ReviewedBuildUpdateV1, - ReviewedLaneBindingV1, APPROVED_BASELINE_SET_SCHEMA_ID, APPROVED_BASELINE_SET_SCHEMA_VERSION, + PortableArtifactLocator, ReviewedBuildUpdateV1, ReviewedLaneBindingV1, + APPROVED_BASELINE_SET_SCHEMA_ID, APPROVED_BASELINE_SET_SCHEMA_VERSION, }; mod deployment; @@ -114,21 +114,20 @@ pub use project_authoring::{ FixtureCoverageRequirementState, FixtureCoverageReviewedNotApplicable, FixtureCoverageSemanticComparison, FixtureCoverageSummary, FixtureCoverageTarget, FixtureCoverageTargetComparisonInput, FixtureCoverageTargetContract, - FixtureCoverageTargetIdentity, FixtureCoverageTargetSetState, FixtureDisclosureMode, - FixtureEvidenceScope, FixtureLimit, FixtureMutationTargetClass, FixturePassState, - FixtureProtocolHelper, FixtureRequestBindingCoverage, FixtureRequestBindingState, + FixtureCoverageTargetIdentity, FixtureCoverageTargetSetState, FixtureEvidenceScope, + FixtureLimit, FixtureMutationTargetClass, FixturePassState, FixtureProtocolHelper, FixtureRequirementCoverage, FixtureSafeCode, FixtureSemanticExpectation, FixtureSemanticOutcome, FixtureSetState, FixtureStatusMapping, FixtureStatusOutcome, GeneratedFixtureCoverage, GeneratedNotApplicableReason, GeneratedRecipeApplicability, GeneratedSourceFixture, GeneratorRecipe, GeneratorRecipeId, GeneratorRecipeVersion, - GovernedRequestEvidence, HumanIntentSource, InactiveOrUnusedDeclaration, - InactiveOrUnusedReason, InstalledCapabilityEvidence, InstalledCapabilityState, - LiveCompatibilityEvaluation, MissingSupport, NullBehavior, PlatformCoverageComponent, - PlatformGeneratedCaseId, PlatformGeneratedFixtureCoverage, PreflightAttemptState, - PreflightCheckState, PreflightContact, PreflightDiagnostic, PreflightDiagnosticCode, - PreflightDiagnosticMessage, PreflightExecutionBoundary, PreflightFieldAddress, - PreflightGenerationState, PreflightJsonPointer, PreflightMode, PreflightPermissionInvariant, - PreflightPhase, PreflightProduct, PreflightProductCapability, PreflightProductValidatorCheck, + HumanIntentSource, InactiveOrUnusedDeclaration, InactiveOrUnusedReason, + InstalledCapabilityEvidence, InstalledCapabilityState, LiveCompatibilityEvaluation, + MissingSupport, NullBehavior, PlatformCoverageComponent, PlatformGeneratedCaseId, + PlatformGeneratedFixtureCoverage, PreflightAttemptState, PreflightCheckState, PreflightContact, + PreflightDiagnostic, PreflightDiagnosticCode, PreflightDiagnosticMessage, + PreflightExecutionBoundary, PreflightFieldAddress, PreflightGenerationState, + PreflightJsonPointer, PreflightMode, PreflightPermissionInvariant, PreflightPhase, + PreflightProduct, PreflightProductCapability, PreflightProductValidatorCheck, PreflightProjectRelativeFile, PreflightRemediation, PreflightReportLimits, PreflightRuleId, PreflightRuntimeBoundary, PreflightRuntimeFileCheck, PreflightRuntimeFileKind, PreflightRuntimeScope, PreflightSecretCheck, PreflightSecretConsumer, PreflightSeverity, @@ -148,15 +147,15 @@ pub use project_authoring::{ RequiredFixtureCoverageRequirement, RequiredProductAction, Requiredness, ReviewCompareOptions, ReviewComparisonReportV1, ReviewedBuildRecordV1, ReviewedProjectBuildOptions, ReviewedProjectBuildReportV1, RuntimeActivationEvaluation, SchemaConstraint, SemanticChange, - Sha256Digest, SourceAccessAssertion, SourceCallExpectation, StructuralIntent, - SupportAssessment, SupportComponent, SupportEvidence, SupportKind, SupportState, - SupportedCapabilityVersion, ValidationStage, VersionChange, VersionHistoryEntry, - CONFIGURATION_REFERENCE_COVERAGE_SCHEMA_ID, CONFIGURATION_REFERENCE_FORMAT_VERSION, - CONFIGURATION_REFERENCE_SCHEMA_ID, PROJECT_ARTIFACT_MANIFEST_FORMAT_VERSION_V1, - PROJECT_ARTIFACT_MANIFEST_SCHEMA_VERSION_V1, PROJECT_CAPABILITY_INVENTORY_SCHEMA_VERSION_V1, - PROJECT_COMMAND_REPORT_SCHEMA_VERSION_V1, PROJECT_EXPLANATION_SCHEMA_VERSION_V1, - PROJECT_FIXTURE_COVERAGE_SCHEMA_VERSION_V1, PROJECT_PREFLIGHT_SCHEMA_VERSION_V1, - PROJECT_SEMANTIC_IMPACT_SCHEMA_VERSION_V1, REVIEWED_BUILD_RECORD_FILE, + Sha256Digest, StructuralIntent, SupportAssessment, SupportComponent, SupportEvidence, + SupportKind, SupportState, SupportedCapabilityVersion, ValidationStage, VersionChange, + VersionHistoryEntry, CONFIGURATION_REFERENCE_COVERAGE_SCHEMA_ID, + CONFIGURATION_REFERENCE_FORMAT_VERSION, CONFIGURATION_REFERENCE_SCHEMA_ID, + PROJECT_ARTIFACT_MANIFEST_FORMAT_VERSION_V1, PROJECT_ARTIFACT_MANIFEST_SCHEMA_VERSION_V1, + PROJECT_CAPABILITY_INVENTORY_SCHEMA_VERSION_V1, PROJECT_COMMAND_REPORT_SCHEMA_VERSION_V1, + PROJECT_EXPLANATION_SCHEMA_VERSION_V1, PROJECT_FIXTURE_COVERAGE_SCHEMA_VERSION_V1, + PROJECT_PREFLIGHT_SCHEMA_VERSION_V1, PROJECT_SEMANTIC_IMPACT_SCHEMA_VERSION_V1, + REVIEWED_BUILD_RECORD_FILE, }; pub use project_authoring::{build_reviewed_project, compare_reviewed_project}; @@ -278,6 +277,7 @@ pub fn inspect_config_bundle(artifact_root: &Path) -> Result = envelope @@ -309,6 +309,7 @@ pub fn verify_config_bundle_cli( artifact_root.display() ) })?; + validate_relay_acceptance_identity(&verified.manifest.acceptance_identity)?; let config_path = verified .config_path .strip_prefix(&bundle_dir) @@ -623,7 +624,6 @@ fn bundle_relative_path(root: &Path, path: &Path) -> Result { fn primary_config_path(product: &str, files: &[BundleInputFile]) -> Result { let expected = match product { - "registry-notary" => Some("config/notary.yaml"), "registry-relay" => Some("config/relay.yaml"), _ => None, }; @@ -864,10 +864,6 @@ fn legacy_product_acceptance_identity( }; (ProductAcceptanceProductV1::RegistryRelay, lane) } - "registry-notary" => ( - ProductAcceptanceProductV1::RegistryNotary, - ProductAcceptanceLaneV1::Notary, - ), _ => bail!("unsupported config bundle product"), }; let identity = ProductAcceptanceIdentityV1 { @@ -888,10 +884,22 @@ fn legacy_product_acceptance_identity( fn product_acceptance_product_name(product: ProductAcceptanceProductV1) -> &'static str { match product { ProductAcceptanceProductV1::RegistryRelay => "registry-relay", - ProductAcceptanceProductV1::RegistryNotary => "registry-notary", + _ => "unsupported", } } +fn validate_relay_acceptance_identity(identity: &ProductAcceptanceIdentityV1) -> Result<()> { + if identity.product != ProductAcceptanceProductV1::RegistryRelay + || !matches!( + identity.lane, + ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation + ) + { + bail!("registryctl trust supports only Registry Relay acceptance lanes"); + } + Ok(()) +} + fn signing_algorithm_label(algorithm: SigningAlgorithm) -> &'static str { match algorithm { SigningAlgorithm::EdDsa => "EdDSA", diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 2370aeac4..19c21f154 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -26,11 +26,6 @@ fn main() -> ExitCode { if registry_relay::rhai_worker::is_worker_invocation(std::env::args_os()) { return registry_relay::rhai_worker::run_worker_stdio(); } - if is_exact_internal_mode("__registryctl-cel-worker-v1") { - registry_notary_server::cel_worker::run_stdio_worker(); - return ExitCode::SUCCESS; - } - let cli = match Cli::try_parse() { Ok(cli) => cli, Err(error) => { @@ -264,7 +259,7 @@ enum DeployCommand { after_help = "Governed handoff:\n Input owner: approved-set owner and installed Registry Stack release\n Output owner: deployment operator\n Mutation: creates or safely regenerates only the selected managed package directory; does not activate it\n Next command: registryctl deploy verify --package " )] Generate { - /// Verified three-lane approved baseline set. + /// Verified two-lane Relay approved baseline set. #[arg(long)] approved_set: PathBuf, /// Absent, empty, or verified managed package directory. @@ -334,7 +329,7 @@ enum TrustCommand { #[command(subcommand)] command: TrustBundleCommand, }, - /// Assemble the exact three independently verified product lanes. + /// Assemble the exact two independently verified Relay lanes. ApprovedSet { #[command(subcommand)] command: ApprovedSetCommand, @@ -470,9 +465,6 @@ enum ApprovedSetCommand { /// Verified relay-consultation signed artifact directory. #[arg(long, value_name = "SIGNED_ARTIFACT_DIRECTORY")] relay_consultation: Option, - /// Verified notary signed artifact directory. - #[arg(long, value_name = "SIGNED_ARTIFACT_DIRECTORY")] - notary: Option, /// New immutable approved baseline set file. #[arg(long, value_name = "FILE")] output_file: PathBuf, @@ -563,7 +555,6 @@ enum XwFormat { enum Lane { RelayPublic, RelayConsultation, - Notary, } impl From for registry_platform_config::ProductAcceptanceLaneV1 { @@ -571,7 +562,6 @@ impl From for registry_platform_config::ProductAcceptanceLaneV1 { match value { Lane::RelayPublic => Self::RelayPublic, Lane::RelayConsultation => Self::RelayConsultation, - Lane::Notary => Self::Notary, } } } @@ -581,7 +571,6 @@ impl From for ApprovedLaneV1 { match value { Lane::RelayPublic => Self::RelayPublic, Lane::RelayConsultation => Self::RelayConsultation, - Lane::Notary => Self::Notary, } } } @@ -893,10 +882,6 @@ fn run_dev( environment ); println!("{}", dev_api_line("Relay API", &report.relay_api_url)?); - println!( - "{}", - dev_api_line("Evidence API", &report.evidence_api_url)? - ); println!("Source mode: {}", json_enum(&report.source_mode)?); if let Some(command) = &report.records_denied_command { println!("Records denied request: {command}"); @@ -904,7 +889,6 @@ fn run_dev( if let Some(command) = &report.records_request_command { println!("Records request: {command}"); } - println!("Evidence request: {}", report.evidence_request_command); println!("Smoke: {}", report.smoke_command); println!("Logs: {}", report.logs_command); println!("Down: {}", report.down_command); @@ -930,17 +914,12 @@ fn run_dev( ); } println!("{}", dev_api_line("Relay API", &report.relay_api_url)?); - println!( - "{}", - dev_api_line("Evidence API", &report.evidence_api_url)? - ); if let Some(command) = &report.records_denied_command { println!("Records denied request: {command}"); } if let Some(command) = &report.records_request_command { println!("Records request: {command}"); } - println!("Evidence request: {}", report.evidence_request_command); } } } @@ -1228,18 +1207,14 @@ fn run_doctor( if let Some(lock) = release_lock.as_ref() { let images = lock.managed_images(); let available = daemon_available - && [ - images.relay(), - images.notary(), - images.postgresql_state_plane(), - ] - .into_iter() - .all(|image| { - Command::new("docker") - .args(["image", "inspect", "--format", "{{.Id}}", image]) - .output() - .is_ok_and(|output| output.status.success()) - }); + && [images.relay(), images.postgresql_state_plane()] + .into_iter() + .all(|image| { + Command::new("docker") + .args(["image", "inspect", "--format", "{{.Id}}", image]) + .output() + .is_ok_and(|output| output.status.success()) + }); checks.push(if available { DoctorCheckV1::ready("locked_images") } else { @@ -1656,7 +1631,6 @@ fn run_trust(project_dir: Option<&Path>, command: TrustCommand) -> CliResult { preceding_set, relay_public, relay_consultation, - notary, output_file, format, } => { @@ -1668,7 +1642,6 @@ fn run_trust(project_dir: Option<&Path>, command: TrustCommand) -> CliResult { preceding_set, relay_public, relay_consultation, - notary, output_file, }) .map_err(CliFailure::domain)?; @@ -2028,7 +2001,6 @@ fn render_project_test_report(report: ®istryctl::ProjectCommandReport, trace: ("inputs", &fixture.inputs), ("calls", &fixture.calls), ("outputs", &fixture.outputs), - ("claims", &fixture.claims), ] { if !values.is_empty() { write!(output, "\n {label}: {}", human_list(values)) @@ -2267,7 +2239,7 @@ fn render_acceptance_lane(lane: registry_platform_config::ProductAcceptanceLaneV registry_platform_config::ProductAcceptanceLaneV1::RelayConsultation => { "relay-consultation" } - registry_platform_config::ProductAcceptanceLaneV1::Notary => "notary", + _ => "unsupported", } } @@ -2276,16 +2248,10 @@ fn render_acceptance_product( ) -> &'static str { match product { registry_platform_config::ProductAcceptanceProductV1::RegistryRelay => "registry-relay", - registry_platform_config::ProductAcceptanceProductV1::RegistryNotary => "registry-notary", + _ => "unsupported", } } -fn is_exact_internal_mode(expected: &str) -> bool { - let mut args = std::env::args_os(); - let _program = args.next(); - args.next().as_deref() == Some(OsStr::new(expected)) && args.next().is_none() -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index e8cd7c3a4..e5c2e4b38 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -//! Deterministic Registry Stack project authoring for Relay and Notary. +//! Deterministic Registry Stack project authoring for Relay. use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; @@ -10,7 +10,6 @@ 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}; use registry_relay::source_plan::{ authoring::{ @@ -51,7 +50,12 @@ const MAX_FIXTURES: usize = 128; const MAX_ENVIRONMENTS: usize = 64; const MAX_OPERATIONS: usize = 16; const MAX_OUTPUTS: usize = 64; -const MAX_CLAIMS: usize = 64; +const MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1: usize = 8; +const MAX_RELAY_OUTPUT_SCHEMA_NODES_V1: usize = 256; +const MAX_RELAY_OUTPUT_EXPANDED_NODES_V1: usize = 4_096; +const MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1: usize = 32; +const MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1: u16 = 256; +const MAX_RELAY_OUTPUT_VALUE_BYTES_V1: u32 = 64 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ReleasedScriptRuntime { RhaiV1, @@ -95,7 +99,7 @@ include!("project_authoring/compiler/semantic_impact.rs"); include!("project_authoring/compiler/explanation.rs"); include!("project_authoring/compiler/artifacts.rs"); include!("project_authoring/compiler/relay.rs"); -include!("project_authoring/compiler/notary.rs"); +include!("project_authoring/compiler/claim_semantics.rs"); include!("project_authoring/project.rs"); include!("project_authoring/fixtures.rs"); include!("project_authoring/output.rs"); diff --git a/crates/registryctl/src/project_authoring/artifact_manifest.rs b/crates/registryctl/src/project_authoring/artifact_manifest.rs index 6324adf29..506cfc41c 100644 --- a/crates/registryctl/src/project_authoring/artifact_manifest.rs +++ b/crates/registryctl/src/project_authoring/artifact_manifest.rs @@ -249,7 +249,6 @@ fn classify_generated_artifact(relative: &Path) -> Result Result Result Result Result Result Result Result Result Option { | "signing-inputs/relay-consultation/signing-input.v1.json" => { Some(ArtifactConsumer::RegistryRelay) } - "signing-inputs/notary/signing-input.v1.json" => { - Some(ArtifactConsumer::RegistryNotary) - } _ => None, } } fn signing_input_legacy_path(path: &str) -> Option { - for lane in ["relay-public", "relay-consultation", "notary"] { + for lane in ["relay-public", "relay-consultation"] { let prefix = format!("signing-inputs/{lane}/"); if let Some(tail) = path.strip_prefix(&prefix) { return Some(format!("private/{lane}/{tail}")); @@ -656,10 +602,8 @@ mod artifact_manifest_tests { for path in [ "signing-inputs/relay-public/signing-input.v1.json", "signing-inputs/relay-consultation/signing-input.v1.json", - "signing-inputs/notary/signing-input.v1.json", "signing-inputs/relay-public/config/relay.yaml", "signing-inputs/relay-consultation/config/relay.yaml", - "signing-inputs/notary/config/notary.yaml", ] { classify_generated_artifact(Path::new(path)) .unwrap_or_else(|error| panic!("{path} should be classified: {error:#}")); diff --git a/crates/registryctl/src/project_authoring/authoring_contract.rs b/crates/registryctl/src/project_authoring/authoring_contract.rs index 2ebb2ef34..7a587993d 100644 --- a/crates/registryctl/src/project_authoring/authoring_contract.rs +++ b/crates/registryctl/src/project_authoring/authoring_contract.rs @@ -1,18 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 -// Generated Notary consultation outputs live under the Relay result root and -// its closed `outputs` object. Reserve the same fixed depth and node envelope -// here so authoring acceptance implies downstream Notary config acceptance. +// Consultation outputs live under the Relay result root and its closed +// `outputs` object. Reserve a fixed depth and node envelope at authoring time. const OUTPUT_SCHEMA_ROOT_DEPTH_V1: usize = 3; const OUTPUT_SCHEMA_ENVELOPE_NODES_V1: usize = 20; -const MAX_OUTPUT_SCHEMA_DEPTH_V1: usize = - registry_notary_core::MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1; -const MAX_OUTPUT_SCHEMA_NODES_V1: usize = - registry_notary_core::MAX_RELAY_OUTPUT_SCHEMA_NODES_V1; -const MAX_OUTPUT_EXPANDED_NODES_V1: usize = - registry_notary_core::MAX_RELAY_OUTPUT_EXPANDED_NODES_V1; -// Relay parsing and Notary recursive-schema validation accept exact JSON -// integers only through 2^53 - 1, so authoring must enforce the same boundary. +const MAX_OUTPUT_SCHEMA_DEPTH_V1: usize = MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1; +const MAX_OUTPUT_SCHEMA_NODES_V1: usize = MAX_RELAY_OUTPUT_SCHEMA_NODES_V1; +const MAX_OUTPUT_EXPANDED_NODES_V1: usize = MAX_RELAY_OUTPUT_EXPANDED_NODES_V1; +// Relay parsing accepts exact JSON integers only through 2^53 - 1. const MAX_JSON_SAFE_INTEGER_V1: i64 = 9_007_199_254_740_991; /// The pre-1.0 project authoring contract. Runtime artifacts may still lower @@ -461,8 +456,6 @@ enum AuthoredByteSize { struct AuthoredFixtureDocument { name: String, classification: AuthoredFixtureClassification, - #[serde(default)] - request: Option, input: BTreeMap, #[serde(default)] variables: BTreeMap, @@ -572,11 +565,8 @@ impl schemars::JsonSchema for AuthoredFixtureBody { "AuthoredFixtureBody".into() } - fn json_schema( - generator: &mut schemars::SchemaGenerator, - ) -> schemars::Schema { - let file_reference = - generator.subschema_for::(); + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let file_reference = generator.subschema_for::(); schemars::json_schema!({ "oneOf": [ file_reference, @@ -725,23 +715,23 @@ fn lower_authored_integration( request_fixture: reason.request_fixture.clone(), } }), - subject_mismatch: authored.not_applicable.subject_mismatch.as_ref().map(|reason| { - NotApplicableReason { + subject_mismatch: authored + .not_applicable + .subject_mismatch + .as_ref() + .map(|reason| NotApplicableReason { rationale: reason.rationale.clone(), request_fixture: reason.request_fixture.clone(), - } - }), + }), }, bounds: BoundsDeclaration { - calls: if matches!( - &authored.capability, - AuthoredCapabilityDeclaration::Http(_) - ) || authored - .source - .as_ref() - .and_then(|source| source.protocol.as_ref()) - .and_then(|protocol| protocol.signed_dci.as_ref()) - .is_some() + calls: if matches!(&authored.capability, AuthoredCapabilityDeclaration::Http(_)) + || authored + .source + .as_ref() + .and_then(|source| source.protocol.as_ref()) + .and_then(|protocol| protocol.signed_dci.as_ref()) + .is_some() { 1 } else { @@ -760,7 +750,10 @@ fn validate_authored_integration_contract(authored: &AuthoredIntegrationDocument validate_stable_id(&authored.id, "integration id")?; for (field, reason) in [ ("ambiguity", &authored.not_applicable.ambiguity), - ("subject_mismatch", &authored.not_applicable.subject_mismatch), + ( + "subject_mismatch", + &authored.not_applicable.subject_mismatch, + ), ] { let Some(reason) = reason else { continue; @@ -840,10 +833,7 @@ fn validate_authored_integration_contract(authored: &AuthoredIntegrationDocument } } if let Some(limits) = &authored.limits { - if limits - .calls - .is_some_and(|calls| !(1..=16).contains(&calls)) - { + if limits.calls.is_some_and(|calls| !(1..=16).contains(&calls)) { bail!("authored limits exceed the v1 hard ceilings"); } if let Some(request_bytes) = &limits.request_bytes { @@ -965,7 +955,10 @@ fn signed_dci_pointer_segments(pointer: &str) -> Result> { if segments.iter().any(String::is_empty) { bail!("signed DCI selector response pointer must be canonical"); } - if segments.first().is_some_and(|segment| segment == "identifier") { + if segments + .first() + .is_some_and(|segment| segment == "identifier") + { let valid = matches!( segments.as_slice(), [_, index, field] @@ -1072,24 +1065,20 @@ fn validate_authored_output( )?; Ok(1) } - AuthoredOutputDeclaration::Object(output) => { - validate_authored_output_object( - name, - &format!("outputs.{name}"), - output, - OUTPUT_SCHEMA_ROOT_DEPTH_V1, - nodes, - ) - } - AuthoredOutputDeclaration::Array(output) => { - validate_authored_output_array( - name, - &format!("outputs.{name}"), - output, - OUTPUT_SCHEMA_ROOT_DEPTH_V1, - nodes, - ) - } + AuthoredOutputDeclaration::Object(output) => validate_authored_output_object( + name, + &format!("outputs.{name}"), + output, + OUTPUT_SCHEMA_ROOT_DEPTH_V1, + nodes, + ), + AuthoredOutputDeclaration::Array(output) => validate_authored_output_array( + name, + &format!("outputs.{name}"), + output, + OUTPUT_SCHEMA_ROOT_DEPTH_V1, + nodes, + ), } } @@ -1117,19 +1106,15 @@ fn validate_authored_scalar_output( } } AuthoredScalarType::Boolean => { - if format.is_some() - || max_length.is_some() - || minimum.is_some() - || maximum.is_some() - { + if format.is_some() || max_length.is_some() || minimum.is_some() || maximum.is_some() { bail!("outputs.{name} Boolean schema has incompatible constraints"); } } AuthoredScalarType::Integer => { - let minimum = minimum - .ok_or_else(|| anyhow!("outputs.{name}.minimum is required for Integer"))?; - let maximum = maximum - .ok_or_else(|| anyhow!("outputs.{name}.maximum is required for Integer"))?; + let minimum = + minimum.ok_or_else(|| anyhow!("outputs.{name}.minimum is required for Integer"))?; + let maximum = + maximum.ok_or_else(|| anyhow!("outputs.{name}.maximum is required for Integer"))?; if minimum > maximum || minimum < -MAX_JSON_SAFE_INTEGER_V1 || maximum > MAX_JSON_SAFE_INTEGER_V1 @@ -1194,34 +1179,24 @@ fn validate_authored_output_object( nodes: &mut usize, ) -> Result { record_output_schema_node(name, depth, nodes)?; - if !(1..=registry_notary_core::MAX_RELAY_OUTPUT_VALUE_BYTES_V1) - .contains(&object.max_bytes) - { + if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(&object.max_bytes) { bail!( "{path}.max_bytes must be between 1 and {}", - registry_notary_core::MAX_RELAY_OUTPUT_VALUE_BYTES_V1 + MAX_RELAY_OUTPUT_VALUE_BYTES_V1 ); } - if object.fields.is_empty() - || object.fields.len() > registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 - { + if object.fields.is_empty() || object.fields.len() > MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 { bail!( "{path}.fields must contain between 1 and {} entries", - registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 + MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 ); } let mut expanded = 1_usize; for (field_name, field) in &object.fields { - validate_input_name(field_name) - .with_context(|| format!("{path}.fields.{field_name}"))?; + validate_input_name(field_name).with_context(|| format!("{path}.fields.{field_name}"))?; let child_path = format!("{path}.fields.{field_name}.schema"); - let child = validate_authored_output_schema( - name, - &child_path, - &field.schema, - depth + 1, - nodes, - )?; + let child = + validate_authored_output_schema(name, &child_path, &field.schema, depth + 1, nodes)?; expanded = expanded .checked_add(child) .ok_or_else(|| anyhow!("outputs.{name} recursive expansion overflows"))?; @@ -1243,18 +1218,16 @@ fn validate_authored_output_array( nodes: &mut usize, ) -> Result { record_output_schema_node(name, depth, nodes)?; - if !(1..=registry_notary_core::MAX_RELAY_OUTPUT_VALUE_BYTES_V1) - .contains(&array.max_bytes) - { + if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(&array.max_bytes) { bail!( "{path}.max_bytes must be between 1 and {}", - registry_notary_core::MAX_RELAY_OUTPUT_VALUE_BYTES_V1 + MAX_RELAY_OUTPUT_VALUE_BYTES_V1 ); } - if !(1..=registry_notary_core::MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1).contains(&array.max_items) { + if !(1..=MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1).contains(&array.max_items) { bail!( "{path}.max_items must be between 1 and {}", - registry_notary_core::MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1 + MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1 ); } let child = validate_authored_output_schema( @@ -1338,8 +1311,7 @@ fn minimum_authored_output_object_json_bytes( bytes = checked_minimum_json_bytes_add(path, bytes, field_name_bytes)?; bytes = checked_minimum_json_bytes_add(path, bytes, 3)?; let child_path = format!("{path}.fields.{field_name}.schema"); - let child_bytes = - minimum_authored_output_schema_json_bytes(&child_path, &field.schema)?; + let child_bytes = minimum_authored_output_schema_json_bytes(&child_path, &field.schema)?; bytes = checked_minimum_json_bytes_add(path, bytes, child_bytes)?; required_fields = required_fields .checked_add(1) @@ -1800,11 +1772,11 @@ fn lower_script_capability( request_headers: source.request_headers.clone(), response_headers: source.response_headers.clone(), response: ScriptResponseDeclaration { - format: response.map_or(AuthoredResponseFormat::Json, |response| { - response.format - }), + format: response + .map_or(AuthoredResponseFormat::Json, |response| response.format), max_bytes, - max_bytes_authored: response.is_some_and(|response| response.max_bytes.is_some()), + max_bytes_authored: response + .is_some_and(|response| response.max_bytes.is_some()), }, signed_dci: signed_dci.cloned(), script: script.file.clone(), @@ -1905,82 +1877,75 @@ fn lower_output_map( authored .iter() .map(|(name, declaration)| { - let ( - output_type, - nullable, - max_bytes, - minimum, - maximum, - structured_schema, - pointer, - ) = match declaration { - AuthoredOutputDeclaration::Scalar(declaration) => { - let lowered = lower_authored_scalar_output( - name, - &declaration.output_type, - declaration.format, - declaration.max_length, - declaration.minimum, - declaration.maximum, - )?; - let pointer = match (&declaration.source, require_source) { - (Some(pointer), true) => { - pointer_segments(pointer)?; - Some(pointer.clone()) + let (output_type, nullable, max_bytes, minimum, maximum, structured_schema, pointer) = + match declaration { + AuthoredOutputDeclaration::Scalar(declaration) => { + let lowered = lower_authored_scalar_output( + name, + &declaration.output_type, + declaration.format, + declaration.max_length, + declaration.minimum, + declaration.maximum, + )?; + let pointer = match (&declaration.source, require_source) { + (Some(pointer), true) => { + pointer_segments(pointer)?; + Some(pointer.clone()) + } + (Some(_), false) => None, + (None, true) => { + bail!("outputs.{name}.x-registry-source is required for http") + } + (None, false) => None, + }; + ( + lowered.output_type, + lowered.nullable, + lowered.max_bytes, + lowered.minimum, + lowered.maximum, + None, + pointer, + ) + } + AuthoredOutputDeclaration::Object(declaration) => { + match declaration.output_type { + AuthoredOutputObjectType::Object => {} } - (Some(_), false) => None, - (None, true) => { - bail!("outputs.{name}.x-registry-source is required for http") + if require_source { + bail!("outputs.{name}: structured outputs require capability.script"); } - (None, false) => None, - }; - ( - lowered.output_type, - lowered.nullable, - lowered.max_bytes, - lowered.minimum, - lowered.maximum, - None, - pointer, - ) - } - AuthoredOutputDeclaration::Object(declaration) => { - match declaration.output_type { - AuthoredOutputObjectType::Object => {} - } - if require_source { - bail!("outputs.{name}: structured outputs require capability.script"); - } - let schema = lower_authored_output_object(name, declaration)?; - ( - OutputType::Object, - declaration.nullable, - Some(declaration.max_bytes), - None, - None, - Some(schema), - None, - ) - } - AuthoredOutputDeclaration::Array(declaration) => { - match declaration.output_type { - AuthoredOutputArrayType::Array => {} + let schema = lower_authored_output_object(name, declaration)?; + ( + OutputType::Object, + declaration.nullable, + Some(declaration.max_bytes), + None, + None, + Some(schema), + None, + ) } - if require_source { - bail!("outputs.{name}: structured outputs require capability.script"); + AuthoredOutputDeclaration::Array(declaration) => { + match declaration.output_type { + AuthoredOutputArrayType::Array => {} + } + if require_source { + bail!("outputs.{name}: structured outputs require capability.script"); + } + let schema = lower_authored_output_array(name, declaration)?; + ( + OutputType::Array, + declaration.nullable, + Some(declaration.max_bytes), + None, + None, + Some(schema), + None, + ) } - let schema = lower_authored_output_array(name, declaration)?; - ( - OutputType::Array, - declaration.nullable, - Some(declaration.max_bytes), - None, - None, - Some(schema), - None, - ) - } - }; + }; Ok(( name.clone(), OutputDeclaration { @@ -2017,16 +1982,14 @@ fn lower_authored_scalar_output( ) -> Result { let (scalar, nullable) = schema_type_parts(output_type)?; match (scalar, format) { - (AuthoredScalarType::String, Some(AuthoredStringFormat::Date)) => { - Ok(LoweredOutputScalar { - schema: StructuredOutputSchema::Date { nullable }, - output_type: OutputType::Date, - nullable, - max_bytes: None, - minimum: None, - maximum: None, - }) - } + (AuthoredScalarType::String, Some(AuthoredStringFormat::Date)) => Ok(LoweredOutputScalar { + schema: StructuredOutputSchema::Date { nullable }, + output_type: OutputType::Date, + nullable, + max_bytes: None, + minimum: None, + maximum: None, + }), (AuthoredScalarType::String, None) => { let max_bytes = max_length .ok_or_else(|| anyhow!("outputs.{name}.maxLength is required"))? @@ -2053,10 +2016,8 @@ fn lower_authored_scalar_output( maximum: None, }), (AuthoredScalarType::Integer, None) => { - let minimum = - minimum.ok_or_else(|| anyhow!("outputs.{name}.minimum is required"))?; - let maximum = - maximum.ok_or_else(|| anyhow!("outputs.{name}.maximum is required"))?; + let minimum = minimum.ok_or_else(|| anyhow!("outputs.{name}.minimum is required"))?; + let maximum = maximum.ok_or_else(|| anyhow!("outputs.{name}.maximum is required"))?; Ok(LoweredOutputScalar { schema: StructuredOutputSchema::Integer { nullable, diff --git a/crates/registryctl/src/project_authoring/capability_inventory.rs b/crates/registryctl/src/project_authoring/capability_inventory.rs index 356299f08..a462a5a49 100644 --- a/crates/registryctl/src/project_authoring/capability_inventory.rs +++ b/crates/registryctl/src/project_authoring/capability_inventory.rs @@ -43,28 +43,22 @@ pub enum CapabilityId { RhaiRuntime, RhaiAbi, RegistryRelayProduct, - RegistryNotaryProduct, RegistryRelayValidator, - RegistryNotaryValidator, ProjectAuthoringSchemas, RegistryRelayConfigSchema, - RegistryNotaryConfigSchema, } impl CapabilityId { - const ALL: [Self; 12] = [ + const ALL: [Self; 9] = [ Self::SourceHttp, Self::SourceScript, Self::SourceSnapshot, Self::RhaiRuntime, Self::RhaiAbi, Self::RegistryRelayProduct, - Self::RegistryNotaryProduct, Self::RegistryRelayValidator, - Self::RegistryNotaryValidator, Self::ProjectAuthoringSchemas, Self::RegistryRelayConfigSchema, - Self::RegistryNotaryConfigSchema, ]; const fn project_declarable(self) -> bool { @@ -74,7 +68,6 @@ impl CapabilityId { | Self::SourceScript | Self::SourceSnapshot | Self::RegistryRelayProduct - | Self::RegistryNotaryProduct ) } @@ -99,7 +92,6 @@ pub enum CapabilityKind { pub enum CapabilityOwner { Registryctl, RegistryRelay, - RegistryNotary, ReleaseEngineering, } @@ -117,7 +109,6 @@ pub enum SupportedCapabilityVersion { RhaiLanguageV1, RhaiXwV1, RegistryRelayConfigV1, - RegistryNotaryConfigV1, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -145,7 +136,7 @@ pub(crate) const COMPILED_CAPABILITY_RELEASE_FACTS: [( CapabilityId, InstalledCapabilityState, InstalledCapabilityEvidence, -); 12] = [ +); 9] = [ ( CapabilityId::SourceHttp, InstalledCapabilityState::Compiled, @@ -176,21 +167,11 @@ pub(crate) const COMPILED_CAPABILITY_RELEASE_FACTS: [( InstalledCapabilityState::Compiled, InstalledCapabilityEvidence::LinkedCrate, ), - ( - CapabilityId::RegistryNotaryProduct, - InstalledCapabilityState::Compiled, - InstalledCapabilityEvidence::LinkedCrate, - ), ( CapabilityId::RegistryRelayValidator, InstalledCapabilityState::Compiled, InstalledCapabilityEvidence::LinkedProductValidator, ), - ( - CapabilityId::RegistryNotaryValidator, - InstalledCapabilityState::Compiled, - InstalledCapabilityEvidence::LinkedProductValidator, - ), ( CapabilityId::ProjectAuthoringSchemas, InstalledCapabilityState::Compiled, @@ -201,11 +182,6 @@ pub(crate) const COMPILED_CAPABILITY_RELEASE_FACTS: [( InstalledCapabilityState::Compiled, InstalledCapabilityEvidence::EmbeddedSchema, ), - ( - CapabilityId::RegistryNotaryConfigSchema, - InstalledCapabilityState::Compiled, - InstalledCapabilityEvidence::EmbeddedSchema, - ), ]; #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -228,14 +204,11 @@ pub enum EnvironmentEnablementState { pub struct CapabilityUsageCounts { pub services: u32, pub consultations: u32, - pub claims: u32, } impl CapabilityUsageCounts { fn total(self) -> Option { - self.services - .checked_add(self.consultations)? - .checked_add(self.claims) + self.services.checked_add(self.consultations) } fn is_empty(self) -> bool { @@ -252,10 +225,9 @@ impl Serialize for CapabilityUsageCounts { .total() .filter(|total| *total <= MAX_CAPABILITY_USAGE_COUNT) .ok_or_else(|| serde::ser::Error::custom("capability usage exceeds the report cap"))?; - let mut state = serializer.serialize_struct("CapabilityUsageCounts", 4)?; + let mut state = serializer.serialize_struct("CapabilityUsageCounts", 3)?; state.serialize_field("services", &self.services)?; state.serialize_field("consultations", &self.consultations)?; - state.serialize_field("claims", &self.claims)?; state.serialize_field("total", &total)?; state.end() } @@ -271,7 +243,6 @@ impl<'de> Deserialize<'de> for CapabilityUsageCounts { struct Wire { services: u32, consultations: u32, - claims: u32, total: u32, } @@ -279,7 +250,6 @@ impl<'de> Deserialize<'de> for CapabilityUsageCounts { let usage = Self { services: wire.services, consultations: wire.consultations, - claims: wire.claims, }; validate_usage(usage).map_err(|_| { de::Error::custom("capability usage total exceeds the report aggregate cap") @@ -329,37 +299,29 @@ pub enum SupportComponent { SnapshotMaterializationWorker, RhaiXwProtocolHelper, RegistryRelayProduct, - RegistryNotaryProduct, RegistryRelayValidator, - RegistryNotaryValidator, ProjectAuthoringSchema, RegistryRelayConfigSchema, - RegistryNotaryConfigSchema, RegistryctlDistribution, RegistryRelayImage, - RegistryNotaryImage, } impl SupportComponent { - const ALL: [Self; 14] = [ + const ALL: [Self; 10] = [ Self::HttpSourceWorker, Self::RhaiScriptWorker, Self::SnapshotMaterializationWorker, Self::RhaiXwProtocolHelper, Self::RegistryRelayProduct, - Self::RegistryNotaryProduct, Self::RegistryRelayValidator, - Self::RegistryNotaryValidator, Self::ProjectAuthoringSchema, Self::RegistryRelayConfigSchema, - Self::RegistryNotaryConfigSchema, Self::RegistryctlDistribution, Self::RegistryRelayImage, - Self::RegistryNotaryImage, ]; const fn is_image(self) -> bool { - matches!(self, Self::RegistryRelayImage | Self::RegistryNotaryImage) + matches!(self, Self::RegistryRelayImage) } } @@ -915,7 +877,6 @@ fn validate_usage(usage: CapabilityUsageCounts) -> Result<(), CapabilityInventor .ok_or(CapabilityInventoryError::UsageCountOutOfRange)?; if usage.services > MAX_CAPABILITY_USAGE_COUNT || usage.consultations > MAX_CAPABILITY_USAGE_COUNT - || usage.claims > MAX_CAPABILITY_USAGE_COUNT || total > MAX_CAPABILITY_USAGE_COUNT { return Err(CapabilityInventoryError::UsageCountOutOfRange); @@ -1057,21 +1018,11 @@ fn capability_metadata(capability: CapabilityId) -> CapabilityMetadata { owner: CapabilityOwner::RegistryRelay, supported_versions: &[Version::RegistryRelayConfigV1], }, - CapabilityId::RegistryNotaryProduct => CapabilityMetadata { - kind: CapabilityKind::Product, - owner: CapabilityOwner::RegistryNotary, - supported_versions: &[Version::RegistryNotaryConfigV1], - }, CapabilityId::RegistryRelayValidator => CapabilityMetadata { kind: CapabilityKind::ProductValidator, owner: CapabilityOwner::RegistryRelay, supported_versions: &[Version::RegistryRelayConfigV1], }, - CapabilityId::RegistryNotaryValidator => CapabilityMetadata { - kind: CapabilityKind::ProductValidator, - owner: CapabilityOwner::RegistryNotary, - supported_versions: &[Version::RegistryNotaryConfigV1], - }, CapabilityId::ProjectAuthoringSchemas => CapabilityMetadata { kind: CapabilityKind::Schema, owner: CapabilityOwner::Registryctl, @@ -1082,11 +1033,6 @@ fn capability_metadata(capability: CapabilityId) -> CapabilityMetadata { owner: CapabilityOwner::RegistryRelay, supported_versions: &[Version::RegistryRelayConfigV1], }, - CapabilityId::RegistryNotaryConfigSchema => CapabilityMetadata { - kind: CapabilityKind::Schema, - owner: CapabilityOwner::RegistryNotary, - supported_versions: &[Version::RegistryNotaryConfigV1], - }, } } @@ -1129,21 +1075,11 @@ fn support_metadata(component: SupportComponent) -> SupportMetadata { Capability::RegistryRelayProduct, ], }, - SupportComponent::RegistryNotaryProduct => SupportMetadata { - kind: SupportKind::Product, - owner: CapabilityOwner::RegistryNotary, - required_by: &[Capability::RegistryNotaryProduct], - }, SupportComponent::RegistryRelayValidator => SupportMetadata { kind: SupportKind::ProductValidator, owner: CapabilityOwner::RegistryRelay, required_by: &[Capability::RegistryRelayProduct], }, - SupportComponent::RegistryNotaryValidator => SupportMetadata { - kind: SupportKind::ProductValidator, - owner: CapabilityOwner::RegistryNotary, - required_by: &[Capability::RegistryNotaryProduct], - }, SupportComponent::ProjectAuthoringSchema => SupportMetadata { kind: SupportKind::Schema, owner: CapabilityOwner::Registryctl, @@ -1158,11 +1094,6 @@ fn support_metadata(component: SupportComponent) -> SupportMetadata { owner: CapabilityOwner::RegistryRelay, required_by: &[Capability::RegistryRelayProduct], }, - SupportComponent::RegistryNotaryConfigSchema => SupportMetadata { - kind: SupportKind::Schema, - owner: CapabilityOwner::RegistryNotary, - required_by: &[Capability::RegistryNotaryProduct], - }, SupportComponent::RegistryctlDistribution => SupportMetadata { kind: SupportKind::Distribution, owner: CapabilityOwner::ReleaseEngineering, @@ -1178,10 +1109,5 @@ fn support_metadata(component: SupportComponent) -> SupportMetadata { owner: CapabilityOwner::ReleaseEngineering, required_by: &[Capability::RegistryRelayProduct], }, - SupportComponent::RegistryNotaryImage => SupportMetadata { - kind: SupportKind::Image, - owner: CapabilityOwner::ReleaseEngineering, - required_by: &[Capability::RegistryNotaryProduct], - }, } } diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index ba8d2cb8a..c96baea5b 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -304,7 +304,7 @@ pub fn test_registry_project_selected_with_context( let compiled = compile_project_for_environment(&loaded, "offline-fixture", &offline_environment, None)?; validate_generated_product_configs(&compiled)?; - let (reports, generated_observations, request_observations, call_budget_actual) = + let (reports, generated_observations, call_budget_actual) = execute_all_fixtures_with_coverage_observations( &loaded, &compiled, @@ -319,7 +319,6 @@ pub fn test_registry_project_selected_with_context( &loaded, &reports, &generated_observations, - &request_observations, call_budget_actual, )?) } else { @@ -342,9 +341,8 @@ pub fn test_registry_project_selected_with_context( } fn offline_fixture_environment(loaded: &LoadedRegistryProject) -> Result { - let (requires_relay, requires_notary) = project_product_topology(&loaded.project); - let requires_issuance = project_issues_credentials(&loaded.project); - let requires_notary_relay = project_requires_notary_relay(&loaded.project); + let requires_relay = project_requires_relay(&loaded.project); + let requires_relay_consultation = project_requires_consultation_relay(&loaded.project); let mut integrations = BTreeMap::new(); for (alias, integration) in &loaded.integrations { if matches!( @@ -473,67 +471,29 @@ fn offline_fixture_environment(loaded: &LoadedRegistryProject) -> Result { - name: &'a str, - declaration: &'a InputDeclaration, -} - -fn validate_governed_fixture_request( - loaded: &LoadedRegistryProject, - outbound: &GovernedFixtureRequest, -) -> Result<()> { - let purpose = outbound.purpose.as_str(); - let services = loaded - .project - .services - .values() - .filter(|service| service.kind == ServiceKind::Evidence && service.purpose == purpose) - .collect::>(); - if services.is_empty() { - bail!("governed fixture request purpose is not declared by this project"); - } - let claims = &outbound.claims; - if claims.is_empty() || claims.len() > MAX_CLAIMS { - bail!("governed fixture request claim count is outside the project bound"); - } - let mut ids = Vec::with_capacity(claims.len()); - let mut claim_versions = BTreeMap::new(); - let mut selected_claims = Vec::with_capacity(claims.len()); - for claim in claims { - let id = claim.id.as_str(); - let service = services - .iter() - .copied() - .find(|service| service.claims.contains_key(id)) - .ok_or_else(|| anyhow!("governed fixture request contains an unknown project claim"))?; - let authored_version = service.version.to_string(); - if claim - .version - .as_deref() - .is_some_and(|version| version != authored_version) - { - bail!("governed fixture request claim version does not match the authored project"); - } - if claim_versions - .insert(id.to_string(), authored_version) - .is_some() - { - bail!("governed fixture request contains an unknown or duplicate project claim"); - } - ids.push(id.to_string()); - let selected_claim = service - .claims - .get(id) - .ok_or_else(|| anyhow!("selected project claim is absent after claim resolution"))?; - selected_claims.push((service, selected_claim)); - } - let first_claim = selected_claims - .first() - .map(|(_, claim)| *claim) - .ok_or_else(|| anyhow!("governed fixture request must select at least one project claim"))?; - let disclosure = outbound - .disclosure - .as_deref() - .unwrap_or_else(|| expanded_disclosure(&first_claim.disclosure).0); - if registry_notary_core::DisclosureProfile::parse(disclosure).is_none() { - bail!("governed fixture request disclosure profile is invalid"); - } - if selected_claims.iter().any(|(_, claim)| { - !expanded_disclosure(&claim.disclosure) - .1 - .contains(&disclosure) - }) { - bail!("governed fixture request disclosure is not allowed for every selected project claim"); - } - if outbound - .format - .as_deref() - .is_some_and(|format| format != registry_notary_core::FORMAT_CLAIM_RESULT_JSON) - { - bail!("governed fixture request format must be the governed claim-result media type"); - } - for (name, _) in outbound.variables.iter() { - if !selected_claims - .iter() - .any(|(service, _)| service.variables.contains_key(name)) - { - bail!("governed fixture request variable is not declared by a selected project service"); - } - } - let subject_type = selected_claim_subject_type(&selected_claims)?; - let mut input_claims = selected_claims.clone(); - let representative_binding = loaded - .environment - .as_ref() - .and_then(|environment| environment.oid4vci.as_ref()) - .and_then(|binding| { - binding - .representative_issuance - .as_ref() - .map(|representative| (binding, representative)) - }); - if let Some((binding, representative)) = representative_binding { - if let Some(proof_claim) = representative_proof_claim_for_selected_ids( - &loaded.project, - &binding.credential.service, - &binding.credential.profile, - &representative.proof_claim, - &ids, - )? { - input_claims.push(proof_claim); - } - } - validate_governed_fixture_target( - loaded, - &input_claims, - outbound.requester.as_ref(), - &outbound.target, - &outbound.variables, - subject_type, - )?; - Ok(()) -} - -fn representative_proof_claim_for_selected_ids<'a>( - project: &'a RegistryProject, - service_id: &str, - profile_id: &str, - proof_claim_id: &str, - selected_claim_ids: &[String], -) -> Result> { - let service = project - .services - .get(service_id) - .ok_or_else(|| anyhow!("representative credential service is absent"))?; - let credential = service - .credential_profiles - .get(profile_id) - .ok_or_else(|| anyhow!("representative credential profile is absent"))?; - let selected_root = credential - .claims - .first() - .is_some_and(|root| selected_claim_ids.iter().any(|selected| selected == root)); - if !selected_root - || selected_claim_ids - .iter() - .any(|selected| selected == proof_claim_id) - { - return Ok(None); - } - let proof_claim = service - .claims - .get(proof_claim_id) - .ok_or_else(|| anyhow!("representative proof claim is absent"))?; - Ok(Some((service, proof_claim))) -} - -fn selected_claim_subject_type( - selected_claims: &[(&ServiceDeclaration, &ClaimDeclaration)], -) -> Result<&'static str> { - let mut subject_types = selected_claims - .iter() - .map(|(service, _)| service.effective_subject_type()) - .collect::>(); - if subject_types.len() != 1 { - bail!("governed fixture request cannot combine evidence services with different subject types"); - } - Ok(subject_types - .pop_first() - .ok_or_else(|| anyhow!("governed fixture request selected no evidence service"))? - .as_str()) -} - -fn validate_governed_fixture_target( - loaded: &LoadedRegistryProject, - selected_claims: &[(&ServiceDeclaration, &ClaimDeclaration)], - requester: Option<&GovernedFixtureTarget>, - target: &GovernedFixtureTarget, - variables: ®istry_notary_core::RequestVariables, - subject_type: &str, -) -> Result<()> { - if !target.entity_type.eq_ignore_ascii_case(subject_type) { - bail!("governed fixture request target type does not match the authored project"); - } - - let mut id_contracts = Vec::new(); - let mut identifier_contracts = BTreeMap::>>::new(); - let mut requester_identifier_contracts = - BTreeMap::>>::new(); - let mut attribute_contracts = BTreeMap::>>::new(); - let mut variable_contracts = BTreeMap::>>::new(); - for (service, claim) in selected_claims { - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - bail!("governed governed fixture requests require registry-backed project claims"); - } - let consultation_name = claim_consultation_name(service, claim)?; - let consultation = service - .consultations - .get(consultation_name) - .ok_or_else(|| anyhow!("selected project claim has no authored consultation"))?; - let integration = loaded - .integrations - .get(&consultation.integration) - .ok_or_else(|| anyhow!("selected consultation has no authored integration"))?; - for (name, mapping) in &consultation.input { - let declaration = integration.document.input.get(name).ok_or_else(|| { - anyhow!("selected consultation input has no authored contract") - })?; - let contract = GovernedFixtureInputContract { name, declaration }; - if mapping == "request.target.id" { - id_contracts.push(contract); - } else if let Some(scheme) = mapping.strip_prefix("request.target.identifiers.") { - identifier_contracts - .entry(scheme.to_string()) - .or_default() - .push(contract); - } else if let Some(scheme) = mapping.strip_prefix("request.requester.identifiers.") { - requester_identifier_contracts - .entry(scheme.to_string()) - .or_default() - .push(contract); - } else if let Some(name) = mapping.strip_prefix("request.target.attributes.") { - attribute_contracts - .entry(name.to_string()) - .or_default() - .push(contract); - } else if let Some(name) = mapping.strip_prefix("request.variables.") { - variable_contracts - .entry(name.to_string()) - .or_default() - .push(contract); - } else { - bail!("authored consultation uses an unsupported governed fixture input"); - } - } - } - - if id_contracts.is_empty() != target.id.is_none() { - bail!("governed fixture request target fields do not exactly match the selected authored inputs"); - } - if requester_identifier_contracts.is_empty() != requester.is_none() { - bail!( - "governed fixture request requester must be present exactly when selected claims bind authenticated requester identifiers" - ); - } - let mut identifiers = BTreeMap::new(); - for identifier in &target.identifiers { - if identifiers - .insert(identifier.scheme.as_str(), identifier.value.as_str()) - .is_some() - { - bail!("governed fixture request target contains a duplicate identifier scheme"); - } - } - if identifiers.keys().copied().collect::>() - != identifier_contracts - .keys() - .map(String::as_str) - .collect::>() - || target - .attributes - .keys() - .map(String::as_str) - .collect::>() - != attribute_contracts - .keys() - .map(String::as_str) - .collect::>() - { - bail!("governed fixture request target fields do not exactly match the selected authored inputs"); - } - if let Some(requester) = requester { - validate_governed_requester_type(requester)?; - if requester.id.is_some() || !requester.attributes.is_empty() { - bail!( - "governed fixture request requester must contain only the required authenticated identifiers" - ); - } - let mut requester_identifiers = BTreeMap::new(); - for identifier in &requester.identifiers { - if requester_identifiers - .insert(identifier.scheme.as_str(), identifier.value.as_str()) - .is_some() - { - bail!("governed fixture request requester contains a duplicate identifier scheme"); - } - } - if requester_identifiers - .keys() - .copied() - .collect::>() - != requester_identifier_contracts - .keys() - .map(String::as_str) - .collect::>() - { - bail!( - "governed fixture request requester identifiers do not exactly match the selected authored inputs" - ); - } - for (scheme, contracts) in &requester_identifier_contracts { - let value = requester_identifiers.get(scheme.as_str()).ok_or_else(|| { - anyhow!("governed fixture request requester identifier is absent after exact-shape validation") - })?; - validate_governed_fixture_input( - &format!("requester.identifiers.{scheme}"), - contracts, - &Value::String((*value).to_string()), - )?; - } - } - - if let Some(id) = &target.id { - validate_governed_fixture_input("target.id", &id_contracts, &Value::String(id.clone()))?; - } - for (scheme, contracts) in &identifier_contracts { - let value = identifiers.get(scheme.as_str()).ok_or_else(|| { - anyhow!("governed fixture request target identifier is absent after exact-shape validation") - })?; - validate_governed_fixture_input( - &format!("target.identifiers.{scheme}"), - contracts, - &Value::String((*value).to_string()), - )?; - } - for (name, contracts) in &attribute_contracts { - let value = target.attributes.get(name).ok_or_else(|| { - anyhow!("governed fixture request target attribute is absent after exact-shape validation") - })?; - validate_governed_fixture_input(&format!("target.attributes.{name}"), contracts, value)?; - } - for (name, contracts) in &variable_contracts { - let value = variables.get(name).ok_or_else(|| { - anyhow!("governed fixture request omits a variable required by the selected authored inputs") - })?; - validate_governed_fixture_input( - &format!("variables.{name}"), - contracts, - &Value::String(value.to_string()), - )?; - } - Ok(()) -} - -fn validate_governed_requester_type(requester: &GovernedFixtureTarget) -> Result<()> { - if !requester.entity_type.eq_ignore_ascii_case("person") { - bail!("governed fixture request requester type must be Person"); - } - Ok(()) -} - -fn validate_governed_fixture_input( - path: &str, - contracts: &[GovernedFixtureInputContract<'_>], - value: &Value, -) -> Result<()> { - for contract in contracts { - validate_fixture_input_value(contract.name, contract.declaration, value).map_err(|_| { - anyhow!("governed fixture request {path} violates its selected authored type or bounds") - })?; - } - Ok(()) -} - -fn contains_sensitive_request_key(value: &Value) -> bool { - match value { - Value::Object(object) => object.iter().any(|(key, value)| { - matches!( - key.to_ascii_lowercase().as_str(), - "credential" | "credentials" | "password" | "secret" | "token" | "api_key" - ) || contains_sensitive_request_key(value) - }), - Value::Array(values) => values.iter().any(contains_sensitive_request_key), - _ => false, - } -} - pub fn check_registry_project(options: &ProjectCheckOptions) -> Result { let execution_context = ProjectExecutionContext::for_current_executable()?; check_registry_project_with_context(options, &execution_context) @@ -1016,7 +602,7 @@ fn check_registry_project_internal( let compiled = compile_project(&loaded, (!baselines.is_empty()).then_some(&baselines))?; validate_generated_product_configs(&compiled)?; validate_project_workbook_inputs(&loaded, &compiled)?; - let (fixtures, generated_observations, request_observations, call_budget_actual) = + let (fixtures, generated_observations, call_budget_actual) = execute_all_fixtures_with_coverage_observations( &loaded, &compiled, @@ -1030,7 +616,6 @@ fn check_registry_project_internal( &loaded, &fixtures, &generated_observations, - &request_observations, call_budget_actual, )?; let authored_values = if include_trusted_local_authored_values { @@ -1089,15 +674,10 @@ pub fn preflight_registry_project( .as_ref() .ok_or_else(|| anyhow!("preflight requires an explicit environment"))?; let mut input = offline_preflight_input(&loaded, environment, &options.environment)?; - let (requires_relay, requires_notary) = project_product_topology(&loaded.project); - if requires_relay { + if project_requires_relay(&loaded.project) { input.require_product(PreflightProduct::RegistryRelay); input.record_product_validator_available(PreflightProduct::RegistryRelay); } - if requires_notary { - input.require_product(PreflightProduct::RegistryNotary); - input.record_product_validator_available(PreflightProduct::RegistryNotary); - } Ok(run_offline_preflight(input)) } @@ -1144,21 +724,11 @@ pub fn inspect_project_capabilities( SupportState::Available, SupportEvidence::LinkedCrate, ), - ( - SupportComponent::RegistryNotaryProduct, - SupportState::Available, - SupportEvidence::LinkedCrate, - ), ( SupportComponent::RegistryRelayValidator, SupportState::Available, SupportEvidence::LinkedProductValidator, ), - ( - SupportComponent::RegistryNotaryValidator, - SupportState::Available, - SupportEvidence::LinkedProductValidator, - ), ( SupportComponent::ProjectAuthoringSchema, SupportState::Available, @@ -1169,11 +739,6 @@ pub fn inspect_project_capabilities( SupportState::Available, SupportEvidence::EmbeddedSchema, ), - ( - SupportComponent::RegistryNotaryConfigSchema, - SupportState::Available, - SupportEvidence::EmbeddedSchema, - ), ( SupportComponent::RegistryctlDistribution, SupportState::Available, @@ -1184,11 +749,6 @@ pub fn inspect_project_capabilities( SupportState::NotEvaluated, SupportEvidence::NoEvidence, ), - ( - SupportComponent::RegistryNotaryImage, - SupportState::NotEvaluated, - SupportEvidence::NoEvidence, - ), ] { input.record_support(component, state, evidence)?; } @@ -1216,19 +776,12 @@ pub fn inspect_project_capabilities( enabled.insert(capability); } } - let (requires_relay, requires_notary) = project_product_topology(&loaded.project); - if requires_relay { + if project_requires_relay(&loaded.project) { declarations.insert(CapabilityId::RegistryRelayProduct); } - if requires_notary { - declarations.insert(CapabilityId::RegistryNotaryProduct); - } if environment.deployment.relay.is_some() { enabled.insert(CapabilityId::RegistryRelayProduct); } - if environment.deployment.notary.is_some() { - enabled.insert(CapabilityId::RegistryNotaryProduct); - } for capability in declarations { input.record_project_declaration(capability)?; } @@ -1257,28 +810,7 @@ pub fn inspect_project_capabilities( .checked_add(1) .ok_or_else(|| anyhow!("capability service count exceeds the report cap"))?; } - for claim in service.claims.values() { - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - continue; - } - let consultation_name = claim_consultation_name(service, claim)?; - let consultation = service - .consultations - .get(consultation_name) - .ok_or_else(|| anyhow!("registry-backed claim consultation is unavailable"))?; - let capability = *integration_capabilities - .get(consultation.integration.as_str()) - .ok_or_else(|| anyhow!("consultation capability is unavailable"))?; - let counts = usage.entry(capability).or_default(); - counts.claims = counts - .claims - .checked_add(1) - .ok_or_else(|| anyhow!("capability claim count exceeds the report cap"))?; - } - let product = match service.kind { - ServiceKind::RecordsApi => CapabilityId::RegistryRelayProduct, - ServiceKind::Evidence => CapabilityId::RegistryNotaryProduct, - }; + let product = CapabilityId::RegistryRelayProduct; let counts = usage.entry(product).or_default(); counts.services = counts .services @@ -1288,10 +820,6 @@ pub fn inspect_project_capabilities( .consultations .checked_add(u32::try_from(service.consultations.len())?) .ok_or_else(|| anyhow!("product consultation count exceeds the report cap"))?; - counts.claims = counts - .claims - .checked_add(u32::try_from(service.claims.len())?) - .ok_or_else(|| anyhow!("product claim count exceeds the report cap"))?; } if let Some(script_usage) = usage.get(&CapabilityId::SourceScript).copied() { usage.insert(CapabilityId::RhaiRuntime, script_usage); @@ -1451,36 +979,6 @@ fn offline_preflight_input( )?, } } - if let Some(issuance) = &environment.issuance { - add_preflight_secret( - &mut input, - &environment_file, - "/issuance/signing_key/secret", - &issuance.signing_key, - PreflightSecretConsumer::IssuanceSigningKey, - )?; - } - for (caller_id, caller) in &environment.callers { - add_preflight_secret( - &mut input, - &environment_file, - &format!( - "/callers/{}/api_key_fingerprint/secret", - escape_explanation_pointer_segment(caller_id) - ), - &caller.api_key_fingerprint, - PreflightSecretConsumer::CallerApiKeyFingerprint, - )?; - } - if let Some(binding) = &environment.notary_relay { - add_preflight_runtime_file( - &mut input, - &environment_file, - "/notary_relay/token_file", - &binding.token_file, - PreflightRuntimeFileKind::NotaryToRelayToken, - )?; - } if let Some(binding) = &environment.relay_state { add_preflight_runtime_file( &mut input, @@ -1490,36 +988,6 @@ fn offline_preflight_input( PreflightRuntimeFileKind::RelayStateRootCertificate, )?; } - if let Some(binding) = &environment.notary_state { - add_preflight_runtime_file( - &mut input, - &environment_file, - "/notary_state/postgresql/root_certificate_path", - &binding.postgresql.root_certificate_path, - PreflightRuntimeFileKind::NotaryStateRootCertificate, - )?; - } - if let Some(oid4vci) = &environment.oid4vci { - for (reference, consumer, pointer) in [ - ( - &oid4vci.client.signing_key, - PreflightSecretConsumer::Oid4vciClientSigningKey, - "/oid4vci/client/signing_key/secret", - ), - ( - &oid4vci.access_token.signing_key, - PreflightSecretConsumer::Oid4vciAccessTokenSigningKey, - "/oid4vci/access_token/signing_key/secret", - ), - ( - &oid4vci.sensitive_state_key, - PreflightSecretConsumer::Oid4vciSensitiveStateKey, - "/oid4vci/sensitive_state_key/secret", - ), - ] { - add_preflight_secret(&mut input, &environment_file, pointer, reference, consumer)?; - } - } Ok(input) } @@ -1642,7 +1110,7 @@ fn build_registry_project_inner( let compiled = compile_project(&loaded, (!baselines.is_empty()).then_some(&baselines))?; validate_generated_product_configs(&compiled)?; let artifact_inputs = validate_project_workbook_inputs(&loaded, &compiled)?; - let (fixtures, generated_observations, request_observations, call_budget_actual) = + let (fixtures, generated_observations, call_budget_actual) = execute_all_fixtures_with_coverage_observations( &loaded, &compiled, @@ -1656,7 +1124,6 @@ fn build_registry_project_inner( &loaded, &fixtures, &generated_observations, - &request_observations, call_budget_actual, )?; let output = loaded diff --git a/crates/registryctl/src/project_authoring/compiler/artifacts.rs b/crates/registryctl/src/project_authoring/compiler/artifacts.rs index 1723c2b55..cb2ab4478 100644 --- a/crates/registryctl/src/project_authoring/compiler/artifacts.rs +++ b/crates/registryctl/src/project_authoring/compiler/artifacts.rs @@ -82,7 +82,7 @@ fn compile_project_for_environment( let mut profiles = Vec::new(); for (service_id, service) in &loaded.project.services { - if service.kind != ServiceKind::Evidence { + if service.kind != ServiceKind::ConsultationApi { continue; } for (consultation_name, consultation) in &service.consultations { @@ -155,10 +155,7 @@ fn compile_project_for_environment( .into_bytes() .into_boxed_slice(), ); - if environment.notary_relay.is_some() { - if profiles.is_empty() { - relay_consultation_private.clear(); - } + if !profiles.is_empty() { let consultation_relay_config = generated_relay_config( loaded, environment_name, @@ -212,36 +209,6 @@ fn compile_project_for_environment( } else { relay_consultation_private.clear(); } - let mut notary_private = BTreeMap::new(); - if let Some(notary_service) = &environment.deployment.notary { - let notary_config = - generated_notary_config(loaded, environment_name, environment, &profiles)?; - notary_private.insert( - PathBuf::from("config/notary.yaml"), - serde_norway::to_string(¬ary_config)? - .into_bytes() - .into_boxed_slice(), - ); - notary_private.insert( - PathBuf::from("descriptors/operations.json"), - canonical_json_line(&operational_descriptor( - "registry-notary", - ¬ary_service.service, - environment.deployment.profile, - profiles.len(), - ))? - .into_boxed_slice(), - ); - notary_private.insert( - PathBuf::from("descriptors/secret-consumers.json"), - canonical_json_line(&secret_consumer_descriptor( - "registry-notary", - ¬ary_config, - ))? - .into_boxed_slice(), - ); - } - let reviewable_digest = closure_digest(&reviewable)?; let relay_digest = (!relay_private.is_empty()) .then(|| closure_digest(&relay_private)) @@ -249,31 +216,20 @@ fn compile_project_for_environment( let relay_consultation_digest = (!relay_consultation_private.is_empty()) .then(|| closure_digest(&relay_consultation_private)) .transpose()?; - let notary_digest = (!notary_private.is_empty()) - .then(|| closure_digest(¬ary_private)) - .transpose()?; let closure_digests = json!({ "reviewable": reviewable_digest, "relay": relay_digest, "relay_consultation": relay_consultation_digest, - "notary": notary_digest, }); - let disclosure_profiles = disclosure_review_profiles(&loaded.project); - let disclosure_digest = digest_json( - &serde_json::to_value(&disclosure_profiles) - .context("failed to serialize disclosure review profiles")?, - )?; let baseline_state = baseline.map(|baseline| &baseline.approval_state); - let semantic_changes = semantic_change_records(loaded, baseline_state, &disclosure_digest); - let semantic_impact = - project_semantic_impact_report(loaded, baseline_state, &disclosure_digest); + let semantic_changes = semantic_change_records(loaded, baseline_state); + let semantic_impact = project_semantic_impact_report(loaded, baseline_state); let entity_materializations = generated_entity_materialization_review(loaded, environment)?; let review = json!({ "schema": REVIEW_SCHEMA, "registry": loaded.project.registry.id, "compiler_version": env!("CARGO_PKG_VERSION"), "baseline": if baseline.is_some() { "verified_signed_bundle" } else { "initial_without_baseline" }, - "disclosure_profiles": disclosure_profiles, "semantic_changes": semantic_changes, "environment": environment_name, "entity_materializations": entity_materializations, @@ -294,7 +250,6 @@ fn compile_project_for_environment( "report_digest": sha256_uri(&canonical_json_line(&review)?), "authored_input_digest": loaded.authored_hash, "semantic_digests": loaded.semantic_digests, - "disclosure_digest": disclosure_digest, "promotion_projection": project_promotion_projection(loaded, environment)?, "generated_closure_digests": closure_digests, "baseline": baselines.filter(|baselines| !baselines.is_empty()).map(|baselines| json!({ @@ -306,8 +261,6 @@ fn compile_project_for_environment( let fixture_profiles = profiles .iter() .map(|profile| FixtureProfile { - service_id: profile.service_id.clone(), - consultation_id: profile.consultation_name.clone(), integration_alias: profile.integration_alias.clone(), id: profile.id.clone(), version: profile.version.clone(), @@ -321,7 +274,6 @@ fn compile_project_for_environment( reviewable, relay_private, relay_consultation_private, - notary_private, review, approval_state, explanation, @@ -339,7 +291,6 @@ fn operational_descriptor( ) -> Value { let config = match product { "registry-relay" => "config/relay.yaml", - "registry-notary" => "config/notary.yaml", _ => "config.yaml", }; json!({ @@ -2461,10 +2412,10 @@ fn consultation_contract_document( "output": pack_spec.get("output"), "authorization": { "workload": environment - .notary_relay + .relay .as_ref() - .ok_or_else(|| anyhow!("Notary-to-Relay workload binding is absent"))? - .workload_client_id, + .and_then(|relay| relay.allowed_clients.first()) + .ok_or_else(|| anyhow!("Relay consultation client is absent"))?, "required_scope": bounded_scope(&["registry", "consult", service_id])?, "purposes": [service.purpose.as_str()], "legal_basis": service.legal_basis, diff --git a/crates/registryctl/src/project_authoring/compiler/claim_semantics.rs b/crates/registryctl/src/project_authoring/compiler/claim_semantics.rs new file mode 100644 index 000000000..21dfb347a --- /dev/null +++ b/crates/registryctl/src/project_authoring/compiler/claim_semantics.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Shared CEL authoring semantics for Relay consultation and attribute-release +// expressions. + +#[derive(Debug, Default, PartialEq, Eq)] +struct CelReferences { + roots: BTreeSet, + first_level_members: BTreeMap>, + uses_index: bool, +} + +fn cel_references(expression: &str) -> Result { + let program = cel::Program::compile(expression) + .map_err(|_| anyhow!("CEL expression contains invalid syntax"))?; + let mut references = CelReferences::default(); + collect_cel_references(program.expression(), &BTreeSet::new(), &mut references); + Ok(references) +} + +fn cel_member_roots(expression: &str) -> Result> { + Ok(cel_references(expression)?.roots) +} + +fn collect_cel_references( + expression: &IdedExpr, + locals: &BTreeSet, + references: &mut CelReferences, +) { + match &expression.expr { + Expr::Unspecified | Expr::Literal(_) => {} + Expr::Ident(name) => { + if !name.starts_with('@') && !locals.contains(name) { + references.roots.insert(name.clone()); + } + } + Expr::Select(select) => { + if let Expr::Ident(root) = &select.operand.expr { + if !root.starts_with('@') && !locals.contains(root) { + references + .first_level_members + .entry(root.clone()) + .or_default() + .insert(select.field.clone()); + } + } + collect_cel_references(&select.operand, locals, references); + } + Expr::Call(call) => { + if matches!( + call.func_name.as_str(), + cel::common::ast::operators::INDEX | cel::common::ast::operators::OPT_INDEX + ) { + references.uses_index = true; + } + if let Some(target) = &call.target { + collect_cel_references(target, locals, references); + } + for argument in &call.args { + collect_cel_references(argument, locals, references); + } + } + Expr::List(list) => { + for element in &list.elements { + collect_cel_references(element, locals, references); + } + } + Expr::Map(map) => { + for entry in &map.entries { + collect_cel_entry_references(&entry.expr, locals, references); + } + } + Expr::Struct(value) => { + for entry in &value.entries { + collect_cel_entry_references(&entry.expr, locals, references); + } + } + Expr::Comprehension(comprehension) => { + collect_cel_references(&comprehension.iter_range, locals, references); + collect_cel_references(&comprehension.accu_init, locals, references); + + 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_references(&comprehension.loop_cond, &scoped_locals, references); + collect_cel_references(&comprehension.loop_step, &scoped_locals, references); + collect_cel_references(&comprehension.result, &scoped_locals, references); + } + } +} + +fn collect_cel_entry_references( + entry: &EntryExpr, + locals: &BTreeSet, + references: &mut CelReferences, +) { + match entry { + EntryExpr::StructField(field) => { + collect_cel_references(&field.value, locals, references); + } + EntryExpr::MapEntry(entry) => { + collect_cel_references(&entry.key, locals, references); + collect_cel_references(&entry.value, locals, references); + } + } +} diff --git a/crates/registryctl/src/project_authoring/compiler/explanation.rs b/crates/registryctl/src/project_authoring/compiler/explanation.rs index f3aedd21b..a05b31e25 100644 --- a/crates/registryctl/src/project_authoring/compiler/explanation.rs +++ b/crates/registryctl/src/project_authoring/compiler/explanation.rs @@ -147,7 +147,6 @@ enum ApprovedSemanticValue { Capability, Count, DeclarationClass, - DisclosureClass, HumanIntent, Limit, Policy, @@ -402,12 +401,7 @@ fn generated_explanation( &environment_document, ExplanationSource::EnvironmentBound, )?; - add_environment_effective_fields( - &mut builder, - environment_name, - environment, - &environment_document, - )?; + add_environment_effective_fields(&mut builder, environment_name, environment)?; } } @@ -531,12 +525,10 @@ fn add_project_topology_fields( loaded: &LoadedRegistryProject, ) -> Result<()> { let scope = ExplanationAddressScope::Project; - let (requires_relay, requires_notary) = project_product_topology(&loaded.project); - let topology = match (requires_relay, requires_notary) { - (true, false) => "relay_only", - (false, true) => "notary_only", - (true, true) => "combined", - (false, false) => "none", + let requires_relay = project_has_relay_topology(&loaded.project); + let topology = match requires_relay { + true => "relay_only", + false => "none", }; add_derived_scalar( builder, @@ -574,16 +566,6 @@ fn add_project_topology_fields( .filter(|service| service.kind == ServiceKind::RecordsApi) .count(), ), - ( - "/topology/evidence_service_count", - "/properties/services", - loaded - .project - .services - .values() - .filter(|service| service.kind == ServiceKind::Evidence) - .count(), - ), ] { add_derived_scalar( builder, @@ -597,49 +579,19 @@ fn add_project_topology_fields( )?; } for (service_id, service) in &loaded.project.services { - for (name, count) in [ - ("consultation_count", service.consultations.len()), - ("claim_count", service.claims.len()), - ( - "credential_profile_count", - service.credential_profiles.len(), + add_derived_scalar( + builder, + &scope, + &format!( + "/services/{}/consultation_count", + escape_explanation_pointer_segment(service_id) ), - ] { - add_derived_scalar( - builder, - &scope, - &format!( - "/services/{}/{name}", - escape_explanation_pointer_segment(service_id) - ), - knowledge::SchemaKind::Project, - "/properties/services", - ExplanationScalar::Unsigned(count as u64), - ApprovedSemanticValue::Count, - "compiler.service_contract_count", - )?; - } - for (claim_id, claim) in &service.claims { - // This is the compiler's authored dependency classification. It - // does not assert live Relay activation or interoperability. - let evidence = match inferred_claim_evidence(service, claim)? { - ClaimEvidence::RegistryBacked => "registry_backed", - }; - add_derived_scalar( - builder, - &scope, - &format!( - "/services/{}/claims/{}/evidence", - escape_explanation_pointer_segment(service_id), - escape_explanation_pointer_segment(claim_id) - ), - knowledge::SchemaKind::Project, - "/$defs/evidenceService/properties/claims", - ExplanationScalar::Text(evidence.to_owned()), - ApprovedSemanticValue::DeclarationClass, - "compiler.claim_evidence_dependency", - )?; - } + knowledge::SchemaKind::Project, + "/properties/services", + ExplanationScalar::Unsigned(service.consultations.len() as u64), + ApprovedSemanticValue::Count, + "compiler.service_contract_count", + )?; } Ok(()) } @@ -864,30 +816,18 @@ fn add_environment_effective_fields( builder: &mut ExplanationBuilder<'_>, environment_name: &str, environment: &EnvironmentDocument, - authored: &Value, ) -> Result<()> { let scope = ExplanationAddressScope::Environment(environment_name.to_owned()); - for (path, present) in [ - ( - "/topology/relay_bound", - environment.deployment.relay.is_some(), - ), - ( - "/topology/notary_bound", - environment.deployment.notary.is_some(), - ), - ] { - add_derived_scalar( - builder, - &scope, - path, - knowledge::SchemaKind::Environment, - "/properties/deployment", - ExplanationScalar::Boolean(present), - ApprovedSemanticValue::ProductTopology, - "compiler.environment_product_binding", - )?; - } + add_derived_scalar( + builder, + &scope, + "/topology/relay_bound", + knowledge::SchemaKind::Environment, + "/properties/deployment", + ExplanationScalar::Boolean(environment.deployment.relay.is_some()), + ApprovedSemanticValue::ProductTopology, + "compiler.environment_product_binding", + )?; for (integration_id, integration) in &environment.integrations { add_derived_scalar( builder, @@ -915,60 +855,9 @@ fn add_environment_effective_fields( "compiler.credential_class", )?; } - if let Some(issuance) = &environment.issuance { - add_effective_environment_default( - builder, - &scope, - "/issuance/algorithm", - "/properties/issuance/properties/algorithm", - ExplanationScalar::Text(issuance.algorithm.as_str().to_owned()), - ExplanationScalar::Text("EdDSA".to_owned()), - authored.pointer("/issuance/algorithm").is_some(), - ApprovedSemanticValue::DeclarationClass, - )?; - } - if let Some(oid4vci) = &environment.oid4vci { - add_effective_environment_default( - builder, - &scope, - "/oid4vci/tx_code/required", - "/$defs/oid4vci/properties/tx_code/properties/required", - ExplanationScalar::Boolean(oid4vci.tx_code.required), - ExplanationScalar::Boolean(true), - authored.pointer("/oid4vci/tx_code/required").is_some(), - ApprovedSemanticValue::Policy, - )?; - } Ok(()) } -#[allow(clippy::too_many_arguments)] -fn add_effective_environment_default( - builder: &mut ExplanationBuilder<'_>, - scope: &ExplanationAddressScope, - data_path: &str, - schema_path: &str, - value: ExplanationScalar, - default: ExplanationScalar, - authored: bool, - approval: ApprovedSemanticValue, -) -> Result<()> { - builder.add(PendingExplanationField { - scope: scope.clone(), - data_path: data_path.to_owned(), - schema_kind: knowledge::SchemaKind::Environment, - schema_path: schema_path.to_owned(), - source: if authored { - ExplanationSource::EnvironmentBound - } else { - ExplanationSource::Defaulted - }, - value: Some(value), - approval: Some(approval), - default: Some((FieldDefaultSource::AuthoringSchema, !authored, default)), - }) -} - #[allow(clippy::too_many_arguments)] fn add_derived_scalar( builder: &mut ExplanationBuilder<'_>, @@ -1410,12 +1299,6 @@ fn approved_authored_semantic_value( || schema_path.ends_with("/properties/value/properties/type") { Some(ApprovedSemanticValue::DeclarationClass) - } else if schema_path.ends_with("/properties/disclosure") - || schema_path.ends_with("/properties/default") - || (schema_path.ends_with("/properties/allowed/items") - && schema_path.contains("disclosure")) - { - Some(ApprovedSemanticValue::DisclosureClass) } else if schema_path.ends_with("/properties/version") && !matches!(value, ExplanationScalar::Text(_)) { @@ -1639,27 +1522,6 @@ mod explanation_tests { .expect("integration explanation field exists") } - fn project_public_text<'a>(report: &'a ProjectExplanationReportV1, path: &str) -> &'a str { - let field = report - .fields - .iter() - .find(|field| { - matches!( - &field.address, - ProjectFieldAddress::Project { path: actual_path } - if actual_path.as_str() == path - ) - }) - .expect("project explanation field exists"); - let ClassifierSafeReportedValue::Public { value } = &field.reported_value else { - panic!("classifier-approved project classification is public"); - }; - value - .as_value() - .as_str() - .expect("project classification is text") - } - #[test] fn trusted_local_terminal_rendering_fails_closed_for_prohibited_internal_states() { const SENTINEL: &str = "TRUSTED_LOCAL_PROHIBITED_SENTINEL"; @@ -1690,8 +1552,11 @@ mod explanation_tests { }; let parser = ProjectTrustedLocalAuthoredValue { address: ProjectFieldAddress::Project { - path: JsonPointer::new("/services/example/claims/example/cel".to_owned()) - .expect("pointer is valid"), + path: JsonPointer::new( + "/services/example/api/attribute_release_profiles/example/release_conditions/expression/cel" + .to_owned(), + ) + .expect("pointer is valid"), }, source: FieldSourceKind::Authored, sensitivity: FieldSensitivity::Internal, @@ -1753,10 +1618,6 @@ mod explanation_tests { .knowledge .generated_artifacts .contains(&FieldGeneratedArtifact::RelayConfig)); - assert!(request_bytes - .knowledge - .generated_artifacts - .contains(&FieldGeneratedArtifact::NotaryConfig)); let calls = integration_field(&report, "person-record", "/limits/calls"); assert_eq!(calls.source.kind, FieldSourceKind::Derived); @@ -1800,7 +1661,7 @@ mod explanation_tests { assert!(purpose .knowledge .consumers - .contains(&FieldKnowledgeConsumer::RegistryNotary)); + .contains(&FieldKnowledgeConsumer::RegistryRelay)); let ClassifierSafeReportedValue::Public { value } = &purpose.reported_value else { panic!("classifier-approved human intent is public"); }; @@ -1808,37 +1669,6 @@ mod explanation_tests { value.as_value(), &json!("public-service-person-verification") ); - assert_eq!( - project_public_text( - &report, - "/services/person-verification/claims/person-active/evidence" - ), - "registry_backed" - ); - assert_eq!( - project_public_text( - &report, - "/services/person-verification/claims/person-record-exists/evidence" - ), - "registry_backed" - ); - let issuance_algorithm = report - .fields - .iter() - .find(|field| { - matches!( - &field.address, - ProjectFieldAddress::Environment { path, .. } - if path.as_str() == "/issuance/algorithm" - ) - }) - .expect("effective issuance algorithm exists"); - assert_eq!(issuance_algorithm.source.kind, FieldSourceKind::Defaulted); - assert!(issuance_algorithm - .default - .as_ref() - .is_some_and(|default| default.applied)); - let serialized_once = serde_json::to_vec(&report).expect("report serializes"); let serialized_twice = serde_json::to_vec( &generated_explanation(&loaded, "local") @@ -1869,29 +1699,22 @@ integrations: credential: token: { secret: SECRET_REFERENCE_SENTINEL } generation: 77 -issuance: - issuer: did:web:ISSUER_SENTINEL.invalid - signing_kid: SIGNING_ID_SENTINEL - signing_key: { secret: SIGNING_SECRET_SENTINEL } - generation: 88 -callers: - evidence-client: - api_key_fingerprint: { secret: CALLER_SECRET_SENTINEL } - scopes: ["evidence:person:read"] + mtls: + certificate_file: /etc/registry/SOURCE_CERTIFICATE_SENTINEL.pem + private_key: { secret: SOURCE_PRIVATE_KEY_SENTINEL } + generation: 78 relay: origin: https://RELAY_ORIGIN_SENTINEL.invalid issuer: https://ENDPOINT_SENTINEL.invalid jwks_url: https://ENDPOINT_SENTINEL.invalid/JWKS_PATH_SENTINEL audience: CLIENT_ID_SENTINEL allowed_clients: [CLIENT_ID_SENTINEL] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: CLIENT_ID_SENTINEL - token_file: /ABSOLUTE/RUNTIME/FILE/PATH_SENTINEL + consultation: + client_id: CONSULTATION_CLIENT_ID_SENTINEL + principal_id: PRINCIPAL_ID_SENTINEL deployment: profile: local relay: { service: fictional-registry-relay } - notary: { service: fictional-registry-notary } "#, ) .expect("sentinel environment writes"); @@ -1952,21 +1775,9 @@ interactions: expect: outcome: match outputs: { active: true } - claims: { person-record-exists: true, person-active: true } "#, ) .expect("sentinel fixture writes"); - let project_path = temporary.path().join(PROJECT_FILE); - let project = fs::read_to_string(&project_path).expect("project reads"); - fs::write( - &project_path, - project.replace( - "cel: person_record.matched", - "cel: 'person_record.matched && \"CEL_SENTINEL\" == \"CEL_SENTINEL\"'", - ), - ) - .expect("sentinel project writes"); - let loaded = load_registry_project(temporary.path(), Some("local")).expect("sentinel project loads"); let report = @@ -1976,13 +1787,13 @@ expect: "ORIGIN_SENTINEL", "10.77.0.0/16", "SECRET_REFERENCE_SENTINEL", - "SIGNING_SECRET_SENTINEL", - "CALLER_SECRET_SENTINEL", - "SIGNING_ID_SENTINEL", + "SOURCE_CERTIFICATE_SENTINEL", + "SOURCE_PRIVATE_KEY_SENTINEL", "ENDPOINT_SENTINEL", "JWKS_PATH_SENTINEL", "CLIENT_ID_SENTINEL", - "/ABSOLUTE/RUNTIME/FILE/PATH_SENTINEL", + "CONSULTATION_CLIENT_ID_SENTINEL", + "PRINCIPAL_ID_SENTINEL", "REQUEST/PATH/SENTINEL", "QUERY_SENTINEL", "QUERY_VALUE_SENTINEL", @@ -1992,7 +1803,6 @@ expect: "SOURCE_VALUE_SENTINEL", "FIXTURE_INPUT_SENTINEL", "FIXTURE_BODY_SENTINEL", - "CEL_SENTINEL", ] { assert!( !serialized.contains(sentinel), @@ -2037,7 +1847,7 @@ expect: .map(|field| serde_json::to_string(&field.value).expect("scalar serializes")) .collect::>() .join("\n"); - for visible in ["ORIGIN_SENTINEL", "ISSUER_SENTINEL"] { + for visible in ["ORIGIN_SENTINEL", "SOURCE_CERTIFICATE_SENTINEL"] { assert!( trusted_values.contains(visible), "trusted-local review should expose authored non-secret metadata {visible}" @@ -2045,14 +1855,11 @@ expect: } for hidden in [ "SECRET_REFERENCE_SENTINEL", - "SIGNING_SECRET_SENTINEL", - "CALLER_SECRET_SENTINEL", + "SOURCE_PRIVATE_KEY_SENTINEL", "FIXTURE_INPUT_SENTINEL", "FIXTURE_BODY_SENTINEL", - "/ABSOLUTE/RUNTIME/FILE/PATH_SENTINEL", "REQUEST_PAYLOAD_SENTINEL", "SOURCE_VALUE_SENTINEL", - "CEL_SENTINEL", ] { assert!( !trusted_values.contains(hidden), diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs deleted file mode 100644 index 8093d8317..000000000 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ /dev/null @@ -1,1398 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -fn generated_notary_config( - loaded: &LoadedRegistryProject, - environment_name: &str, - environment: &EnvironmentDocument, - profiles: &[GeneratedProfile], -) -> Result { - let notary_service = environment - .deployment - .notary - .as_ref() - .ok_or_else(|| anyhow!("Notary deployment binding is absent"))?; - let mut variables = Map::new(); - let mut claims = Vec::new(); - let mut credential_profiles = Map::new(); - let mut allowed_purposes = BTreeSet::new(); - let mut seen_claims = BTreeSet::new(); - let mut max_validity_seconds = 600_u64; - for (service_id, service) in &loaded.project.services { - if service.kind != ServiceKind::Evidence { - continue; - } - let notary_consultation_aliases = - generated_notary_consultation_aliases(service.consultations.keys().map(String::as_str)); - allowed_purposes.insert(service.purpose.clone()); - for (name, variable) in &service.variables { - let declaration = json!({ "from": variable.from, "type": "date" }); - if variables - .insert(name.clone(), declaration.clone()) - .is_some_and(|prior| prior != declaration) - { - bail!("request variable has conflicting service declarations"); - } - } - for (credential_id, credential) in &service.credential_profiles { - validate_compiler_credential_profile_evidence( - service_id, - credential_id, - service, - credential, - )?; - let issuance = environment - .issuance - .as_ref() - .ok_or_else(|| anyhow!("Notary issuance binding is absent"))?; - let profile_id = bounded_join_id(&[service_id, credential_id])?; - let validity_seconds = parse_validity_seconds(&credential.validity)?; - max_validity_seconds = max_validity_seconds.max(validity_seconds); - let mut generated_profile = json!({ - "format": normalize_credential_format(&credential.format), - "issuer": issuance.issuer, - "signing_key": "project-issuer", - "vct": credential.credential_type, - "validity_seconds": validity_seconds, - "allowed_claims": credential.claims, - "disclosure": { "allowed": ["value", "predicate", "redacted"] }, - }); - if environment.oid4vci.as_ref().is_some_and(|binding| { - binding.credential.service == *service_id - && binding.credential.profile == *credential_id - }) { - generated_profile["holder_binding"] = json!({ - "mode": "did", - "proof_of_possession": "required", - "allowed_did_methods": ["did:jwk"], - }); - } - credential_profiles.insert(profile_id, generated_profile); - } - for (claim_id, claim) in &service.claims { - if !seen_claims.insert(claim_id) { - bail!("Notary claim ids must be unique across project services"); - } - let claim_credential_profiles = service - .credential_profiles - .iter() - .filter(|(_, credential)| credential.claims.iter().any(|id| id == claim_id)) - .map(|(credential, _)| bounded_join_id(&[service_id, credential])) - .collect::>>()?; - // Claim formats describe evaluation renderers, not credential - // issuance. SD-JWT VC stays on the credential profile it belongs - // to, so every generated claim retains the canonical evaluation - // response format. - let formats = vec!["application/vnd.registry-notary.claim-result+json".to_string()]; - let (default_disclosure, allowed_disclosures) = expanded_disclosure(&claim.disclosure); - let (evidence_mode, value_type, nullable, value_max_bytes, rule) = - match inferred_claim_evidence(service, claim)? { - ClaimEvidence::RegistryBacked => { - let consultation_name = claim_consultation_name(service, claim)?; - let notary_consultation_name = notary_consultation_aliases - .get(consultation_name) - .ok_or_else(|| { - anyhow!("generated Notary consultation alias is absent") - })?; - let consultation = &service.consultations[consultation_name]; - let integration = &loaded.integrations[&consultation.integration]; - let profile = profiles - .iter() - .find(|profile| { - profile.service_id == *service_id - && profile.consultation_name == consultation_name - }) - .ok_or_else(|| anyhow!("claim consultation profile is absent"))?; - let outputs = generated_notary_output_contracts(&integration.document)?; - let (value_type, nullable, rule) = generated_notary_claim_rule( - claim_id, - claim, - consultation_name, - notary_consultation_name, - &integration.document, - integration, - )?; - let inputs = consultation - .input - .iter() - .map(|(name, source)| { - ( - name.clone(), - Value::String(if source == "request.target.id" { - "target.id".to_string() - } else { - source.clone() - }), - ) - }) - .collect::>(); - let consultation_config = json!({ - "profile": { - "id": profile.id, - "contract_hash": profile.contract.artifact().typed_hash(), - }, - "inputs": inputs, - "outputs": outputs, - }); - let consultations = Map::from_iter([( - notary_consultation_name.clone(), - consultation_config, - )]); - ( - json!({ "type": "registry_backed", "consultations": consultations }), - value_type, - nullable, - None::, - rule, - ) - } - }; - let mut claim_value = json!({ "type": value_type, "nullable": nullable }); - if let Some(max_bytes) = value_max_bytes { - claim_value["max_bytes"] = json!(max_bytes); - } - let depends_on = - representative_claim_dependencies(environment, service_id, claim_id, service); - let mut generated_claim = json!({ - "id": claim_id, - "title": claim_id.replace('-', " "), - "version": service.version.to_string(), - "subject_type": service.effective_subject_type().as_str(), - "evidence_mode": evidence_mode, - "value": claim_value, - "purpose": service.purpose, - "required_scopes": service.access.scopes, - "rule": rule, - "disclosure": { - "default": default_disclosure, - "allowed": allowed_disclosures, - "downgrade": "deny", - }, - "formats": formats, - "credential_profiles": claim_credential_profiles, - }); - if !depends_on.is_empty() { - generated_claim["depends_on"] = json!(depends_on); - } - claims.push(generated_claim); - } - } - let api_keys = environment - .callers - .iter() - .map(|(id, caller)| { - json!({ - "id": id, - "fingerprint": { - "provider": "env", - "name": caller.api_key_fingerprint.secret, - }, - "scopes": caller.scopes, - }) - }) - .collect::>(); - let mut evidence = json!({ - "enabled": true, - "service_id": notary_service.service, - "max_credential_validity_seconds": max_validity_seconds, - "allowed_purposes": allowed_purposes, - "variables": variables, - "claims": claims, - "credential_profiles": credential_profiles, - }); - let mut signing_keys = Map::new(); - if let Some(issuance) = &environment.issuance { - signing_keys.insert( - "project-issuer".to_string(), - json!({ - "provider": "local_jwk_env", - "private_jwk_env": issuance.signing_key.secret, - "alg": issuance.algorithm.as_str(), - "kid": issuance.signing_kid, - "status": "active", - }), - ); - } - if let Some(binding) = &environment.oid4vci { - signing_keys.insert( - "oid4vci-access-token".to_string(), - json!({ - "provider": "local_jwk_env", - "private_jwk_env": binding.access_token.signing_key.secret, - "alg": "EdDSA", - "kid": binding.access_token.signing_kid, - "status": "active", - }), - ); - signing_keys.insert( - "oid4vci-esignet-client".to_string(), - json!({ - "provider": "local_jwk_env", - "private_jwk_env": binding.client.signing_key.secret, - "alg": "RS256", - "kid": binding.client.signing_kid, - "status": "active", - }), - ); - } - if !signing_keys.is_empty() { - evidence["signing_keys"] = Value::Object(signing_keys); - } - if let Some(connection) = &environment.notary_relay { - let base_url = normalize_url_scheme(&connection.base_url)?; - let local_notary_add_on = matches!( - environment.deployment.profile, - DeploymentProfile::Local - ) && connection.base_url == LOCAL_NOTARY_RELAY_BASE_URL - && connection.workload_client_id == LOCAL_NOTARY_RELAY_CLIENT_ID - && connection.token_file == Path::new(LOCAL_NOTARY_RELAY_TOKEN_FILE); - let private_service_http = url::Url::parse(&base_url).is_ok_and(|url| { - url.scheme() == "http" && matches!(url.host(), Some(url::Host::Domain(_))) - }); - evidence["relay"] = json!({ - "base_url": base_url, - "workload_client_id": connection.workload_client_id, - "token_file": connection.token_file, - "allowed_private_cidrs": if local_notary_add_on { - vec![LOCAL_NOTARY_RELAY_PRIVATE_CIDR] - } else { - Vec::new() - }, - "allow_insecure_localhost": !local_notary_add_on - && !private_service_http - && url_uses_http(&connection.base_url), - "allow_insecure_private_network": private_service_http, - "max_in_flight": 8, - }); - } - let mut state = if environment.notary_state.is_some() { - let state_defaults = StatePostgresqlConfig::default(); - json!({ - "storage": "postgresql", - "postgresql": { - // Keep the environment-backed database secret discoverable in the - // generated consumer descriptor. Runtime policy uses Notary's - // authoritative defaults unless the project gains an explicit - // operator-facing override. - "url_env": state_defaults.url_env, - }, - }) - } else { - json!({ "storage": "in_memory" }) - }; - if let Some(binding) = &environment.notary_state { - state["postgresql"]["root_certificate_path"] = Value::String( - binding - .postgresql - .root_certificate_path - .to_string_lossy() - .into_owned(), - ); - } - if state["postgresql"].is_object() { - if let Some(binding) = &environment.oid4vci { - state["postgresql"]["sensitive_state_key_env"] = - Value::String(binding.sensitive_state_key.secret.clone()); - } - } - - let mut instance = json!({ - "id": notary_service.service, - "environment": environment_name, - }); - let mut auth = json!({ "api_keys": api_keys }); - if let Some(binding) = &environment.oid4vci { - let public_base_url = binding.public_base_url.trim_end_matches('/'); - let allowed_clients = std::iter::once(binding.client.id.as_str()) - .chain(binding.registrar_clients.iter().map(String::as_str)) - .collect::>(); - instance["public_base_url"] = Value::String(public_base_url.to_string()); - evidence["api_base_url"] = Value::String(public_base_url.to_string()); - auth["oidc"] = json!({ - "issuer": binding.authorization_server.issuer, - "jwks_url": binding.authorization_server.jwks_url, - "userinfo_endpoint": binding.authorization_server.userinfo_url, - "audiences": [binding.client.id.as_str(), public_base_url], - "allowed_clients": allowed_clients, - "allowed_algorithms": ["RS256"], - "allowed_token_types": ["JWT"], - "scope_claim": "scope", - "scope_separator": " ", - "principal_claim": "sub", - "leeway": "60s", - "allow_insecure_localhost": url_uses_http(&binding.authorization_server.issuer), - }); - auth["access_token_signing"] = json!({ - "enabled": true, - "issuer": public_base_url, - "audiences": [public_base_url], - "allowed_algorithms": ["EdDSA"], - "token_typ": "registry-notary-access+jwt", - "signing_key_id": "oid4vci-access-token", - "access_token_ttl_seconds": 300, - }); - } - let mut generated = json!({ - "instance": instance, - "server": { "bind": "0.0.0.0:8081", "request_timeout": "30s" }, - "auth": auth, - "audit": { - "sink": "file", - "path": "/var/lib/registry/audit/audit.jsonl", - "hash_secret_env": "REGISTRY_NOTARY_AUDIT_HASH_SECRET", - }, - "state": state, - "evidence": evidence, - "deployment": { "profile": environment.deployment.profile.as_str() }, - }); - if let Some(binding) = &environment.notary_cel { - generated["cel"] = json!({ - "worker_memory_bytes": binding.worker_memory_bytes, - }); - } - if let Some(binding) = &environment.oid4vci { - add_oid4vci_config(&mut generated, loaded, binding)?; - } - Ok(generated) -} - -fn validate_compiler_credential_profile_evidence( - service_id: &str, - credential_id: &str, - service: &ServiceDeclaration, - credential: &CredentialProfileDeclaration, -) -> Result<()> { - for claim_id in &credential.claims { - let claim = service.claims.get(claim_id).ok_or_else(|| { - anyhow!( - "credential profile {service_id}.{credential_id} references absent claim {claim_id}" - ) - })?; - inferred_claim_evidence(service, claim).with_context(|| { - format!( - "credential profile {service_id}.{credential_id} requires claim {claim_id} to reference a declared Relay consultation" - ) - })?; - } - Ok(()) -} - -fn representative_claim_dependencies( - environment: &EnvironmentDocument, - service_id: &str, - claim_id: &str, - service: &ServiceDeclaration, -) -> Vec { - let Some(binding) = environment.oid4vci.as_ref() else { - return Vec::new(); - }; - let Some(representative) = binding.representative_issuance.as_ref() else { - return Vec::new(); - }; - if binding.credential.service != service_id { - return Vec::new(); - } - let Some(credential) = service.credential_profiles.get(&binding.credential.profile) else { - return Vec::new(); - }; - if credential.claims.first().is_none_or(|root| root != claim_id) { - return Vec::new(); - } - vec![representative.proof_claim.clone()] -} - -fn add_oid4vci_config( - generated: &mut Value, - loaded: &LoadedRegistryProject, - binding: &Oid4vciBinding, -) -> Result<()> { - let service = loaded - .project - .services - .get(&binding.credential.service) - .ok_or_else(|| anyhow!("validated OID4VCI service is absent"))?; - let credential = service - .credential_profiles - .get(&binding.credential.profile) - .ok_or_else(|| anyhow!("validated OID4VCI credential profile is absent"))?; - let claim_id = credential - .claims - .first() - .ok_or_else(|| anyhow!("validated OID4VCI credential claim is absent"))?; - let claim = service - .claims - .get(claim_id) - .ok_or_else(|| anyhow!("validated OID4VCI claim is absent"))?; - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - bail!( - "refusing to compile OID4VCI credential capability without registry-backed claim evidence" - ); - } - let profile_id = bounded_join_id(&[ - binding.credential.service.as_str(), - binding.credential.profile.as_str(), - ])?; - let validity_seconds = parse_validity_seconds(&credential.validity)?; - let scope = service - .access - .scopes - .first() - .ok_or_else(|| anyhow!("validated OID4VCI service access scope is absent"))?; - let (_, allowed_disclosures) = expanded_disclosure(&claim.disclosure); - let representative = binding.representative_issuance.as_ref(); - let public_base_url = binding.public_base_url.trim_end_matches('/'); - let insecure_esignet = [ - binding.authorization_server.issuer.as_str(), - binding.authorization_server.jwks_url.as_str(), - binding.authorization_server.userinfo_url.as_str(), - binding.authorization_server.authorize_url.as_str(), - binding.authorization_server.token_url.as_str(), - ] - .into_iter() - .any(url_uses_http); - let mut subject_access = json!({ - "enabled": true, - "subject_binding": { - "token_claim": binding.subject.token_claim, - "claim_source": "userinfo", - "request_field": "SubjectId", - "id_type": binding.subject.id_type, - "normalize": "exact", - "allow_sub_as_civil_id": false, - }, - "citizen_clients": { - "allowed_client_ids": [binding.client.id], - "allowed_audiences": [binding.client.id], - }, - "token_policy": { - "assurance_claim_source": "id_token", - "max_auth_age_seconds": 1_200, - "max_access_token_lifetime_seconds": 1_200, - "max_evaluation_age_seconds": representative - .map(|representative| representative.max_proof_age_seconds) - .unwrap_or(300) - .max(300), - "max_credential_validity_seconds": validity_seconds, - "max_clock_leeway_seconds": 60, - }, - "allowed_operations": { - "evaluate": representative.is_some(), - "render": false, - "issue_credential": true, - "batch_evaluate": false, - }, - "allowed_purposes": [service.purpose], - "allowed_claims": [claim_id], - "allowed_formats": ["application/vnd.registry-notary.claim-result+json"], - "allowed_disclosures": allowed_disclosures, - "scope_policy": "disabled", - "required_scopes": [], - "allowed_wallet_origins": binding.allowed_wallet_origins, - "credential_profiles": [profile_id], - "rate_limits": { - "invalid_token_per_client_address_per_minute": 20, - "per_principal_per_minute": 10, - "subject_mismatch_per_principal_per_hour": 5, - "per_holder_per_hour": 10, - "credential_issuance_per_principal_per_hour": 5, - "tx_code_attempts_per_code_per_minute": 10, - }, - }); - if let Some(representative) = representative { - // This binding is delegation-only. Keep the representative credential - // root out of the subject-bound allow-lists so it is validated and - // authorized solely under the relationship's requester/target - // contract. - subject_access["allowed_purposes"] = json!([]); - subject_access["allowed_claims"] = json!([]); - subject_access["allowed_formats"] = json!([]); - subject_access["allowed_disclosures"] = json!([]); - subject_access["delegation"] = json!({ - "enabled": true, - "allowed_relationships": [{ - "relationship_type": representative.relationship, - "proof_claim": representative.proof_claim, - "target_id_type": representative.target_id_type, - "max_proof_age_seconds": representative.max_proof_age_seconds, - "allowed_claims": [claim_id], - "allowed_purposes": [service.purpose], - "allowed_formats": ["application/vnd.registry-notary.claim-result+json"], - "allowed_disclosures": allowed_disclosures, - }], - }); - } - generated["subject_access"] = subject_access; - let mut credential_configuration = json!({ - "claim_id": claim_id, - "credential_profile": profile_id, - "format": "dc+sd-jwt", - "scope": scope, - "vct": credential.credential_type, - "display_name": binding.credential.profile.replace(['-', '_'], " "), - "proof_signing_alg_values_supported": ["EdDSA"], - "cryptographic_binding_methods_supported": ["did:jwk"], - }); - if let Some(representative) = representative { - credential_configuration["representative_issuance"] = json!({ - "ceremony": "digitally_authenticated_representative", - "relationship": representative.relationship, - }); - } - generated["oid4vci"] = json!({ - "enabled": true, - "credential_issuer": public_base_url, - "authorization_servers": [binding.authorization_server.issuer], - "accepted_token_audiences": [public_base_url, binding.client.id], - "credential_endpoint": format!("{public_base_url}/oid4vci/credential"), - "nonce": { "enabled": true, "ttl_seconds": 300 }, - "authorization": { "require_pkce_method": "S256" }, - "proof": { "max_age_seconds": 300, "max_clock_skew_seconds": 30 }, - "pre_authorized_code": { - "enabled": true, - "tx_code": { - "required": binding.tx_code.required, - "input_mode": "numeric", - "length": 6, - }, - "esignet": { - "client_id": binding.client.id, - "client_signing_key_id": "oid4vci-esignet-client", - "redirect_uri": binding.redirect_uri, - "authorize_url": binding.authorization_server.authorize_url, - "token_url": binding.authorization_server.token_url, - "issuer": binding.authorization_server.issuer, - "jwks_uri": binding.authorization_server.jwks_url, - "userinfo_url": binding.authorization_server.userinfo_url, - "scopes": ["openid", "profile"], - "login_state_ttl_seconds": 300, - "allow_insecure_localhost": insecure_esignet, - }, - "pre_authorized_code_ttl_seconds": 300, - }, - "display": [], - "credential_configurations": { - profile_id.clone(): credential_configuration, - }, - }); - if representative.is_some() { - generated["credential_status"] = json!({ - "enabled": true, - "base_url": public_base_url, - "retention_seconds": 86_400, - }); - } - Ok(()) -} - -fn generated_notary_output_contracts(integration: &IntegrationDocument) -> Result { - let outputs = integration - .outputs - .iter() - .map(|(name, output)| { - if let Some(schema) = &output.structured_schema { - return Ok(( - name.clone(), - generated_notary_structured_output_contract(schema)?, - )); - } - let contract = match output.output_type { - OutputType::Boolean => { - json!({ "type": "boolean", "nullable": output.nullable }) - } - OutputType::Integer => { - if let (Some(minimum), Some(maximum)) = (output.minimum, output.maximum) { - json!({ - "type": "integer", - "nullable": output.nullable, - "minimum": minimum, - "maximum": maximum, - }) - } else { - let schema = output_source_schema(integration, output)?; - let SchemaNode::Integer { min, max } = schema else { - bail!("integer output must resolve to an integer response field"); - }; - json!({ "type": "integer", "nullable": output.nullable, "minimum": min, "maximum": max }) - } - } - OutputType::String => json!({ - "type": "string", - "nullable": output.nullable, - "max_bytes": output.max_bytes.ok_or_else(|| anyhow!("string output bound is absent"))?, - }), - OutputType::Date => { - json!({ "type": "date", "nullable": output.nullable }) - } - OutputType::Object | OutputType::Array => { - bail!("structured output schema is absent") - } - OutputType::Presence => bail!("presence is an outcome, not a declared output"), - }; - Ok((name.clone(), contract)) - }) - .collect::>>()?; - Ok(Value::Object(outputs)) -} - -fn generated_notary_structured_output_contract( - schema: &StructuredOutputSchema, -) -> Result { - Ok(match schema { - StructuredOutputSchema::String { - nullable, - max_bytes, - } => json!({ - "type": "string", - "nullable": nullable, - "max_bytes": max_bytes, - }), - StructuredOutputSchema::Boolean { nullable } => json!({ - "type": "boolean", - "nullable": nullable, - }), - StructuredOutputSchema::Integer { - nullable, - minimum, - maximum, - } => json!({ - "type": "integer", - "nullable": nullable, - "minimum": minimum, - "maximum": maximum, - }), - StructuredOutputSchema::Date { nullable } => json!({ - "type": "date", - "nullable": nullable, - }), - StructuredOutputSchema::Object { - nullable, - max_bytes, - fields, - } => { - let fields = fields - .iter() - .map(|(name, field)| { - Ok(( - name.clone(), - json!({ - "required": field.required, - "schema": generated_notary_structured_output_contract(&field.schema)?, - }), - )) - }) - .collect::>>()?; - json!({ - "type": "object", - "nullable": nullable, - "max_bytes": max_bytes, - "fields": fields, - }) - } - StructuredOutputSchema::Array { - nullable, - max_bytes, - max_items, - items, - } => json!({ - "type": "array", - "nullable": nullable, - "max_bytes": max_bytes, - "max_items": max_items, - "items": generated_notary_structured_output_contract(items)?, - }), - }) -} - -fn output_source_schema<'a>( - integration: &'a IntegrationDocument, - output: &OutputDeclaration, -) -> Result<&'a SchemaNode> { - let (operation, _) = output - .from - .as_deref() - .ok_or_else(|| anyhow!("output path is absent"))? - .split_once('.') - .ok_or_else(|| anyhow!("output path is invalid"))?; - let operation = integration_operations(integration) - .get(operation) - .ok_or_else(|| anyhow!("output operation is absent"))?; - let mut schema = operation_record_schema(operation)?; - let pointer = output - .source_pointer - .as_deref() - .ok_or_else(|| anyhow!("HTTP output pointer is absent"))?; - for segment in output_pointer_segments(pointer)? { - schema = match schema { - SchemaNode::Object { fields, .. } => fields - .get(&segment) - .map(|field| &field.schema) - .ok_or_else(|| anyhow!("output path is absent from the response schema"))?, - _ => bail!("output path traverses a non-object response schema"), - }; - } - Ok(schema) -} - -fn output_pointer_segments(pointer: &str) -> Result> { - let pointer = pointer - .strip_prefix('/') - .ok_or_else(|| anyhow!("HTTP output pointer must be absolute"))?; - if pointer.is_empty() { - bail!("HTTP output pointer cannot select the root"); - } - pointer - .split('/') - .map(|segment| { - let decoded = segment.replace("~1", "/").replace("~0", "~"); - (!decoded.is_empty()) - .then_some(decoded) - .ok_or_else(|| anyhow!("HTTP output pointer contains an empty token")) - }) - .collect() -} - -fn generated_notary_claim_rule( - claim_id: &str, - claim: &ClaimDeclaration, - consultation_name: &str, - notary_consultation_name: &str, - integration: &IntegrationDocument, - loaded: &LoadedIntegration, -) -> Result<(String, bool, Value)> { - if let Some(fact_path) = &claim.output { - let (consultation, output_name) = fact_path - .split_once('.') - .ok_or_else(|| anyhow!("direct claim output path is invalid"))?; - if consultation != consultation_name { - bail!("direct claim output path names the wrong consultation"); - } - let output = integration - .outputs - .get(output_name) - .ok_or_else(|| anyhow!("direct claim references an unknown output"))?; - let value_type = match output.output_type { - OutputType::Boolean => "boolean", - OutputType::Integer => "integer", - OutputType::String => "string", - OutputType::Date => "date", - OutputType::Object => "object", - OutputType::Array => "array", - OutputType::Presence => bail!("presence cannot be referenced as an output"), - }; - let nullable = true; - let rule = json!({ - "type": "consultation_output", - "consultation": notary_consultation_name, - "output": output_name - }); - return Ok((value_type.to_string(), nullable, rule)); - } - let expression = claim - .cel - .as_ref() - .ok_or_else(|| anyhow!("claim source is absent"))?; - let expression = rewrite_notary_consultation_root( - expression, - consultation_name, - notary_consultation_name, - integration.outputs.keys().map(String::as_str), - ); - let (value_type, nullable) = infer_fixture_claim_type(claim_id, loaded)?; - Ok(( - value_type, - nullable, - json!({ "type": "cel", "expression": expression }), - )) -} - -// Crosswalk lowers these namespace-qualified helper calls before evaluating -// CEL. Authored consultation names remain product-neutral, so collisions are -// lowered only inside the generated Notary contract rather than rejected. -const CROSSWALK_CEL_HELPER_NAMESPACES: &[&str] = &[ - "address", "code", "date", "email", "geo", "id", "json", "list", "map", "name", "num", - "person", "phone", "privacy", "text", "type", "validate", -]; - -fn generated_notary_consultation_aliases<'a>( - names: impl IntoIterator, -) -> BTreeMap { - let names = names.into_iter().collect::>(); - let mut aliases = BTreeMap::new(); - let mut used = BTreeSet::new(); - for name in &names { - let base = if CROSSWALK_CEL_HELPER_NAMESPACES.contains(name) { - format!("relay_{name}") - } else { - (*name).to_string() - }; - let mut alias = base.clone(); - let mut suffix = 2_u8; - while used.contains(alias.as_str()) - || (alias.as_str() != *name && names.contains(alias.as_str())) - { - alias = format!("{base}_{suffix}"); - suffix = suffix.saturating_add(1); - } - used.insert(alias.clone()); - aliases.insert((*name).to_string(), alias); - } - aliases -} - -fn rewrite_notary_consultation_root<'a>( - expression: &str, - authored_name: &str, - notary_name: &str, - output_names: impl IntoIterator, -) -> String { - if authored_name == notary_name { - return expression.to_string(); - } - // Only typed consultation members move to the internal alias. This keeps - // real helper calls such as date.age_on(...) intact when an author also - // chose a helper namespace as the consultation name. - let mut members = output_names.into_iter().collect::>(); - members.extend(["matched", "outcome"]); - let bytes = expression.as_bytes(); - let mut rewritten = Vec::with_capacity(expression.len() + notary_name.len()); - let mut index = 0; - let mut quote = None; - let mut escaped = false; - while index < bytes.len() { - let byte = bytes[index]; - if let Some(active_quote) = quote { - rewritten.push(byte); - index += 1; - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == active_quote { - quote = None; - } - continue; - } - if matches!(byte, b'\'' | b'"' | b'`') { - quote = Some(byte); - rewritten.push(byte); - index += 1; - continue; - } - if !is_cel_identifier_start_byte(byte) { - rewritten.push(byte); - index += 1; - continue; - } - - let start = index; - index += 1; - while index < bytes.len() && is_cel_identifier_continue_byte(bytes[index]) { - index += 1; - } - let token = &expression[start..index]; - let previous = bytes[..start] - .iter() - .rfind(|byte| !byte.is_ascii_whitespace()) - .copied(); - let mut dot = index; - while dot < bytes.len() && bytes[dot].is_ascii_whitespace() { - dot += 1; - } - let mut member_start = dot.saturating_add(1); - while member_start < bytes.len() && bytes[member_start].is_ascii_whitespace() { - member_start += 1; - } - let mut member_end = member_start; - if bytes.get(dot) == Some(&b'.') - && bytes - .get(member_start) - .is_some_and(|byte| is_cel_identifier_start_byte(*byte)) - { - member_end += 1; - while member_end < bytes.len() && is_cel_identifier_continue_byte(bytes[member_end]) { - member_end += 1; - } - } - let member = expression.get(member_start..member_end); - if token == authored_name - && previous != Some(b'.') - && member.is_some_and(|member| members.contains(member)) - { - rewritten.extend_from_slice(notary_name.as_bytes()); - } else { - rewritten.extend_from_slice(token.as_bytes()); - } - } - String::from_utf8(rewritten).expect("CEL root rewriting preserves UTF-8") -} - -fn is_cel_identifier_start_byte(byte: u8) -> bool { - byte == b'_' || byte.is_ascii_alphabetic() -} - -fn is_cel_identifier_continue_byte(byte: u8) -> bool { - byte == b'_' || byte.is_ascii_alphanumeric() -} - -fn infer_fixture_claim_type( - claim_id: &str, - integration: &LoadedIntegration, -) -> Result<(String, bool)> { - let mut value_type = None; - let mut nullable = false; - for (_, fixture) in &integration.fixtures { - let Some(value) = fixture.expect.claims.get(claim_id) else { - continue; - }; - if value.is_null() { - nullable = true; - continue; - } - let candidate = if value.is_boolean() { - "boolean" - } else if value.as_i64().is_some() { - "integer" - } else if value - .as_str() - .is_some_and(|value| validate_full_date(value).is_ok()) - { - "date" - } else if value.is_string() { - "string" - } else { - bail!("CEL fixture claim must be a scalar v1 value"); - }; - match value_type { - Some(previous) if previous != candidate => { - bail!("CEL fixture claim has inconsistent result types") - } - None => value_type = Some(candidate), - Some(_) => {} - } - } - Ok(( - value_type - .ok_or_else(|| anyhow!("CEL claim lacks a typed fixture result"))? - .to_string(), - nullable, - )) -} - -fn claim_consultation_name<'a>( - service: &'a ServiceDeclaration, - claim: &'a ClaimDeclaration, -) -> Result<&'a str> { - if let Some(output) = &claim.output { - let (consultation, _) = output - .split_once('.') - .ok_or_else(|| anyhow!("direct claim output path is invalid"))?; - if service.consultations.contains_key(consultation) { - return Ok(consultation); - } - } - let roots = claim - .cel - .as_deref() - .map(cel_member_roots) - .transpose()? - .unwrap_or_default(); - let referenced = service - .consultations - .keys() - .filter(|name| roots.contains(name.as_str())) - .map(String::as_str) - .collect::>(); - match referenced.as_slice() { - [name] => Ok(name), - [] if service.consultations.len() == 1 => Ok(service - .consultations - .first_key_value() - .expect("one consultation was checked") - .0), - _ => bail!("v1 claim must depend on exactly one consultation"), - } -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct CelReferences { - roots: BTreeSet, - first_level_members: BTreeMap>, - uses_index: bool, -} - -fn cel_references(expression: &str) -> Result { - let program = cel::Program::compile(expression) - .map_err(|_| anyhow!("CEL expression contains invalid syntax"))?; - let mut references = CelReferences::default(); - collect_cel_references(program.expression(), &BTreeSet::new(), &mut references); - Ok(references) -} - -fn cel_member_roots(expression: &str) -> Result> { - Ok(cel_references(expression)?.roots) -} - -fn collect_cel_references( - expression: &IdedExpr, - locals: &BTreeSet, - references: &mut CelReferences, -) { - match &expression.expr { - Expr::Unspecified | Expr::Literal(_) => {} - Expr::Ident(name) => { - if !name.starts_with('@') && !locals.contains(name) { - references.roots.insert(name.clone()); - } - } - Expr::Select(select) => { - if let Expr::Ident(root) = &select.operand.expr { - if !root.starts_with('@') && !locals.contains(root) { - references - .first_level_members - .entry(root.clone()) - .or_default() - .insert(select.field.clone()); - } - } - collect_cel_references(&select.operand, locals, references); - } - Expr::Call(call) => { - if matches!( - call.func_name.as_str(), - cel::common::ast::operators::INDEX | cel::common::ast::operators::OPT_INDEX - ) { - references.uses_index = true; - } - if let Some(target) = &call.target { - collect_cel_references(target, locals, references); - } - for argument in &call.args { - collect_cel_references(argument, locals, references); - } - } - Expr::List(list) => { - for element in &list.elements { - collect_cel_references(element, locals, references); - } - } - Expr::Map(map) => { - for entry in &map.entries { - collect_cel_entry_references(&entry.expr, locals, references); - } - } - Expr::Struct(value) => { - for entry in &value.entries { - collect_cel_entry_references(&entry.expr, locals, references); - } - } - Expr::Comprehension(comprehension) => { - collect_cel_references(&comprehension.iter_range, locals, references); - collect_cel_references(&comprehension.accu_init, locals, references); - - 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_references( - &comprehension.loop_cond, - &scoped_locals, - references, - ); - collect_cel_references( - &comprehension.loop_step, - &scoped_locals, - references, - ); - collect_cel_references(&comprehension.result, &scoped_locals, references); - } - } -} - -fn collect_cel_entry_references( - entry: &EntryExpr, - locals: &BTreeSet, - references: &mut CelReferences, -) { - match entry { - EntryExpr::StructField(field) => { - collect_cel_references(&field.value, locals, references); - } - EntryExpr::MapEntry(entry) => { - collect_cel_references(&entry.key, locals, references); - collect_cel_references(&entry.value, locals, references); - } - } -} - -fn expanded_disclosure(disclosure: &DisclosureDeclaration) -> (&str, Vec<&str>) { - match disclosure { - DisclosureDeclaration::Mode(DisclosureMode::Value) => ("value", vec!["value", "redacted"]), - DisclosureDeclaration::Mode(DisclosureMode::Predicate) => { - ("predicate", vec!["predicate", "redacted"]) - } - DisclosureDeclaration::Mode(DisclosureMode::Redacted) => ("redacted", vec!["redacted"]), - DisclosureDeclaration::Policy { default, allowed } => ( - match default { - DisclosureMode::Value => "value", - DisclosureMode::Predicate => "predicate", - DisclosureMode::Redacted => "redacted", - }, - allowed - .iter() - .map(|mode| match mode { - DisclosureMode::Value => "value", - DisclosureMode::Predicate => "predicate", - DisclosureMode::Redacted => "redacted", - }) - .collect(), - ), - } -} - -fn disclosure_review_profiles(project: &RegistryProject) -> DisclosureReviewProfiles { - project - .services - .iter() - .filter(|(_, service)| service.kind == ServiceKind::Evidence) - .map(|(service_id, service)| { - let claims = service - .claims - .iter() - .map(|(claim_id, claim)| { - let (default, allowed) = expanded_disclosure(&claim.disclosure); - let default = match default { - "value" => DisclosureMode::Value, - "predicate" => DisclosureMode::Predicate, - "redacted" => DisclosureMode::Redacted, - _ => unreachable!("expanded disclosure uses a closed mode set"), - }; - let allowed = allowed - .into_iter() - .map(|mode| match mode { - "value" => DisclosureMode::Value, - "predicate" => DisclosureMode::Predicate, - "redacted" => DisclosureMode::Redacted, - _ => unreachable!("expanded disclosure uses a closed mode set"), - }) - .collect(); - ( - claim_id.clone(), - DisclosureReviewProfile { default, allowed }, - ) - }) - .collect(); - (service_id.clone(), claims) - }) - .collect() -} - -#[cfg(test)] -fn disclosure_rank(mode: DisclosureMode) -> u8 { - match mode { - DisclosureMode::Redacted => 0, - DisclosureMode::Predicate => 1, - DisclosureMode::Value => 2, - } -} - -#[cfg(test)] -fn disclosure_change_classes( - current: &DisclosureReviewProfiles, - baseline: Option<&Value>, -) -> (bool, bool) { - let Some(baseline) = baseline.and_then(|review| review.get("disclosure_profiles")) else { - return (true, true); - }; - let Ok(previous) = serde_json::from_value::(baseline.clone()) else { - return (true, true); - }; - let mut narrowing = false; - let mut widening = false; - let service_ids = current - .keys() - .chain(previous.keys()) - .collect::>(); - for service_id in service_ids { - let current_claims = current.get(service_id); - let previous_claims = previous.get(service_id); - let claim_ids = current_claims - .into_iter() - .flat_map(BTreeMap::keys) - .chain(previous_claims.into_iter().flat_map(BTreeMap::keys)) - .collect::>(); - for claim_id in claim_ids { - match ( - current_claims.and_then(|claims| claims.get(claim_id)), - previous_claims.and_then(|claims| claims.get(claim_id)), - ) { - (Some(current), Some(previous)) => { - let current_no_wider = disclosure_profile_no_wider(current, previous); - let previous_no_wider = disclosure_profile_no_wider(previous, current); - narrowing |= current_no_wider && !previous_no_wider; - widening |= previous_no_wider && !current_no_wider; - if !current_no_wider && !previous_no_wider { - narrowing = true; - widening = true; - } - } - (Some(current), None) => { - if current.default == DisclosureMode::Redacted - && current.allowed == BTreeSet::from([DisclosureMode::Redacted]) - { - narrowing = true; - } else { - widening = true; - } - } - (None, Some(_)) => narrowing = true, - (None, None) => unreachable!("claim id came from one disclosure map"), - } - } - } - (narrowing, widening) -} - -#[cfg(test)] -fn disclosure_profile_no_wider( - candidate: &DisclosureReviewProfile, - reference: &DisclosureReviewProfile, -) -> bool { - disclosure_rank(candidate.default) <= disclosure_rank(reference.default) - && candidate.allowed.iter().all(|candidate_mode| { - reference.allowed.iter().any(|reference_mode| { - disclosure_rank(*candidate_mode) <= disclosure_rank(*reference_mode) - }) - }) -} - -fn normalize_credential_format(format: &str) -> String { - match format { - "dc+sd-jwt" => "application/dc+sd-jwt".to_string(), - value => value.to_string(), - } -} - -fn parse_validity_seconds(value: &str) -> Result { - let (number, multiplier) = if let Some(value) = value.strip_suffix('s') { - (value, 1) - } else if let Some(value) = value.strip_suffix('m') { - (value, 60) - } else if let Some(value) = value.strip_suffix('h') { - (value, 3600) - } else { - bail!("credential validity must use s, m, or h") - }; - number - .parse::()? - .checked_mul(multiplier) - .ok_or_else(|| anyhow!("credential validity overflows")) -} - -#[cfg(test)] -mod notary_compiler_tests { - use super::*; - - #[test] - fn compiler_rejects_claims_without_relay_consultations() { - let project: RegistryProject = serde_norway::from_str( - r#"version: 1 -registry: { id: compiler-boundary-test } -services: - evaluation: - kind: evidence - version: 1 - purpose: test - legal_basis: test - consent: not_required - access: { scopes: [evidence:test:read] } - claims: - declaration: - cel: "true" - value: { type: boolean } - disclosure: predicate - credential_profiles: - declaration: - format: dc+sd-jwt - type: https://credentials.invalid/declaration/v1 - validity: 5m - claims: [declaration] -"#, - ) - .expect("compiler-boundary project deserializes"); - let service = &project.services["evaluation"]; - let credential = &service.credential_profiles["declaration"]; - - let error = validate_compiler_credential_profile_evidence( - "evaluation", - "declaration", - service, - credential, - ) - .expect_err("compiler boundary must reject claims without Relay consultations"); - assert!(format!("{error:#}").contains("must derive from one declared Relay consultation")); - } - - #[test] - fn crosswalk_helper_namespaces_receive_unique_internal_aliases() { - let mut names = CROSSWALK_CEL_HELPER_NAMESPACES.to_vec(); - names.extend(["relay_person", "relay_person_2", "household"]); - let aliases = generated_notary_consultation_aliases(names.iter().copied()); - - assert_eq!(aliases["person"], "relay_person_3"); - assert_eq!(aliases["relay_person"], "relay_person"); - assert_eq!(aliases["relay_person_2"], "relay_person_2"); - assert_eq!(aliases["household"], "household"); - assert!(CROSSWALK_CEL_HELPER_NAMESPACES - .iter() - .all(|name| aliases[*name] != *name)); - assert_eq!( - aliases.values().collect::>().len(), - aliases.len() - ); - } - - #[test] - fn consultation_root_rewrite_is_token_and_string_literal_aware() { - let expression = r#"person.matched - && person . status == "person.status" - && 'escaped \' person.matched' - && `person.status` - && payload.person.status == "active" - && person.age(person.birth_date, today) > 17"#; - let rewritten = rewrite_notary_consultation_root( - expression, - "person", - "relay_person", - ["status", "birth_date"], - ); - - assert_eq!( - rewritten, - r#"relay_person.matched - && relay_person . status == "person.status" - && 'escaped \' person.matched' - && `person.status` - && payload.person.status == "active" - && person.age(relay_person.birth_date, today) > 17"# - ); - } - - #[test] - fn consultation_root_rewrite_leaves_unrelated_identifiers_unchanged() { - let expression = "person_id == 'person.matched' && other.matched"; - assert_eq!( - rewrite_notary_consultation_root(expression, "person", "relay_person", ["person_id"]), - expression - ); - assert_eq!( - rewrite_notary_consultation_root( - "household.matched", - "household", - "household", - std::iter::empty() - ), - "household.matched" - ); - } -} diff --git a/crates/registryctl/src/project_authoring/compiler/relay.rs b/crates/registryctl/src/project_authoring/compiler/relay.rs index 82d8d0bd0..58b1f93db 100644 --- a/crates/registryctl/src/project_authoring/compiler/relay.rs +++ b/crates/registryctl/src/project_authoring/compiler/relay.rs @@ -4,13 +4,6 @@ pub(crate) const LOCAL_RELAY_MATCH_KEY_HASH_ENV: &str = "REGISTRYCTL_LOCAL_RELAY_MATCH_KEY_HASH"; pub(crate) const LOCAL_RELAY_NO_MATCH_KEY_HASH_ENV: &str = "REGISTRYCTL_LOCAL_RELAY_NO_MATCH_KEY_HASH"; -const LOCAL_NOTARY_WORKLOAD_ISSUER: &str = "https://registryctl-local-notary.invalid"; -const LOCAL_NOTARY_WORKLOAD_JWKS_FILE: &str = - "/run/registry/dev-public/notary-workload-jwks.json"; -const LOCAL_NOTARY_RELAY_BASE_URL: &str = "http://10.89.0.4:8080"; -const LOCAL_NOTARY_RELAY_CLIENT_ID: &str = "registryctl-local-notary"; -const LOCAL_NOTARY_RELAY_TOKEN_FILE: &str = "/run/secrets/relay-workload-token"; -const LOCAL_NOTARY_RELAY_PRIVATE_CIDR: &str = "10.89.0.4/32"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GeneratedRelayConfigKind { @@ -165,26 +158,10 @@ fn generated_relay_config( allowed_clients.sort(); allowed_clients.dedup(); let relay_origin = normalize_url_scheme(&relay.origin)?; - let local_notary_add_on = kind == GeneratedRelayConfigKind::Consultation - && matches!(environment.deployment.profile, DeploymentProfile::Local) - && relay.issuer == LOCAL_NOTARY_WORKLOAD_ISSUER - && environment.notary_relay.as_ref().is_some_and(|binding| { - binding.base_url == LOCAL_NOTARY_RELAY_BASE_URL - && binding.workload_client_id == LOCAL_NOTARY_RELAY_CLIENT_ID - && binding.token_file == Path::new(LOCAL_NOTARY_RELAY_TOKEN_FILE) - }); - let relay_issuer = if local_notary_add_on { - LOCAL_NOTARY_WORKLOAD_ISSUER.to_string() - } else { - normalize_url_scheme(&relay.issuer)? - }; - let relay_jwks_url = if local_notary_add_on { - None - } else { - Some(normalize_url_scheme(&relay.jwks_url)?) - }; - let allow_dev_insecure_fetch_urls = !local_notary_add_on - && (url_uses_http(&relay.issuer) || url_uses_http(&relay.jwks_url)); + let relay_issuer = normalize_url_scheme(&relay.issuer)?; + let relay_jwks_url = normalize_url_scheme(&relay.jwks_url)?; + let allow_dev_insecure_fetch_urls = + url_uses_http(&relay.issuer) || url_uses_http(&relay.jwks_url); let oidc_auth = |allowed_clients: Vec| { let mut oidc = json!({ "issuer": relay_issuer, @@ -195,19 +172,7 @@ fn generated_relay_config( let oidc = oidc .as_object_mut() .expect("compiler-owned OIDC configuration is an object"); - if local_notary_add_on { - oidc.insert( - "development_jwks_file".to_string(), - json!(LOCAL_NOTARY_WORKLOAD_JWKS_FILE), - ); - } else { - oidc.insert( - "jwks_url".to_string(), - json!(relay_jwks_url - .as_deref() - .expect("non-local OIDC source has a JWKS URL")), - ); - } + oidc.insert("jwks_url".to_string(), json!(relay_jwks_url)); json!({ "mode": "oidc", "oidc": oidc, @@ -257,11 +222,11 @@ fn generated_relay_config( oidc_auth(allowed_clients) } } else { - let Some(workload) = &environment.notary_relay else { - bail!("consultation Relay requires a Notary-to-Relay workload binding"); - }; - let allowed_clients = vec![workload.workload_client_id.clone()]; - oidc_auth(allowed_clients) + let consultation = relay + .consultation + .as_ref() + .ok_or_else(|| anyhow!("consultation Relay identity binding is absent"))?; + oidc_auth(vec![consultation.client_id.clone()]) }; let instance_id = if kind == GeneratedRelayConfigKind::Consultation { format!("{}-consultation", relay_service.service) @@ -290,16 +255,16 @@ fn generated_relay_config( "deployment": { "profile": environment.deployment.profile.as_str() }, }); if kind == GeneratedRelayConfigKind::Consultation && !profiles.is_empty() && !packs.is_empty() { - let workload = environment - .notary_relay + let consultation = relay + .consultation .as_ref() - .ok_or_else(|| anyhow!("Notary-to-Relay workload binding is absent"))?; + .ok_or_else(|| anyhow!("consultation Relay identity binding is absent"))?; config["consultation"] = json!({ "authorized_workload": { "audience": relay.audience, "client_claim_selector": "azp", - "client_value": workload.workload_client_id, - "principal_id": workload.workload_client_id, + "client_value": consultation.client_id, + "principal_id": consultation.principal_id, }, "state_plane": { "database_url_env": "REGISTRY_RELAY_CONSULTATION_DATABASE_URL", diff --git a/crates/registryctl/src/project_authoring/compiler/semantic_impact.rs b/crates/registryctl/src/project_authoring/compiler/semantic_impact.rs index 6e5d2f692..8ccf02bcf 100644 --- a/crates/registryctl/src/project_authoring/compiler/semantic_impact.rs +++ b/crates/registryctl/src/project_authoring/compiler/semantic_impact.rs @@ -10,7 +10,6 @@ fn project_semantic_impact_report( loaded: &LoadedRegistryProject, baseline: Option<&Value>, - disclosure_digest: &str, ) -> ProjectSemanticImpactReportV1 { let report_baseline = if baseline.is_some() { ProjectBaseline::VerifiedSignedBundle @@ -22,11 +21,12 @@ fn project_semantic_impact_report( } else { SemanticDirection::Unbaselined }; - let changes = changed_semantic_dimensions(loaded, baseline, disclosure_digest) + let changes = changed_semantic_dimensions(loaded, baseline) .into_iter() .filter(|dimension| { affected_products(loaded, baseline, *dimension).any() - && (baseline.is_some() || semantic_dimension_has_current_subjects(loaded, *dimension)) + && (baseline.is_some() + || semantic_dimension_has_current_subjects(loaded, *dimension)) }) .map(|dimension| semantic_impact_for_dimension(loaded, baseline, dimension, direction)) .collect(); @@ -43,11 +43,6 @@ fn semantic_dimension_has_current_subjects( dimension: SemanticDimension, ) -> bool { match dimension { - SemanticDimension::Claim | SemanticDimension::Disclosure => loaded - .project - .services - .values() - .any(|service| !service.claims.is_empty()), SemanticDimension::Integration => { !loaded.project.integrations.is_empty() || !loaded.project.entities.is_empty() @@ -65,17 +60,9 @@ fn semantic_dimension_has_current_subjects( fn changed_semantic_dimensions( loaded: &LoadedRegistryProject, baseline: Option<&Value>, - disclosure_digest: &str, ) -> Vec { let previous_digests = baseline.and_then(|state| state.get("semantic_digests")); let mut changes = [ - ( - SemanticDimension::Claim, - loaded.semantic_digests.claim.as_str(), - previous_digests - .and_then(|digests| digests.get("claim")) - .and_then(Value::as_str), - ), ( SemanticDimension::Integration, loaded.semantic_digests.integration.as_str(), @@ -97,13 +84,6 @@ fn changed_semantic_dimensions( .and_then(|digests| digests.get("operator_security")) .and_then(Value::as_str), ), - ( - SemanticDimension::Disclosure, - disclosure_digest, - baseline - .and_then(|state| state.get("disclosure_digest")) - .and_then(Value::as_str), - ), ] .into_iter() .filter(|(_, current, previous)| *previous != Some(*current)) @@ -152,7 +132,10 @@ fn semantic_impact_for_dimension( struct AffectedProducts { relay_public: bool, relay_consultation: bool, - notary: bool, +} + +fn project_has_relay_topology(project: &RegistryProject) -> bool { + !project.integrations.is_empty() || !project.entities.is_empty() || !project.services.is_empty() } impl AffectedProducts { @@ -160,7 +143,6 @@ impl AffectedProducts { Self { relay_public: false, relay_consultation: false, - notary: false, } } @@ -168,15 +150,10 @@ impl AffectedProducts { Self { relay_public: true, relay_consultation: true, - notary: true, } } const fn any(self) -> bool { - self.relay_public || self.relay_consultation || self.notary - } - - const fn any_relay(self) -> bool { self.relay_public || self.relay_consultation } @@ -184,7 +161,6 @@ impl AffectedProducts { Self { relay_public: self.relay_public || other.relay_public, relay_consultation: self.relay_consultation || other.relay_consultation, - notary: self.notary || other.notary, } } @@ -192,7 +168,6 @@ impl AffectedProducts { Self { relay_public: self.relay_public && other.relay_public, relay_consultation: self.relay_consultation && other.relay_consultation, - notary: self.notary && other.notary, } } } @@ -202,7 +177,7 @@ fn affected_products( baseline: Option<&Value>, dimension: SemanticDimension, ) -> AffectedProducts { - let (requires_relay, requires_notary) = project_product_topology(&loaded.project); + let requires_relay = project_has_relay_topology(&loaded.project); let current_products = AffectedProducts { relay_public: requires_relay, relay_consultation: requires_relay @@ -211,26 +186,18 @@ fn affected_products( .services .values() .any(|service| !service.consultations.is_empty()), - notary: requires_notary, }; let product_topology = current_products.union(baseline_product_topology(baseline)); let dimension_products = match dimension { - SemanticDimension::Claim | SemanticDimension::Disclosure => AffectedProducts { - relay_public: false, - relay_consultation: false, - notary: true, - }, SemanticDimension::Integration => AffectedProducts { relay_public: !product_topology.relay_consultation, relay_consultation: product_topology.relay_consultation, - notary: true, }, SemanticDimension::ServicePolicy | SemanticDimension::OperatorSecurity | SemanticDimension::Compiler => AffectedProducts { relay_public: true, relay_consultation: true, - notary: true, }, }; dimension_products.intersect(product_topology) @@ -248,7 +215,6 @@ fn baseline_product_topology(baseline: Option<&Value>) -> AffectedProducts { for product in products { match product.as_str() { Some("relay") => topology.relay_public = true, - Some("notary") => topology.notary = true, _ => return AffectedProducts::both(), } } @@ -267,15 +233,14 @@ fn baseline_product_topology(baseline: Option<&Value>) -> AffectedProducts { relay_consultation: digests .get("relay_consultation") .is_some_and(Value::is_string), - notary: digests.get("notary").is_some_and(Value::is_string), }; if topology.any() { return topology; } } - // Older or malformed signed baselines do not provide enough product - // inventory to prove that a removed product has no retirement obligation. + // Older or malformed signed baselines do not distinguish the two Relay + // deployment lanes, so retain obligations for both. AffectedProducts::both() } @@ -293,8 +258,8 @@ fn affected_subjects( // A dimension digest cannot identify the changed member. Include the full // authored identity closure that can feed compilation, fixture validation, - // policy review, or disclosure review. Only stable authored identifiers and - // aliases are retained, never authored values or filesystem locations. + // policy review, or route review. Only stable authored identifiers and aliases + // are retained, never authored values or filesystem locations. for (integration_alias, integration) in &loaded.integrations { add(AffectedSubjectKind::Integration, integration_alias.clone()); for (_, fixture) in &integration.fixtures { @@ -312,11 +277,6 @@ fn affected_subjects( format!("{service_id}.{consultation_id}"), ); } - for claim_id in service.claims.keys() { - let id = format!("{service_id}.{claim_id}"); - add(AffectedSubjectKind::Claim, id.clone()); - add(AffectedSubjectKind::Disclosure, id); - } } if products.relay_public { @@ -331,7 +291,13 @@ fn affected_subjects( "registry-relay.consultation.config".to_string(), ); } - if products.any_relay() { + if products.relay_public { + add( + AffectedSubjectKind::GeneratedArtifact, + "registry-relay.runtime-config".to_string(), + ); + } + if products.relay_consultation { for artifact in [ "registry-relay.consultation-contracts", "registry-relay.integration-packs", @@ -341,19 +307,6 @@ fn affected_subjects( add(AffectedSubjectKind::GeneratedArtifact, artifact.to_string()); } } - if products.notary { - add( - AffectedSubjectKind::ProductInput, - "registry-notary.config".to_string(), - ); - for artifact in [ - "registry-notary.claim-configuration", - "registry-notary.disclosure-policy", - "registry-notary.runtime-config", - ] { - add(AffectedSubjectKind::GeneratedArtifact, artifact.to_string()); - } - } subjects .into_iter() @@ -371,9 +324,7 @@ fn has_consultation_signing_input( .values() .any(|service| !service.consultations.is_empty()) || baseline - .and_then(|state| { - state.pointer("/generated_closure_digests/relay_consultation") - }) + .and_then(|state| state.pointer("/generated_closure_digests/relay_consultation")) .is_some_and(Value::is_string) } @@ -383,21 +334,16 @@ const fn subject_kind_rank(kind: AffectedSubjectKind) -> u8 { AffectedSubjectKind::Fixture => 1, AffectedSubjectKind::ServicePolicy => 2, AffectedSubjectKind::Consultation => 3, - AffectedSubjectKind::Claim => 4, - AffectedSubjectKind::Disclosure => 5, - AffectedSubjectKind::ProductInput => 6, - AffectedSubjectKind::GeneratedArtifact => 7, + AffectedSubjectKind::ProductInput => 4, + AffectedSubjectKind::GeneratedArtifact => 5, } } fn consumers(products: AffectedProducts) -> Vec { let mut consumers = vec![ImpactConsumer::RegistryctlAuthoring]; - if products.any_relay() { + if products.any() { consumers.push(ImpactConsumer::RegistryRelay); } - if products.notary { - consumers.push(ImpactConsumer::RegistryNotary); - } consumers.push(ImpactConsumer::EditorTooling); consumers.push(ImpactConsumer::DocsGenerator); consumers.push(ImpactConsumer::BundleSigner); @@ -418,26 +364,11 @@ fn review_classes( ImpactReviewClass::Privacy, ImpactReviewClass::Security, ImpactReviewClass::Relay, - ImpactReviewClass::Notary, ImpactReviewClass::Compatibility, ImpactReviewClass::Testing, ImpactReviewClass::Operations, ImpactReviewClass::Release, ], - SemanticDimension::Claim | SemanticDimension::Disclosure => vec![ - ImpactReviewClass::Contract, - ImpactReviewClass::Authoring, - ImpactReviewClass::Semantics, - ImpactReviewClass::Interoperability, - ImpactReviewClass::Privacy, - ImpactReviewClass::Security, - ImpactReviewClass::Notary, - ImpactReviewClass::Compatibility, - ImpactReviewClass::Documentation, - ImpactReviewClass::Testing, - ImpactReviewClass::Operations, - ImpactReviewClass::Release, - ], SemanticDimension::Integration | SemanticDimension::ServicePolicy | SemanticDimension::Compiler => vec![ @@ -448,7 +379,6 @@ fn review_classes( ImpactReviewClass::Privacy, ImpactReviewClass::Security, ImpactReviewClass::Relay, - ImpactReviewClass::Notary, ImpactReviewClass::Compatibility, ImpactReviewClass::Documentation, ImpactReviewClass::Testing, @@ -456,19 +386,13 @@ fn review_classes( ImpactReviewClass::Release, ], }; - if !products.any_relay() { + if !products.any() { classes.retain(|class| *class != ImpactReviewClass::Relay); } - if !products.notary { - classes.retain(|class| *class != ImpactReviewClass::Notary); - } classes } -fn product_impacts( - dimension: SemanticDimension, - products: AffectedProducts, -) -> Vec { +fn product_impacts(dimension: SemanticDimension, products: AffectedProducts) -> Vec { let runtime_impact = if dimension == SemanticDimension::OperatorSecurity { ProductImpactClass::Reconfigure } else { @@ -478,18 +402,12 @@ fn product_impacts( product: ProjectProduct::Registryctl, impact: ProductImpactClass::Revalidate, }]; - if products.any_relay() { + if products.any() { impacts.push(ProductImpact { product: ProjectProduct::Relay, impact: runtime_impact, }); } - if products.notary { - impacts.push(ProductImpact { - product: ProjectProduct::Notary, - impact: runtime_impact, - }); - } impacts.push(ProductImpact { product: ProjectProduct::Docs, impact: ProductImpactClass::Republish, @@ -499,15 +417,11 @@ fn product_impacts( fn requirements(products: AffectedProducts) -> ImpactRequirements { let actions = [ - ( - products.relay_public, - RequiredProductAction::RelayPublic, - ), + (products.relay_public, RequiredProductAction::RelayPublic), ( products.relay_consultation, RequiredProductAction::RelayConsultation, ), - (products.notary, RequiredProductAction::Notary), ] .into_iter() .filter_map(|(required, action)| required.then_some(action)) @@ -541,20 +455,14 @@ mod semantic_impact_tests { .expect("Relay-only semantic-impact fixture loads") } - fn disclosure_digest() -> String { - format!("sha256:{}", "d".repeat(64)) - } - - fn matching_baseline(loaded: &LoadedRegistryProject, disclosure_digest: &str) -> Value { + fn matching_baseline(loaded: &LoadedRegistryProject) -> Value { json!({ "compiler_version": env!("CARGO_PKG_VERSION"), "semantic_digests": { - "claim": loaded.semantic_digests.claim, "integration": loaded.semantic_digests.integration, "service_policy": loaded.semantic_digests.service_policy, "operator_security": loaded.semantic_digests.operator_security, }, - "disclosure_digest": disclosure_digest, }) } @@ -574,12 +482,11 @@ mod semantic_impact_tests { match consumer { ImpactConsumer::RegistryctlAuthoring => 0, ImpactConsumer::RegistryRelay => 1, - ImpactConsumer::RegistryNotary => 2, - ImpactConsumer::EditorTooling => 3, - ImpactConsumer::DocsGenerator => 4, - ImpactConsumer::BundleSigner => 5, - ImpactConsumer::DeploymentTooling => 6, - ImpactConsumer::Operator => 7, + ImpactConsumer::EditorTooling => 2, + ImpactConsumer::DocsGenerator => 3, + ImpactConsumer::BundleSigner => 4, + ImpactConsumer::DeploymentTooling => 5, + ImpactConsumer::Operator => 6, } } @@ -592,12 +499,11 @@ mod semantic_impact_tests { ImpactReviewClass::Privacy => 4, ImpactReviewClass::Security => 5, ImpactReviewClass::Relay => 6, - ImpactReviewClass::Notary => 7, - ImpactReviewClass::Compatibility => 8, - ImpactReviewClass::Documentation => 9, - ImpactReviewClass::Testing => 10, - ImpactReviewClass::Operations => 11, - ImpactReviewClass::Release => 12, + ImpactReviewClass::Compatibility => 7, + ImpactReviewClass::Documentation => 8, + ImpactReviewClass::Testing => 9, + ImpactReviewClass::Operations => 10, + ImpactReviewClass::Release => 11, } } @@ -605,27 +511,23 @@ mod semantic_impact_tests { match product { ProjectProduct::Registryctl => 0, ProjectProduct::Relay => 1, - ProjectProduct::Notary => 2, - ProjectProduct::Editor => 3, - ProjectProduct::Docs => 4, + ProjectProduct::Editor => 2, + ProjectProduct::Docs => 3, } } #[test] - fn initial_report_is_dimension_precise_and_preserves_legacy_projection() { + fn initial_report_is_dimension_precise() { let loaded = loaded_project(); - let disclosure_digest = disclosure_digest(); - let report = project_semantic_impact_report(&loaded, None, &disclosure_digest); + let report = project_semantic_impact_report(&loaded, None); assert_eq!(report.baseline, ProjectBaseline::InitialWithoutBaseline); assert_eq!( dimensions(&report), vec![ - SemanticDimension::Claim, SemanticDimension::Integration, SemanticDimension::ServicePolicy, SemanticDimension::OperatorSecurity, - SemanticDimension::Disclosure, ] ); assert!(report.changes.iter().all(|change| { @@ -633,30 +535,23 @@ mod semantic_impact_tests { && change.direction == SemanticDirection::Unbaselined })); - let legacy = semantic_change_records(&loaded, None, &disclosure_digest); - assert_eq!( - serde_json::to_value(report.dimension_only_changes()).expect("projection serializes"), - serde_json::to_value(legacy).expect("legacy changes serialize"), - ); + assert_eq!(report.dimension_only_changes().len(), report.changes.len()); } #[test] fn verified_baseline_reports_each_changed_dimension_conservatively() { let loaded = loaded_project(); - let disclosure_digest = disclosure_digest(); let dimension_cases = [ - (SemanticDimension::Claim, "claim"), (SemanticDimension::Integration, "integration"), (SemanticDimension::ServicePolicy, "service_policy"), (SemanticDimension::OperatorSecurity, "operator_security"), - (SemanticDimension::Disclosure, "disclosure_digest"), (SemanticDimension::Compiler, "compiler_version"), ]; for (expected, key) in dimension_cases { - let mut baseline = matching_baseline(&loaded, &disclosure_digest); + let mut baseline = matching_baseline(&loaded); match key { - "disclosure_digest" | "compiler_version" => { + "compiler_version" => { baseline[key] = Value::String("previous".to_string()); } semantic_digest => { @@ -664,8 +559,7 @@ mod semantic_impact_tests { Value::String(format!("sha256:{}", "0".repeat(64))); } } - let report = - project_semantic_impact_report(&loaded, Some(&baseline), &disclosure_digest); + let report = project_semantic_impact_report(&loaded, Some(&baseline)); assert_eq!(report.baseline, ProjectBaseline::VerifiedSignedBundle); assert_eq!( dimensions(&report), @@ -681,9 +575,8 @@ mod semantic_impact_tests { #[test] fn matching_verified_baseline_has_no_changes() { let loaded = loaded_project(); - let disclosure_digest = disclosure_digest(); - let baseline = matching_baseline(&loaded, &disclosure_digest); - let report = project_semantic_impact_report(&loaded, Some(&baseline), &disclosure_digest); + let baseline = matching_baseline(&loaded); + let report = project_semantic_impact_report(&loaded, Some(&baseline)); assert_eq!(report.baseline, ProjectBaseline::VerifiedSignedBundle); assert!(report.changes.is_empty()); @@ -691,9 +584,9 @@ mod semantic_impact_tests { } #[test] - fn relay_only_impact_never_requires_notary_review_signing_or_activation() { + fn relay_public_impact_uses_only_the_public_action_lane() { let loaded = loaded_relay_only_project(); - let report = project_semantic_impact_report(&loaded, None, &disclosure_digest()); + let report = project_semantic_impact_report(&loaded, None); assert_eq!( dimensions(&report), @@ -704,29 +597,19 @@ mod semantic_impact_tests { ); for change in report.changes { assert!(change.consumers.contains(&ImpactConsumer::RegistryRelay)); - assert!(!change.consumers.contains(&ImpactConsumer::RegistryNotary)); assert!(change.review_classes.contains(&ImpactReviewClass::Relay)); - assert!(!change.review_classes.contains(&ImpactReviewClass::Notary)); assert!(change .product_impacts .iter() .any(|impact| impact.product == ProjectProduct::Relay)); - assert!(!change - .product_impacts - .iter() - .any(|impact| impact.product == ProjectProduct::Notary)); let expected = actions(&[RequiredProductAction::RelayPublic]); assert_eq!(change.requirements.signing, expected); assert_eq!(change.requirements.activation, expected); assert_eq!(change.requirements.restart, expected); - assert!(!change.affected_subjects.iter().any(|subject| { - subject.id.starts_with("registry-notary.") - || subject.id == "registry-relay.consultation.config" - || matches!( - subject.kind, - AffectedSubjectKind::Claim | AffectedSubjectKind::Disclosure - ) - })); + assert!(!change + .affected_subjects + .iter() + .any(|subject| { subject.id == "registry-relay.consultation.config" })); } } @@ -746,102 +629,24 @@ mod semantic_impact_tests { .map(|subject| subject.id.as_str()) .collect::>(); - assert_eq!( - product_inputs, - vec![ - "registry-notary.config", - "registry-relay.consultation.config", - ] - ); - } - - #[test] - fn verified_baseline_product_removal_keeps_removed_product_obligations() { - let current = loaded_relay_only_project(); - let previous = loaded_project(); - let mut baseline = matching_baseline( - &previous, - &format!("sha256:{}", "b".repeat(64)), - ); - for digest in [ - "claim", - "integration", - "service_policy", - "operator_security", - ] { - baseline["semantic_digests"][digest] = - Value::String(format!("sha256:{}", "0".repeat(64))); - } - baseline["promotion_projection"] = json!({ - "products": ["relay", "notary"], - }); - baseline["generated_closure_digests"] = json!({ - "relay_consultation": format!("sha256:{}", "c".repeat(64)), - }); - let report = - project_semantic_impact_report(¤t, Some(&baseline), &disclosure_digest()); - - for dimension in [SemanticDimension::Claim, SemanticDimension::Disclosure] { - let change = report - .changes - .iter() - .find(|change| change.dimension == dimension) - .unwrap_or_else(|| panic!("{dimension:?} product-removal impact is retained")); - assert!(change.consumers.contains(&ImpactConsumer::RegistryNotary)); - assert!(!change.consumers.contains(&ImpactConsumer::RegistryRelay)); - let expected = actions(&[RequiredProductAction::Notary]); - assert_eq!(change.requirements.signing, expected); - assert_eq!(change.requirements.activation, expected); - assert_eq!(change.requirements.restart, expected); - } - for dimension in [ - SemanticDimension::Integration, - SemanticDimension::ServicePolicy, - SemanticDimension::OperatorSecurity, - ] { - let change = report - .changes - .iter() - .find(|change| change.dimension == dimension) - .unwrap_or_else(|| panic!("{dimension:?} product-removal impact is retained")); - let expected = if dimension == SemanticDimension::Integration { - actions(&[ - RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, - ]) - } else { - actions(&[ - RequiredProductAction::RelayPublic, - RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, - ]) - }; - assert_eq!(change.requirements.signing, expected); - assert_eq!(change.requirements.activation, expected); - assert_eq!(change.requirements.restart, expected); - } + assert_eq!(product_inputs, vec!["registry-relay.consultation.config"]); } #[test] fn legacy_baseline_without_product_inventory_stays_conservative() { let current = loaded_relay_only_project(); let previous = loaded_project(); - let mut baseline = - matching_baseline(&previous, &format!("sha256:{}", "b".repeat(64))); + let mut baseline = matching_baseline(&previous); baseline["semantic_digests"]["integration"] = Value::String(format!("sha256:{}", "0".repeat(64))); - let report = - project_semantic_impact_report(¤t, Some(&baseline), &disclosure_digest()); + let report = project_semantic_impact_report(¤t, Some(&baseline)); let integration = report .changes .iter() .find(|change| change.dimension == SemanticDimension::Integration) .expect("legacy baseline conservatively retains integration impact"); - let expected = actions(&[ - RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, - ]); + let expected = actions(&[RequiredProductAction::RelayConsultation]); assert_eq!(integration.requirements.signing, expected); assert_eq!(integration.requirements.activation, expected); assert_eq!(integration.requirements.restart, expected); @@ -851,23 +656,15 @@ mod semantic_impact_tests { fn each_dimension_has_conservative_signing_activation_and_restart() { let loaded = loaded_project(); let expected = [ - ( - SemanticDimension::Claim, - actions(&[RequiredProductAction::Notary]), - ), ( SemanticDimension::Integration, - actions(&[ - RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, - ]), + actions(&[RequiredProductAction::RelayConsultation]), ), ( SemanticDimension::ServicePolicy, actions(&[ RequiredProductAction::RelayPublic, RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, ]), ), ( @@ -875,30 +672,20 @@ mod semantic_impact_tests { actions(&[ RequiredProductAction::RelayPublic, RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, ]), ), - ( - SemanticDimension::Disclosure, - actions(&[RequiredProductAction::Notary]), - ), ( SemanticDimension::Compiler, actions(&[ RequiredProductAction::RelayPublic, RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, ]), ), ]; for (dimension, expected) in expected { - let impact = semantic_impact_for_dimension( - &loaded, - None, - dimension, - SemanticDirection::Changed, - ); + let impact = + semantic_impact_for_dimension(&loaded, None, dimension, SemanticDirection::Changed); assert_eq!(impact.requirements.signing, expected); assert_eq!(impact.requirements.activation, expected); assert_eq!(impact.requirements.restart, expected); @@ -918,19 +705,13 @@ mod semantic_impact_tests { fn each_dimension_names_the_full_safe_authored_identity_closure() { let loaded = loaded_project(); for dimension in [ - SemanticDimension::Claim, SemanticDimension::Integration, SemanticDimension::ServicePolicy, SemanticDimension::OperatorSecurity, - SemanticDimension::Disclosure, SemanticDimension::Compiler, ] { - let impact = semantic_impact_for_dimension( - &loaded, - None, - dimension, - SemanticDirection::Changed, - ); + let impact = + semantic_impact_for_dimension(&loaded, None, dimension, SemanticDirection::Changed); let subjects = impact .affected_subjects .iter() @@ -974,14 +755,6 @@ mod semantic_impact_tests { subject.kind == AffectedSubjectKind::Consultation && subject.id == "health-verification.health" })); - assert!(impact.affected_subjects.iter().any(|subject| { - subject.kind == AffectedSubjectKind::Claim - && subject.id == "health-verification.child-program-active" - })); - assert!(impact.affected_subjects.iter().any(|subject| { - subject.kind == AffectedSubjectKind::Disclosure - && subject.id == "health-verification.child-program-active" - })); assert!(impact .affected_subjects .iter() @@ -1000,21 +773,14 @@ mod semantic_impact_tests { schema_version: ProjectSemanticImpactSchemaVersion::V1, baseline: ProjectBaseline::VerifiedSignedBundle, changes: [ - SemanticDimension::Claim, SemanticDimension::Integration, SemanticDimension::ServicePolicy, SemanticDimension::OperatorSecurity, - SemanticDimension::Disclosure, SemanticDimension::Compiler, ] .into_iter() .map(|dimension| { - semantic_impact_for_dimension( - &loaded, - None, - dimension, - SemanticDirection::Changed, - ) + semantic_impact_for_dimension(&loaded, None, dimension, SemanticDirection::Changed) }) .collect(), }; @@ -1025,7 +791,6 @@ mod semantic_impact_tests { "127.0.0.1", "/run/secrets/relay-workload-token", "HEALTH_REGISTRY_USERNAME", - "REGISTRY_NOTARY_ISSUER_JWK", "health-relay-client", "A0000000001", "Nia", @@ -1039,18 +804,17 @@ mod semantic_impact_tests { } #[test] - fn verified_projection_matches_legacy_changes_including_compiler() { + fn verified_projection_includes_changed_integration_and_compiler() { let loaded = loaded_project(); - let disclosure_digest = disclosure_digest(); - let mut baseline = matching_baseline(&loaded, &disclosure_digest); - baseline["semantic_digests"]["claim"] = Value::String(format!("sha256:{}", "0".repeat(64))); + let mut baseline = matching_baseline(&loaded); + baseline["semantic_digests"]["integration"] = + Value::String(format!("sha256:{}", "0".repeat(64))); baseline["compiler_version"] = Value::String("previous".to_string()); - let report = project_semantic_impact_report(&loaded, Some(&baseline), &disclosure_digest); - let legacy = semantic_change_records(&loaded, Some(&baseline), &disclosure_digest); + let report = project_semantic_impact_report(&loaded, Some(&baseline)); assert_eq!( - serde_json::to_value(report.dimension_only_changes()).expect("projection serializes"), - serde_json::to_value(legacy).expect("legacy changes serialize"), + dimensions(&report), + vec![SemanticDimension::Integration, SemanticDimension::Compiler], ); } } diff --git a/crates/registryctl/src/project_authoring/development.rs b/crates/registryctl/src/project_authoring/development.rs index 39a14b914..11bebc841 100644 --- a/crates/registryctl/src/project_authoring/development.rs +++ b/crates/registryctl/src/project_authoring/development.rs @@ -1,14 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 use crate::dev_credentials::{ - DevCredentialPublicProjection, DevCredentialRequirements, DevIssuanceCredentialRequirement, - DevOAuthCredentialProfile, DevRelayApiKeyRequirements, DevSourceCredentialProfile, + DevCredentialPublicProjection, DevCredentialRequirements, DevOAuthCredentialProfile, + DevRelayApiKeyRequirements, DevSourceCredentialProfile, DevSourceCredentialProjection, PreparedDevCredentialClosure, }; use crate::dev_runtime::{ dev_claim_results_commitment, AuthoredDevScenario, AuthoredDevelopment, AuthoredLocalSnapshot, AuthoredSyntheticOauthRequest, AuthoredSyntheticSourcePlan, - AuthoredSyntheticSourceRequest, DevClaimResultExpectation, DevEnvironmentProfile, + AuthoredSyntheticSourceRequest, DevEnvironmentProfile, DevOAuthProfile, DevSourceMode, DevSourceProvider, SyntheticOAuthResponseCase, SyntheticRequestEncoding, SyntheticRequestMethod, SyntheticSourceAuth, SyntheticSourceScenario, }; @@ -34,8 +34,6 @@ pub(crate) struct DevAuthoringProjection { pub environment_id: String, pub environment_profile: DevEnvironmentProfile, pub development: AuthoredDevelopment, - pub caller_id: String, - pub caller_fingerprint_locator: String, /// Exact authored source credential environment locators required by an /// operator-bound consultation Relay. Sorted and deduplicated. Empty for /// synthetic development. @@ -81,10 +79,8 @@ pub(crate) struct CompiledSignedDevLanes { pub relay_public_anchor: PathBuf, pub relay_consultation_bundle: PathBuf, pub relay_consultation_anchor: PathBuf, - pub notary_bundle: PathBuf, - pub notary_anchor: PathBuf, - /// Relay public, Relay consultation, and Notary, in that order. - pub lane_config_digests: [String; 3], + /// Relay public and Relay consultation, in that order. + pub lane_config_digests: [String; 2], } impl DevAuthoringProjection { @@ -139,16 +135,6 @@ pub(crate) fn compile_dev_runtime_authoring( .unwrap_or(&fixture.0) .to_string_lossy(); let fixture_document = &fixture.1; - let (caller_id, caller_fingerprint_locator, service_id) = select_development_caller( - &loaded, - environment_id, - default_integration, - default_fixture, - fixture_document, - )?; - let request = fixture_document.request.as_ref().ok_or_else(|| { - anyhow!("{fixture_relative}#/request is required for the default development scenario") - })?; if fixture_document.interactions.len() != 1 { bail!( "{fixture_relative}#/interactions must contain exactly one interaction for the closed development source profile" @@ -176,12 +162,9 @@ pub(crate) fn compile_dev_runtime_authoring( DevelopmentCredentialRequirementsInput { loaded: &loaded, environment_id, - service_id: &service_id, integration_id: default_integration, source_mode, credential, - caller_id: &caller_id, - caller_fingerprint_env: &caller_fingerprint_locator, relay_api_key_scopes: records_request .as_ref() .map(|request| request.scopes.as_slice()), @@ -204,10 +187,10 @@ pub(crate) fn compile_dev_runtime_authoring( oauth_request, response_body, }); - let request_json = - serde_json::to_vec(request).context("failed to compile the local governed request")?; - let (minimized_claim_ids, expected_claim_results_sha256) = - compile_development_claim_result_expectation(&loaded, request, fixture_document)?; + let request_json = Vec::new(); + let minimized_claim_ids = Vec::new(); + let expected_claim_results_sha256 = dev_claim_results_commitment(Vec::new()) + .map_err(|_| anyhow!("empty development result expectation is invalid"))?; let scenario = AuthoredDevScenario { integration_id: default_integration.to_owned(), fixture_id: default_fixture.to_owned(), @@ -233,10 +216,7 @@ pub(crate) fn compile_dev_runtime_authoring( default_fixture: default_fixture.to_owned(), operator_source_binding_present, relay_port: development.relay_port, - notary_port: development.notary_port, }, - caller_id, - caller_fingerprint_locator, operator_source_secret_env, scenarios: vec![scenario], records_request, @@ -245,91 +225,6 @@ pub(crate) fn compile_dev_runtime_authoring( }) } -fn compile_development_claim_result_expectation( - loaded: &LoadedRegistryProject, - request: &GovernedFixtureRequest, - fixture: &FixtureDocument, -) -> Result<(Vec, String)> { - let first_claim = request - .claims - .first() - .ok_or_else(|| anyhow!("default development request must select at least one claim"))?; - let first_declaration = loaded - .project - .services - .values() - .filter(|service| service.kind == ServiceKind::Evidence) - .filter(|service| service.purpose == request.purpose) - .find_map(|service| service.claims.get(&first_claim.id)) - .ok_or_else(|| anyhow!("default development request claim is not declared"))?; - let disclosure = request - .disclosure - .as_deref() - .unwrap_or_else(|| expanded_disclosure(&first_declaration.disclosure).0); - let mut minimized_claim_ids = Vec::with_capacity(request.claims.len()); - let mut expectations = Vec::with_capacity(request.claims.len()); - for claim in &request.claims { - let authored_value = fixture.expect.claims.get(&claim.id).ok_or_else(|| { - anyhow!( - "default development request claim {} has no fixture result expectation", - claim.id - ) - })?; - let declaration = loaded - .project - .services - .values() - .filter(|service| service.kind == ServiceKind::Evidence) - .filter(|service| service.purpose == request.purpose) - .find_map(|service| service.claims.get(&claim.id)) - .ok_or_else(|| anyhow!("default development request claim is not declared"))?; - let oracle_is_redacted = - expanded_disclosure(&declaration.disclosure).0 == "redacted"; - minimized_claim_ids.push(claim.id.clone()); - expectations.push(compile_development_claim_result( - &claim.id, - authored_value, - disclosure, - oracle_is_redacted, - )?); - } - minimized_claim_ids.sort(); - let commitment = dev_claim_results_commitment(expectations) - .map_err(|_| anyhow!("default development claim result expectation is invalid"))?; - Ok((minimized_claim_ids, commitment)) -} - -fn compile_development_claim_result( - claim_id: &str, - authored_value: &Value, - disclosure: &str, - oracle_is_redacted: bool, -) -> Result { - if oracle_is_redacted && disclosure != "redacted" { - bail!( - "default development claim {claim_id} cannot derive {disclosure} disclosure from a redacted fixture result" - ); - } - let (value, satisfied) = match disclosure { - "value" => (authored_value.clone(), authored_value.as_bool()), - "predicate" => ( - authored_value - .as_bool() - .map(Value::Bool) - .unwrap_or(Value::Null), - authored_value.as_bool(), - ), - "redacted" => (Value::Null, None), - _ => bail!("default development request disclosure is invalid"), - }; - Ok(DevClaimResultExpectation { - claim_id: claim_id.to_string(), - value, - satisfied, - disclosure: disclosure.to_string(), - }) -} - fn select_development_records_request( loaded: &LoadedRegistryProject, environment_id: &str, @@ -432,12 +327,9 @@ fn operator_source_secret_env( struct DevelopmentCredentialRequirementsInput<'a> { loaded: &'a LoadedRegistryProject, environment_id: &'a str, - service_id: &'a str, integration_id: &'a str, source_mode: DevelopmentSourceMode, credential: &'a CredentialInterface, - caller_id: &'a str, - caller_fingerprint_env: &'a str, relay_api_key_scopes: Option<&'a [String]>, } @@ -447,12 +339,9 @@ fn development_credential_requirements( let DevelopmentCredentialRequirementsInput { loaded, environment_id, - service_id, integration_id, source_mode, credential, - caller_id, - caller_fingerprint_env, relay_api_key_scopes, } = input; let environment = loaded @@ -530,20 +419,9 @@ fn development_credential_requirements( } } }; - let issuance = environment - .issuance - .as_ref() - .map(|issuance| DevIssuanceCredentialRequirement { - issuer: issuance.issuer.clone(), - signing_kid: issuance.signing_kid.clone(), - private_jwk_env: issuance.signing_key.secret.clone(), - }); Ok(DevCredentialRequirements { project_id: loaded.project.registry.id.clone(), environment_id: environment_id.to_owned(), - service_id: service_id.to_owned(), - caller_id: caller_id.to_owned(), - caller_fingerprint_env: caller_fingerprint_env.to_owned(), relay_api_keys: match ( environment .relay @@ -560,7 +438,6 @@ fn development_credential_requirements( _ => bail!("development Relay API-key scopes do not match the selected records request"), }, source, - issuance, }) } @@ -596,14 +473,6 @@ fn validate_development_ports( if development.relay_port == Some(0) { bail!("environments/{environment_id}.yaml#/development/relay_port must be non-zero"); } - if development.notary_port == Some(0) { - bail!("environments/{environment_id}.yaml#/development/notary_port must be non-zero"); - } - if development.relay_port.is_some() && development.relay_port == development.notary_port { - bail!( - "environments/{environment_id}.yaml#/development/relay_port and environments/{environment_id}.yaml#/development/notary_port must be distinct" - ); - } Ok(()) } @@ -635,92 +504,6 @@ fn selected_development_fixture<'a>( Some((integration, fixture)) } -fn select_development_caller( - loaded: &LoadedRegistryProject, - environment_id: &str, - integration_id: &str, - fixture_id: &str, - fixture: &FixtureDocument, -) -> Result<(String, String, String)> { - let environment = loaded - .environment - .as_ref() - .expect("selected environment was loaded"); - let expected_claims = fixture.expect.claims.keys().collect::>(); - let matching_services = loaded - .project - .services - .iter() - .filter(|(_, service)| { - service.kind == ServiceKind::Evidence - && service - .consultations - .values() - .any(|consultation| consultation.integration == integration_id) - && service - .claims - .keys() - .any(|claim| expected_claims.contains(claim)) - }) - .collect::>(); - if matching_services.is_empty() { - bail!( - "environments/{environment_id}.yaml#/development/default_integration and environments/{environment_id}.yaml#/development/default_fixture select {integration_id}.{fixture_id}, which has no evidence service scope contract" - ); - } - if matching_services.len() != 1 { - let service_ids = matching_services - .iter() - .map(|(service_id, _)| service_id.as_str()) - .collect::>(); - bail!( - "environments/{environment_id}.yaml#/development/default_integration and environments/{environment_id}.yaml#/development/default_fixture select {integration_id}.{fixture_id}, which must resolve to one exact evidence service; matching service ids: {}", - service_ids.join(", ") - ); - } - let (service_id, service) = matching_services[0]; - let required_scopes = service.access.scopes.iter().collect::>(); - - let mut candidates = environment - .callers - .iter() - .filter(|(_, caller)| { - let caller_scopes = caller.scopes.iter().collect::>(); - required_scopes - .iter() - .all(|scope| caller_scopes.contains(scope)) - }) - .map(|(id, caller)| (id.clone(), caller.api_key_fingerprint.secret.clone())) - .collect::>(); - candidates.sort_by(|left, right| left.0.cmp(&right.0)); - if candidates.len() != 1 { - let candidate_ids = candidates - .iter() - .map(|(id, _)| id.as_str()) - .collect::>(); - let available_ids = environment - .callers - .keys() - .map(String::as_str) - .collect::>(); - bail!( - "environments/{environment_id}.yaml#/development/default_integration and environments/{environment_id}.yaml#/development/default_fixture require one exact caller for {integration_id}.{fixture_id}; matching caller ids: {}; available caller ids: {}", - if candidate_ids.is_empty() { - "".to_string() - } else { - candidate_ids.join(", ") - }, - if available_ids.is_empty() { - "".to_string() - } else { - available_ids.join(", ") - } - ); - } - let (caller_id, fingerprint_locator) = candidates.remove(0); - Ok((caller_id, fingerprint_locator, service_id.to_string())) -} - fn validate_development_source_mode( environment_id: &str, integration_id: &str, @@ -1006,7 +789,6 @@ const fn development_environment_profile(profile: DeploymentProfile) -> DevEnvir DeploymentProfile::Local => DevEnvironmentProfile::Local, DeploymentProfile::HostedLab => DevEnvironmentProfile::HostedLab, DeploymentProfile::Production => DevEnvironmentProfile::Production, - DeploymentProfile::EvidenceGrade => DevEnvironmentProfile::EvidenceGrade, } } @@ -1016,11 +798,6 @@ impl DevBindingProjection { credentials: &DevCredentialPublicProjection, entity_id: Option, ) -> Result { - if credentials.caller.id != authoring.caller_id - || credentials.caller.fingerprint_env != authoring.caller_fingerprint_locator - { - bail!("generated development credentials do not match the validated caller binding"); - } Ok(Self { project_id: authoring.project_id.clone(), environment_id: authoring.environment_id.clone(), @@ -1047,24 +824,10 @@ impl DevBindingProjection { .as_mut() .ok_or_else(|| anyhow!("development binding projection lacks its environment"))?; - let mut selected_caller = environment - .callers - .remove(&self.credentials.caller.id) - .ok_or_else(|| anyhow!("development caller disappeared after validation"))?; - selected_caller.api_key_fingerprint.secret = - self.credentials.caller.fingerprint_env.clone(); - environment.callers.clear(); - environment - .callers - .insert(self.credentials.caller.id.clone(), selected_caller); - let relay = environment .relay .as_mut() .ok_or_else(|| anyhow!("development Relay binding is absent"))?; - relay.issuer = self.credentials.relay_oidc.issuer.clone(); - relay.audience = self.credentials.relay_oidc.audience.clone(); - relay.allowed_clients = vec![self.credentials.relay_oidc.client_id.clone()]; relay.local_api_keys = self.credentials.relay_api_keys.as_ref().map(|keys| { RelayLocalApiKeyBinding { match_principal: keys.match_principal.clone(), @@ -1073,11 +836,6 @@ impl DevBindingProjection { } }); - environment.notary_relay = Some(NotaryRelayBinding { - base_url: self.credentials.notary_relay.base_url.clone(), - workload_client_id: self.credentials.notary_relay.workload_client_id.clone(), - token_file: PathBuf::from(&self.credentials.notary_relay.token_file), - }); environment.relay_state = Some(RelayStateBinding { postgresql: RelayPostgresqlBinding { root_certificate_path: PathBuf::from( @@ -1085,25 +843,6 @@ impl DevBindingProjection { ), }, }); - environment.notary_state = Some(NotaryStateBinding { - postgresql: NotaryPostgresqlBinding { - root_certificate_path: PathBuf::from( - &self.credentials.databases.root_certificate_path, - ), - }, - }); - - match (&mut environment.issuance, &self.credentials.issuance) { - (Some(binding), Some(issuance)) => { - binding.issuer = issuance.issuer.clone(); - binding.signing_kid = issuance.signing_kid.clone(); - binding.signing_key.secret = issuance.private_jwk_env.clone(); - binding.algorithm = IssuanceSigningAlgorithm::EdDsa; - } - (None, None) => {} - _ => bail!("generated issuance credentials do not match the validated environment"), - } - if let Some(origin) = self .synthetic_source_origin .as_ref() @@ -1264,10 +1003,6 @@ pub(crate) fn compile_and_sign_dev_lanes( &mut compiled.relay_consultation_private, &projection.credentials, )?; - inject_development_notary_relay_transport( - &mut compiled.notary_private, - &projection.credentials, - )?; let relative_output = output_root .strip_prefix(&loaded.root) @@ -1331,37 +1066,6 @@ fn inject_development_bootstrap( Ok(()) } -fn inject_development_notary_relay_transport( - files: &mut BTreeMap>, - credentials: &DevCredentialPublicProjection, -) -> Result<()> { - let path = PathBuf::from("config/notary.yaml"); - let bytes = files - .get(&path) - .ok_or_else(|| anyhow!("compiled Notary config is absent"))?; - let mut config: Value = - serde_norway::from_slice(bytes).context("failed to parse compiled Notary config")?; - let relay = config - .pointer_mut("/evidence/relay") - .and_then(Value::as_object_mut) - .ok_or_else(|| anyhow!("compiled Notary Relay binding is absent"))?; - relay.insert( - "allowed_private_cidrs".to_string(), - json!([credentials.notary_relay.allowed_private_cidr]), - ); - relay.insert("allow_insecure_localhost".to_string(), json!(false)); - relay.insert( - "allow_insecure_private_network".to_string(), - json!(false), - ); - let rendered = serde_norway::to_string(&config) - .context("failed to render bound Notary config")? - .into_bytes() - .into_boxed_slice(); - files.insert(path, rendered); - Ok(()) -} - fn development_active_write_deadline_unix_ms() -> Result { let now_unix_ms = OffsetDateTime::now_utc() .unix_timestamp_nanos() @@ -1397,23 +1101,12 @@ fn write_signed_development_lanes( "relay-consultation", &compiled.relay_consultation_private, )?; - let notary = sign_development_lane( - output_root, - projection, - credentials, - ProductAcceptanceLaneV1::Notary, - ProductAcceptanceProductV1::RegistryNotary, - "notary", - &compiled.notary_private, - )?; Ok(CompiledSignedDevLanes { relay_public_bundle: relay_public.0, relay_public_anchor: relay_public.1, relay_consultation_bundle: relay_consultation.0, relay_consultation_anchor: relay_consultation.1, - notary_bundle: notary.0, - notary_anchor: notary.1, - lane_config_digests: [relay_public.2, relay_consultation.2, notary.2], + lane_config_digests: [relay_public.2, relay_consultation.2], }) } @@ -1482,7 +1175,7 @@ fn sign_development_lane( ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => { "config/relay.yaml" } - ProductAcceptanceLaneV1::Notary => "config/notary.yaml", + _ => panic!(), }; let config_hash = manifest_files .iter() @@ -1584,663 +1277,6 @@ const fn development_lane_name(lane: ProductAcceptanceLaneV1) -> &'static str { match lane { ProductAcceptanceLaneV1::RelayPublic => "relay-public", ProductAcceptanceLaneV1::RelayConsultation => "relay-consultation", - ProductAcceptanceLaneV1::Notary => "notary", + _ => panic!(), } } - -#[cfg(test)] -mod development_authoring_tests { - use super::*; - - fn starter_path(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("assets/project-starters") - .join(name) - } - - #[test] - fn http_starter_compiles_one_exact_synthetic_default() { - let projection = - compile_dev_runtime_authoring(&starter_path("bounded-http"), "local").unwrap(); - assert_eq!(projection.development.source_mode, DevSourceMode::Synthetic); - assert!(!projection.development.operator_source_binding_present); - assert_eq!(projection.caller_id, "evidence-client"); - assert_eq!( - projection.caller_fingerprint_locator, - "EVIDENCE_CLIENT_TOKEN_HASH" - ); - assert_eq!( - projection.credential_requirements().service_id, - "person-verification" - ); - assert_eq!(projection.scenarios.len(), 1); - assert!(projection.records_request.is_none()); - let scenario = &projection.scenarios[0]; - assert_eq!(scenario.integration_id, "person-record"); - assert_eq!(scenario.fixture_id, "active-person"); - assert_eq!(scenario.source_provider, DevSourceProvider::Http); - assert_eq!(scenario.oauth_profile, DevOAuthProfile::None); - let source = scenario - .synthetic_source - .as_ref() - .expect("synthetic source projection"); - assert_eq!(source.source_auth, Some(SyntheticSourceAuth::StaticBearer)); - assert_eq!(source.source_request.method, SyntheticRequestMethod::Get); - assert_eq!(source.source_request.path, "/people/AB-123456"); - assert_eq!( - source.source_request.query, - BTreeMap::from([("fields".to_string(), "active".to_string())]) - ); - assert!(source.source_request.headers.is_empty()); - assert_eq!( - serde_json::from_slice::( - source.response_body.as_deref().expect("response body") - ) - .unwrap(), - json!({"active": true}) - ); - let request: Value = serde_json::from_slice(&scenario.request_json).unwrap(); - assert_eq!( - request.pointer("/target/identifiers/0/value"), - Some(&json!("AB-123456")) - ); - assert_eq!( - scenario.minimized_claim_ids, - vec!["person-record-exists".to_string()] - ); - } - - #[test] - fn development_claim_commitment_does_not_invent_a_redacted_value() { - let error = compile_development_claim_result( - "redacted-claim", - &json!("redacted"), - "value", - true, - ) - .err() - .expect("redacted fixture oracle cannot prove a value result"); - assert!(error.to_string().contains("cannot derive value disclosure")); - - let literal = compile_development_claim_result( - "literal-claim", - &json!("redacted"), - "value", - false, - ) - .expect("a value-disclosed literal remains distinguishable from the marker"); - assert_eq!(literal.value, json!("redacted")); - } - - #[test] - fn spreadsheet_starter_uses_the_generic_typed_development_projection() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("spreadsheet").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let project = temporary.path(); - let environment_path = project.join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path) - .unwrap() - .replace( - "scopes: [projects:metadata, projects:rows]", - "scopes: [projects:metadata, projects:rows, unrelated:admin]", - ); - fs::write(&environment_path, environment).unwrap(); - let projection = compile_dev_runtime_authoring(project, "local").unwrap(); - assert_eq!(projection.environment_profile, DevEnvironmentProfile::Local); - assert_eq!(projection.caller_id, "public-works-service"); - assert_eq!( - projection.credential_requirements().service_id, - "public-works-verification" - ); - let records = projection.records_request.as_ref().unwrap(); - assert_eq!(records.dataset_id, "projects"); - assert_eq!(records.entity_id, "projects"); - assert_eq!(records.record_id, "pw_001"); - assert_eq!(records.purpose, "public-works-case-management"); - assert_eq!( - projection - .credential_requirements() - .relay_api_keys - .unwrap() - .scopes, - ["projects:metadata", "projects:rows"] - ); - let scenario = &projection.scenarios[0]; - assert_eq!(projection.development.source_mode, DevSourceMode::LocalSnapshot); - assert_eq!(scenario.integration_id, "project-record-snapshot"); - assert_eq!(scenario.fixture_id, "match"); - assert_eq!(scenario.source_provider, DevSourceProvider::Spreadsheet); - assert!(scenario.synthetic_source.is_none()); - let snapshot = projection.local_snapshot.as_ref().expect("local snapshot"); - assert_eq!( - snapshot.host_path, - std::fs::canonicalize(project.join("data/public_works_projects.xlsx")).unwrap() - ); - assert_eq!( - snapshot.container_path, - "/var/lib/registry/public_works_projects.xlsx" - ); - assert!(matches!( - projection.credential_requirements().source, - DevSourceCredentialProfile::OperatorBound - )); - let credentials = - PreparedDevCredentialClosure::generate(projection.credential_requirements()).unwrap(); - assert!(matches!( - credentials.public_projection().source, - DevSourceCredentialProjection::OperatorBound - )); - let signed = compile_and_sign_dev_lanes( - project, - "local", - &credentials, - &project.join(".registry-stack/dev-artifacts/test/signed-lanes"), - ) - .unwrap(); - assert_eq!(signed.lane_config_digests.len(), 3); - let public = registry_platform_config::verify_config_bundle( - &signed.relay_public_bundle, - &signed.relay_public_anchor, - ) - .unwrap(); - let public: Value = serde_norway::from_slice(&public.config_bytes).unwrap(); - assert_eq!(public.pointer("/auth/mode"), Some(&json!("api_key"))); - assert_eq!( - public.pointer("/auth/api_keys/0/fingerprint/name"), - Some(&json!(LOCAL_RELAY_MATCH_KEY_HASH_ENV)) - ); - assert_eq!( - public.pointer("/auth/api_keys/1/fingerprint/name"), - Some(&json!(LOCAL_RELAY_NO_MATCH_KEY_HASH_ENV)) - ); - assert_eq!( - public.pointer("/auth/api_keys/0/scopes"), - Some(&json!(["projects:metadata", "projects:rows"])) - ); - assert_eq!( - public.pointer("/auth/api_keys/1/scopes"), - Some(&json!(["projects:metadata", "projects:rows"])) - ); - assert!(!public.to_string().contains("unrelated:admin")); - assert!(public - .to_string() - .contains("/var/lib/registry/public_works_projects.xlsx")); - let consultation = registry_platform_config::verify_config_bundle( - &signed.relay_consultation_bundle, - &signed.relay_consultation_anchor, - ) - .unwrap(); - let consultation: Value = - serde_norway::from_slice(&consultation.config_bytes).unwrap(); - assert_eq!(consultation.pointer("/auth/mode"), Some(&json!("oidc"))); - assert!(consultation.pointer("/auth/api_keys").is_none()); - assert!(consultation - .to_string() - .contains("/var/lib/registry/public_works_projects.xlsx")); - } - - #[test] - fn local_snapshot_authoring_rejects_an_oversized_workbook() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("spreadsheet").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - std::fs::OpenOptions::new() - .write(true) - .open(temporary.path().join("data/public_works_projects.xlsx")) - .unwrap() - .set_len(crate::dev_runtime::MAX_LOCAL_SNAPSHOT_BYTES + 1) - .unwrap(); - - let error = compile_dev_runtime_authoring(temporary.path(), "local") - .err() - .expect("oversized local snapshot must fail"); - assert!( - error.to_string().contains("unsafe, unreadable, or too large"), - "{error:#}" - ); - } - - #[test] - fn spreadsheet_integration_requires_explicit_local_snapshot_mode() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("spreadsheet").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let environment = temporary.path().join("environments/local.yaml"); - let bytes = fs::read_to_string(&environment) - .unwrap() - .replacen("source_mode: local_snapshot", "source_mode: synthetic", 1); - fs::write(environment, bytes).unwrap(); - - let error = compile_dev_runtime_authoring(temporary.path(), "local") - .err() - .expect("spreadsheet source cannot use synthetic HTTP mode"); - assert!(error.to_string().contains("must be local_snapshot"), "{error:#}"); - } - - #[test] - fn records_request_rejects_ambiguous_service_or_purpose() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("spreadsheet").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let mut loaded = load_registry_project(temporary.path(), Some("local")).unwrap(); - loaded - .project - .services - .get_mut("projects-records") - .unwrap() - .api - .as_mut() - .unwrap() - .required_principal_filters = vec!["district_code".to_string()]; - let error = select_development_records_request(&loaded, "local") - .err() - .expect("non-primary principal binding fails"); - assert!(error.to_string().contains("entity primary key")); - loaded - .project - .services - .get_mut("projects-records") - .unwrap() - .api - .as_mut() - .unwrap() - .required_principal_filters = vec!["project_id".to_string()]; - loaded - .project - .services - .get_mut("projects-records") - .unwrap() - .api - .as_mut() - .unwrap() - .purposes - .push("secondary-purpose".to_string()); - let error = select_development_records_request(&loaded, "local") - .err() - .expect("ambiguous purpose fails"); - assert!(error.to_string().contains("must contain exactly one purpose")); - - loaded - .project - .services - .get_mut("projects-records") - .unwrap() - .api - .as_mut() - .unwrap() - .purposes - .truncate(1); - let second: ServiceDeclaration = serde_norway::from_str( - r#" -kind: records_api -entity: projects -api: - scopes: - metadata: projects:metadata - rows: projects:rows - aggregate: projects:aggregate - evidence_verification: projects:evidence_verification - purposes: [public-works-case-management] - projection: [project_id] - pagination: { default_limit: 1, max_limit: 1 } - filters: { project_id: [eq] } - required_principal_filters: [project_id] - standards: { ogc_features: false, sp_dci: false } -"#, - ) - .unwrap(); - loaded - .project - .services - .insert("projects-records-second".to_string(), second); - let error = select_development_records_request(&loaded, "local") - .err() - .expect("ambiguous service fails"); - assert!(error - .to_string() - .contains("requires one exact snapshot records service")); - } - - #[test] - fn opencrvs_oauth_development_scope_uses_its_compiled_service() { - let project = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/project-authoring/opencrvs-events-api"); - let loaded = load_registry_project(&project, Some("local")).unwrap(); - let (integration, fixture) = - selected_development_fixture(&loaded, "birth-event-search", "birth-event-match") - .unwrap(); - let (caller_id, caller_fingerprint_locator, service_id) = select_development_caller( - &loaded, - "local", - "birth-event-search", - "birth-event-match", - &fixture.1, - ) - .unwrap(); - let (_, credential) = development_provider_and_credential(integration); - let requirements = development_credential_requirements( - DevelopmentCredentialRequirementsInput { - loaded: &loaded, - environment_id: "local", - service_id: &service_id, - integration_id: "birth-event-search", - source_mode: DevelopmentSourceMode::Synthetic, - credential, - caller_id: &caller_id, - caller_fingerprint_env: &caller_fingerprint_locator, - relay_api_key_scopes: None, - }, - ) - .unwrap(); - assert_eq!(requirements.service_id, "birth-event-verification"); - assert!(matches!( - requirements.source, - DevSourceCredentialProfile::SyntheticOAuthClientCredentials { - profile: DevOAuthCredentialProfile::Oauth2BearerNoExpiry, - .. - } - )); - } - - #[test] - fn spreadsheet_planned_fixture_is_a_complete_development_scenario() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("spreadsheet").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let match_projection = compile_dev_runtime_authoring(temporary.path(), "local").unwrap(); - let match_commitment = match_projection.scenarios[0] - .expected_claim_results_sha256 - .clone(); - let environment_path = temporary.path().join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path) - .unwrap() - .replace("default_fixture: match", "default_fixture: planned"); - fs::write(&environment_path, environment).unwrap(); - - let projection = compile_dev_runtime_authoring(temporary.path(), "local").unwrap(); - let scenario = &projection.scenarios[0]; - assert_eq!(scenario.fixture_id, "planned"); - assert_ne!(scenario.expected_claim_results_sha256, match_commitment); - let request: Value = serde_json::from_slice(&scenario.request_json).unwrap(); - assert_eq!( - request.pointer("/target/identifiers/0/scheme"), - Some(&json!("project_id")) - ); - assert_eq!( - request.pointer("/target/identifiers/0/value"), - Some(&json!("PW-002")) - ); - } - - #[test] - fn generated_credentials_bind_and_sign_three_self_verified_development_lanes() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let project = temporary.path(); - let authoring = compile_dev_runtime_authoring(project, "local").unwrap(); - let credentials = - PreparedDevCredentialClosure::generate(authoring.credential_requirements()).unwrap(); - let output = project.join(".registry-stack/dev-artifacts/test/signed-lanes"); - let signed = compile_and_sign_dev_lanes(project, "local", &credentials, &output).unwrap(); - - let lanes = [ - ( - &signed.relay_public_bundle, - &signed.relay_public_anchor, - ProductAcceptanceLaneV1::RelayPublic, - ), - ( - &signed.relay_consultation_bundle, - &signed.relay_consultation_anchor, - ProductAcceptanceLaneV1::RelayConsultation, - ), - ( - &signed.notary_bundle, - &signed.notary_anchor, - ProductAcceptanceLaneV1::Notary, - ), - ]; - for (index, (bundle, anchor, lane)) in lanes.into_iter().enumerate() { - let verified = registry_platform_config::verify_config_bundle(bundle, anchor).unwrap(); - assert_eq!( - verified.manifest.acceptance_identity.trust_domain, - ProductTrustDomainV1::Development - ); - assert_eq!(verified.manifest.acceptance_identity.lane, lane); - assert_eq!( - verified.manifest.config_hash, - signed.lane_config_digests[index] - ); - } - - let verified = registry_platform_config::verify_config_bundle( - &signed.relay_consultation_bundle, - &signed.relay_consultation_anchor, - ) - .unwrap(); - let relay: Value = serde_norway::from_slice(&verified.config_bytes).unwrap(); - assert_eq!( - relay.pointer("/auth/oidc/issuer"), - Some(&json!("https://registryctl-local-notary.invalid")) - ); - assert_eq!( - relay.pointer("/auth/oidc/development_jwks_file"), - Some(&json!( - "/run/registry/dev-public/notary-workload-jwks.json" - )) - ); - assert_eq!( - relay.pointer("/consultation/bootstrap/migration_database_url_env"), - Some(&json!("REGISTRY_RELAY_CONSULTATION_MIGRATION_DATABASE_URL")) - ); - assert_eq!( - relay.pointer("/consultation/bootstrap/owner_role"), - Some(&json!("registry_relay_owner")) - ); - let now_unix_ms = - i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000).unwrap(); - let deadline = relay - .pointer("/consultation/bootstrap/active_write_deadline_unix_ms") - .and_then(Value::as_i64) - .unwrap(); - assert!(deadline > now_unix_ms); - assert!(deadline <= now_unix_ms + DEV_AUDIT_PSEUDONYM_WRITE_WINDOW_MS); - assert_eq!( - relay.pointer("/consultation/bootstrap/keyring_maintenance_database_url_env"), - Some(&json!( - "REGISTRY_RELAY_CONSULTATION_MAINTENANCE_DATABASE_URL" - )) - ); - assert_eq!( - relay.pointer("/consultation/bootstrap/keyring_reader_database_url_env"), - Some(&json!("REGISTRY_RELAY_CONSULTATION_READER_DATABASE_URL")) - ); - - let binding: Value = - serde_json::from_slice( - &fs::read(signed.relay_consultation_bundle.join( - "config/artifacts/private-bindings/person-verification-person_record.json", - )) - .unwrap(), - ) - .unwrap(); - assert_eq!( - binding.pointer("/data_destination/origin"), - Some(&json!(format!( - "{}/", - crate::dev_runtime::DEV_SYNTHETIC_SOURCE_ORIGIN - ))) - ); - assert_eq!( - binding.pointer("/data_destination/allowed_private_cidrs"), - Some(&json!(["10.89.0.3/32"])) - ); - assert_eq!( - binding.pointer("/data_destination/ca/file"), - Some(&json!("/run/registry/dev-public/synthetic-source-tls.crt")) - ); - assert_eq!( - binding.pointer("/credential_destination/allowed_private_cidrs"), - None - ); - assert_eq!(binding.pointer("/credential_destination/ca/file"), None); - let notary = registry_platform_config::verify_config_bundle( - &signed.notary_bundle, - &signed.notary_anchor, - ) - .unwrap(); - let notary_config: Value = serde_norway::from_slice(¬ary.config_bytes).unwrap(); - let typed_notary: registry_notary_core::StandaloneRegistryNotaryConfig = - serde_norway::from_slice(¬ary.config_bytes).unwrap(); - typed_notary.validate().unwrap(); - assert_eq!( - notary_config.pointer("/auth/api_keys/0/fingerprint/name"), - Some(&json!("EVIDENCE_CLIENT_TOKEN_HASH")) - ); - assert_eq!( - notary_config.pointer("/evidence/relay/base_url"), - Some(&json!("http://10.89.0.4:8080")) - ); - assert!(notary_config - .pointer("/evidence/relay/root_certificate_path") - .is_none()); - assert_eq!( - notary_config.pointer("/evidence/relay/allowed_private_cidrs"), - Some(&json!(["10.89.0.4/32"])) - ); - assert_eq!( - notary_config.pointer("/evidence/relay/allow_insecure_localhost"), - Some(&json!(false)) - ); - assert_eq!( - notary_config.pointer("/evidence/relay/allow_insecure_private_network"), - Some(&json!(false)) - ); - } - - #[test] - fn invalid_default_reports_exact_fields_and_available_scenario_ids() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let environment_path = temporary.path().join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path).unwrap().replacen( - "default_fixture: active-person", - "default_fixture: missing", - 1, - ); - fs::write(environment_path, environment).unwrap(); - - let error = compile_dev_runtime_authoring(temporary.path(), "local") - .err() - .expect("invalid default fails") - .to_string(); - assert!(error.contains( - "environments/local.yaml#/development/default_integration and environments/local.yaml#/development/default_fixture" - )); - assert!(error.contains( - "available scenario ids: person-record.active-person, person-record.ambiguous-person, person-record.no-person" - ), "{error}"); - } - - #[test] - fn missing_development_field_and_closed_unknown_field_fail_precisely() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let environment_path = temporary.path().join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path).unwrap(); - let without_fixture = environment.replacen(" default_fixture: active-person\n", "", 1); - fs::write(&environment_path, without_fixture).unwrap(); - let error = compile_dev_runtime_authoring(temporary.path(), "local") - .err() - .expect("missing default fails") - .to_string(); - assert!( - error.contains("environments/local.yaml#/development/default_fixture is required"), - "{error}" - ); - - let unknown = environment.replacen( - " default_fixture: active-person\n", - " default_fixture: active-person\n arbitrary: forbidden\n", - 1, - ); - fs::write(environment_path, unknown).unwrap(); - let error = compile_dev_runtime_authoring(temporary.path(), "local") - .err() - .expect("unknown development field fails") - .to_string(); - assert!(error.contains("unknown field"), "{error}"); - } - - #[test] - fn operator_bound_is_explicit_and_never_carries_synthetic_material() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let environment_path = temporary.path().join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path).unwrap().replacen( - "source_mode: synthetic", - "source_mode: operator_bound", - 1, - ); - fs::write(environment_path, environment).unwrap(); - - let projection = compile_dev_runtime_authoring(temporary.path(), "local").unwrap(); - assert_eq!( - projection.development.source_mode, - DevSourceMode::OperatorBound - ); - assert!(projection.development.operator_source_binding_present); - assert!(projection.scenarios[0].synthetic_source.is_none()); - assert_eq!( - projection.operator_source_secret_env, - vec!["FICTIONAL_REGISTRY_TOKEN"] - ); - } - - #[test] - fn deployment_profile_is_exposed_for_fail_closed_runtime_rejection() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let environment_path = temporary.path().join("environments/local.yaml"); - let environment = fs::read_to_string(&environment_path).unwrap().replacen( - "profile: local", - "profile: evidence_grade", - 1, - ); - fs::write(environment_path, environment).unwrap(); - - let projection = compile_dev_runtime_authoring(temporary.path(), "local").unwrap(); - assert_eq!( - projection.environment_profile, - DevEnvironmentProfile::EvidenceGrade - ); - } -} - -#[test] -fn development_signing_refuses_the_runtime_secret_root() { - let temporary = tempfile::tempdir().unwrap(); - let embedded = PROJECT_STARTERS.get_dir("bounded-http").unwrap(); - copy_embedded_dir(embedded, temporary.path()).unwrap(); - let project = temporary.path(); - let authoring = compile_dev_runtime_authoring(project, "local").unwrap(); - let credentials = - PreparedDevCredentialClosure::generate(authoring.credential_requirements()).unwrap(); - - let error = compile_and_sign_dev_lanes( - project, - "local", - &credentials, - &project.join(".registry-stack/dev/test/signed-lanes"), - ) - .unwrap_err(); - assert!(error - .to_string() - .contains("must remain under .registry-stack/dev-artifacts")); -} diff --git a/crates/registryctl/src/project_authoring/diagnostic_reference.rs b/crates/registryctl/src/project_authoring/diagnostic_reference.rs index 2b8ccf2f9..72d9b5a30 100644 --- a/crates/registryctl/src/project_authoring/diagnostic_reference.rs +++ b/crates/registryctl/src/project_authoring/diagnostic_reference.rs @@ -8,9 +8,6 @@ use std::collections::BTreeSet; -use registry_notary_server::{ - NotaryActivationCode, NotaryActivationCodeLifecycle, NOTARY_ACTIVATION_CODE_DEFINITIONS, -}; use registry_platform_ops::{ BundleVerificationCode, BundleVerificationCodeLifecycle, BundleVerificationEvidencePolicy, BUNDLE_VERIFICATION_CODE_DEFINITIONS, @@ -44,7 +41,6 @@ pub enum ErrorReferenceFamily { AuthoringValidation, BundleVerification, FixtureExecution, - NotaryActivation, OperatorPreflight, RelayActivation, RelayProcessStartup, @@ -57,7 +53,6 @@ impl ErrorReferenceFamily { Self::AuthoringValidation => "authoring_validation", Self::BundleVerification => "bundle_verification", Self::FixtureExecution => "fixture_execution", - Self::NotaryActivation => "notary_activation", Self::OperatorPreflight => "operator_preflight", Self::RelayActivation => "relay_activation", Self::RelayProcessStartup => "relay_process_startup", @@ -69,7 +64,6 @@ impl ErrorReferenceFamily { Self::AuthoringValidation => "authoring", Self::FixtureExecution => "fixture", Self::BundleVerification - | Self::NotaryActivation | Self::OperatorPreflight | Self::RelayActivation | Self::RelayProcessStartup => "operator", @@ -80,7 +74,6 @@ impl ErrorReferenceFamily { #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ErrorReferenceOwner { - RegistryNotary, RegistryPlatformOps, RegistryRelay, Registryctl, @@ -90,7 +83,6 @@ impl ErrorReferenceOwner { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::RegistryNotary => "registry_notary", Self::RegistryPlatformOps => "registry_platform_ops", Self::RegistryRelay => "registry_relay", Self::Registryctl => "registryctl", @@ -101,7 +93,6 @@ impl ErrorReferenceOwner { #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum ErrorReferenceProduct { - RegistryNotary, RegistryPlatformOps, RegistryRelay, Registryctl, @@ -112,7 +103,6 @@ impl ErrorReferenceProduct { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::RegistryNotary => "registry_notary", Self::RegistryPlatformOps => "registry_platform_ops", Self::RegistryRelay => "registry_relay", Self::Registryctl => "registryctl", @@ -183,7 +173,6 @@ pub struct FixtureErrorReferenceV1 { #[serde(rename_all = "snake_case")] pub enum OperatorErrorOmissionFamily { BundleVerification, - NotaryActivation, OperatorPreflight, RelayActivation, RelayProcessStartup, @@ -193,7 +182,6 @@ impl OperatorErrorOmissionFamily { const fn as_str(self) -> &'static str { match self { Self::BundleVerification => "bundle_verification", - Self::NotaryActivation => "notary_activation", Self::OperatorPreflight => "operator_preflight", Self::RelayActivation => "relay_activation", Self::RelayProcessStartup => "relay_process_startup", @@ -319,7 +307,6 @@ pub fn fixture_error_reference() -> FixtureErrorReferenceV1 { pub fn operator_error_reference() -> OperatorErrorReferenceV1 { let mut entries = preflight_reference_entries(); entries.extend(bundle_verification_reference_entries()); - entries.extend(notary_activation_reference_entries()); entries.extend(relay_activation_reference_entries()); entries.extend(relay_process_startup_reference_entries()); entries.sort_by(|left, right| entry_key(left).cmp(&entry_key(right))); @@ -488,44 +475,6 @@ fn relay_process_startup_reference_entries() -> Vec { .collect() } -fn notary_activation_reference_entries() -> Vec { - NOTARY_ACTIVATION_CODE_DEFINITIONS - .iter() - .map(|definition| { - let code = definition.code.as_str().to_string(); - let lifecycle = match definition.lifecycle { - NotaryActivationCodeLifecycle::Unreleased => ErrorReferenceLifecycle::Unreleased, - NotaryActivationCodeLifecycle::Released { .. } => ErrorReferenceLifecycle::Released, - }; - ErrorReferenceEntry { - family: ErrorReferenceFamily::NotaryActivation, - code: code.clone(), - owner: ErrorReferenceOwner::RegistryNotary, - product: ErrorReferenceProduct::RegistryNotary, - phase: definition.phase.to_string(), - safe_meaning: definition.meaning.to_string(), - rule: definition.rule.to_string(), - safe_remediation: definition.remediation.to_string(), - field_address_pattern: None, - evidence_scope: definition.evidence_scope.to_string(), - secret_sensitive_value_policy: ErrorReferenceValuePolicy::NoRuntimeValues, - docs_anchor: docs_anchor( - ErrorReferenceFamily::NotaryActivation, - ErrorReferenceProduct::RegistryNotary, - definition.docs_slug, - ), - lifecycle, - introduced_in: definition - .lifecycle - .introduced_version() - .map(str::to_string), - stability: ErrorReferenceStability::Pre1StableCode, - evidence_limitation: definition.evidence_limitation.to_string(), - } - }) - .collect() -} - fn expected_operator_omissions() -> Vec { Vec::new() } @@ -685,28 +634,6 @@ fn validate_source_catalogs() -> Result<(), ErrorReferenceValidationError> { { return Err(ErrorReferenceValidationError::SourceCatalogMismatch); } - let mut notary_docs_slugs = BTreeSet::new(); - if NOTARY_ACTIVATION_CODE_DEFINITIONS.len() != NotaryActivationCode::ALL.len() - || !NotaryActivationCode::ALL.iter().all(|code| { - let definition = code.definition(); - definition.code == *code - && notary_lifecycle_version_is_valid(definition.lifecycle) - && static_metadata_is_complete(&[ - definition.phase, - definition.meaning, - definition.rule, - definition.remediation, - definition.evidence_scope, - definition.evidence_policy, - definition.evidence_limitation, - ]) - && docs_slug_is_valid(definition.docs_slug) - && notary_docs_slugs.insert(definition.docs_slug) - && NOTARY_ACTIVATION_CODE_DEFINITIONS.contains(definition) - }) - { - return Err(ErrorReferenceValidationError::SourceCatalogMismatch); - } Ok(()) } @@ -721,15 +648,6 @@ fn docs_slug_is_valid(slug: &str) -> bool { .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } -fn notary_lifecycle_version_is_valid(lifecycle: NotaryActivationCodeLifecycle) -> bool { - match lifecycle { - NotaryActivationCodeLifecycle::Unreleased => lifecycle.introduced_version().is_none(), - NotaryActivationCodeLifecycle::Released { introduced_version } => { - is_numeric_release_version(introduced_version) - } - } -} - fn lifecycle_version_is_valid( lifecycle: ErrorReferenceLifecycle, introduced_in: Option<&str>, @@ -786,10 +704,6 @@ fn expected_docs_anchor(entry: &ErrorReferenceEntry) -> String { .iter() .find(|definition| definition.code.as_str() == entry.code) .map(|definition| definition.docs_slug), - ErrorReferenceFamily::NotaryActivation => NOTARY_ACTIVATION_CODE_DEFINITIONS - .iter() - .find(|definition| definition.code.as_str() == entry.code) - .map(|definition| definition.docs_slug), ErrorReferenceFamily::RelayActivation => consultation_service_activation_definitions() .iter() .find(|definition| definition.code.as_str() == entry.code) diff --git a/crates/registryctl/src/project_authoring/diagnostics.rs b/crates/registryctl/src/project_authoring/diagnostics.rs index 544972cd1..82f8fa8d0 100644 --- a/crates/registryctl/src/project_authoring/diagnostics.rs +++ b/crates/registryctl/src/project_authoring/diagnostics.rs @@ -442,10 +442,6 @@ fn collect_project_authoring_diagnostics( &project, &integrations, )); - diagnostics.extend(claim_integration_link_diagnostics( - &project, - &integrations, - )); if validate_project_entity_links(&project, &integrations, &entities).is_err() { if let Some(collision) = project_records_scope_collision(&project, &entities) { let (field, cause, remediation) = match collision.kind { @@ -528,7 +524,7 @@ fn project_declaration_semantic_diagnostics( for (service_id, service) in project .services .iter() - .filter(|(_, service)| service.kind == ServiceKind::Evidence) + .filter(|(_, service)| service.kind == ServiceKind::ConsultationApi) { for (consultation_id, consultation) in &service.consultations { if !project.integrations.contains_key(&consultation.integration) { @@ -579,115 +575,6 @@ fn project_declaration_semantic_diagnostics( } } - for (claim_id, claim) in &service.claims { - if let Some(output) = claim.output.as_deref() { - let consultation = output.split_once('.').map(|(name, _)| name); - if consultation.is_none() - || consultation.is_some_and(|name| !service.consultations.contains_key(name)) - { - diagnostics.push(cross_file_diagnostic( - "registryctl.authoring.project.invalid", - PROJECT_FILE, - Some("services.claims.output"), - "A direct claim output does not name a declared consultation.", - "Use . with a consultation declared by this service.", - Some(PROJECT_SCHEMA_HINT), - vec![ - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "claims", claim_id, "output"], - ), - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "consultations"], - ), - ], - )); - } - } else if let Some(expression) = claim.cel.as_deref() { - let roots = cel_member_roots(expression); - if roots.as_ref().is_err() - || roots.is_ok_and(|roots| { - !service - .consultations - .keys() - .any(|name| roots.contains(name.as_str())) - }) - { - diagnostics.push(cross_file_diagnostic( - "registryctl.authoring.project.invalid", - PROJECT_FILE, - Some("services.claims.cel"), - "A claim evaluation does not resolve to a declared consultation.", - "Reference one declared Relay consultation.", - Some(PROJECT_SCHEMA_HINT), - vec![ - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "claims", claim_id, "cel"], - ), - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "consultations"], - ), - ], - )); - } - } - } - - for (profile_id, profile) in &service.credential_profiles { - for (index, claim_id) in profile.claims.iter().enumerate() { - if !service.claims.contains_key(claim_id) { - let index = index.to_string(); - diagnostics.push(cross_file_diagnostic( - "registryctl.authoring.project.invalid", - PROJECT_FILE, - Some("services.credential_profiles.claims"), - "A credential profile references an unknown claim.", - "Reference only claims declared by this service.", - Some(PROJECT_SCHEMA_HINT), - vec![ - diagnostic_address( - PROJECT_FILE, - &[ - "services", - service_id, - "credential_profiles", - profile_id, - "claims", - &index, - ], - ), - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "claims"], - ), - ], - )); - } - } - if parse_validity_seconds(&profile.validity).is_err() { - diagnostics.push(cross_file_diagnostic( - "registryctl.authoring.project.invalid", - PROJECT_FILE, - Some("services.credential_profiles.validity"), - "A credential profile validity is invalid.", - "Use a positive bounded validity in seconds, minutes, or hours.", - Some(PROJECT_SCHEMA_HINT), - vec![diagnostic_address( - PROJECT_FILE, - &[ - "services", - service_id, - "credential_profiles", - profile_id, - "validity", - ], - )], - )); - } - } } diagnostics } @@ -932,7 +819,7 @@ fn service_integration_link_diagnostics( for (service_id, service) in project .services .iter() - .filter(|(_, service)| service.kind == ServiceKind::Evidence) + .filter(|(_, service)| service.kind == ServiceKind::ConsultationApi) { for (consultation_id, consultation) in &service.consultations { let Some(integration) = integrations.get(&consultation.integration) else { @@ -1020,56 +907,6 @@ fn service_integration_link_diagnostics( diagnostics } -fn claim_integration_link_diagnostics( - project: &RegistryProject, - integrations: &BTreeMap, -) -> Vec { - let mut diagnostics = Vec::new(); - for (service_id, service) in project - .services - .iter() - .filter(|(_, service)| service.kind == ServiceKind::Evidence) - { - for (claim_id, claim) in &service.claims { - let Some((consultation_id, output_id)) = - claim.output.as_deref().and_then(|output| output.split_once('.')) - else { - continue; - }; - let Some(consultation) = service.consultations.get(consultation_id) else { - continue; - }; - let Some(integration) = integrations.get(&consultation.integration) else { - continue; - }; - if integration.document.outputs.contains_key(output_id) { - continue; - } - let Some(reference) = project.integrations.get(&consultation.integration) else { - continue; - }; - let integration_file = - relative_path_string(&reference.file).unwrap_or_else(|| PROJECT_FILE.to_string()); - diagnostics.push(cross_file_diagnostic( - "registryctl.authoring.project.invalid", - PROJECT_FILE, - Some("services.claims.output"), - "A direct claim references an unknown integration output.", - "Reference an output declared by the selected consultation's integration.", - Some(PROJECT_SCHEMA_HINT), - vec![ - diagnostic_address( - PROJECT_FILE, - &["services", service_id, "claims", claim_id, "output"], - ), - diagnostic_address(&integration_file, &["outputs"]), - ], - )); - } - } - diagnostics -} - fn project_entity_link_diagnostics( project: &RegistryProject, integrations: &BTreeMap, @@ -1660,23 +1497,6 @@ fn collect_environment_semantics( )); } } - if let Some(issuance) = &environment.issuance { - if issuance.generation == 0 - || validate_secret_reference(&issuance.signing_key).is_err() - || validate_token(&issuance.issuer, "issuance issuer", 2048).is_err() - || validate_token(&issuance.signing_kid, "issuance signing kid", 2048).is_err() - { - diagnostics.push(environment_invalid( - &file, - "issuance", - "The issuance binding is invalid.", - "Use bounded issuer metadata, a safe secret reference, and a positive generation.", - )); - } - } - if let Some(diagnostic) = representative_issuance_diagnostic(project, environment, &file) { - diagnostics.push(diagnostic); - } if environment .relay .as_ref() @@ -1686,7 +1506,7 @@ fn collect_environment_semantics( &file, "relay.allowed_clients", "The public Relay has no admitted OpenID Connect client.", - "Add at least one intended public Relay client id. Keep the private Notary workload client only in notary_relay.", + "Add at least one intended Relay client id.", )); } if diagnostics.len() == before @@ -1702,245 +1522,6 @@ fn collect_environment_semantics( } } -fn representative_issuance_diagnostic( - project: &RegistryProject, - environment: &EnvironmentDocument, - file: &str, -) -> Option { - let binding = environment.oid4vci.as_ref()?; - let representative = binding.representative_issuance.as_ref()?; - if !binding.registrar_clients.is_empty() { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance"), - "Representative issuance and registrar-created offers select incompatible authorities in Registryctl's single-credential binding.", - "Remove registrar_clients, or use a separate environment and Notary deployment for the registrar-created credential.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - diagnostic_address(file, &["oid4vci", "registrar_clients"]), - diagnostic_address(file, &["oid4vci", "representative_issuance"]), - ], - )); - } - let service = project.services.get(&binding.credential.service)?; - let credential = service - .credential_profiles - .get(&binding.credential.profile)?; - let credential_claim = credential.claims.first()?; - let environment_proof = diagnostic_address( - file, - &["oid4vci", "representative_issuance", "proof_claim"], - ); - if let Some((shared_profile, _)) = - service - .credential_profiles - .iter() - .find(|(profile_id, profile)| { - *profile_id != &binding.credential.profile - && profile - .claims - .iter() - .any(|claim_id| claim_id == credential_claim) - }) - { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.credential.profile"), - "The representative credential claim is shared by another credential profile.", - "Use a credential claim root that is exclusive to the representative profile, or create a separate representative-specific claim.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - diagnostic_address(file, &["oid4vci", "credential", "profile"]), - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "credential_profiles", - &binding.credential.profile, - "claims", - ], - ), - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "credential_profiles", - shared_profile, - "claims", - ], - ), - ], - )); - } - - let Some(proof) = service.claims.get(&representative.proof_claim) else { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.proof_claim"), - "The representative proof claim does not exist in the selected credential service.", - "Set proof_claim to a registry-backed claim in the same service as the credential profile.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - environment_proof, - diagnostic_address( - PROJECT_FILE, - &["services", &binding.credential.service, "claims"], - ), - ], - )); - }; - if representative.proof_claim == *credential_claim { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.proof_claim"), - "The representative proof claim is also the credential claim.", - "Use a separate registry-backed claim that proves the relationship; Registryctl will add it as a dependency of the credential claim.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - environment_proof, - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "credential_profiles", - &binding.credential.profile, - "claims", - ], - ), - ], - )); - } - if inferred_claim_evidence(service, proof).ok()? != ClaimEvidence::RegistryBacked { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.proof_claim"), - "The representative proof claim is not backed by a registry consultation.", - "Bind proof_claim to a Relay consultation that verifies the relationship in an authoritative source.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - environment_proof, - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "claims", - &representative.proof_claim, - ], - ), - ], - )); - } - let consultation_name = claim_consultation_name(service, proof).ok()?; - let consultation = service.consultations.get(consultation_name)?; - let requester_mapping = format!( - "request.requester.identifiers.{}", - binding.subject.id_type - ); - if !consultation - .input - .values() - .any(|mapping| mapping == &requester_mapping) - { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.proof_claim"), - "The relationship-proof consultation does not bind the authenticated representative.", - "Map one proof-claim consultation input from request.requester.identifiers..", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - environment_proof, - diagnostic_address( - file, - &["oid4vci", "subject", "id_type"], - ), - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "consultations", - consultation_name, - "input", - ], - ), - ], - )); - } - let target_mapping = format!( - "request.target.identifiers.{}", - representative.target_id_type - ); - if !consultation - .input - .values() - .any(|mapping| mapping == &target_mapping) - { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.target_id_type"), - "The relationship-proof consultation does not bind the represented subject.", - "Map one proof-claim consultation input from request.target.identifiers..", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - diagnostic_address( - file, - &["oid4vci", "representative_issuance", "target_id_type"], - ), - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "consultations", - consultation_name, - "input", - ], - ), - ], - )); - } - if let Some((input_name, _)) = consultation - .input - .iter() - .find(|(_, mapping)| *mapping != &requester_mapping && *mapping != &target_mapping) - { - return Some(cross_file_diagnostic( - "registryctl.authoring.environment.invalid", - file, - Some("oid4vci.representative_issuance.proof_claim"), - "The relationship-proof consultation requires an input that the target-selection ceremony cannot supply.", - "Keep exactly two proof-claim inputs: the authenticated requester identifier and the selected target identifier.", - Some(ENVIRONMENT_SCHEMA_HINT), - vec![ - environment_proof, - diagnostic_address( - PROJECT_FILE, - &[ - "services", - &binding.credential.service, - "consultations", - consultation_name, - "input", - input_name, - ], - ), - ], - )); - } - None -} - fn environment_relationship_diagnostic( project: &RegistryProject, integrations: &BTreeMap, diff --git a/crates/registryctl/src/project_authoring/documentation.rs b/crates/registryctl/src/project_authoring/documentation.rs index e9b46c8f8..e139f4e87 100644 --- a/crates/registryctl/src/project_authoring/documentation.rs +++ b/crates/registryctl/src/project_authoring/documentation.rs @@ -37,11 +37,7 @@ const ENTITY_SCHEMA: &str = include_str!("../../schemas/project-authoring/entity const KNOWLEDGE_ASSET: &str = include_str!("../../schemas/project-authoring/parity-coverage.json"); const RELAY_RUNTIME_INTENT_ASSET: &str = include_str!("../../../registry-relay/config/documentation-intent.json"); -const NOTARY_RUNTIME_INTENT_ASSET: &str = - include_str!("../../../registry-notary-core/config/documentation-intent.json"); const RELAY_RUNTIME_INTENT_SOURCE: &str = "crates/registry-relay/config/documentation-intent.json"; -const NOTARY_RUNTIME_INTENT_SOURCE: &str = - "crates/registry-notary-core/config/documentation-intent.json"; const CONSTRAINT_KEYWORDS: [&str; 21] = [ "const", @@ -270,7 +266,6 @@ pub enum ConfigurationSchemaKind { Fixture, Entity, Relay, - Notary, } impl fmt::Display for ConfigurationSchemaKind { @@ -282,7 +277,6 @@ impl fmt::Display for ConfigurationSchemaKind { Self::Fixture => "fixture", Self::Entity => "entity", Self::Relay => "relay", - Self::Notary => "notary", }) } } @@ -1000,13 +994,8 @@ fn prepare_runtime_intent<'a>( "runtime intent must identify the strict v1 intent contract", )); } - if !matches!( - intent.runtime_schema, - ConfigurationSchemaKind::Relay | ConfigurationSchemaKind::Notary - ) { - return Err(documentation_error( - "runtime intent schema must be relay or notary", - )); + if !matches!(intent.runtime_schema, ConfigurationSchemaKind::Relay) { + return Err(documentation_error("runtime intent schema must be relay")); } if document.get("$id").and_then(Value::as_str) != Some(intent.schema_id.as_str()) { return Err(documentation_error(format!( @@ -1251,15 +1240,6 @@ fn validate_runtime_profile( GeneratedArtifact::RelayConfig, ReviewClass::Relay, ), - ConfigurationSchemaKind::Notary => ( - "notary_", - SemanticOwner::NotaryRuntime, - HumanOwner::NotaryMaintainers, - Product::Notary, - Consumer::RegistryNotary, - GeneratedArtifact::NotaryConfig, - ReviewClass::Notary, - ), _ => { return Err(documentation_error( "runtime profile validation requires a product runtime schema", @@ -1285,7 +1265,6 @@ fn validate_runtime_profile( } else { let product_prefix = match schema { ConfigurationSchemaKind::Relay => "registry.relay.config.", - ConfigurationSchemaKind::Notary => "registry.notary.config.", _ => unreachable!("runtime profile schema was restricted above"), }; let suffix = profile @@ -1888,52 +1867,29 @@ fn embedded_seven_domain_fields() -> Result< > { let authored = with_embedded_inputs(generate_configuration_reference)?; let relay_document = registry_relay::config::schema::document(); - let notary_document = registry_notary_core::config::schema::document(); let relay_intent: RuntimeIntentCatalog = serde_json::from_str(RELAY_RUNTIME_INTENT_ASSET) .map_err(|error| documentation_error(format!("embedded Relay runtime intent: {error}")))?; - let notary_intent: RuntimeIntentCatalog = serde_json::from_str(NOTARY_RUNTIME_INTENT_ASSET) - .map_err(|error| documentation_error(format!("embedded Notary runtime intent: {error}")))?; validate_embedded_runtime_identity( &relay_intent, ConfigurationSchemaKind::Relay, "registry-relay.config.schema.json", )?; - validate_embedded_runtime_identity( - ¬ary_intent, - ConfigurationSchemaKind::Notary, - "registry-notary.config.schema.json", - )?; let relay_required = runtime_schema_paths(ConfigurationSchemaKind::Relay, &relay_document)?.len(); - let notary_required = - runtime_schema_paths(ConfigurationSchemaKind::Notary, ¬ary_document)?.len(); let mut missing = runtime_configuration_intent_gaps(&relay_document, &relay_intent)?; - let notary_missing = runtime_configuration_intent_gaps(¬ary_document, ¬ary_intent)?; let relay_fields = if missing.is_empty() { generate_runtime_configuration_fields(&relay_document, &relay_intent)? } else { runtime_configuration_fields(&relay_document, &relay_intent)?.0 }; - let notary_fields = if notary_missing.is_empty() { - generate_runtime_configuration_fields(¬ary_document, ¬ary_intent)? - } else { - runtime_configuration_fields(¬ary_document, ¬ary_intent)?.0 - }; if relay_fields.len() + missing.len() != relay_required { return Err(documentation_error( "Relay runtime reference coverage does not match its authoritative schema paths", )); } - if notary_fields.len() + notary_missing.len() != notary_required { - return Err(documentation_error( - "Notary runtime reference coverage does not match its authoritative schema paths", - )); - } - missing.extend(notary_missing); missing.sort(); let mut fields = authored.fields; fields.extend(relay_fields); - fields.extend(notary_fields); fields.sort_by(|left, right| left.address.cmp(&right.address)); if fields .windows(2) @@ -1968,7 +1924,6 @@ fn combined_source_contract() -> ReferenceSourceContract { ConfigurationSchemaKind::Fixture, ConfigurationSchemaKind::Entity, ConfigurationSchemaKind::Relay, - ConfigurationSchemaKind::Notary, ], schema_sources: vec![ "project.schema.json".to_owned(), @@ -1977,11 +1932,10 @@ fn combined_source_contract() -> ReferenceSourceContract { "fixture.schema.json".to_owned(), "entity.schema.json".to_owned(), "registry-relay.config.schema.json".to_owned(), - "registry-notary.config.schema.json".to_owned(), ], field_knowledge: "schemas/project-authoring/parity-coverage.json#field_knowledge", human_intent: "schemas/project-authoring/documentation-intent.json", - runtime_intent: vec![RELAY_RUNTIME_INTENT_SOURCE, NOTARY_RUNTIME_INTENT_SOURCE], + runtime_intent: vec![RELAY_RUNTIME_INTENT_SOURCE], reads_country_workspaces: false, reads_runtime_configuration: false, } @@ -2855,7 +2809,7 @@ mod tests { .expect("embedded reference coverage is readable"); assert!(!coverage.source_contract.reads_country_workspaces); assert!(!coverage.source_contract.reads_runtime_configuration); - assert_eq!(coverage.coverage.path_count, 702); + assert_eq!(coverage.coverage.path_count, 562); } #[test] diff --git a/crates/registryctl/src/project_authoring/fixture_coverage.rs b/crates/registryctl/src/project_authoring/fixture_coverage.rs index 19d2d8ee9..541c4f834 100644 --- a/crates/registryctl/src/project_authoring/fixture_coverage.rs +++ b/crates/registryctl/src/project_authoring/fixture_coverage.rs @@ -4,7 +4,7 @@ //! Coverage is reported per integration target. Evidence contains only stable //! identifiers, content digests, bounded counts, closed outcomes, and closed //! safe error classes. Fixture values, request material, source observations, -//! paths, origins, outputs, claims, and secrets have no representation here. +//! paths, origins, outputs, and secrets have no representation here. use std::collections::BTreeSet; @@ -19,7 +19,6 @@ pub(crate) const MAX_FIXTURE_COVERAGE_AUTHORED_RECORDS: usize = 1_024; pub(crate) const MAX_FIXTURE_COVERAGE_GENERATED_RECORDS: usize = MAX_FIXTURE_COVERAGE_AUTHORED_RECORDS * GeneratorRecipeId::ALL.len(); pub(crate) const MAX_FIXTURE_COVERAGE_PLATFORM_RECORDS: usize = PlatformGeneratedCaseId::ALL.len(); -pub(crate) const MAX_FIXTURE_COVERAGE_CONSULTATIONS: usize = 512; const INVALID_REPORT: &str = "fixture coverage report violates the closed v1 invariants"; const INVALID_COMPARISON: &str = @@ -49,14 +48,6 @@ pub enum LiveCompatibilityEvaluation { NotEvaluated, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum GovernedRequestEvidence { - /// Proof method only. Per-target requirement state remains authoritative - /// about whether every reachable consultation has a passing witness. - PerConsultationAuthoredRequestWitnessEvaluation, -} - #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum FixtureCapability { @@ -89,8 +80,6 @@ pub enum FixtureSemanticOutcome { #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] pub enum FixtureSafeCode { - #[serde(rename = "authorization.denied")] - AuthorizationDenied, #[serde(rename = "failure.subject_mismatch")] FailureSubjectMismatch, #[serde(rename = "fixture.execution_contract_invalid")] @@ -128,7 +117,6 @@ pub enum FixtureSafeCode { impl FixtureSafeCode { pub(crate) fn from_runtime_code(code: &str) -> Self { match code { - "authorization.denied" => Self::AuthorizationDenied, "failure.subject_mismatch" => Self::FailureSubjectMismatch, "fixture.execution_contract_invalid" => Self::FixtureExecutionContractInvalid, "fixture.profile_not_found" => Self::FixtureProfileNotFound, @@ -177,14 +165,6 @@ pub enum FixturePassState { NotExecuted, } -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum FixtureDisclosureMode { - Predicate, - Redacted, - Value, -} - #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum FixtureStatusOutcome { @@ -248,38 +228,9 @@ pub struct AuthoredSemanticFixtureCoverage { pub interaction_count: u32, pub input_ids: Vec, pub output_ids: Vec, - pub claim_ids: Vec, pub exercised_status_mappings: Vec, pub classification: FixtureCoverageClassification, pub pass_state: FixturePassState, - pub request_to_consultation_binding: FixtureRequestBindingCoverage, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum FixtureRequestBindingState { - NotAuthored, - NotExecuted, - Passed, - Failed, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct FixtureRequestBindingCoverage { - pub state: FixtureRequestBindingState, - /// Safe authored identities selected by a passing production Notary plan. - /// Values, selectors, and rendered requests never enter this report. - pub consultations: Vec, - pub actual_relay_consultations: Option, - pub safe_error_code: Option, -} - -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct FixtureConsultationIdentity { - pub service_id: String, - pub consultation_id: String, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] @@ -292,12 +243,11 @@ pub enum GeneratorRecipeId { ByteCeiling, Timeout, ProtocolVerification, - AuthorizationBeforeSource, OutputMinimization, } impl GeneratorRecipeId { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 8] = [ Self::RequestAuthority, Self::RequestOrder, Self::StatusRejection, @@ -305,7 +255,6 @@ impl GeneratorRecipeId { Self::ByteCeiling, Self::Timeout, Self::ProtocolVerification, - Self::AuthorizationBeforeSource, Self::OutputMinimization, ]; @@ -318,7 +267,6 @@ impl GeneratorRecipeId { Self::ByteCeiling => FixtureMutationTargetClass::DeclaredResponseByteCount, Self::Timeout => FixtureMutationTargetClass::SourceDeadline, Self::ProtocolVerification => FixtureMutationTargetClass::ProtocolResponseEnvelope, - Self::AuthorizationBeforeSource => FixtureMutationTargetClass::AuthorizationGate, Self::OutputMinimization => FixtureMutationTargetClass::UnselectedResponseMember, } } @@ -334,7 +282,6 @@ impl GeneratorRecipeId { } Self::ByteCeiling => Some(FixtureSafeCode::SourceResponseTooLarge), Self::Timeout => Some(FixtureSafeCode::SourceDeadlineExceeded), - Self::AuthorizationBeforeSource => Some(FixtureSafeCode::AuthorizationDenied), Self::OutputMinimization => None, } } @@ -363,7 +310,6 @@ pub enum FixtureMutationTargetClass { DeclaredResponseByteCount, SourceDeadline, ProtocolResponseEnvelope, - AuthorizationGate, UnselectedResponseMember, SourceCallBudget, } @@ -377,7 +323,6 @@ pub enum GeneratedNotApplicableReason { NoDistinguishableRequestPair, NoGeneratedRequestMatcher, FinalResponseIsNotJsonObject, - IntegrationHasNoProductClaims, SnapshotUsesClosedMaterialization, ProtocolMatcherOwnsResponseMutation, } @@ -391,7 +336,6 @@ pub enum CoverageInvariant { OrderMutationRequiresDistinguishableSourceInteractions, ProtocolMutationRequiresGeneratedRequestMatcher, MutationRequiresFinalJsonObjectResponse, - AuthorizationCheckRequiresProductClaimEvaluation, SnapshotOutputUsesClosedMaterializationProjection, ProtocolMatcherFixtureUsesProtocolVerificationInstead, } @@ -413,20 +357,6 @@ pub struct GeneratedSourceFixture { pub fixture_digest: Sha256Digest, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SourceCallExpectation { - Zero, -} - -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct SourceAccessAssertion { - pub expected_source_calls: SourceCallExpectation, - pub actual_source_calls: Option, - pub passed: bool, -} - #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct GeneratedFixtureCoverage { @@ -438,7 +368,6 @@ pub struct GeneratedFixtureCoverage { pub expected_safe_code: Option, pub actual_safe_code: Option, pub pass_state: FixturePassState, - pub source_access_assertion: Option, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] @@ -475,8 +404,6 @@ pub struct PlatformGeneratedFixtureCoverage { pub struct FixtureCoverageDimensions { pub input_ids: Vec, pub output_ids: Vec, - pub claim_ids: Vec, - pub disclosure_modes: Vec, pub status_mappings: Vec, pub protocol_helpers: Vec, pub limits: Vec, @@ -506,10 +433,6 @@ pub struct FixtureCoverageTargetContract { /// operation cardinality, not a source endpoint or authored path. pub source_operation_count: Option, pub reviewed_not_applicable: Vec, - /// Every registry-backed consultation reachable through claims for this - /// integration. Coverage must include an independently authored passing - /// request for each identity, not merely any passing fixture. - pub registry_backed_consultations: Vec, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] @@ -520,22 +443,16 @@ pub enum RequiredFixtureCoverageRequirement { SemanticAmbiguity, SubjectMismatch, SemanticNull, - AuthorizationDenial, SourceFailure, RequestRendering, - RequestToConsultationBinding, ExpectedSourceInteractions, SourceInteractionOrder, OutputFields, - Claims, - DeclaredDisclosureModes, - ExercisedDisclosureModes, ScriptBranches, PaginationAndContinuation, StatusMappings, ProtocolHelpers, ProtocolVerification, - AuthorizationBeforeSource, MalformedDecoding, StructuralLimits, RequestBytes, @@ -548,33 +465,26 @@ pub enum RequiredFixtureCoverageRequirement { OutputMinimization, ChangedInputAffectedFixtures, ChangedOutputAffectedFixtures, - ChangedClaimAffectedFixtures, ChangedSourceContractAffectedFixtures, } impl RequiredFixtureCoverageRequirement { - pub const ALL: [Self; 35] = [ + pub const ALL: [Self; 28] = [ Self::SemanticMatch, Self::SemanticNoMatch, Self::SemanticAmbiguity, Self::SubjectMismatch, Self::SemanticNull, - Self::AuthorizationDenial, Self::SourceFailure, Self::RequestRendering, - Self::RequestToConsultationBinding, Self::ExpectedSourceInteractions, Self::SourceInteractionOrder, Self::OutputFields, - Self::Claims, - Self::DeclaredDisclosureModes, - Self::ExercisedDisclosureModes, Self::ScriptBranches, Self::PaginationAndContinuation, Self::StatusMappings, Self::ProtocolHelpers, Self::ProtocolVerification, - Self::AuthorizationBeforeSource, Self::MalformedDecoding, Self::StructuralLimits, Self::RequestBytes, @@ -587,7 +497,6 @@ impl RequiredFixtureCoverageRequirement { Self::OutputMinimization, Self::ChangedInputAffectedFixtures, Self::ChangedOutputAffectedFixtures, - Self::ChangedClaimAffectedFixtures, Self::ChangedSourceContractAffectedFixtures, ]; } @@ -597,7 +506,6 @@ impl RequiredFixtureCoverageRequirement { pub enum FixtureCoverageGapReason { RequiredEvidenceMissing, TargetHasNoFixtures, - RuntimeDimensionNotObserved, NumericBoundaryNotExercised, ScriptBranchContractNotDeclared, } @@ -605,7 +513,6 @@ pub enum FixtureCoverageGapReason { #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum FixtureCoverageNotApplicableReason { - NoProductClaimsDeclared, NoProtocolHelpersDeclared, NoVerificationProtocolDeclared, NoContinuationProtocolDeclared, @@ -694,15 +601,13 @@ pub enum FixtureCoverageRequirementState { pub enum FixtureCoverageChangeKind { ChangedInput, ChangedOutput, - ChangedClaim, ChangedSourceContract, } impl FixtureCoverageChangeKind { - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 3] = [ Self::ChangedInput, Self::ChangedOutput, - Self::ChangedClaim, Self::ChangedSourceContract, ]; @@ -712,7 +617,6 @@ impl FixtureCoverageChangeKind { Self::ChangedOutput => { RequiredFixtureCoverageRequirement::ChangedOutputAffectedFixtures } - Self::ChangedClaim => RequiredFixtureCoverageRequirement::ChangedClaimAffectedFixtures, Self::ChangedSourceContract => { RequiredFixtureCoverageRequirement::ChangedSourceContractAffectedFixtures } @@ -798,7 +702,6 @@ pub struct ProjectFixtureCoverageReportV1 { pub evidence_scope: FixtureEvidenceScope, pub compatibility_claim: FixtureCompatibilityClaim, pub live_compatibility: LiveCompatibilityEvaluation, - pub governed_request_evidence: GovernedRequestEvidence, pub targets: Vec, pub summary: FixtureCoverageSummary, } @@ -812,7 +715,6 @@ struct UncheckedProjectFixtureCoverageReportV1 { evidence_scope: FixtureEvidenceScope, compatibility_claim: FixtureCompatibilityClaim, live_compatibility: LiveCompatibilityEvaluation, - governed_request_evidence: GovernedRequestEvidence, targets: Vec, summary: FixtureCoverageSummary, } @@ -831,15 +733,13 @@ impl ProjectFixtureCoverageReportV1 { evidence_scope: FixtureEvidenceScope::OfflineSynthetic, compatibility_claim: FixtureCompatibilityClaim::None, live_compatibility: LiveCompatibilityEvaluation::NotEvaluated, - governed_request_evidence: - GovernedRequestEvidence::PerConsultationAuthoredRequestWitnessEvaluation, targets, summary, }; Self::try_from(unchecked) } - /// Adds a closed semantic comparison and recomputes the four affected + /// Adds a closed semantic comparison and recomputes the three affected /// fixture requirements for every target. Selection is deliberately based /// only on declared fixture member identifiers. Generated cases are a /// different evidence class and can never become affected authored @@ -880,7 +780,6 @@ impl ProjectFixtureCoverageReportV1 { evidence_scope: self.evidence_scope, compatibility_claim: self.compatibility_claim, live_compatibility: self.live_compatibility, - governed_request_evidence: self.governed_request_evidence, targets: self.targets, summary: self.summary, }; @@ -900,7 +799,6 @@ impl TryFrom for ProjectFixtureCoverage evidence_scope: value.evidence_scope, compatibility_claim: value.compatibility_claim, live_compatibility: value.live_compatibility, - governed_request_evidence: value.governed_request_evidence, targets: value.targets, summary: value.summary, }) @@ -922,7 +820,6 @@ pub struct FixtureCoverageTargetComparisonInput { pub integration: String, pub changed_input_ids: Vec, pub changed_output_ids: Vec, - pub changed_claim_ids: Vec, pub source_contract_changed: bool, } @@ -964,7 +861,6 @@ fn validate_comparison_input_targets( !is_report_identifier(&target.integration) || !is_sorted_unique_identifiers(&target.changed_input_ids) || !is_sorted_unique_identifiers(&target.changed_output_ids) - || !is_sorted_unique_identifiers(&target.changed_claim_ids) }) { return Err(INVALID_COMPARISON); @@ -1018,7 +914,6 @@ fn build_target_comparison( let changed_member_ids = match kind { FixtureCoverageChangeKind::ChangedInput => input.changed_input_ids.clone(), FixtureCoverageChangeKind::ChangedOutput => input.changed_output_ids.clone(), - FixtureCoverageChangeKind::ChangedClaim => input.changed_claim_ids.clone(), FixtureCoverageChangeKind::ChangedSourceContract if input.source_contract_changed => { vec!["source-contract".to_owned()] } @@ -1034,9 +929,6 @@ fn build_target_comparison( FixtureCoverageChangeKind::ChangedOutput => { slices_intersect(&fixture.output_ids, &changed_member_ids) } - FixtureCoverageChangeKind::ChangedClaim => { - slices_intersect(&fixture.claim_ids, &changed_member_ids) - } FixtureCoverageChangeKind::ChangedSourceContract => input.source_contract_changed, }) .map(|fixture| fixture.fixture_id.clone()) @@ -1260,7 +1152,6 @@ fn validate_authored_fixture( && fixture.interaction_count <= 16 && is_sorted_unique_identifiers(&fixture.input_ids) && is_sorted_unique_identifiers(&fixture.output_ids) - && is_sorted_unique_identifiers(&fixture.claim_ids) && valid_status_mappings(&fixture.exercised_status_mappings) && fixture.exercised_status_mappings.iter().all(|mapping| { target.declared.status_mappings.iter().any(|declared| { @@ -1275,10 +1166,6 @@ fn validate_authored_fixture( target.identity.integration, fixture.fixture_id ) && fixture.evidence.digest == fixture.fixture_digest - && valid_request_binding( - &fixture.request_to_consultation_binding, - &target.contract.registry_backed_consultations, - ) } fn valid_target_contract( @@ -1289,7 +1176,6 @@ fn valid_target_contract( .reviewed_not_applicable .windows(2) .all(|pair| pair[0] < pair[1]) - && valid_consultation_identities(&contract.registry_backed_consultations) && match identity.capability { FixtureCapability::DeclarativeHttp => contract.source_operation_count.is_some(), FixtureCapability::Script | FixtureCapability::Snapshot => { @@ -1298,42 +1184,6 @@ fn valid_target_contract( } } -fn valid_request_binding( - binding: &FixtureRequestBindingCoverage, - declared: &[FixtureConsultationIdentity], -) -> bool { - valid_consultation_identities(&binding.consultations) - && slice_is_subset(&binding.consultations, declared) - && match binding.state { - FixtureRequestBindingState::NotAuthored | FixtureRequestBindingState::NotExecuted => { - binding.consultations.is_empty() - && binding.actual_relay_consultations.is_none() - && binding.safe_error_code.is_none() - } - FixtureRequestBindingState::Passed => { - !binding.consultations.is_empty() - && binding - .actual_relay_consultations - .is_some_and(|count| count > 0) - && binding.safe_error_code.is_none() - } - FixtureRequestBindingState::Failed => { - binding.consultations.is_empty() - && binding.actual_relay_consultations.is_some() - && binding.safe_error_code.is_some() - } - } -} - -fn valid_consultation_identities(identities: &[FixtureConsultationIdentity]) -> bool { - identities.len() <= MAX_FIXTURE_COVERAGE_CONSULTATIONS - && identities.windows(2).all(|pair| pair[0] < pair[1]) - && identities.iter().all(|identity| { - is_report_identifier(&identity.service_id) - && is_report_identifier(&identity.consultation_id) - }) -} - fn validate_generated_case( target: &FixtureCoverageTarget, case: &GeneratedFixtureCoverage, @@ -1361,8 +1211,7 @@ fn validate_generated_case( .is_none_or(|fixture| fixture.fixture_digest != case.source_fixture.fixture_digest) || (!applicable && (case.pass_state != FixturePassState::NotExecuted - || case.actual_safe_code.is_some() - || case.source_access_assertion.is_some())) + || case.actual_safe_code.is_some())) || (applicable && case.pass_state == FixturePassState::Passed && case.actual_safe_code != case.expected_safe_code) @@ -1374,16 +1223,7 @@ fn validate_generated_case( return false; } } - if case.recipe.id == GeneratorRecipeId::AuthorizationBeforeSource && applicable { - case.source_access_assertion - .as_ref() - .is_some_and(|assertion| { - assertion.expected_source_calls == SourceCallExpectation::Zero - && assertion.passed == (assertion.actual_source_calls == Some(0)) - }) - } else { - case.source_access_assertion.is_none() - } + true } fn validate_platform_case( @@ -1460,9 +1300,6 @@ fn validate_comparison( FixtureCoverageChangeKind::ChangedOutput => { slices_intersect(&fixture.output_ids, &impact.changed_member_ids) } - FixtureCoverageChangeKind::ChangedClaim => { - slices_intersect(&fixture.claim_ids, &impact.changed_member_ids) - } FixtureCoverageChangeKind::ChangedSourceContract => { impact.changed_member_ids == ["source-contract"] } @@ -1604,22 +1441,6 @@ fn derive_fixture_coverage_requirement( authored(|fixture| fixture.semantic_null), fixture_gap, ), - Requirement::AuthorizationDenial => { - if target.declared.claim_ids.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - covered_or_missing( - requirement, - authored(|fixture| { - fixture.expectation - == FixtureSemanticExpectation::SafeErrorCode { - code: FixtureSafeCode::AuthorizationDenied, - } - }), - fixture_gap, - ) - } - } Requirement::SourceFailure => { if target.identity.capability == FixtureCapability::Snapshot { no_remote_not_applicable(target, requirement) @@ -1649,40 +1470,6 @@ fn derive_fixture_coverage_requirement( ) } } - Requirement::RequestToConsultationBinding => { - let required = &target.contract.registry_backed_consultations; - if required.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - let passing = target - .fixture_inventory - .iter() - .filter(|fixture| { - fixture.pass_state == FixturePassState::Passed - && fixture.request_to_consultation_binding.state - == FixtureRequestBindingState::Passed - }) - .collect::>(); - let covered_consultations = passing - .iter() - .flat_map(|fixture| { - fixture.request_to_consultation_binding.consultations.iter() - }) - .collect::>(); - let evidence = passing - .into_iter() - .map(|fixture| fixture.evidence.clone()) - .collect::>(); - if required - .iter() - .all(|consultation| covered_consultations.contains(consultation)) - { - covered(requirement, evidence) - } else { - missing(requirement, fixture_gap, evidence) - } - } - } Requirement::ExpectedSourceInteractions => { let evidence = authored(|_| true); if !target.fixture_inventory.is_empty() @@ -1726,36 +1513,6 @@ fn derive_fixture_coverage_requirement( missing(requirement, fixture_gap, evidence) } } - Requirement::Claims => { - if target.declared.claim_ids.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - let evidence = authored(|fixture| !fixture.claim_ids.is_empty()); - if target.exercised.claim_ids == target.declared.claim_ids && !evidence.is_empty() { - covered(requirement, evidence) - } else { - missing(requirement, fixture_gap, evidence) - } - } - } - Requirement::DeclaredDisclosureModes => { - if target.declared.claim_ids.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - covered(requirement, vec![target.compiled_contract.clone()]) - } - } - Requirement::ExercisedDisclosureModes => { - if target.declared.claim_ids.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - missing( - requirement, - FixtureCoverageGapReason::RuntimeDimensionNotObserved, - Vec::new(), - ) - } - } Requirement::ScriptBranches => { if target.identity.capability != FixtureCapability::Script { not_applicable( @@ -1841,18 +1598,6 @@ fn derive_fixture_coverage_requirement( ) } } - Requirement::AuthorizationBeforeSource => { - if target.declared.claim_ids.is_empty() { - no_claims_not_applicable(target, requirement) - } else { - generated_requirement( - target, - requirement, - GeneratorRecipeId::AuthorizationBeforeSource, - fixture_gap, - ) - } - } Requirement::MalformedDecoding => { if target.identity.capability == FixtureCapability::Snapshot { no_remote_not_applicable(target, requirement) @@ -1973,7 +1718,6 @@ fn derive_fixture_coverage_requirement( } Requirement::ChangedInputAffectedFixtures | Requirement::ChangedOutputAffectedFixtures - | Requirement::ChangedClaimAffectedFixtures | Requirement::ChangedSourceContractAffectedFixtures => { comparison_requirement(target, requirement, comparison_reason) } @@ -2020,13 +1764,7 @@ fn generated_recipe_evidence( .collect::>(); let evidence = applicable .iter() - .filter(|case| { - case.pass_state == FixturePassState::Passed - && case - .source_access_assertion - .as_ref() - .is_none_or(|assertion| assertion.passed) - }) + .filter(|case| case.pass_state == FixturePassState::Passed) .map(|case| case.evidence.clone()) .collect::>(); ( @@ -2081,17 +1819,6 @@ fn comparison_requirement( } } -fn no_claims_not_applicable( - target: &FixtureCoverageTarget, - requirement: RequiredFixtureCoverageRequirement, -) -> FixtureRequirementCoverage { - not_applicable( - requirement, - FixtureCoverageNotApplicableReason::NoProductClaimsDeclared, - vec![target.compiled_contract.clone()], - ) -} - fn no_remote_not_applicable( target: &FixtureCoverageTarget, requirement: RequiredFixtureCoverageRequirement, @@ -2158,11 +1885,6 @@ fn not_applicable( fn valid_dimensions(dimensions: &FixtureCoverageDimensions) -> bool { is_sorted_unique_identifiers(&dimensions.input_ids) && is_sorted_unique_identifiers(&dimensions.output_ids) - && is_sorted_unique_identifiers(&dimensions.claim_ids) - && dimensions - .disclosure_modes - .windows(2) - .all(|pair| pair[0] < pair[1]) && valid_status_mappings(&dimensions.status_mappings) && dimensions .protocol_helpers @@ -2190,12 +1912,6 @@ fn dimensions_subset_error( if !slice_is_subset(&exercised.output_ids, &declared.output_ids) { return Some("fixture coverage exercised outputs exceed declarations"); } - if !slice_is_subset(&exercised.claim_ids, &declared.claim_ids) { - return Some("fixture coverage exercised claims exceed declarations"); - } - if !slice_is_subset(&exercised.disclosure_modes, &declared.disclosure_modes) { - return Some("fixture coverage exercised disclosure modes exceed declarations"); - } if !exercised.status_mappings.iter().all(|mapping| { declared.status_mappings.iter().any(|declared_mapping| { mapping.outcome == declared_mapping.outcome @@ -2318,10 +2034,6 @@ fn generated_not_applicable_is_valid( recipe, GeneratorRecipeId::ProtocolVerification | GeneratorRecipeId::OutputMinimization ), - ( - GeneratedNotApplicableReason::IntegrationHasNoProductClaims, - CoverageInvariant::AuthorizationCheckRequiresProductClaimEvaluation, - ) => recipe == GeneratorRecipeId::AuthorizationBeforeSource, ( GeneratedNotApplicableReason::SnapshotUsesClosedMaterialization, CoverageInvariant::SnapshotOutputUsesClosedMaterializationProjection, @@ -2343,7 +2055,6 @@ pub(crate) const fn recipe_suffix(recipe: GeneratorRecipeId) -> &'static str { GeneratorRecipeId::ByteCeiling => "byte_ceiling", GeneratorRecipeId::Timeout => "timeout", GeneratorRecipeId::ProtocolVerification => "protocol_verification", - GeneratorRecipeId::AuthorizationBeforeSource => "authorization_before_source", GeneratorRecipeId::OutputMinimization => "output_minimization", } } @@ -2352,7 +2063,6 @@ pub(crate) const fn change_suffix(kind: FixtureCoverageChangeKind) -> &'static s match kind { FixtureCoverageChangeKind::ChangedInput => "changed-input", FixtureCoverageChangeKind::ChangedOutput => "changed-output", - FixtureCoverageChangeKind::ChangedClaim => "changed-claim", FixtureCoverageChangeKind::ChangedSourceContract => "changed-source-contract", } } @@ -2388,15 +2098,4 @@ pub(crate) struct GeneratedFixtureObservation { pub recipe_id: GeneratorRecipeId, pub actual_safe_code: Option, pub pass_state: FixturePassState, - pub actual_source_calls: Option, -} - -#[derive(Clone, Debug)] -pub(crate) struct AuthoredRequestBindingObservation { - pub integration: String, - pub source_fixture_id: String, - pub pass_state: FixturePassState, - pub consultations: Vec, - pub actual_safe_code: Option, - pub actual_relay_consultations: Option, } diff --git a/crates/registryctl/src/project_authoring/fixture_diagnostics.rs b/crates/registryctl/src/project_authoring/fixture_diagnostics.rs index ae79caee7..d7baf3f13 100644 --- a/crates/registryctl/src/project_authoring/fixture_diagnostics.rs +++ b/crates/registryctl/src/project_authoring/fixture_diagnostics.rs @@ -30,12 +30,6 @@ macro_rules! fixture_diagnostic { /// Complete Registryctl offline-fixture diagnostic catalog in lexical code /// order. pub(crate) const FIXTURE_DIAGNOSTIC_DEFINITIONS: &[FixtureDiagnosticDefinition] = &[ - fixture_diagnostic!( - AuthorizationDenied, - "Authorization denied fixture execution before source access.", - "authorization_before_source", - "Align the fixture identity and authorization expectation with the compiled policy." - ), fixture_diagnostic!( FailureSubjectMismatch, "The source observation did not preserve the requested subject binding.", @@ -136,21 +130,20 @@ pub(crate) const fn fixture_diagnostic_definition( code: FixtureSafeCode, ) -> &'static FixtureDiagnosticDefinition { match code { - FixtureSafeCode::AuthorizationDenied => &FIXTURE_DIAGNOSTIC_DEFINITIONS[0], - FixtureSafeCode::FailureSubjectMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[1], - FixtureSafeCode::FixtureExecutionContractInvalid => &FIXTURE_DIAGNOSTIC_DEFINITIONS[2], - FixtureSafeCode::FixtureProfileNotFound => &FIXTURE_DIAGNOSTIC_DEFINITIONS[3], - FixtureSafeCode::FixtureRequestMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[4], - FixtureSafeCode::FixtureSourceOperationUnknown => &FIXTURE_DIAGNOSTIC_DEFINITIONS[5], - FixtureSafeCode::InputPatternMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[6], - FixtureSafeCode::RedactedUnclassifiedError => &FIXTURE_DIAGNOSTIC_DEFINITIONS[7], - FixtureSafeCode::SourceCallBudgetExceeded => &FIXTURE_DIAGNOSTIC_DEFINITIONS[8], - FixtureSafeCode::SourceCardinalityViolation => &FIXTURE_DIAGNOSTIC_DEFINITIONS[9], - FixtureSafeCode::SourceDeadlineExceeded => &FIXTURE_DIAGNOSTIC_DEFINITIONS[10], - FixtureSafeCode::SourceResponseMalformed => &FIXTURE_DIAGNOSTIC_DEFINITIONS[11], - FixtureSafeCode::SourceResponseTooLarge => &FIXTURE_DIAGNOSTIC_DEFINITIONS[12], - FixtureSafeCode::SourceStatusRejected => &FIXTURE_DIAGNOSTIC_DEFINITIONS[13], - FixtureSafeCode::SourceUnavailable => &FIXTURE_DIAGNOSTIC_DEFINITIONS[14], - FixtureSafeCode::SourceUnavailableLegacy => &FIXTURE_DIAGNOSTIC_DEFINITIONS[15], + FixtureSafeCode::FailureSubjectMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[0], + FixtureSafeCode::FixtureExecutionContractInvalid => &FIXTURE_DIAGNOSTIC_DEFINITIONS[1], + FixtureSafeCode::FixtureProfileNotFound => &FIXTURE_DIAGNOSTIC_DEFINITIONS[2], + FixtureSafeCode::FixtureRequestMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[3], + FixtureSafeCode::FixtureSourceOperationUnknown => &FIXTURE_DIAGNOSTIC_DEFINITIONS[4], + FixtureSafeCode::InputPatternMismatch => &FIXTURE_DIAGNOSTIC_DEFINITIONS[5], + FixtureSafeCode::RedactedUnclassifiedError => &FIXTURE_DIAGNOSTIC_DEFINITIONS[6], + FixtureSafeCode::SourceCallBudgetExceeded => &FIXTURE_DIAGNOSTIC_DEFINITIONS[7], + FixtureSafeCode::SourceCardinalityViolation => &FIXTURE_DIAGNOSTIC_DEFINITIONS[8], + FixtureSafeCode::SourceDeadlineExceeded => &FIXTURE_DIAGNOSTIC_DEFINITIONS[9], + FixtureSafeCode::SourceResponseMalformed => &FIXTURE_DIAGNOSTIC_DEFINITIONS[10], + FixtureSafeCode::SourceResponseTooLarge => &FIXTURE_DIAGNOSTIC_DEFINITIONS[11], + FixtureSafeCode::SourceStatusRejected => &FIXTURE_DIAGNOSTIC_DEFINITIONS[12], + FixtureSafeCode::SourceUnavailable => &FIXTURE_DIAGNOSTIC_DEFINITIONS[13], + FixtureSafeCode::SourceUnavailableLegacy => &FIXTURE_DIAGNOSTIC_DEFINITIONS[14], } } diff --git a/crates/registryctl/src/project_authoring/fixtures.rs b/crates/registryctl/src/project_authoring/fixtures.rs index 830274864..79c3953ec 100644 --- a/crates/registryctl/src/project_authoring/fixtures.rs +++ b/crates/registryctl/src/project_authoring/fixtures.rs @@ -91,7 +91,6 @@ fn rhai_diagnostic_source( type FixtureExecutionObservations = ( Vec, Vec, - Vec, Option, ); @@ -106,7 +105,7 @@ fn execute_all_fixtures_with_coverage_observations( let call_budget_actual = platform_call_budget_result(loaded, compiled, execution_context.worker_program())?; if loaded.integrations.is_empty() { - return Ok((Vec::new(), Vec::new(), Vec::new(), call_budget_actual)); + return Ok((Vec::new(), Vec::new(), call_budget_actual)); } if let Some(selected) = integration_filter { if !loaded.integrations.contains_key(selected) { @@ -146,8 +145,10 @@ fn execute_all_fixtures_with_coverage_observations( .any(|fixture| fixture.name == selected) }); if !selected_exists { - let selected_id = integration_filter - .map_or_else(|| selected.to_string(), |integration| format!("{integration}.{selected}")); + let selected_id = integration_filter.map_or_else( + || selected.to_string(), + |integration| format!("{integration}.{selected}"), + ); bail!( "selected fixture {selected_id} does not exist; available fixture ids: {}", available.join(", ") @@ -171,7 +172,6 @@ fn execute_all_fixtures_with_coverage_observations( )?; let mut reports = Vec::new(); let mut generated_observations = Vec::new(); - let mut request_observations = Vec::new(); for (alias, integration) in &loaded.integrations { if integration_filter.is_some_and(|selected| selected != alias) { continue; @@ -180,35 +180,6 @@ fn execute_all_fixtures_with_coverage_observations( if fixture_filter.is_some_and(|selected| selected != fixture.name) { continue; } - if let Some(request) = fixture.request.as_ref() { - if validate_governed_fixture_request(loaded, request).is_err() { - request_observations.push(AuthoredRequestBindingObservation { - integration: alias.clone(), - source_fixture_id: fixture.name.clone(), - pass_state: FixturePassState::Failed, - consultations: Vec::new(), - actual_safe_code: Some(FixtureSafeCode::RedactedUnclassifiedError), - actual_relay_consultations: Some(0), - }); - reports.push(FixtureReport { - integration: alias.clone(), - fixture: fixture.name.clone(), - inputs: fixture.input.keys().cloned().collect(), - calls: Vec::new(), - outputs: Vec::new(), - claims: Vec::new(), - outcome: None, - expected_error: fixture.expect.error.clone(), - source_access: Some(false), - passed: false, - failure: Some( - "request_to_consultation_binding_invalid: relay_consultations=0" - .to_owned(), - ), - }); - continue; - } - } let mut actual_calls = Vec::new(); let relay = execute_fixture( compiled, @@ -218,39 +189,7 @@ fn execute_all_fixtures_with_coverage_observations( &mut actual_calls, trace, ); - let (result, evaluated_claims) = match relay { - Ok((outputs, outcome)) - if matches!(outcome, "match" | "no_match") - && !integration_has_product_claims(loaded, alias) => - { - (Ok((outputs, outcome)), Some(BTreeMap::new())) - } - Ok((outputs, outcome)) if matches!(outcome, "match" | "no_match") => { - match evaluate_product_claims( - loaded, - compiled, - alias, - fixture, - Some((&outputs, outcome)), - registry_notary_server::standalone::OfflineAuthentication::Valid, - false, - execution_context.worker_program(), - ) - .with_context(|| { - format!( - "failed to evaluate product claims for fixture {}.{}", - alias, fixture.name - ) - })? - .result - { - Ok(claims) => (Ok((outputs, outcome)), Some(claims)), - Err(error) => (Err(error), None), - } - } - Ok(result) => (Ok(result), None), - Err(error) => (Err(error), None), - }; + let result = relay; let passed = match (&result, &fixture.expect.error) { (Ok((outputs, _)), None) => { let outcome_matches = @@ -259,15 +198,7 @@ fn execute_all_fixtures_with_coverage_observations( .as_ref() .is_ok_and(|(_, outcome)| *outcome == expected) }); - let claims_match = if result - .as_ref() - .is_ok_and(|(_, outcome)| *outcome == "ambiguous") - { - fixture.expect.claims.is_empty() - } else { - evaluated_claims.as_ref() == Some(&fixture.expect.claims) - }; - outputs == &fixture.expect.outputs && claims_match && outcome_matches + outputs == &fixture.expect.outputs && outcome_matches } (Err(code), Some(expected)) => code == expected, _ => false, @@ -289,16 +220,6 @@ fn execute_all_fixtures_with_coverage_observations( fixture.expect.outcome.as_deref().unwrap_or("unspecified") ) } - (Ok(_), None) if evaluated_claims.as_ref() != Some(&fixture.expect.claims) => { - format!( - "claims_mismatch: claims={}", - mismatched_optional_map_keys( - evaluated_claims.as_ref(), - &fixture.expect.claims, - ) - .join("|") - ) - } (Err(actual), Some(expected)) if actual != expected => { format!("error_mismatch: expected={expected}, actual={actual}") } @@ -333,10 +254,6 @@ fn execute_all_fixtures_with_coverage_observations( inputs: fixture.input.keys().cloned().collect(), calls: actual_calls, outputs, - claims: evaluated_claims - .as_ref() - .map(|claims| claims.keys().cloned().collect()) - .unwrap_or_default(), outcome: result .as_ref() .ok() @@ -349,87 +266,6 @@ fn execute_all_fixtures_with_coverage_observations( passed, failure, }); - if let Some(request) = fixture.request.as_ref() { - let binding = - result - .as_ref() - .map_err(|code| code.clone()) - .and_then(|(outputs, outcome)| { - evaluate_authored_governed_request( - loaded, - compiled, - fixture, - request, - outputs, - outcome, - execution_context.worker_program(), - ) - .map_err(|_| "request.binding_evaluation_failed".to_owned()) - }); - let (pass_state, consultations, actual_safe_code, actual_relay_consultations) = - match binding { - Ok(evaluation) - if evaluation.result.is_ok() && evaluation.relay_calls > 0 => - { - ( - FixturePassState::Passed, - evaluation.consultations, - None, - Some(u32::try_from(evaluation.relay_calls).unwrap_or(u32::MAX)), - ) - } - Ok(evaluation) => ( - FixturePassState::Failed, - Vec::new(), - evaluation - .result - .as_ref() - .err() - .map(|code| FixtureSafeCode::from_runtime_code(code)), - Some(u32::try_from(evaluation.relay_calls).unwrap_or(u32::MAX)), - ), - Err(_) => ( - FixturePassState::Failed, - Vec::new(), - Some(FixtureSafeCode::RedactedUnclassifiedError), - Some(0), - ), - }; - request_observations.push(AuthoredRequestBindingObservation { - integration: alias.clone(), - source_fixture_id: fixture.name.clone(), - pass_state, - consultations, - actual_safe_code, - actual_relay_consultations, - }); - reports.push(FixtureReport { - integration: alias.clone(), - fixture: format!("{}::derived/request_to_consultation_binding", fixture.name), - inputs: fixture.input.keys().cloned().collect(), - calls: if actual_relay_consultations.unwrap_or_default() > 0 { - vec!["notary-relay-consultation".to_owned()] - } else { - Vec::new() - }, - outputs: Vec::new(), - claims: request - .claims - .iter() - .map(|claim| claim.id.clone()) - .collect(), - outcome: None, - expected_error: None, - source_access: Some(actual_relay_consultations.unwrap_or_default() > 0), - passed: pass_state == FixturePassState::Passed, - failure: (pass_state != FixturePassState::Passed).then(|| { - format!( - "request_to_consultation_binding_failed: relay_consultations={}", - actual_relay_consultations.unwrap_or_default() - ) - }), - }); - } reports.extend(derived_fixture_reports( loaded, compiled, @@ -442,12 +278,7 @@ fn execute_all_fixtures_with_coverage_observations( )?); } } - Ok(( - reports, - generated_observations, - request_observations, - call_budget_actual, - )) + Ok((reports, generated_observations, call_budget_actual)) } // The execution seam keeps each authority and observation channel explicit. @@ -460,7 +291,7 @@ fn derived_fixture_reports( fixture: &FixtureDocument, trace: bool, generated_observations: &mut Vec, - worker_program: &Path, + _worker_program: &Path, ) -> Result> { use registry_relay::offline_fixture::OfflineSourceResponse; @@ -610,7 +441,6 @@ fn derived_fixture_reports( } else { FixturePassState::Failed }, - actual_source_calls: Some(u32::try_from(calls.len()).unwrap_or(u32::MAX)), }); FixtureReport { integration: integration_alias.to_owned(), @@ -622,7 +452,6 @@ fn derived_fixture_reports( inputs: fixture.input.keys().cloned().collect(), calls, outputs: Vec::new(), - claims: Vec::new(), outcome: None, expected_error: Some(expected.to_owned()), source_access: Some(error_implies_source_access(expected)), @@ -637,55 +466,6 @@ fn derived_fixture_reports( }) .collect::>(); - if integration_has_product_claims(loaded, integration_alias) { - let authorization = evaluate_product_claims( - loaded, - compiled, - integration_alias, - fixture, - None, - registry_notary_server::standalone::OfflineAuthentication::WrongCredential, - true, - worker_program, - )?; - let authorization_error = authorization.result.err(); - let authorization_passed = authorization_error.as_deref() == Some("authorization.denied"); - let actual_source_calls = u32::try_from(authorization.relay_calls).unwrap_or(u32::MAX); - let passed = authorization_passed && actual_source_calls == 0; - generated_observations.push(GeneratedFixtureObservation { - integration: integration_alias.to_owned(), - source_fixture_id: fixture.name.clone(), - recipe_id: GeneratorRecipeId::AuthorizationBeforeSource, - actual_safe_code: authorization_error - .as_deref() - .map(FixtureSafeCode::from_runtime_code), - pass_state: if passed { - FixturePassState::Passed - } else { - FixturePassState::Failed - }, - actual_source_calls: Some(actual_source_calls), - }); - reports.push(FixtureReport { - integration: integration_alias.to_owned(), - fixture: format!("{}::derived/authorization_before_source", fixture.name), - inputs: fixture.input.keys().cloned().collect(), - calls: Vec::new(), - outputs: Vec::new(), - claims: Vec::new(), - outcome: None, - expected_error: Some("authorization.denied".to_owned()), - source_access: Some(actual_source_calls != 0), - passed, - failure: (!passed).then(|| { - format!( - "derived_authorization_mismatch: expected=authorization.denied, actual={}, source_calls={actual_source_calls}", - authorization_error.as_deref().unwrap_or("success"), - ) - }), - }); - } - let mut minimized = base; // Ignoring unselected upstream members is a declarative HTTP projection // guarantee. Snapshot rows are the reviewed materialization contract, so @@ -736,44 +516,18 @@ fn derived_fixture_reports( "ambiguous" } }; - let evaluated_claims = if matches!(outcome, "match" | "no_match") - && integration_has_product_claims(loaded, integration_alias) - { - evaluate_product_claims( - loaded, - compiled, - integration_alias, - fixture, - Some((&observation.outputs, outcome)), - registry_notary_server::standalone::OfflineAuthentication::Valid, - false, - worker_program, - )? - .result - .map(Some) - } else if matches!(outcome, "match" | "no_match") { - Ok(Some(BTreeMap::new())) - } else { - Ok(None) - }; - evaluated_claims - .map(|claims| (observation.outputs, outcome.to_owned(), claims)) + Ok((observation.outputs, outcome.to_owned())) } Err(error) => Err(error), }; let passed = match (&evaluated, fixture.expect.error.as_deref()) { - (Ok((outputs, outcome, claims)), None) => { + (Ok((outputs, outcome)), None) => { let outcome_matches = fixture .expect .outcome .as_deref() .is_none_or(|expected| expected == outcome); - let claims_match = if outcome == "ambiguous" { - fixture.expect.claims.is_empty() - } else { - claims.as_ref() == Some(&fixture.expect.claims) - }; - outputs == &fixture.expect.outputs && outcome_matches && claims_match + outputs == &fixture.expect.outputs && outcome_matches } (Err(actual), Some(expected)) => actual == expected, _ => false, @@ -785,26 +539,15 @@ fn derived_fixture_reports( let outputs = evaluated .as_ref() .ok() - .map(|(outputs, _, _)| outputs.keys().cloned().collect()) - .unwrap_or_default(); - let claims = evaluated - .as_ref() - .ok() - .and_then(|(_, _, claims)| claims.as_ref()) - .map(|claims| claims.keys().cloned().collect()) + .map(|(outputs, _)| outputs.keys().cloned().collect()) .unwrap_or_default(); - let outcome = evaluated - .as_ref() - .ok() - .map(|(_, outcome, _)| outcome.clone()); - let actual_source_calls = u32::try_from(trace_calls.len()).unwrap_or(u32::MAX); + let outcome = evaluated.as_ref().ok().map(|(_, outcome)| outcome.clone()); reports.push(FixtureReport { integration: integration_alias.to_owned(), fixture: format!("{}::derived/output_minimization", fixture.name), inputs: fixture.input.keys().cloned().collect(), calls: trace_calls, outputs, - claims, outcome, expected_error: fixture.expect.error.clone(), source_access: evaluated @@ -826,7 +569,6 @@ fn derived_fixture_reports( } else { FixturePassState::Failed }, - actual_source_calls: Some(actual_source_calls), }); } } @@ -834,52 +576,6 @@ fn derived_fixture_reports( Ok(reports) } -fn integration_has_product_claims(loaded: &LoadedRegistryProject, integration_alias: &str) -> bool { - loaded.project.services.values().any(|service| { - service.kind == ServiceKind::Evidence - && service.claims.values().any(|claim| { - claim_consultation_name(service, claim).is_ok_and(|consultation| { - service.consultations[consultation].integration == integration_alias - }) - }) - }) -} - -fn fixture_subject_type( - loaded: &LoadedRegistryProject, - integration_alias: &str, -) -> Result { - let mut subject_types = BTreeSet::new(); - for service in loaded - .project - .services - .values() - .filter(|service| service.kind == ServiceKind::Evidence) - { - let selects_integration = - service - .claims - .values() - .try_fold(false, |selected, claim| -> Result { - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - return Ok(selected); - } - let consultation = claim_consultation_name(service, claim)?; - Ok(selected - || service.consultations[consultation].integration == integration_alias) - })?; - if selects_integration { - subject_types.insert(service.effective_subject_type()); - } - } - if subject_types.len() != 1 { - bail!("offline fixture cannot combine evidence services with different subject types"); - } - subject_types - .pop_first() - .ok_or_else(|| anyhow!("offline fixture selected no evidence service")) -} - fn contains_generated_fixture_matcher(value: &Value) -> bool { match value { Value::Array(values) => values.iter().any(contains_generated_fixture_matcher), @@ -900,7 +596,6 @@ fn generated_recipe_fixture_suffix(recipe: GeneratorRecipeId) -> &'static str { GeneratorRecipeId::ByteCeiling => "byte_ceiling", GeneratorRecipeId::Timeout => "timeout", GeneratorRecipeId::ProtocolVerification => "protocol_verification", - GeneratorRecipeId::AuthorizationBeforeSource => "authorization_before_source", GeneratorRecipeId::OutputMinimization => "output_minimization", } } @@ -910,19 +605,17 @@ fn generated_recipe_fixture_suffix(recipe: GeneratorRecipeId) -> &'static str { /// /// Every integration is an isolated target with the same ordered requirement /// matrix. The regular command has no baseline-to-candidate comparison, so the -/// four affected-fixture requirements remain honestly `not_evaluated`. +/// three affected-fixture requirements remain honestly `not_evaluated`. fn generate_fixture_coverage_report( loaded: &LoadedRegistryProject, fixture_reports: &[FixtureReport], generated_observations: &[GeneratedFixtureObservation], - request_observations: &[AuthoredRequestBindingObservation], call_budget_actual: Option, ) -> Result { generate_fixture_coverage_report_with_comparison( loaded, fixture_reports, generated_observations, - request_observations, call_budget_actual, None, ) @@ -932,7 +625,6 @@ fn generate_fixture_coverage_report_with_comparison( loaded: &LoadedRegistryProject, fixture_reports: &[FixtureReport], generated_observations: &[GeneratedFixtureObservation], - request_observations: &[AuthoredRequestBindingObservation], call_budget_actual: Option, comparison_input: Option<&FixtureCoverageComparisonInput>, ) -> Result { @@ -976,18 +668,6 @@ fn generate_fixture_coverage_report_with_comparison( bail!("fixture coverage received a duplicate generated observation"); } } - let mut request_observation_index = - BTreeMap::<(&str, &str), &AuthoredRequestBindingObservation>::new(); - for observation in request_observations { - let key = ( - observation.integration.as_str(), - observation.source_fixture_id.as_str(), - ); - if request_observation_index.insert(key, observation).is_some() { - bail!("fixture coverage received a duplicate governed request observation"); - } - } - let platform_case = call_budget_actual .map(build_platform_call_budget_case) .transpose()?; @@ -1030,45 +710,12 @@ fn generate_fixture_coverage_report_with_comparison( .map_err(|_| anyhow!("fixture interaction count exceeds the report range"))?, input_ids: fixture.input.keys().cloned().collect(), output_ids: fixture.expect.outputs.keys().cloned().collect(), - claim_ids: fixture.expect.claims.keys().cloned().collect(), exercised_status_mappings: fixture_exercised_status_mappings_for_fixture( &integration.document, fixture, ), classification: FixtureCoverageClassification::Synthetic, pass_state, - request_to_consultation_binding: match ( - fixture.request.is_some(), - request_observation_index - .get(&(integration_alias.as_str(), fixture.name.as_str())) - .copied(), - ) { - (false, None) => FixtureRequestBindingCoverage { - state: FixtureRequestBindingState::NotAuthored, - consultations: Vec::new(), - actual_relay_consultations: None, - safe_error_code: None, - }, - (false, Some(_)) => { - bail!("fixture coverage observed a governed request that was not authored") - } - (true, None) => FixtureRequestBindingCoverage { - state: FixtureRequestBindingState::NotExecuted, - consultations: Vec::new(), - actual_relay_consultations: None, - safe_error_code: None, - }, - (true, Some(observation)) => FixtureRequestBindingCoverage { - state: if observation.pass_state == FixturePassState::Passed { - FixtureRequestBindingState::Passed - } else { - FixtureRequestBindingState::Failed - }, - consultations: observation.consultations.clone(), - actual_relay_consultations: observation.actual_relay_consultations, - safe_error_code: observation.actual_safe_code, - }, - }, }); for recipe_id in GeneratorRecipeId::ALL { @@ -1095,18 +742,6 @@ fn generate_fixture_coverage_report_with_comparison( } else { observation.map_or(FixturePassState::NotExecuted, |value| value.pass_state) }; - let source_access_assertion = (recipe_id - == GeneratorRecipeId::AuthorizationBeforeSource - && matches!(applicability, GeneratedRecipeApplicability::Applicable {})) - .then(|| { - let actual_source_calls = - observation.and_then(|value| value.actual_source_calls); - SourceAccessAssertion { - expected_source_calls: SourceCallExpectation::Zero, - actual_source_calls, - passed: actual_source_calls == Some(0), - } - }); let actual_safe_code = if recipe_id == GeneratorRecipeId::OutputMinimization { None } else { @@ -1120,7 +755,6 @@ fn generate_fixture_coverage_report_with_comparison( recipe_id.expected_safe_code(), actual_safe_code, pass_state, - &source_access_assertion, )) .map_err(|error| anyhow!(error))?; let evidence = fixture_coverage_evidence( @@ -1145,7 +779,6 @@ fn generate_fixture_coverage_report_with_comparison( expected_safe_code: recipe_id.expected_safe_code(), actual_safe_code, pass_state, - source_access_assertion, }); } } @@ -1163,7 +796,7 @@ fn generate_fixture_coverage_report_with_comparison( } else { Vec::new() }; - let declared = fixture_target_declared_dimensions(loaded, integration_alias, integration); + let declared = fixture_target_declared_dimensions(integration); let exercised = fixture_target_exercised_dimensions( integration, &fixture_inventory, @@ -1174,7 +807,7 @@ fn generate_fixture_coverage_report_with_comparison( integration: integration_alias.clone(), capability: fixture_coverage_capability(&integration.document.capability), }; - let contract = fixture_coverage_target_contract(loaded, integration_alias, integration)?; + let contract = fixture_coverage_target_contract(integration)?; let compiled_contract = target_compiled_contract_evidence(&identity, &contract, &declared) .map_err(|error| anyhow!(error))?; let mut target = FixtureCoverageTarget { @@ -1274,8 +907,6 @@ fn fixture_coverage_capability(capability: &CapabilityDeclaration) -> FixtureCap } fn fixture_coverage_target_contract( - loaded: &LoadedRegistryProject, - integration_alias: &str, integration: &LoadedIntegration, ) -> Result { let source_operation_count = match &integration.document.capability { @@ -1300,42 +931,9 @@ fn fixture_coverage_target_contract( Ok(FixtureCoverageTargetContract { source_operation_count, reviewed_not_applicable, - registry_backed_consultations: fixture_target_registry_backed_consultations( - loaded, - integration_alias, - )?, }) } -fn fixture_target_registry_backed_consultations( - loaded: &LoadedRegistryProject, - integration_alias: &str, -) -> Result> { - let mut identities = BTreeSet::new(); - for (service_id, service) in &loaded.project.services { - if service.kind != ServiceKind::Evidence { - continue; - } - for claim in service.claims.values() { - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - continue; - } - let consultation_id = claim_consultation_name(service, claim)?; - let consultation = service - .consultations - .get(consultation_id) - .ok_or_else(|| anyhow!("registry-backed claim consultation is absent"))?; - if consultation.integration == integration_alias { - identities.insert(FixtureConsultationIdentity { - service_id: service_id.clone(), - consultation_id: consultation_id.to_owned(), - }); - } - } - } - Ok(identities.into_iter().collect()) -} - fn distinguishable_request_pair( interactions: &[registry_relay::offline_fixture::OfflineInteraction], ) -> Option<(usize, usize)> { @@ -1451,14 +1049,6 @@ fn generated_recipe_applicability( invariant: CoverageInvariant::MutationRequiresFinalJsonObjectResponse, } } - GeneratorRecipeId::AuthorizationBeforeSource - if !integration_has_product_claims(loaded, integration_alias) => - { - GeneratedRecipeApplicability::NotApplicable { - reason: GeneratedNotApplicableReason::IntegrationHasNoProductClaims, - invariant: CoverageInvariant::AuthorizationCheckRequiresProductClaimEvaluation, - } - } GeneratorRecipeId::OutputMinimization if !matches!( loaded.integrations[integration_alias].document.capability, @@ -1487,17 +1077,11 @@ fn generated_recipe_applicability( } fn fixture_target_declared_dimensions( - loaded: &LoadedRegistryProject, - integration_alias: &str, integration: &LoadedIntegration, ) -> FixtureCoverageDimensions { - let (claim_ids, disclosure_modes) = - fixture_target_claims_and_disclosures(loaded, integration_alias); FixtureCoverageDimensions { input_ids: integration.document.input.keys().cloned().collect(), output_ids: integration.document.outputs.keys().cloned().collect(), - claim_ids, - disclosure_modes, status_mappings: fixture_status_mappings(&integration.document), protocol_helpers: fixture_protocol_helpers(&integration.document), limits: fixture_declared_limits(&integration.document), @@ -1527,12 +1111,6 @@ fn fixture_target_exercised_dimensions( .collect::>() .into_iter() .collect(); - let claim_ids = passed - .iter() - .flat_map(|fixture| fixture.claim_ids.iter().cloned()) - .collect::>() - .into_iter() - .collect(); let protocol_helpers = if generated_recipe_complete(generated, GeneratorRecipeId::ProtocolVerification).0 { fixture_protocol_helpers(&integration.document) @@ -1560,9 +1138,6 @@ fn fixture_target_exercised_dimensions( FixtureCoverageDimensions { input_ids, output_ids, - claim_ids, - // Claim evaluation does not exercise disclosure selection. - disclosure_modes: Vec::new(), status_mappings: fixture_exercised_status_mappings(integration, inventory), protocol_helpers, limits: limits.into_iter().collect(), @@ -1571,47 +1146,6 @@ fn fixture_target_exercised_dimensions( } } -fn fixture_target_claims_and_disclosures( - loaded: &LoadedRegistryProject, - integration_alias: &str, -) -> (Vec, Vec) { - let mut claim_ids = BTreeSet::new(); - let mut modes = BTreeSet::new(); - for service in loaded.project.services.values() { - if service.kind != ServiceKind::Evidence { - continue; - } - for (claim_id, claim) in &service.claims { - let belongs_to_target = claim_consultation_name(service, claim) - .ok() - .and_then(|consultation| service.consultations.get(consultation)) - .is_some_and(|consultation| consultation.integration == integration_alias); - if !belongs_to_target { - continue; - } - claim_ids.insert(claim_id.clone()); - match &claim.disclosure { - DisclosureDeclaration::Mode(mode) => { - modes.insert(fixture_disclosure_mode(*mode)); - } - DisclosureDeclaration::Policy { default, allowed } => { - modes.insert(fixture_disclosure_mode(*default)); - modes.extend(allowed.iter().copied().map(fixture_disclosure_mode)); - } - } - } - } - (claim_ids.into_iter().collect(), modes.into_iter().collect()) -} - -fn fixture_disclosure_mode(mode: DisclosureMode) -> FixtureDisclosureMode { - match mode { - DisclosureMode::Value => FixtureDisclosureMode::Value, - DisclosureMode::Predicate => FixtureDisclosureMode::Predicate, - DisclosureMode::Redacted => FixtureDisclosureMode::Redacted, - } -} - fn fixture_status_mappings(integration: &IntegrationDocument) -> Vec { let CapabilityDeclaration::Http { http } = &integration.capability else { return Vec::new(); @@ -1758,7 +1292,6 @@ fn fixture_has_semantic_null(fixture: &FixtureDocument) -> bool { .input .values() .chain(fixture.expect.outputs.values()) - .chain(fixture.expect.claims.values()) .any(Value::is_null) } @@ -1778,13 +1311,7 @@ fn generated_recipe_complete( .collect::>(); let mut evidence = applicable .iter() - .filter(|case| { - case.pass_state == FixturePassState::Passed - && case - .source_access_assertion - .as_ref() - .is_none_or(|assertion| assertion.passed) - }) + .filter(|case| case.pass_state == FixturePassState::Passed) .map(|case| case.evidence.clone()) .collect::>(); evidence.sort(); @@ -1823,510 +1350,10 @@ fn mismatched_map_keys( .collect() } -fn mismatched_optional_map_keys( - actual: Option<&BTreeMap>, - expected: &BTreeMap, -) -> Vec { - actual.map_or_else( - || expected.keys().cloned().collect(), - |actual| mismatched_map_keys(actual, expected), - ) -} - fn error_implies_source_access(code: &str) -> bool { code.starts_with("source.") || code == "failure.subject_mismatch" } -struct ProductClaimsFixtureEvaluation { - result: std::result::Result, String>, - relay_calls: u64, - consultations: Vec, -} - -/// Execute the independently authored governed request through the production -/// Notary request planner. The fixture input remains a separate oracle for the -/// exact Relay consultation key, so this path cannot derive a passing request -/// from the consultation mapping it is intended to verify. -fn evaluate_authored_governed_request( - loaded: &LoadedRegistryProject, - compiled: &CompiledProject, - fixture: &FixtureDocument, - request: &GovernedFixtureRequest, - outputs: &BTreeMap, - outcome: &str, - worker_program: &Path, -) -> Result { - use registry_notary_server::standalone::{ - OfflineAuthentication, OfflineNotaryHarness, OfflineNotaryRequest, - OfflineRelayConsultation, OfflineRelayOutcome, - }; - - let expected_consultations = governed_request_consultation_identities(loaded, request)?; - let relay_outcome = match outcome { - "match" => OfflineRelayOutcome::Match, - "no_match" => OfflineRelayOutcome::NoMatch, - "ambiguous" => OfflineRelayOutcome::Ambiguous, - _ => bail!("offline Relay returned an unknown product outcome"), - }; - let relay_inputs = fixture - .input - .iter() - .map(|(name, value)| { - let value = match value { - Value::Null => "null".to_owned(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - Value::String(value) => value.clone(), - Value::Array(_) | Value::Object(_) => { - bail!("fixture input is not a bounded scalar") - } - }; - Ok((name.clone(), value)) - }) - .collect::>>()?; - let relay_evidence = compiled - .fixture_profiles - .iter() - .map(|profile| { - let is_selected = - loaded.project.services[&profile.service_id].purpose == request.purpose; - OfflineRelayConsultation::decoded_inputs( - profile.id.clone(), - profile.contract_hash.clone(), - loaded.project.services[&profile.service_id].purpose.clone(), - relay_inputs.clone(), - if is_selected { - relay_outcome - } else { - OfflineRelayOutcome::NoMatch - }, - if is_selected && relay_outcome == OfflineRelayOutcome::Match { - outputs.clone() - } else { - BTreeMap::new() - }, - ) - }) - .collect::>(); - if relay_evidence.is_empty() { - bail!("offline governed request has no exact Relay consultation profile"); - } - let notary_config = compiled - .notary_private - .get(Path::new("config/notary.yaml")) - .ok_or_else(|| anyhow!("generated Notary config is absent"))?; - let notary_config: StandaloneRegistryNotaryConfig = serde_norway::from_slice(notary_config) - .context("generated Notary config did not parse for offline governed request")?; - let harness = OfflineNotaryHarness::compile( - notary_config, - relay_evidence, - project_cel_worker_config(worker_program), - ) - .context("production Notary offline harness did not compile")?; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("failed to build the offline governed request runtime")?; - let evidence = runtime.block_on( - harness.evaluate( - OfflineNotaryRequest::new(OfflineAuthentication::Valid, request.to_evaluate_request()) - .with_header_purpose(request.purpose.as_str()), - ), - ); - let relay_calls = evidence.relay_calls(); - if let Some(error) = evidence.error_class() { - return Ok(ProductClaimsFixtureEvaluation { - result: Err(error.as_str().to_owned()), - relay_calls, - consultations: Vec::new(), - }); - } - let verified = (|| -> Result<_> { - if relay_calls != evidence.consultation_count() as u64 { - bail!("offline Notary did not reuse each governed consultation exactly once"); - } - let consultations = - runtime_consultation_identities(compiled, evidence.relay_profile_ids())?; - if evidence.consultation_count() != consultations.len() { - bail!("offline Notary selected a different governed consultation cardinality"); - } - if consultations != expected_consultations { - bail!("offline Notary selected a different governed consultation set"); - } - let mut claims = BTreeMap::new(); - for claim in evidence.claims() { - if claims - .insert(claim.claim_id().to_owned(), Value::Null) - .is_some() - { - bail!("offline governed request returned a duplicate claim id"); - } - } - let requested = request - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect::>(); - if claims.keys().map(String::as_str).collect::>() != requested { - bail!("offline governed request did not return the exact selected claim set"); - } - Ok((claims, consultations)) - })(); - Ok(match verified { - Ok((claims, consultations)) => ProductClaimsFixtureEvaluation { - result: Ok(claims), - relay_calls, - consultations, - }, - Err(_) => ProductClaimsFixtureEvaluation { - result: Err("request.binding_evaluation_failed".to_owned()), - relay_calls, - consultations: Vec::new(), - }, - }) -} - -fn runtime_consultation_identities( - compiled: &CompiledProject, - relay_profile_ids: &[String], -) -> Result> { - let mut identities = BTreeSet::new(); - for profile_id in relay_profile_ids { - let profile = compiled - .fixture_profiles - .iter() - .find(|profile| profile.id == *profile_id) - .ok_or_else(|| anyhow!("offline Notary selected an unknown Relay profile"))?; - if !identities.insert(FixtureConsultationIdentity { - service_id: profile.service_id.clone(), - consultation_id: profile.consultation_id.clone(), - }) { - bail!("offline Notary selected a duplicate governed consultation"); - } - } - Ok(identities.into_iter().collect()) -} - -fn governed_request_consultation_identities( - loaded: &LoadedRegistryProject, - request: &GovernedFixtureRequest, -) -> Result> { - let mut identities = BTreeSet::new(); - for requested_claim in &request.claims { - let (service_id, service) = loaded - .project - .services - .iter() - .find(|(_, service)| { - service.kind == ServiceKind::Evidence - && service.purpose == request.purpose - && service.claims.contains_key(&requested_claim.id) - }) - .ok_or_else(|| anyhow!("governed request claim has no selected evidence service"))?; - let claim = service - .claims - .get(&requested_claim.id) - .ok_or_else(|| anyhow!("selected governed request claim is absent"))?; - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - bail!("governed request claim is not registry-backed"); - } - let consultation_id = claim_consultation_name(service, claim)?; - identities.insert(FixtureConsultationIdentity { - service_id: service_id.clone(), - consultation_id: consultation_id.to_owned(), - }); - } - if identities.is_empty() { - bail!("governed request selected no registry-backed consultation"); - } - Ok(identities.into_iter().collect()) -} - -// Authentication and pre-source denial are independent security inputs and -// remain explicit at this offline product boundary. -#[allow(clippy::too_many_arguments)] -fn evaluate_product_claims( - loaded: &LoadedRegistryProject, - compiled: &CompiledProject, - integration_alias: &str, - fixture: &FixtureDocument, - relay_result: Option<(&BTreeMap, &str)>, - authentication: registry_notary_server::standalone::OfflineAuthentication, - require_pre_source_denial: bool, - worker_program: &Path, -) -> Result { - use registry_notary_core::{ - ClaimRef, EvaluateRequest, EvidenceEntity, EvidenceIdentifier, RequestVariables, - FORMAT_CLAIM_RESULT_JSON, - }; - use registry_notary_server::standalone::{ - OfflineNotaryHarness, OfflineNotaryRequest, OfflineRelayConsultation, OfflineRelayOutcome, - }; - - let empty_outputs = BTreeMap::new(); - let subject_type = fixture_subject_type(loaded, integration_alias)?; - let (outputs, outcome) = relay_result.unwrap_or((&empty_outputs, "no_match")); - let relay_outcome = match outcome { - "match" => OfflineRelayOutcome::Match, - "no_match" => OfflineRelayOutcome::NoMatch, - "ambiguous" => OfflineRelayOutcome::Ambiguous, - _ => bail!("offline Relay returned an unknown product outcome"), - }; - let relay_inputs = fixture - .input - .iter() - .map(|(name, value)| { - let value = match value { - Value::Null => "null".to_owned(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - Value::String(value) => value.clone(), - Value::Array(_) | Value::Object(_) => { - bail!("fixture input is not a bounded scalar") - } - }; - Ok((name.clone(), value)) - }) - .collect::>>()?; - let relay_evidence = compiled - .fixture_profiles - .iter() - .map(|profile| { - let purpose = &loaded.project.services[&profile.service_id].purpose; - let is_fixture_integration = profile.integration_alias == integration_alias; - OfflineRelayConsultation::decoded_inputs( - profile.id.clone(), - profile.contract_hash.clone(), - purpose.clone(), - relay_inputs.clone(), - if is_fixture_integration { - relay_outcome - } else { - OfflineRelayOutcome::NoMatch - }, - if is_fixture_integration && relay_outcome == OfflineRelayOutcome::Match { - outputs.clone() - } else { - BTreeMap::new() - }, - ) - }) - .collect::>(); - if relay_evidence.is_empty() { - bail!("offline Notary fixture has no exact Relay consultation profile"); - } - let notary_config = compiled - .notary_private - .get(Path::new("config/notary.yaml")) - .ok_or_else(|| anyhow!("generated Notary config is absent"))?; - let notary_config: StandaloneRegistryNotaryConfig = serde_norway::from_slice(notary_config) - .context("generated Notary config did not parse for offline evaluation")?; - let harness = OfflineNotaryHarness::compile( - notary_config, - relay_evidence, - project_cel_worker_config(worker_program), - ) - .context("production Notary offline harness did not compile")?; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("failed to build the offline Notary evaluation runtime")?; - let mut claims = BTreeMap::new(); - let mut evaluated_any = false; - let mut relay_calls = 0_u64; - for service in loaded.project.services.values() { - if service.kind != ServiceKind::Evidence { - continue; - } - let mut claim_groups = BTreeMap::>::new(); - for (claim_id, claim) in &service.claims { - let consultation = claim_consultation_name(service, claim)?; - if service.consultations[consultation].integration != integration_alias { - continue; - } - let disclosure = match &claim.disclosure { - DisclosureDeclaration::Mode(mode) => *mode, - DisclosureDeclaration::Policy { default, .. } => *default, - }; - claim_groups - .entry(disclosure) - .or_default() - .push(claim_id.clone()); - } - if claim_groups.is_empty() { - continue; - } - evaluated_any = true; - let mut target = EvidenceEntity::new(subject_type.as_str()); - let mut identifiers = BTreeMap::new(); - let mut requester_identifiers = BTreeMap::new(); - let mut attributes = BTreeMap::new(); - for consultation in service - .consultations - .values() - .filter(|consultation| consultation.integration == integration_alias) - { - for (name, request_path) in &consultation.input { - let value = fixture - .input - .get(name) - .ok_or_else(|| anyhow!("fixture omitted a compiled consultation input"))?; - if request_path == "request.target.id" { - target.id = Some( - value - .as_str() - .ok_or_else(|| anyhow!("target id fixture input must be a String"))? - .to_string(), - ); - } else if let Some(scheme) = - request_path.strip_prefix("request.target.identifiers.") - { - identifiers.insert( - scheme.to_string(), - value - .as_str() - .ok_or_else(|| { - anyhow!("target identifier fixture input must be a String") - })? - .to_string(), - ); - } else if let Some(name) = request_path.strip_prefix("request.target.attributes.") { - attributes.insert(name.to_string(), value.clone()); - } else if let Some(scheme) = - request_path.strip_prefix("request.requester.identifiers.") - { - requester_identifiers.insert( - scheme.to_string(), - value - .as_str() - .ok_or_else(|| { - anyhow!("requester identifier fixture input must be a String") - })? - .to_string(), - ); - } else { - bail!("compiled consultation input uses an unsupported request path"); - } - } - } - target.identifiers = identifiers - .into_iter() - .map(|(scheme, value)| EvidenceIdentifier { - scheme, - value, - issuer: None, - country: None, - }) - .collect(); - target.attributes = attributes; - let requester = if requester_identifiers.is_empty() { - None - } else { - let mut requester = EvidenceEntity::new("AuthenticatedRequester"); - requester.identifiers = requester_identifiers - .into_iter() - .map(|(scheme, value)| EvidenceIdentifier { - scheme, - value, - issuer: None, - country: None, - }) - .collect(); - Some(requester) - }; - let variables = fixture - .variables - .iter() - .map(|(name, value)| { - value - .as_str() - .map(|value| (name.clone(), value.to_string())) - .ok_or_else(|| anyhow!("fixture variable is not a full-date string")) - }) - .collect::>>()?; - let purpose = service.purpose.as_str(); - let variables = RequestVariables::try_new(variables).map_err(|error| anyhow!(error))?; - for (disclosure, claim_ids) in claim_groups { - let request = EvaluateRequest { - requester: requester.clone(), - target: Some(target.clone()), - relationship: None, - on_behalf_of: None, - variables: variables.clone(), - claims: claim_ids - .iter() - .map(|claim| ClaimRef::from(claim.as_str())) - .collect(), - disclosure: Some( - match disclosure { - DisclosureMode::Value => "value", - DisclosureMode::Predicate => "predicate", - DisclosureMode::Redacted => "redacted", - } - .to_string(), - ), - format: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), - purpose: Some(purpose.to_string()), - }; - let evidence = runtime.block_on(harness.evaluate( - OfflineNotaryRequest::new(authentication, request).with_header_purpose(purpose), - )); - relay_calls = relay_calls.saturating_add(evidence.relay_calls()); - if let Some(error) = evidence.error_class() { - if require_pre_source_denial && evidence.relay_calls() != 0 { - bail!("derived authorization denial occurred after Relay access"); - } - return Ok(ProductClaimsFixtureEvaluation { - result: Err(error.as_str().to_string()), - relay_calls, - consultations: Vec::new(), - }); - } - if evidence.relay_calls() != evidence.consultation_count() as u64 { - bail!("offline Notary did not reuse each request-scoped consultation exactly once"); - } - for claim in evidence.claims() { - let value = if claim.disclosure() == "redacted" { - Value::String("redacted".to_string()) - } else if claim.disclosure() == "predicate" { - claim.satisfied().map_or(Value::Null, Value::Bool) - } else if let Some(value) = claim.value() { - value.clone() - } else { - Value::Null - }; - if claims.insert(claim.claim_id().to_string(), value).is_some() { - bail!("offline Notary returned a duplicate project claim id"); - } - } - } - } - if !evaluated_any { - bail!("offline fixture does not select a project Notary service"); - } - Ok(ProductClaimsFixtureEvaluation { - result: Ok(claims), - relay_calls, - consultations: Vec::new(), - }) -} - -fn project_cel_worker_config( - worker_program: &Path, -) -> registry_notary_server::cel_worker::CelWorkerConfig { - let mut config = - registry_notary_server::cel_worker::CelWorkerConfig::for_current_exe_subcommand(); - config.command = worker_program.to_path_buf(); - config.command_args = vec![std::ffi::OsString::from("__registryctl-cel-worker-v1")]; - config.command_envs.clear(); - config.current_dir = None; - // Debug and sanitizer builds can take longer than the production worker's - // evaluation deadline to cold-start the isolated subprocess. Keep startup - // separately bounded while preserving the production evaluation deadline. - config.startup_timeout = std::time::Duration::from_secs(10); - config -} - struct CallBudgetCoverageHost; #[async_trait::async_trait] @@ -2421,14 +1448,13 @@ fn platform_call_budget_result( ); matches!( runtime.block_on( - WorkerProcess::with_program(worker_program) - .evaluate_with_host( - &request, - &mut CallBudgetCoverageHost, - tokio::time::Instant::now() - .checked_add(std::time::Duration::from_secs(10)) - .expect("fixture hard deadline is representable"), - ), + WorkerProcess::with_program(worker_program).evaluate_with_host( + &request, + &mut CallBudgetCoverageHost, + tokio::time::Instant::now() + .checked_add(std::time::Duration::from_secs(10)) + .expect("fixture hard deadline is representable"), + ), ), Err(WorkerError::BudgetExceeded) ) diff --git a/crates/registryctl/src/project_authoring/knowledge.rs b/crates/registryctl/src/project_authoring/knowledge.rs index d7feefa1e..4a46f6a92 100644 --- a/crates/registryctl/src/project_authoring/knowledge.rs +++ b/crates/registryctl/src/project_authoring/knowledge.rs @@ -64,7 +64,6 @@ pub enum SemanticOwner { FixtureHarness, EntityContract, RelayRuntime, - NotaryRuntime, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -79,7 +78,6 @@ pub enum HumanOwner { TestMaintainers, DataModelMaintainers, RelayMaintainers, - NotaryMaintainers, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -118,7 +116,6 @@ impl Sensitivity { pub enum Product { Registryctl, Relay, - Notary, Editor, Docs, } @@ -150,7 +147,6 @@ pub enum Migration { pub enum Consumer { RegistryctlAuthoring, RegistryRelay, - RegistryNotary, EditorTooling, DocsGenerator, } @@ -161,7 +157,6 @@ pub enum GeneratedArtifact { EditorSchemas, ProjectBuild, RelayConfig, - NotaryConfig, FixtureReport, FieldReference, } @@ -173,7 +168,6 @@ pub enum ReviewClass { Security, Privacy, Relay, - Notary, Compatibility, Documentation, Testing, diff --git a/crates/registryctl/src/project_authoring/model.rs b/crates/registryctl/src/project_authoring/model.rs index c541d9dc7..401770b68 100644 --- a/crates/registryctl/src/project_authoring/model.rs +++ b/crates/registryctl/src/project_authoring/model.rs @@ -210,12 +210,7 @@ fn trusted_local_value_path_is_prohibited(address: &ProjectFieldAddress) -> bool let field_name = path.rsplit('/').next().unwrap_or_default(); matches!( field_name, - "token_file" - | "workload_token_file" - | "secret_file" - | "private_key_file" - | "cel" - | "x-registry-source" + "token_file" | "secret_file" | "private_key_file" | "cel" | "x-registry-source" ) || path == "/starter/content_digest" } @@ -229,9 +224,8 @@ pub struct ProjectBuildOptions { /// Product-labelled approved baselines for a project build. /// -/// Public Relay, consultation Relay, and Notary are independently signed -/// inputs. A consultation project supplies both Relay pairs, plus Notary when -/// that product is projected. The `against` and `anchor` fields on +/// Public Relay and consultation Relay are independently signed inputs. The +/// `against` and `anchor` fields on /// [`ProjectBuildOptions`] remain available for single-lane callers. #[derive(Debug, Clone, Default)] pub struct ProjectBuildBaselineSetOptions { @@ -239,8 +233,6 @@ pub struct ProjectBuildBaselineSetOptions { pub relay_anchor: Option, pub relay_consultation_against: Option, pub relay_consultation_anchor: Option, - pub notary_against: Option, - pub notary_anchor: Option, } #[derive(Debug, Clone)] @@ -335,7 +327,6 @@ pub struct FixtureReport { pub inputs: Vec, pub calls: Vec, pub outputs: Vec, - pub claims: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub outcome: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -405,11 +396,6 @@ struct ServiceDeclaration { kind: ServiceKind, #[serde(default)] version: u32, - /// Subject category evaluated by an evidence service. Omission is - /// normalized to `person` without erasing whether the field was authored, - /// so records services cannot silently accept it. - #[serde(default, skip_serializing_if = "Option::is_none")] - subject_type: Option, #[serde(default)] purpose: String, #[serde(default)] @@ -417,16 +403,10 @@ struct ServiceDeclaration { #[serde(default = "default_consent")] consent: ConsentDeclaration, #[serde(default)] - access: AccessDeclaration, - #[serde(default)] variables: BTreeMap, #[serde(default)] consultations: BTreeMap, #[serde(default)] - claims: BTreeMap, - #[serde(default)] - credential_profiles: BTreeMap, - #[serde(default)] entity: Option, #[serde(default)] title: Option, @@ -446,38 +426,11 @@ struct ServiceDeclaration { api: Option, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq, Ord, PartialOrd)] -#[serde(rename_all = "snake_case")] -enum EvidenceSubjectType { - #[default] - Person, - Project, -} - -impl EvidenceSubjectType { - const fn as_str(self) -> &'static str { - match self { - Self::Person => "person", - Self::Project => "project", - } - } -} - -impl ServiceDeclaration { - const fn effective_subject_type(&self) -> EvidenceSubjectType { - match self.subject_type { - Some(subject_type) => subject_type, - None => EvidenceSubjectType::Person, - } - } -} - #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum ServiceKind { - Evidence, + ConsultationApi, RecordsApi, } @@ -493,14 +446,6 @@ enum ConsentDeclaration { Required, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct AccessDeclaration { - #[serde(default)] - scopes: Vec, -} - #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1018,69 +963,6 @@ struct ConsultationDeclaration { input: BTreeMap, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct ClaimDeclaration { - #[serde(default)] - output: Option, - #[serde(default)] - cel: Option, - #[serde(default)] - value: Option, - disclosure: DisclosureDeclaration, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -enum ClaimEvidence { - RegistryBacked, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct ClaimValueDeclaration { - #[serde(rename = "type")] - value_type: OutputType, - #[serde(default)] - nullable: bool, - #[serde(default)] - max_bytes: Option, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(untagged)] -enum DisclosureDeclaration { - Mode(DisclosureMode), - Policy { - default: DisclosureMode, - allowed: Vec, - }, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "snake_case")] -enum DisclosureMode { - Value, - Predicate, - Redacted, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct CredentialProfileDeclaration { - format: String, - #[serde(rename = "type")] - credential_type: String, - validity: String, - claims: Vec, -} - #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1691,21 +1573,9 @@ struct EnvironmentDocument { #[serde(default)] entities: BTreeMap, #[serde(default)] - issuance: Option, - #[serde(default)] - callers: BTreeMap, - #[serde(default)] relay: Option, #[serde(default)] - notary_relay: Option, - #[serde(default)] relay_state: Option, - #[serde(default)] - notary_state: Option, - #[serde(default)] - notary_cel: Option, - #[serde(default)] - oid4vci: Option, deployment: DeploymentBinding, } @@ -1718,8 +1588,6 @@ struct DevelopmentDeclaration { default_fixture: String, #[serde(default)] relay_port: Option, - #[serde(default)] - notary_port: Option, } #[cfg_attr(test, derive(schemars::JsonSchema))] @@ -1871,45 +1739,6 @@ enum RecordProvider { }, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct IssuanceBinding { - issuer: String, - signing_key: SecretReference, - signing_kid: String, - #[serde(default)] - algorithm: IssuanceSigningAlgorithm, - generation: u64, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] -enum IssuanceSigningAlgorithm { - #[default] - #[serde(rename = "EdDSA")] - EdDsa, - #[serde(rename = "ES256")] - Es256, -} - -impl IssuanceSigningAlgorithm { - const fn as_str(self) -> &'static str { - match self { - Self::EdDsa => "EdDSA", - Self::Es256 => "ES256", - } - } -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct CallerBinding { - api_key_fingerprint: SecretReference, - scopes: Vec, -} - #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1920,25 +1749,26 @@ struct RelayBinding { audience: String, allowed_clients: Vec, #[serde(default)] + consultation: Option, + #[serde(default)] local_api_keys: Option, } #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -struct RelayLocalApiKeyBinding { - match_principal: String, - no_match_principal: String, - scopes: Vec, +struct RelayConsultationBinding { + client_id: String, + principal_id: String, } #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -struct NotaryRelayBinding { - base_url: String, - workload_client_id: String, - token_file: PathBuf, +struct RelayLocalApiKeyBinding { + match_principal: String, + no_match_principal: String, + scopes: Vec, } #[cfg_attr(test, derive(schemars::JsonSchema))] @@ -1955,133 +1785,6 @@ struct RelayPostgresqlBinding { root_certificate_path: PathBuf, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct NotaryStateBinding { - postgresql: NotaryPostgresqlBinding, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct NotaryPostgresqlBinding { - root_certificate_path: PathBuf, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct NotaryCelBinding { - worker_memory_bytes: u64, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciBinding { - public_base_url: String, - credential: Oid4vciCredentialBinding, - authorization_server: Oid4vciAuthorizationServerBinding, - client: Oid4vciClientBinding, - /// Machine OIDC clients admitted to create registrar-initiated offers. - /// - /// These clients use the pinned authorization server and the Notary public - /// base URL as their resource audience. They are deliberately separate - /// from the citizen client so generated subject-access classification - /// remains closed. - #[serde(default)] - registrar_clients: Vec, - access_token: Oid4vciSigningKeyBinding, - sensitive_state_key: SecretReference, - subject: Oid4vciSubjectBinding, - redirect_uri: String, - allowed_wallet_origins: Vec, - #[serde(default)] - tx_code: Oid4vciTxCodeBinding, - #[serde(default, skip_serializing_if = "Option::is_none")] - representative_issuance: Option, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciRepresentativeIssuanceBinding { - relationship: String, - proof_claim: String, - target_id_type: String, - #[serde(default = "default_oid4vci_representative_max_proof_age_seconds")] - max_proof_age_seconds: u64, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciTxCodeBinding { - #[serde(default = "default_oid4vci_tx_code_required")] - required: bool, -} - -impl Default for Oid4vciTxCodeBinding { - fn default() -> Self { - Self { - required: default_oid4vci_tx_code_required(), - } - } -} - -const fn default_oid4vci_tx_code_required() -> bool { - true -} - -const fn default_oid4vci_representative_max_proof_age_seconds() -> u64 { - 300 -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciCredentialBinding { - service: String, - profile: String, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciAuthorizationServerBinding { - issuer: String, - jwks_url: String, - userinfo_url: String, - authorize_url: String, - token_url: String, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciClientBinding { - id: String, - signing_key: SecretReference, - signing_kid: String, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciSigningKeyBinding { - signing_key: SecretReference, - signing_kid: String, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Oid4vciSubjectBinding { - token_claim: String, - id_type: String, -} - #[cfg_attr(test, derive(schemars::JsonSchema))] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -2089,8 +1792,6 @@ struct DeploymentBinding { profile: DeploymentProfile, #[serde(default)] relay: Option, - #[serde(default)] - notary: Option, } #[cfg_attr(test, derive(schemars::JsonSchema))] @@ -2100,7 +1801,6 @@ enum DeploymentProfile { Local, HostedLab, Production, - EvidenceGrade, } impl DeploymentProfile { @@ -2109,7 +1809,6 @@ impl DeploymentProfile { Self::Local => "local", Self::HostedLab => "hosted_lab", Self::Production => "production", - Self::EvidenceGrade => "evidence_grade", } } } @@ -2125,113 +1824,12 @@ struct ServiceBinding { struct FixtureDocument { name: String, classification: AuthoredFixtureClassification, - #[serde(default)] - request: Option, input: BTreeMap, #[serde(default)] variables: BTreeMap, interactions: Vec, expect: FixtureExpectation, } - -/// The closed governed request accepted by an independently authored synthetic -/// fixture witness. -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct GovernedFixtureRequest { - #[serde(default, skip_serializing_if = "Option::is_none")] - requester: Option, - target: GovernedFixtureTarget, - #[cfg_attr(test, schemars(with = "BTreeMap"))] - #[serde( - default, - skip_serializing_if = "registry_notary_core::RequestVariables::is_empty" - )] - variables: registry_notary_core::RequestVariables, - claims: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - disclosure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - format: Option, - purpose: String, -} - -impl GovernedFixtureRequest { - fn to_evaluate_request(&self) -> registry_notary_core::EvaluateRequest { - registry_notary_core::EvaluateRequest { - requester: self.requester.as_ref().map(governed_fixture_entity), - target: Some(registry_notary_core::EvidenceEntity { - entity_type: self.target.entity_type.clone(), - id: self.target.id.clone(), - identifiers: self - .target - .identifiers - .iter() - .map(|identifier| registry_notary_core::EvidenceIdentifier { - scheme: identifier.scheme.clone(), - value: identifier.value.clone(), - issuer: None, - country: None, - }) - .collect(), - attributes: self.target.attributes.clone(), - assurance: None, - profile: None, - }), - relationship: None, - on_behalf_of: None, - variables: self.variables.clone(), - claims: self.claims.clone(), - disclosure: self.disclosure.clone(), - format: self.format.clone(), - purpose: Some(self.purpose.clone()), - } - } -} - -fn governed_fixture_entity(entity: &GovernedFixtureTarget) -> registry_notary_core::EvidenceEntity { - registry_notary_core::EvidenceEntity { - entity_type: entity.entity_type.clone(), - id: entity.id.clone(), - identifiers: entity - .identifiers - .iter() - .map(|identifier| registry_notary_core::EvidenceIdentifier { - scheme: identifier.scheme.clone(), - value: identifier.value.clone(), - issuer: None, - country: None, - }) - .collect(), - attributes: entity.attributes.clone(), - assurance: None, - profile: None, - } -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct GovernedFixtureTarget { - #[serde(rename = "type")] - entity_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - identifiers: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - attributes: BTreeMap, -} - -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct GovernedFixtureIdentifier { - scheme: String, - value: String, -} - #[derive(Debug, Clone, Serialize)] struct FixtureInteraction { expect: FixtureRequestExpectation, @@ -2266,8 +1864,6 @@ struct FixtureExpectation { #[serde(default)] outputs: BTreeMap, #[serde(default)] - claims: BTreeMap, - #[serde(default)] error: Option, #[serde(default)] outcome: Option, @@ -2301,7 +1897,6 @@ struct CompiledProject { reviewable: BTreeMap>, relay_private: BTreeMap>, relay_consultation_private: BTreeMap>, - notary_private: BTreeMap>, review: Value, approval_state: Value, explanation: ProjectExplanationReportV1, @@ -2311,8 +1906,6 @@ struct CompiledProject { } struct FixtureProfile { - service_id: String, - consultation_id: String, integration_alias: String, id: String, version: String, @@ -2334,35 +1927,22 @@ struct VerifiedBaseline { enum VerifiedBaselineLane { Relay, RelayConsultation, - Notary, } #[derive(Default)] struct VerifiedBaselineSet { relay: Option, relay_consultation: Option, - notary: Option, } #[derive(Debug, Clone, Serialize)] #[serde(deny_unknown_fields)] struct SemanticDigests { - claim: String, integration: String, service_policy: String, operator_security: String, } -#[cfg_attr(test, derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct DisclosureReviewProfile { - default: DisclosureMode, - allowed: BTreeSet, -} - -type DisclosureReviewProfiles = BTreeMap>; - struct GeneratedPack { alias: String, id: String, @@ -2387,3 +1967,28 @@ struct GeneratedProfile { contract: AuthoredConsultationContract, binding: AuthoredArtifact, } + +#[cfg(test)] +mod relay_only_authoring_model_tests { + use super::*; + + #[test] + fn consultation_api_uses_the_relay_specific_wire_value() { + assert_eq!( + serde_json::to_value(ServiceKind::ConsultationApi).expect("service kind serializes"), + Value::String("consultation_api".to_string()) + ); + assert_eq!( + serde_json::from_value::(Value::String("consultation_api".to_string())) + .expect("consultation API service kind parses"), + ServiceKind::ConsultationApi + ); + } + + #[test] + fn retired_evidence_service_kind_is_rejected() { + let error = serde_json::from_value::(Value::String("evidence".to_string())) + .expect_err("retired evidence service kind must be unknown"); + assert!(error.to_string().contains("unknown variant")); + } +} diff --git a/crates/registryctl/src/project_authoring/output.rs b/crates/registryctl/src/project_authoring/output.rs index d36854c5c..0311b0193 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -123,7 +123,7 @@ impl ProjectExecutionContext { /// /// The path must be absolute and identify an existing, non-symlink regular /// file with executable permissions. Validation happens before the path can - /// reach either Relay or Notary worker configuration. + /// reach Relay worker configuration. pub fn new(worker_program: impl AsRef) -> Result { let worker_program = worker_program.as_ref(); if !worker_program.is_absolute() { @@ -206,10 +206,7 @@ mod project_execution_context_tests { } fn validate_generated_product_configs(compiled: &CompiledProject) -> Result<()> { - if compiled.relay_private.is_empty() - && compiled.relay_consultation_private.is_empty() - && compiled.notary_private.is_empty() - { + if compiled.relay_private.is_empty() && compiled.relay_consultation_private.is_empty() { bail!("generated deployment has no product configuration"); } if !compiled.relay_private.is_empty() { @@ -230,9 +227,6 @@ fn validate_generated_product_configs(compiled: &CompiledProject) -> Result<()> "config/relay.yaml", )?; } - if !compiled.notary_private.is_empty() { - validate_generated_notary(compiled)?; - } Ok(()) } @@ -371,19 +365,6 @@ fn read_project_workbook(root: &Path, relative: &Path, byte_limit: u64) -> Resul Ok(bytes) } -fn validate_generated_notary(compiled: &CompiledProject) -> Result<()> { - let notary_config = compiled - .notary_private - .get(Path::new("config/notary.yaml")) - .ok_or_else(|| anyhow!("generated Notary config is absent"))?; - let notary: StandaloneRegistryNotaryConfig = - serde_norway::from_slice(notary_config).context("generated Notary config did not parse")?; - notary - .validate() - .context("generated Notary config failed the production validator")?; - Ok(()) -} - fn validate_generated_relay( relay_config: &[u8], files: &BTreeMap>, @@ -686,7 +667,7 @@ fn generated_evidence( struct PreparedReviewedProject { loaded: LoadedRegistryProject, compiled: CompiledProject, - signing_input_identities: [registry_platform_config::ProductAcceptanceIdentityV1; 3], + signing_input_identities: Vec, preceding_approved_set_digest: Option, anchor_rotation_lanes: Vec, retained_rotation_evidence: BTreeMap, @@ -735,18 +716,19 @@ pub fn build_reviewed_project( )?; preflight_project_rhai_scripts(&prepared.loaded)?; validate_generated_product_configs(&prepared.compiled)?; - let has_project_local_file_input = prepared - .loaded - .environment - .as_ref() - .is_some_and(|environment| { - environment - .entities - .values() - .any(|binding| matches!(&binding.provider, RecordProvider::Xlsx { .. })) - }); + let has_project_local_file_input = + prepared + .loaded + .environment + .as_ref() + .is_some_and(|environment| { + environment + .entities + .values() + .any(|binding| matches!(&binding.provider, RecordProvider::Xlsx { .. })) + }); let artifact_inputs = validate_project_workbook_inputs(&prepared.loaded, &prepared.compiled)?; - let (fixtures, generated_observations, request_observations, call_budget_actual) = + let (fixtures, generated_observations, call_budget_actual) = execute_all_fixtures_with_coverage_observations( &prepared.loaded, &prepared.compiled, @@ -760,7 +742,6 @@ pub fn build_reviewed_project( &prepared.loaded, &fixtures, &generated_observations, - &request_observations, call_budget_actual, )?; let output = prepared @@ -921,7 +902,6 @@ fn prepare_reviewed_project( let expected = &signing_input_identities[match lane { crate::ApprovedLaneV1::RelayPublic => 0, crate::ApprovedLaneV1::RelayConsultation => 1, - crate::ApprovedLaneV1::Notary => 2, }]; if verified.acceptance_identity() != expected { bail!("approved set lane identity does not match the selected project environment"); @@ -940,7 +920,6 @@ fn prepare_reviewed_project( crate::ApprovedLaneV1::RelayConsultation => { VerifiedBaselineLane::RelayConsultation } - crate::ApprovedLaneV1::Notary => VerifiedBaselineLane::Notary, }), )? .ok_or_else(|| anyhow!("approved set lane baseline is absent"))?; @@ -968,7 +947,6 @@ fn prepare_reviewed_project( let previous = set.lanes.get(*lane).reviewed_binding(); current.lane_scoped_reviewed_input_digest != previous.lane_scoped_reviewed_input_digest - || current.interfaces != previous.interfaces }) }) .collect::>(); @@ -1014,9 +992,6 @@ fn prepare_reviewed_project( relay_consultation: affected_lanes .contains(&crate::ApprovedLaneV1::RelayConsultation) .then(|| selected_binding(crate::ApprovedLaneV1::RelayConsultation)), - notary: affected_lanes - .contains(&crate::ApprovedLaneV1::Notary) - .then(|| selected_binding(crate::ApprovedLaneV1::Notary)), }; bindings.validate_bindings()?; Ok(PreparedReviewedProject { @@ -1033,20 +1008,10 @@ fn prepare_reviewed_project( fn reviewed_bindings( compiled: &CompiledProject, - identities: &[registry_platform_config::ProductAcceptanceIdentityV1; 3], + identities: &[registry_platform_config::ProductAcceptanceIdentityV1], ) -> Result { let review_bytes = canonical_json_line(&compiled.review)?; let state_bytes = canonical_json_line(&compiled.approval_state)?; - let consultation_interface = compiled - .review - .get("consultations") - .and_then(Value::as_object) - .filter(|consultations| !consultations.is_empty()) - .map(|consultations| { - canonicalize_json(&Value::Object(consultations.clone())).map(|bytes| sha256_uri(&bytes)) - }) - .transpose() - .context("failed to canonicalize reviewed consultation interface")?; let binding = |lane: crate::ApprovedLaneV1, identity: ®istry_platform_config::ProductAcceptanceIdentityV1, files: &BTreeMap>| @@ -1085,14 +1050,6 @@ fn reviewed_bindings( Ok(crate::ReviewedLaneBindingV1 { lane_scoped_reviewed_input_digest: lane_digest, signing_input_closure_digest: format!("sha256:{}", hex::encode(hasher.finalize())), - interfaces: match lane { - crate::ApprovedLaneV1::RelayPublic => crate::CrossLaneInterfaceDigestsV1::default(), - crate::ApprovedLaneV1::RelayConsultation | crate::ApprovedLaneV1::Notary => { - crate::CrossLaneInterfaceDigestsV1 { - consultation_relay_notary: consultation_interface.clone(), - } - } - }, }) }; Ok(crate::ReviewedBuildUpdateV1 { @@ -1101,16 +1058,15 @@ fn reviewed_bindings( &identities[0], &compiled.relay_private, )?), - relay_consultation: Some(binding( - crate::ApprovedLaneV1::RelayConsultation, - &identities[1], - &compiled.relay_consultation_private, - )?), - notary: Some(binding( - crate::ApprovedLaneV1::Notary, - &identities[2], - &compiled.notary_private, - )?), + relay_consultation: (!compiled.relay_consultation_private.is_empty()) + .then(|| { + binding( + crate::ApprovedLaneV1::RelayConsultation, + &identities[1], + &compiled.relay_consultation_private, + ) + }) + .transpose()?, }) } @@ -1136,7 +1092,7 @@ fn write_compiled_project( project: &str, environment: &str, artifact_inputs: &[ArtifactInputDigest], - signing_input_identities: &[registry_platform_config::ProductAcceptanceIdentityV1; 3], + signing_input_identities: &[registry_platform_config::ProductAcceptanceIdentityV1], ) -> Result { let emitted_lanes = signing_input_identities .iter() @@ -1164,17 +1120,14 @@ fn write_compiled_project_selected( project: &str, environment: &str, artifact_inputs: &[ArtifactInputDigest], - signing_input_identities: &[registry_platform_config::ProductAcceptanceIdentityV1; 3], + signing_input_identities: &[registry_platform_config::ProductAcceptanceIdentityV1], emitted_lanes: &[registry_platform_config::ProductAcceptanceLaneV1], retained_rotation_evidence: &BTreeMap, reviewed_build: Option<&ReviewedBuildRecordV1>, ) -> Result { - if compiled.relay_private.is_empty() - || compiled.relay_consultation_private.is_empty() - || compiled.notary_private.is_empty() - { + if compiled.relay_private.is_empty() { bail!( - "governed build requires complete relay-public, relay-consultation, and notary signing inputs; continue authoring with project test and project check, then add the missing product binding before project build" + "governed build requires Relay signing inputs; continue authoring with project test and project check, then add deployment.relay before project build" ); } let expected_parent = root.join(BUILD_ROOT); @@ -1218,28 +1171,23 @@ fn write_compiled_project_selected( write_private_file(&relay_root.join(APPROVAL_REVIEW_PATH), &review_bytes)?; write_private_file(&relay_root.join(APPROVAL_STATE_PATH), &approval_state_bytes)?; } - if !compiled.notary_private.is_empty() { - let notary_root = temporary.join("private/notary"); - create_dir_owner_only(¬ary_root)?; - write_file_map(¬ary_root, &compiled.notary_private)?; - write_private_file(¬ary_root.join(APPROVAL_REVIEW_PATH), &review_bytes)?; - write_private_file( - ¬ary_root.join(APPROVAL_STATE_PATH), - &approval_state_bytes, - )?; - } - for (identity, files) in [ - (&signing_input_identities[0], &compiled.relay_private), - ( - &signing_input_identities[1], - &compiled.relay_consultation_private, - ), - (&signing_input_identities[2], &compiled.notary_private), - ] { + for (identity, files) in + signing_input_identities + .iter() + .filter_map(|identity| match identity.lane { + registry_platform_config::ProductAcceptanceLaneV1::RelayPublic => { + Some((identity, &compiled.relay_private)) + } + registry_platform_config::ProductAcceptanceLaneV1::RelayConsultation => { + Some((identity, &compiled.relay_consultation_private)) + } + _ => None, + }) + { if !emitted_lanes.contains(&identity.lane) { continue; } - let approved_lane = crate::ApprovedLaneV1::from_acceptance_lane(identity.lane); + let approved_lane = crate::ApprovedLaneV1::try_from_acceptance_lane(identity.lane)?; let (lane_review_bytes, lane_approval_state_bytes) = retained_rotation_evidence .get(&approved_lane) .map(|evidence| (evidence.review.as_ref(), evidence.approval_state.as_ref())) @@ -1298,7 +1246,7 @@ fn write_compiled_project_selected( fn governed_signing_input_identities( loaded: &LoadedRegistryProject, -) -> Result<[registry_platform_config::ProductAcceptanceIdentityV1; 3]> { +) -> Result> { use registry_platform_config::{ ProductAcceptanceIdentityV1, ProductAcceptanceLaneV1, ProductAcceptanceProductV1, ProductTrustDomainV1, @@ -1321,15 +1269,6 @@ fn governed_signing_input_identities( "governed build requires a public and consultation Relay binding; continue authoring with project test and project check, then add deployment.relay before project build" ) })?; - let notary = environment - .deployment - .notary - .as_ref() - .ok_or_else(|| { - anyhow!( - "governed build requires a Notary binding; continue authoring with project test and project check, then add deployment.notary before project build" - ) - })?; let project = loaded.project.registry.id.clone(); let identity = |lane, product, instance| ProductAcceptanceIdentityV1 { trust_domain: ProductTrustDomainV1::Governed, @@ -1340,23 +1279,18 @@ fn governed_signing_input_identities( stream: project.clone(), instance, }; - let identities = [ - identity( - ProductAcceptanceLaneV1::RelayPublic, - ProductAcceptanceProductV1::RegistryRelay, - relay.service.clone(), - ), - identity( + let mut identities = vec![identity( + ProductAcceptanceLaneV1::RelayPublic, + ProductAcceptanceProductV1::RegistryRelay, + relay.service.clone(), + )]; + if project_requires_relay_consultation_baseline(loaded) { + identities.push(identity( ProductAcceptanceLaneV1::RelayConsultation, ProductAcceptanceProductV1::RegistryRelay, format!("{}-consultation", relay.service), - ), - identity( - ProductAcceptanceLaneV1::Notary, - ProductAcceptanceProductV1::RegistryNotary, - notary.service.clone(), - ), - ]; + )); + } for identity in &identities { identity .validate() @@ -1373,7 +1307,7 @@ fn product_acceptance_lane_name( registry_platform_config::ProductAcceptanceLaneV1::RelayConsultation => { "relay-consultation" } - registry_platform_config::ProductAcceptanceLaneV1::Notary => "notary", + _ => unreachable!("registryctl emits Relay lanes only"), } } @@ -1460,8 +1394,6 @@ struct ApprovedBaselineSetPaths<'a> { relay_anchor: Option<&'a Path>, relay_consultation_against: Option<&'a Path>, relay_consultation_anchor: Option<&'a Path>, - notary_against: Option<&'a Path>, - notary_anchor: Option<&'a Path>, } impl<'a> ApprovedBaselineSetPaths<'a> { @@ -1473,8 +1405,6 @@ impl<'a> ApprovedBaselineSetPaths<'a> { relay_anchor: None, relay_consultation_against: None, relay_consultation_anchor: None, - notary_against: None, - notary_anchor: None, } } @@ -1491,8 +1421,6 @@ impl<'a> ApprovedBaselineSetPaths<'a> { .and_then(|set| set.relay_consultation_against.as_deref()), relay_consultation_anchor: baselines .and_then(|set| set.relay_consultation_anchor.as_deref()), - notary_against: baselines.and_then(|set| set.notary_against.as_deref()), - notary_anchor: baselines.and_then(|set| set.notary_anchor.as_deref()), } } } @@ -1509,7 +1437,6 @@ impl VerifiedBaselineLane { Self::Relay | Self::RelayConsultation => { registry_platform_config::ProductAcceptanceProductV1::RegistryRelay } - Self::Notary => registry_platform_config::ProductAcceptanceProductV1::RegistryNotary, } } @@ -1519,7 +1446,6 @@ impl VerifiedBaselineLane { Self::RelayConsultation => { registry_platform_config::ProductAcceptanceLaneV1::RelayConsultation } - Self::Notary => registry_platform_config::ProductAcceptanceLaneV1::Notary, } } @@ -1527,38 +1453,29 @@ impl VerifiedBaselineLane { match self { Self::Relay => "relay", Self::RelayConsultation => "relay_consultation", - Self::Notary => "notary", } } } impl VerifiedBaselineSet { fn is_empty(&self) -> bool { - self.relay.is_none() && self.relay_consultation.is_none() && self.notary.is_none() + self.relay.is_none() && self.relay_consultation.is_none() } fn iter(&self) -> impl Iterator { - [ - self.relay.as_ref(), - self.relay_consultation.as_ref(), - self.notary.as_ref(), - ] - .into_iter() - .flatten() + [self.relay.as_ref(), self.relay_consultation.as_ref()] + .into_iter() + .flatten() } fn common(&self) -> Option<&VerifiedBaseline> { - self.relay - .as_ref() - .or(self.relay_consultation.as_ref()) - .or(self.notary.as_ref()) + self.relay.as_ref().or(self.relay_consultation.as_ref()) } fn get(&self, lane: crate::ApprovedLaneV1) -> Option<&VerifiedBaseline> { match lane { crate::ApprovedLaneV1::RelayPublic => self.relay.as_ref(), crate::ApprovedLaneV1::RelayConsultation => self.relay_consultation.as_ref(), - crate::ApprovedLaneV1::Notary => self.notary.as_ref(), } } @@ -1566,7 +1483,6 @@ impl VerifiedBaselineSet { json!({ "relay": self.relay.as_ref().map(|baseline| &baseline.verified_manifest), "relay_consultation": self.relay_consultation.as_ref().map(|baseline| &baseline.verified_manifest), - "notary": self.notary.as_ref().map(|baseline| &baseline.verified_manifest), }) } @@ -1578,12 +1494,7 @@ impl VerifiedBaselineSet { VerifiedBaselineLane::RelayConsultation if self.relay_consultation.is_none() => { self.relay_consultation = Some(baseline); } - VerifiedBaselineLane::Notary if self.notary.is_none() => { - self.notary = Some(baseline); - } - VerifiedBaselineLane::Relay - | VerifiedBaselineLane::RelayConsultation - | VerifiedBaselineLane::Notary => { + VerifiedBaselineLane::Relay | VerifiedBaselineLane::RelayConsultation => { bail!("approved baseline set contains a duplicate product lane") } } @@ -1619,16 +1530,8 @@ fn validate_approved_baseline_set_paths(paths: ApprovedBaselineSetPaths<'_>) -> "--relay-consultation-anchor", paths.relay_consultation_anchor, )?; - validate_named_baseline_pair( - "--notary-against", - paths.notary_against, - "--notary-anchor", - paths.notary_anchor, - )?; if paths.against.is_some() - && (paths.relay_against.is_some() - || paths.relay_consultation_against.is_some() - || paths.notary_against.is_some()) + && (paths.relay_against.is_some() || paths.relay_consultation_against.is_some()) { bail!("--against cannot be combined with product-specific baselines"); } @@ -1656,11 +1559,6 @@ fn load_verified_approved_baseline_set( paths.relay_consultation_anchor, VerifiedBaselineLane::RelayConsultation, ), - ( - paths.notary_against, - paths.notary_anchor, - VerifiedBaselineLane::Notary, - ), ] { if let Some(baseline) = load_verified_baseline(against, anchor, loaded, Some(lane))? { baselines.insert(baseline)?; @@ -1679,7 +1577,6 @@ fn load_verified_approved_baseline_set( .ok_or_else(|| anyhow!("approved baseline comparison requires an environment"))?; let products = project_promotion_products(environment); let requires_relay = products.contains(&PromotionProjectedProduct::Relay); - let requires_notary = products.contains(&PromotionProjectedProduct::Notary); let requires_relay_consultation = project_requires_relay_consultation_baseline(loaded) || baselines.common().is_some_and(|baseline| { baseline @@ -1689,7 +1586,6 @@ fn load_verified_approved_baseline_set( }); if baselines.relay.is_some() != requires_relay || baselines.relay_consultation.is_some() != requires_relay_consultation - || baselines.notary.is_some() != requires_notary { bail!("approved baseline set is incomplete for the selected product topology"); } @@ -1728,13 +1624,12 @@ fn load_verified_baseline( registry_platform_config::ProductAcceptanceLaneV1::RelayConsultation => { VerifiedBaselineLane::RelayConsultation } - registry_platform_config::ProductAcceptanceLaneV1::Notary => VerifiedBaselineLane::Notary, + _ => bail!("verified baseline uses a retired non-Relay lane"), }); let expected_identities = governed_signing_input_identities(loaded)?; let expected_identity = match lane { VerifiedBaselineLane::Relay => &expected_identities[0], VerifiedBaselineLane::RelayConsultation => &expected_identities[1], - VerifiedBaselineLane::Notary => &expected_identities[2], }; if identity != expected_identity { bail!( @@ -1802,24 +1697,6 @@ fn load_verified_baseline( "verified baseline review and approval state disagree on the Relay consultation input" ); } - let disclosure_profiles: DisclosureReviewProfiles = serde_json::from_value( - review - .get("disclosure_profiles") - .cloned() - .ok_or_else(|| anyhow!("baseline review record lacks disclosure_profiles"))?, - ) - .context("baseline review disclosure_profiles are invalid")?; - let disclosure_digest = digest_json( - &serde_json::to_value(&disclosure_profiles) - .context("failed to canonicalize baseline disclosure_profiles")?, - )?; - if approval_state - .get("disclosure_digest") - .and_then(Value::as_str) - != Some(disclosure_digest.as_str()) - { - bail!("verified baseline approval state does not bind the review disclosure profiles"); - } validate_verified_product_closure(&approval_state, &verified.manifest, lane)?; let approval_state_digest = sha256_uri(&approval_state_bytes); let review_digest = sha256_uri(&review_bytes); @@ -1898,7 +1775,6 @@ fn validate_signed_review_record(value: &Value) -> Result<()> { "registry", "compiler_version", "baseline", - "disclosure_profiles", "semantic_changes", "environment", "entity_materializations", @@ -1917,11 +1793,6 @@ fn validate_signed_review_record(value: &Value) -> Result<()> { ) { bail!("baseline review record baseline status is invalid"); } - let profiles_value = review - .get("disclosure_profiles") - .ok_or_else(|| anyhow!("baseline review record lacks disclosure_profiles"))?; - let _: DisclosureReviewProfiles = serde_json::from_value(profiles_value.clone()) - .context("baseline review disclosure_profiles are invalid")?; validate_semantic_changes( review .get("semantic_changes") @@ -1975,7 +1846,6 @@ fn validate_signed_approval_state(value: &Value) -> Result<()> { "report_digest", "authored_input_digest", "semantic_digests", - "disclosure_digest", "promotion_projection", "generated_closure_digests", "baseline", @@ -1988,31 +1858,17 @@ fn validate_signed_approval_state(value: &Value) -> Result<()> { bail!("baseline approval state field {field} must be a string"); } } - for field in [ - "report_digest", - "authored_input_digest", - "disclosure_digest", - ] { + for field in ["report_digest", "authored_input_digest"] { validate_review_sha256(state.get(field), field, false)?; } let semantic = exact_review_object( state .get("semantic_digests") .ok_or_else(|| anyhow!("baseline approval state lacks semantic_digests"))?, - &[ - "claim", - "integration", - "service_policy", - "operator_security", - ], + &["integration", "service_policy", "operator_security"], "baseline approval semantic_digests", )?; - for field in [ - "claim", - "integration", - "service_policy", - "operator_security", - ] { + for field in ["integration", "service_policy", "operator_security"] { validate_review_sha256(semantic.get(field), field, false)?; } let promotion_projection: ProjectPromotionProjectionV1 = serde_json::from_value( @@ -2029,14 +1885,12 @@ fn validate_signed_approval_state(value: &Value) -> Result<()> { state .get("generated_closure_digests") .ok_or_else(|| anyhow!("baseline approval state lacks generated_closure_digests"))?, - &["reviewable", "relay", "relay_consultation", "notary"], + &["reviewable", "relay", "relay_consultation"], "baseline approval generated_closure_digests", )?; validate_review_sha256(closure.get("reviewable"), "reviewable", false)?; - for field in ["relay", "notary"] { - if !closure.get(field).is_some_and(Value::is_null) { - validate_review_sha256(closure.get(field), field, false)?; - } + if !closure.get("relay").is_some_and(Value::is_null) { + validate_review_sha256(closure.get("relay"), "relay", false)?; } if !closure .get("relay_consultation") @@ -2055,16 +1909,11 @@ fn validate_signed_approval_state(value: &Value) -> Result<()> { { bail!("baseline approval Relay consultation closure requires the Relay product closure"); } - for (field, product) in [ - ("relay", PromotionProjectedProduct::Relay), - ("notary", PromotionProjectedProduct::Notary), - ] { - let has_closure = closure.get(field).is_some_and(Value::is_string); - if has_closure != promotion_products.contains(&product) { - bail!( - "baseline approval promotion_projection product inventory disagrees with generated_closure_digests" - ); - } + let has_relay_closure = closure.get("relay").is_some_and(Value::is_string); + if has_relay_closure != promotion_products.contains(&PromotionProjectedProduct::Relay) { + bail!( + "baseline approval promotion_projection product inventory disagrees with generated_closure_digests" + ); } validate_approval_baseline( state.get("baseline"), @@ -2159,12 +2008,7 @@ fn validate_semantic_changes(value: &Value) -> Result<()> { .ok_or_else(|| anyhow!("baseline semantic change dimension must be a string"))?; if !matches!( dimension, - "compiler" - | "claim" - | "integration" - | "service_policy" - | "operator_security" - | "disclosure" + "compiler" | "integration" | "service_policy" | "operator_security" ) || !dimensions.insert(dimension) { bail!("baseline semantic_changes contain an unknown or duplicate dimension"); @@ -2194,7 +2038,7 @@ fn validate_approval_baseline( baseline .get("verified_manifests") .ok_or_else(|| anyhow!("baseline approval state lacks verified_manifests"))?, - &["relay", "relay_consultation", "notary"], + &["relay", "relay_consultation"], "baseline approval verified_manifests", )?; let mut present = 0_usize; @@ -2213,13 +2057,6 @@ fn validate_approval_baseline( PromotionProjectedProduct::Relay, consultation_closure, ), - ( - "notary", - registry_platform_config::ProductAcceptanceProductV1::RegistryNotary, - registry_platform_config::ProductAcceptanceLaneV1::Notary, - PromotionProjectedProduct::Notary, - promotion_products.contains(&PromotionProjectedProduct::Notary), - ), ] { let Some(value) = manifests.get(field) else { bail!("baseline approval state lacks a product manifest identity"); @@ -2256,17 +2093,8 @@ fn validate_approval_baseline( fn semantic_change_records( loaded: &LoadedRegistryProject, baseline: Option<&Value>, - disclosure_digest: &str, ) -> Vec { let mut changes = [ - ( - "claim", - loaded.semantic_digests.claim.as_str(), - baseline - .and_then(|review| review.get("semantic_digests")) - .and_then(|digests| digests.get("claim")) - .and_then(Value::as_str), - ), ( "integration", loaded.semantic_digests.integration.as_str(), @@ -2291,13 +2119,6 @@ fn semantic_change_records( .and_then(|digests| digests.get("operator_security")) .and_then(Value::as_str), ), - ( - "disclosure", - disclosure_digest, - baseline - .and_then(|review| review.get("disclosure_digest")) - .and_then(Value::as_str), - ), ] .into_iter() .filter(|(_, current, previous)| *previous != Some(*current)) @@ -2515,16 +2336,6 @@ fn lower_authored_fixture( body_cache: &mut BTreeMap, max_body_bytes: u64, ) -> Result { - if let Some(request) = authored.request.as_ref() { - if authored.classification != AuthoredFixtureClassification::Synthetic { - bail!("fixture governed requests require classification: synthetic"); - } - let request = serde_json::to_value(request) - .context("failed to inspect the governed synthetic fixture request")?; - if contains_sensitive_request_key(&request) || contains_fixture_secret_reference(&request) { - bail!("fixture governed request contains a forbidden credential-like field"); - } - } let interactions = authored .interactions .into_iter() @@ -2576,7 +2387,6 @@ fn lower_authored_fixture( Ok(FixtureDocument { name: authored.name, classification: authored.classification, - request: authored.request, input: authored.input, variables: authored.variables, interactions, @@ -2584,21 +2394,6 @@ fn lower_authored_fixture( }) } -fn contains_fixture_secret_reference(value: &Value) -> bool { - match value { - Value::String(value) => { - let lower = value.to_ascii_lowercase(); - value.starts_with("${") - || lower.starts_with("secret://") - || lower.starts_with("env://") - || lower.starts_with("vault://") - } - Value::Array(values) => values.iter().any(contains_fixture_secret_reference), - Value::Object(object) => object.values().any(contains_fixture_secret_reference), - Value::Null | Value::Bool(_) | Value::Number(_) => false, - } -} - fn resolve_fixture_body( root: &Path, fixture_directory: &Path, @@ -2789,22 +2584,6 @@ fn validate_request_mapping(mapping: &str) -> Result<()> { Ok(()) } -fn validate_disclosure(disclosure: &DisclosureDeclaration) -> Result<()> { - match disclosure { - DisclosureDeclaration::Mode(_) => Ok(()), - DisclosureDeclaration::Policy { default, allowed } => { - if allowed.is_empty() || !allowed.contains(default) { - bail!("disclosure policy must allow its default mode"); - } - let unique = allowed.iter().copied().collect::>(); - if unique.len() != allowed.len() { - bail!("disclosure allowed modes contain duplicates"); - } - Ok(()) - } - } -} - fn validate_secret_reference(reference: &SecretReference) -> Result<()> { let value = reference.secret.as_str(); let mut bytes = value.bytes(); @@ -2857,27 +2636,6 @@ fn validate_https_or_local_loopback_origin( Ok(()) } -fn validate_internal_https_or_loopback_origin(value: &str, field: &str) -> Result<()> { - let origin = url::Url::parse(value).with_context(|| format!("{field} is not a URL"))?; - let secure = origin.scheme() == "https"; - let local_loopback = origin.scheme() == "http" && url_host_is_ip_loopback(&origin); - let private_service = origin.scheme() == "http" - && matches!(origin.host(), Some(url::Host::Domain(host)) if host != "localhost" && !host.ends_with(".localhost")); - if (!secure && !local_loopback && !private_service) - || origin.host().is_none() - || !origin.username().is_empty() - || origin.password().is_some() - || origin.path() != "/" - || origin.query().is_some() - || origin.fragment().is_some() - { - bail!( - "{field} must be an exact HTTPS origin, HTTP private-service hostname origin, or HTTP IP-loopback origin" - ); - } - Ok(()) -} - fn validate_https_or_local_loopback_resource( value: &str, field: &str, @@ -2944,29 +2702,6 @@ fn validate_absolute_runtime_path(path: &Path, field: &str) -> Result<()> { Ok(()) } -fn validate_full_date(value: &str) -> Result<()> { - if value.len() != 10 - || value.as_bytes()[4] != b'-' - || value.as_bytes()[7] != b'-' - || !value - .bytes() - .enumerate() - .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) - { - bail!("date must use RFC 3339 full-date syntax"); - } - let year = value[0..4].parse::()?; - let month = value[5..7].parse::()?; - let day = value[8..10].parse::()?; - time::Date::from_calendar_date( - year, - time::Month::try_from(month).map_err(|_| anyhow!("date month is invalid"))?, - day, - ) - .context("date is invalid")?; - Ok(()) -} - fn canonical_json_line(value: &Value) -> Result> { let mut bytes = canonicalize_json(value).context("failed to canonicalize generated JSON")?; bytes.push(b'\n'); diff --git a/crates/registryctl/src/project_authoring/preflight.rs b/crates/registryctl/src/project_authoring/preflight.rs index 04289db77..89cf5f1b3 100644 --- a/crates/registryctl/src/project_authoring/preflight.rs +++ b/crates/registryctl/src/project_authoring/preflight.rs @@ -87,7 +87,6 @@ const REQUIRED_STATIC_CAPABILITIES: [PreflightStaticCapability; 4] = [ #[serde(rename_all = "snake_case")] pub enum PreflightProduct { RegistryRelay, - RegistryNotary, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] @@ -109,11 +108,6 @@ pub enum PreflightSecretConsumer { SourceOauthMtlsPrivateKey, SourceJwksMtlsPrivateKey, EntityPostgresConnection, - IssuanceSigningKey, - CallerApiKeyFingerprint, - Oid4vciClientSigningKey, - Oid4vciAccessTokenSigningKey, - Oid4vciSensitiveStateKey, } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] @@ -129,8 +123,6 @@ pub enum PreflightRuntimeFileKind { EntityXlsx, EntityParquet, RelayStateRootCertificate, - NotaryStateRootCertificate, - NotaryToRelayToken, } impl PreflightRuntimeFileKind { @@ -138,7 +130,7 @@ impl PreflightRuntimeFileKind { match self { // Entity source files can contain country-held person data, so they retain the same // owner-only posture as private credential material. - Self::EntityCsv | Self::EntityXlsx | Self::EntityParquet | Self::NotaryToRelayToken => { + Self::EntityCsv | Self::EntityXlsx | Self::EntityParquet => { RuntimeFilePosture::PrivateMaterial } Self::SourceCa @@ -147,8 +139,7 @@ impl PreflightRuntimeFileKind { | Self::SourceOauthMtlsCertificate | Self::SourceJwksCa | Self::SourceJwksMtlsCertificate - | Self::RelayStateRootCertificate - | Self::NotaryStateRootCertificate => RuntimeFilePosture::PublicTrustMaterial, + | Self::RelayStateRootCertificate => RuntimeFilePosture::PublicTrustMaterial, } } @@ -163,17 +154,13 @@ impl PreflightRuntimeFileKind { | Self::SourceOauthMtlsCertificate | Self::SourceJwksCa | Self::SourceJwksMtlsCertificate - | Self::RelayStateRootCertificate - | Self::NotaryStateRootCertificate - | Self::NotaryToRelayToken => MAX_RUNTIME_FILE_BYTES, + | Self::RelayStateRootCertificate => MAX_RUNTIME_FILE_BYTES, } } const fn generation(self) -> PreflightGenerationState { match self { - Self::RelayStateRootCertificate - | Self::NotaryStateRootCertificate - | Self::NotaryToRelayToken => PreflightGenerationState::NotDeclared, + Self::RelayStateRootCertificate => PreflightGenerationState::NotDeclared, Self::SourceCa | Self::SourceMtlsCertificate | Self::SourceOauthCa diff --git a/crates/registryctl/src/project_authoring/project.rs b/crates/registryctl/src/project_authoring/project.rs index 3f3082b5e..3cacd8d8e 100644 --- a/crates/registryctl/src/project_authoring/project.rs +++ b/crates/registryctl/src/project_authoring/project.rs @@ -705,35 +705,6 @@ fn semantic_digests( entities: &BTreeMap, environment: Option<&EnvironmentDocument>, ) -> Result { - let claims = project - .services - .iter() - .map(|(id, service)| { - let service_claims = service - .claims - .iter() - .map(|(claim_id, claim)| { - Ok(( - claim_id, - json!({ - "evidence": inferred_claim_evidence(service, claim)?, - "output": claim.output, - "cel": claim.cel, - "value": claim.value, - }), - )) - }) - .collect::>>()?; - Ok(( - id, - json!({ - "subject_type": service.effective_subject_type(), - "variables": service.variables, - "claims": service_claims, - }), - )) - }) - .collect::>>()?; let policy = project .services .iter() @@ -744,8 +715,7 @@ fn semantic_digests( "purpose": service.purpose, "legal_basis": service.legal_basis, "consent": service.consent, - "access": service.access, - "credential_profiles": service.credential_profiles, + "variables": service.variables, }), ) }) @@ -825,13 +795,6 @@ fn semantic_digests( .iter() .map(|(service, declaration)| (service, &declaration.consultations)) .collect::>(); - let callers = environment.map(|environment| { - environment - .callers - .iter() - .map(|(id, caller)| (id, &caller.scopes)) - .collect::>() - }); let operator = environment.map(|environment| { let integrations = environment .integrations @@ -845,41 +808,24 @@ fn semantic_digests( ) }) .collect::>(); - let caller_credentials = environment - .callers - .iter() - .map(|(id, caller)| (id, &caller.api_key_fingerprint)) - .collect::>(); let mut operator = json!({ "integrations": integrations, "entities": environment.entities, - "caller_credentials": caller_credentials, - "issuance": environment.issuance, "relay": environment.relay, - "notary_relay": environment.notary_relay, - "notary_state": environment.notary_state, - "oid4vci_registrar_clients": environment.oid4vci.as_ref() - .map(|binding| &binding.registrar_clients), "deployment": environment.deployment, }); if let Some(relay_state) = &environment.relay_state { operator["relay_state"] = json!(relay_state); } - if let Some(notary_cel) = &environment.notary_cel { - operator["notary_cel"] = json!(notary_cel); - } operator }); Ok(SemanticDigests { - claim: digest_json(&json!({ "services": claims }))?, integration: digest_json(&json!({ "integrations": integration, "service_consultations": service_consultations, "entities": entity_model, }))?, - service_policy: digest_json( - &json!({ "services": policy, "records": records_policy, "callers": callers }), - )?, + service_policy: digest_json(&json!({ "services": policy, "records": records_policy }))?, operator_security: digest_json(&json!({ "operator": operator }))?, }) } @@ -889,7 +835,7 @@ fn semantic_digests( // A schema or knowledge change must therefore be reviewed for promotion // semantics before a new projection can be emitted. const PROMOTION_FIELD_KNOWLEDGE_REVISION: &str = - "sha256:537dde0f120d9874e74cae1d899d7e4d86ad013a5d1b918021a73e1045f951b1"; + "sha256:481a290b37b5c3db64dd5947a1e07e7a1293f3beb0c63dc0e1486ccca6f96620"; fn project_promotion_projection( loaded: &LoadedRegistryProject, @@ -958,11 +904,6 @@ fn project_promotion_projection( let trust_state = json!({ "integrations": integration_trust, "relay": environment.relay, - "oid4vci_authorization_server": environment.oid4vci.as_ref().map(|binding| json!({ - "issuer": binding.authorization_server.issuer, - })), - "oid4vci_registrar_clients": environment.oid4vci.as_ref() - .map(|binding| &binding.registrar_clients), }); let trust_members = environment .integrations @@ -996,27 +937,6 @@ fn project_promotion_projection( .flat_map(|relay| relay.allowed_clients.iter()) .map(|client| json!(["relay_client", client])), ) - .chain( - environment - .oid4vci - .iter() - .flat_map(|binding| binding.registrar_clients.iter()) - .map(|client| json!(["oid4vci_registrar_client", client])), - ) - .collect::>(); - - let caller_state = environment.callers.iter().collect::>(); - let caller_members = environment - .callers - .iter() - .flat_map(|(id, caller)| { - std::iter::once(json!(["caller", id])).chain( - caller - .scopes - .iter() - .map(move |scope| json!(["caller_scope", id, scope])), - ) - }) .collect::>(); let operational_integrations = environment @@ -1037,16 +957,8 @@ fn project_promotion_projection( "integrations": operational_integrations, "entities": environment.entities, "relay_state": environment.relay_state, - "notary_state": environment.notary_state, - "notary_cel": environment.notary_cel, - "issuance": environment.issuance, - "notary_relay": environment.notary_relay, - "oid4vci": environment.oid4vci, "deployment_profile": environment.deployment.profile, "deployment_relay_service": environment.deployment.relay.as_ref().map(|binding| &binding.service), - "deployment_notary_service": environment.deployment.notary.as_ref().map(|binding| &binding.service), - "oid4vci_subject": environment.oid4vci.as_ref().map(|binding| &binding.subject), - "oid4vci_tx_code": environment.oid4vci.as_ref().map(|binding| &binding.tx_code), }); let purpose_state = loaded @@ -1065,7 +977,6 @@ fn project_promotion_projection( json!({ "legal_basis": service.legal_basis, "consent": service.consent, - "access": service.access, "variables": service.variables, "records": { "entity": service.entity, @@ -1086,77 +997,8 @@ fn project_promotion_projection( .project .services .iter() - .flat_map(|(id, service)| { - let consent = (service.consent == ConsentDeclaration::NotRequired) - .then(|| json!(["consent_not_required", id])); - service - .access - .scopes - .iter() - .map(|scope| json!(["service_scope", id, scope])) - .chain(consent) - .collect::>() - }) - .collect::>(); - - let claim_state = loaded - .project - .services - .iter() - .map(|(service_id, service)| { - let claims = service - .claims - .iter() - .map(|(claim_id, claim)| { - ( - claim_id, - json!({ - "output": claim.output, - "cel": claim.cel, - "value": claim.value, - }), - ) - }) - .collect::>(); - ( - service_id, - json!({ - "claims": claims, - "credential_profiles": service.credential_profiles, - }), - ) - }) - .collect::>(); - let claim_members = loaded - .project - .services - .iter() - .flat_map(|(service_id, service)| { - service - .claims - .keys() - .map(|claim_id| json!(["claim", service_id, claim_id])) - .chain( - service - .credential_profiles - .keys() - .map(|profile| json!(["credential_profile", service_id, profile])), - ) - .collect::>() - }) - .collect::>(); - - let disclosure_state = disclosure_review_profiles(&loaded.project); - let disclosure_members = disclosure_state - .iter() - .flat_map(|(service_id, claims)| { - claims.iter().flat_map(move |(claim_id, profile)| { - profile - .allowed - .iter() - .map(move |mode| json!(["disclosure", service_id, claim_id, mode])) - }) - }) + .filter(|(_, service)| service.consent == ConsentDeclaration::NotRequired) + .map(|(id, _)| json!(["consent_not_required", id])) .collect::>(); let product_state = json!({ "products": products }); @@ -1304,12 +1146,6 @@ fn project_promotion_projection( trust_state, trust_members, ), - ( - PromotionChangeKind::Caller, - PromotionFieldClassification::Sensitive, - json!(caller_state), - caller_members, - ), ( PromotionChangeKind::Operational, PromotionFieldClassification::Internal, @@ -1328,18 +1164,6 @@ fn project_promotion_projection( json!(service_policy_state), service_policy_members, ), - ( - PromotionChangeKind::Claim, - PromotionFieldClassification::Internal, - json!(claim_state), - claim_members, - ), - ( - PromotionChangeKind::Disclosure, - PromotionFieldClassification::Internal, - json!(disclosure_state), - disclosure_members, - ), ( PromotionChangeKind::ProductEnablement, PromotionFieldClassification::Structural, @@ -1420,9 +1244,6 @@ fn project_promotion_products(environment: &EnvironmentDocument) -> Vec Option Option Option { if pointer.contains("/purpose") { Some(Kind::Purpose) - } else if pointer.contains("/disclosure") { - Some(Kind::Disclosure) - } else if pointer.contains("/claims") || pointer.contains("/credential_profiles") { - Some(Kind::Claim) } else if pointer.contains("/integrations") || pointer.contains("/entities") || pointer.contains("/consultations") @@ -1674,24 +1486,19 @@ fn validate_project_shape(project: &RegistryProject) -> Result<()> { bail!("entity {alias} must reference entities/{alias}.yaml"); } } - let mut project_claim_ids = BTreeSet::new(); let mut published_entities = BTreeSet::new(); let mut project_attribute_release_profiles = BTreeSet::new(); for (service_id, service) in &project.services { validate_stable_id(service_id, "service id")?; match service.kind { ServiceKind::RecordsApi => { - if service.subject_type.is_some() - || service.version != 0 + if service.version != 0 || !service.purpose.is_empty() || !service.legal_basis.is_empty() - || !service.access.scopes.is_empty() || !service.variables.is_empty() || !service.consultations.is_empty() - || !service.claims.is_empty() - || !service.credential_profiles.is_empty() { - bail!("records_api service cannot declare evidence-service fields"); + bail!("records_api service cannot declare consultation_api fields"); } let entity = service .entity @@ -1723,7 +1530,7 @@ fn validate_project_shape(project: &RegistryProject) -> Result<()> { } continue; } - ServiceKind::Evidence => { + ServiceKind::ConsultationApi => { if service.entity.is_some() || service.title.is_some() || service.description.is_some() @@ -1734,7 +1541,7 @@ fn validate_project_shape(project: &RegistryProject) -> Result<()> { || !service.conforms_to.is_empty() || service.api.is_some() { - bail!("evidence services cannot declare records_api fields"); + bail!("consultation_api services cannot declare records_api fields"); } } } @@ -1746,12 +1553,11 @@ fn validate_project_shape(project: &RegistryProject) -> Result<()> { if service.consent == ConsentDeclaration::Required { bail!("consent: required is unavailable until sealed consent verification lands"); } - validate_scopes(&service.access.scopes)?; if service.consultations.len() > 16 { bail!("service consultations must contain no more than 16 entries"); } - if service.claims.is_empty() || service.claims.len() > MAX_CLAIMS { - bail!("evidence service claims must contain between one and 64 entries"); + if service.consultations.is_empty() { + bail!("consultation_api service must declare at least one consultation"); } for (name, consultation) in &service.consultations { validate_stable_id(name, "consultation name")?; @@ -1775,85 +1581,10 @@ fn validate_project_shape(project: &RegistryProject) -> Result<()> { bail!("v1 request variables must be exact declared full-date mappings"); } } - for (claim_id, claim) in &service.claims { - validate_stable_id(claim_id, "claim id")?; - if !project_claim_ids.insert(claim_id) { - bail!("Notary claim ids must be unique across project services"); - } - if claim.output.is_some() == claim.cel.is_some() { - bail!("each claim must declare exactly one of output or cel"); - } - match inferred_claim_evidence(service, claim)? { - ClaimEvidence::RegistryBacked => { - if service.consultations.is_empty() { - bail!("registry-backed claims require a Relay consultation"); - } - } - } - if let Some(value) = &claim.value { - if value.value_type == OutputType::String { - let Some(max_bytes) = value.max_bytes else { - bail!("string claim value contracts require max_bytes"); - }; - if !(1..=registry_notary_core::MAX_CLAIM_VALUE_STRING_BYTES_V1) - .contains(&max_bytes) - { - bail!( - "string claim value max_bytes must be between 1 and {}", - registry_notary_core::MAX_CLAIM_VALUE_STRING_BYTES_V1 - ); - } - } - if value.value_type != OutputType::String && value.max_bytes.is_some() { - bail!("only string claim value contracts may declare max_bytes"); - } - } - validate_disclosure(&claim.disclosure)?; - } - for (credential_id, credential) in &service.credential_profiles { - if credential.claims.is_empty() { - bail!("credential claim allow-list must not be empty"); - } - for claim_id in &credential.claims { - let claim = service - .claims - .get(claim_id) - .ok_or_else(|| anyhow!("credential references an unknown claim"))?; - inferred_claim_evidence(service, claim).with_context(|| { - format!( - "credential profile {service_id}.{credential_id} requires claim {claim_id} to reference a declared Relay consultation" - ) - })?; - } - } } Ok(()) } -fn inferred_claim_evidence( - service: &ServiceDeclaration, - claim: &ClaimDeclaration, -) -> Result { - if claim.output.is_some() { - return Ok(ClaimEvidence::RegistryBacked); - } - let roots = claim - .cel - .as_deref() - .map(cel_member_roots) - .transpose()? - .unwrap_or_default(); - if service - .consultations - .keys() - .any(|name| roots.contains(name.as_str())) - { - Ok(ClaimEvidence::RegistryBacked) - } else { - bail!("every claim must derive from one declared Relay consultation") - } -} - fn validate_entity_definition(entity: &EntityDefinition) -> Result<()> { if entity.version != 1 || entity.revision == 0 { bail!("entity version must be 1 and revision must be positive"); @@ -2498,16 +2229,16 @@ fn validate_service_integration_links( for (service_id, service) in project .services .iter() - .filter(|(_, service)| service.kind == ServiceKind::Evidence) + .filter(|(_, service)| service.kind == ServiceKind::ConsultationApi) { for (consultation_name, consultation) in &service.consultations { let integration = &integrations[&consultation.integration].document; if integration.outputs.len() - > registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 + > MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 { bail!( "service {service_id} consultation {consultation_name} integration outputs must contain no more than {} entries", - registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 + MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 ); } if consultation.input.keys().ne(integration.input.keys()) { @@ -2519,36 +2250,6 @@ fn validate_service_integration_links( bail!("consultation target mappings must be injective"); } } - for (claim_id, claim) in &service.claims { - let Some(expression) = claim.cel.as_deref() else { - continue; - }; - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - continue; - } - let references = cel_references(expression) - .with_context(|| format!("invalid CEL for service {service_id} claim {claim_id}"))?; - if references.uses_index { - bail!( - "service {service_id} claim {claim_id} registry-backed CEL cannot use index access" - ); - } - for (consultation_name, consultation) in &service.consultations { - let Some(members) = references.first_level_members.get(consultation_name) else { - continue; - }; - let integration = &integrations[&consultation.integration].document; - for member in members { - if integration.outputs.get(member).is_some_and(|output| { - matches!(output.output_type, OutputType::Object | OutputType::Array) - }) { - bail!( - "service {service_id} claim {claim_id} CEL cannot reference structured consultation output {consultation_name}.{member}" - ); - } - } - } - } } Ok(()) } @@ -2931,25 +2632,13 @@ fn validate_environment( entities: &BTreeMap, environment: &EnvironmentDocument, ) -> Result<()> { - let (requires_relay, requires_notary) = project_product_topology(project); - let requires_issuance = project_issues_credentials(project); - let requires_notary_relay = project_requires_notary_relay(project); + let requires_relay = project_requires_relay(project); + let requires_relay_consultation = project_requires_consultation_relay(project); if environment.deployment.relay.is_some() != requires_relay || environment.relay.is_some() != requires_relay { bail!("environment Relay bindings must exactly match the project topology"); } - if environment.deployment.notary.is_some() != requires_notary { - bail!("environment Notary bindings must exactly match the project topology"); - } - if environment.issuance.is_some() != requires_issuance { - bail!("environment issuance binding is required exactly when credential profiles exist"); - } - if environment.notary_relay.is_some() != requires_notary_relay { - bail!( - "the Notary-to-Relay connection is required exactly when Relay and Notary are both deployed" - ); - } let remote_integrations = integrations .values() .filter(|loaded| { @@ -3011,28 +2700,6 @@ fn validate_environment( { bail!("environment contains an unknown project entity"); } - if requires_notary && environment.callers.is_empty() && environment.oid4vci.is_none() { - bail!("a Notary environment must bind at least one authenticated caller"); - } - if !requires_notary && !environment.callers.is_empty() { - bail!("a Relay-only environment cannot declare Notary callers"); - } - if environment.callers.len() > 64 { - bail!("environment callers exceed the supported bound"); - } - for (caller_id, caller) in &environment.callers { - validate_stable_id(caller_id, "caller id")?; - validate_secret_reference(&caller.api_key_fingerprint)?; - validate_scopes(&caller.scopes)?; - } - if let Some(issuance) = &environment.issuance { - validate_secret_reference(&issuance.signing_key)?; - validate_token(&issuance.issuer, "issuance issuer", 2048)?; - validate_token(&issuance.signing_kid, "issuance signing_kid", 2048)?; - if issuance.generation == 0 { - bail!("issuance generation must be positive"); - } - } if let Some(relay) = &environment.relay { let local = matches!(environment.deployment.profile, DeploymentProfile::Local); validate_https_or_local_loopback_origin(&relay.origin, "Relay origin", local)?; @@ -3048,27 +2715,33 @@ fn validate_environment( bail!("Relay allowed_clients must not contain duplicates"); } } + match (&relay.consultation, requires_relay_consultation) { + (Some(consultation), true) => { + validate_token(&consultation.client_id, "Relay consultation client id", 256)?; + validate_token( + &consultation.principal_id, + "Relay consultation principal id", + 256, + )?; + if allowed_clients.contains(&consultation.client_id) { + bail!("Relay consultation client_id must be separate from relay.allowed_clients"); + } + } + (None, true) => { + bail!("a consultation_api service requires relay.consultation") + } + (Some(_), false) => { + bail!("relay.consultation is valid only with a consultation_api service") + } + (None, false) => {} + } if relay.allowed_clients.is_empty() && relay.local_api_keys.is_none() { - bail!( - "a public Relay requires at least one admitted OIDC client; the Notary workload client belongs only in notary_relay" - ); + bail!("a Relay requires at least one admitted OIDC client"); } validate_https_or_local_loopback_resource(&relay.jwks_url, "Relay OIDC JWKS URL", local)?; } - if let Some(connection) = &environment.notary_relay { - validate_internal_https_or_loopback_origin( - &connection.base_url, - "Notary-to-Relay base URL", - )?; - validate_token( - &connection.workload_client_id, - "Notary-to-Relay workload client id", - 256, - )?; - validate_absolute_runtime_path(&connection.token_file, "Relay workload token file")?; - } if let Some(state) = &environment.relay_state { - if !requires_notary_relay { + if !requires_relay_consultation { bail!("relay_state is valid only when Relay consultations are enabled"); } validate_absolute_runtime_path( @@ -3076,32 +2749,9 @@ fn validate_environment( "Relay PostgreSQL root_certificate_path", )?; } - if let Some(state) = &environment.notary_state { - if !requires_notary { - bail!("notary_state is valid only when the project deploys a Notary"); - } - validate_absolute_runtime_path( - &state.postgresql.root_certificate_path, - "Notary PostgreSQL root_certificate_path", - )?; - } - if let Some(cel) = &environment.notary_cel { - if !requires_notary { - bail!("notary_cel is valid only when the project deploys a Notary"); - } - if !(32 * 1024 * 1024..=1024 * 1024 * 1024).contains(&cel.worker_memory_bytes) { - bail!("notary_cel.worker_memory_bytes must be between 33554432 and 1073741824"); - } - } - if let Some(oid4vci) = &environment.oid4vci { - validate_oid4vci_binding(project, environment, oid4vci)?; - } if let Some(relay) = &environment.deployment.relay { validate_stable_id(&relay.service, "Relay service id")?; } - if let Some(notary) = &environment.deployment.notary { - validate_stable_id(¬ary.service, "Notary service id")?; - } for loaded in integrations.values() { if let CapabilityDeclaration::Script { script } = &loaded.document.capability { if script.runtime != ScriptRuntime::RhaiV1 @@ -3114,330 +2764,19 @@ fn validate_environment( Ok(()) } -fn validate_oid4vci_binding( - project: &RegistryProject, - environment: &EnvironmentDocument, - binding: &Oid4vciBinding, -) -> Result<()> { - if environment.notary_state.is_none() { - bail!("OID4VCI requires a Notary PostgreSQL state binding"); - } - let local = matches!(environment.deployment.profile, DeploymentProfile::Local); - validate_https_or_local_loopback_origin( - &binding.public_base_url, - "OID4VCI public base URL", - local, - )?; - validate_https_or_local_loopback_origin( - &binding.authorization_server.issuer, - "OID4VCI authorization server issuer", - local, - )?; - for (field, value) in [ - ( - "OID4VCI authorization server JWKS URL", - binding.authorization_server.jwks_url.as_str(), - ), - ( - "OID4VCI authorization server userinfo URL", - binding.authorization_server.userinfo_url.as_str(), - ), - ( - "OID4VCI authorization server authorize URL", - binding.authorization_server.authorize_url.as_str(), - ), - ( - "OID4VCI authorization server token URL", - binding.authorization_server.token_url.as_str(), - ), - ("OID4VCI redirect URI", binding.redirect_uri.as_str()), - ] { - validate_https_or_local_loopback_resource(value, field, local)?; - } - for (field, value) in [ - ( - "OID4VCI authorization server JWKS URL", - binding.authorization_server.jwks_url.as_str(), - ), - ( - "OID4VCI authorization server userinfo URL", - binding.authorization_server.userinfo_url.as_str(), - ), - ( - "OID4VCI authorization server token URL", - binding.authorization_server.token_url.as_str(), - ), - ] { - validate_resource_origin(value, &binding.authorization_server.issuer, field)?; - } - let public_base_url = binding.public_base_url.trim_end_matches('/'); - if binding.redirect_uri != format!("{public_base_url}/oid4vci/offer/callback") { - bail!("OID4VCI redirect URI must be the public Notary offer callback"); - } - - if binding.allowed_wallet_origins.is_empty() || binding.allowed_wallet_origins.len() > 16 { - bail!("OID4VCI allowed_wallet_origins must contain between one and 16 exact origins"); - } - let mut wallet_origins = BTreeSet::new(); - for origin in &binding.allowed_wallet_origins { - validate_https_origin(origin, "OID4VCI wallet origin")?; - if !wallet_origins.insert(origin) { - bail!("OID4VCI allowed_wallet_origins must not contain duplicates"); - } - } - - validate_stable_id(&binding.credential.service, "OID4VCI credential service")?; - validate_stable_id(&binding.credential.profile, "OID4VCI credential profile")?; - let service = project - .services - .get(&binding.credential.service) - .ok_or_else(|| anyhow!("OID4VCI references an unknown project service"))?; - if service.kind != ServiceKind::Evidence { - bail!("OID4VCI credential service must be an evidence service"); - } - if service.access.scopes.len() != 1 { - bail!("OID4VCI credential service must declare exactly one access scope"); - } - let credential = service - .credential_profiles - .get(&binding.credential.profile) - .ok_or_else(|| anyhow!("OID4VCI references an unknown credential profile"))?; - if credential.claims.len() != 1 { - bail!("OID4VCI v1 credential profiles must select exactly one claim"); - } - let claim = service - .claims - .get(&credential.claims[0]) - .ok_or_else(|| anyhow!("OID4VCI credential profile claim is absent"))?; - if inferred_claim_evidence(service, claim)? != ClaimEvidence::RegistryBacked { - bail!("OID4VCI credential profiles require registry-backed claim evidence"); - } - if let Some(representative) = &binding.representative_issuance { - if !binding.registrar_clients.is_empty() { - bail!( - "OID4VCI representative_issuance cannot be combined with registrar_clients in Registryctl's single-credential binding" - ); - } - validate_stable_id( - &representative.relationship, - "OID4VCI representative relationship", - )?; - validate_stable_id( - &representative.proof_claim, - "OID4VCI representative proof claim", - )?; - validate_token( - &representative.target_id_type, - "OID4VCI representative target id type", - 256, - )?; - if representative.max_proof_age_seconds == 0 - || representative.max_proof_age_seconds > 600 - { - bail!("OID4VCI representative max_proof_age_seconds must be between one and 600"); - } - let credential_claim = &credential.claims[0]; - if let Some((shared_profile, _)) = - service - .credential_profiles - .iter() - .find(|(profile_id, profile)| { - *profile_id != &binding.credential.profile - && profile - .claims - .iter() - .any(|claim_id| claim_id == credential_claim) - }) - { - bail!( - "OID4VCI representative credential claim '{}' must be exclusive to credential profile '{}'; credential profile '{}' also selects it", - credential_claim, - binding.credential.profile, - shared_profile - ); - } - let proof = service - .claims - .get(&representative.proof_claim) - .ok_or_else(|| { - anyhow!( - "OID4VCI representative_issuance.proof_claim '{}' is not a claim in credential service '{}'", - representative.proof_claim, - binding.credential.service - ) - })?; - if representative.proof_claim == credential.claims[0] { - bail!( - "OID4VCI representative_issuance.proof_claim must differ from the credential claim" - ); - } - if inferred_claim_evidence(service, proof)? != ClaimEvidence::RegistryBacked { - bail!("OID4VCI representative_issuance.proof_claim must be registry-backed"); - } - let consultation_name = claim_consultation_name(service, proof)?; - let consultation = &service.consultations[consultation_name]; - let requester_mapping = format!( - "request.requester.identifiers.{}", - binding.subject.id_type - ); - let target_mapping = format!( - "request.target.identifiers.{}", - representative.target_id_type - ); - if !consultation - .input - .values() - .any(|mapping| mapping == &requester_mapping) - { - bail!( - "OID4VCI representative_issuance.proof_claim '{}' consultation '{}' must bind the authenticated representative with input mapping '{}'", - representative.proof_claim, - consultation_name, - requester_mapping - ); - } - if !consultation - .input - .values() - .any(|mapping| mapping == &target_mapping) - { - bail!( - "OID4VCI representative_issuance.proof_claim '{}' consultation '{}' must bind the represented subject with input mapping '{}'", - representative.proof_claim, - consultation_name, - target_mapping - ); - } - if consultation.input.len() != 2 - || consultation - .input - .values() - .any(|mapping| mapping != &requester_mapping && mapping != &target_mapping) - { - bail!( - "OID4VCI representative_issuance.proof_claim '{}' consultation '{}' must map exactly the authenticated representative and represented subject identifiers; the target-selection ceremony cannot supply additional inputs", - representative.proof_claim, - consultation_name - ); - } - } - if normalize_credential_format(&credential.format) != "application/dc+sd-jwt" { - bail!("OID4VCI credential profile format must be dc+sd-jwt"); - } - let validity_seconds = parse_validity_seconds(&credential.validity)?; - if validity_seconds == 0 || validity_seconds > 600 { - bail!("OID4VCI credential validity must be between one and 600 seconds"); - } - validate_https_or_local_loopback_resource( - &credential.credential_type, - "OID4VCI credential type", - local, - )?; - validate_resource_origin( - &credential.credential_type, - &binding.public_base_url, - "OID4VCI credential type", - )?; - let credential_path = url::Url::parse(&credential.credential_type) - .context("OID4VCI credential type is invalid")? - .path() - .to_string(); - if !credential_path.starts_with("/credentials/") { - bail!("OID4VCI credential type path must start with /credentials/"); - } - - validate_token(&binding.client.id, "OID4VCI client id", 256)?; - if binding.registrar_clients.len() > 64 { - bail!("OID4VCI registrar_clients exceeds the supported bound"); - } - let mut registrar_clients = BTreeSet::new(); - for client in &binding.registrar_clients { - validate_token(client, "OID4VCI registrar client id", 256)?; - if client == &binding.client.id { - bail!("OID4VCI registrar_clients must not contain the citizen client id"); - } - if !registrar_clients.insert(client) { - bail!("OID4VCI registrar_clients must not contain duplicates"); - } - } - validate_secret_reference(&binding.client.signing_key)?; - validate_token( - &binding.client.signing_kid, - "OID4VCI client signing_kid", - 2048, - )?; - validate_secret_reference(&binding.access_token.signing_key)?; - validate_token( - &binding.access_token.signing_kid, - "OID4VCI access-token signing_kid", - 2048, - )?; - validate_secret_reference(&binding.sensitive_state_key)?; - validate_token( - &binding.subject.token_claim, - "OID4VCI subject token claim", - 256, - )?; - validate_token(&binding.subject.id_type, "OID4VCI subject id type", 256)?; - - let issuance = environment - .issuance - .as_ref() - .ok_or_else(|| anyhow!("OID4VCI requires an issuance binding"))?; - let secret_names = [ - issuance.signing_key.secret.as_str(), - binding.client.signing_key.secret.as_str(), - binding.access_token.signing_key.secret.as_str(), - ]; - if secret_names.into_iter().collect::>().len() != secret_names.len() { - bail!("OID4VCI issuer, client, and access-token signing keys must be distinct"); - } - let signing_kids = [ - issuance.signing_kid.as_str(), - binding.client.signing_kid.as_str(), - binding.access_token.signing_kid.as_str(), - ]; - if signing_kids.into_iter().collect::>().len() != signing_kids.len() { - bail!("OID4VCI issuer, client, and access-token signing kids must be distinct"); - } - Ok(()) -} - -fn validate_resource_origin(resource: &str, origin: &str, field: &str) -> Result<()> { - let resource = url::Url::parse(resource).with_context(|| format!("{field} is invalid"))?; - let origin = url::Url::parse(origin).with_context(|| format!("{field} origin is invalid"))?; - if resource.scheme() != origin.scheme() - || resource.host() != origin.host() - || resource.port_or_known_default() != origin.port_or_known_default() - { - bail!("{field} must use its bound origin"); - } - Ok(()) -} - -fn project_product_topology(project: &RegistryProject) -> (bool, bool) { - let requires_notary = project - .services - .values() - .any(|service| service.kind == ServiceKind::Evidence); - let requires_relay = !project.integrations.is_empty() +fn project_requires_relay(project: &RegistryProject) -> bool { + !project.integrations.is_empty() || !project.entities.is_empty() || project.services.values().any(|service| { service.kind == ServiceKind::RecordsApi || !service.consultations.is_empty() - }); - (requires_relay, requires_notary) + }) } -fn project_issues_credentials(project: &RegistryProject) -> bool { +fn project_requires_consultation_relay(project: &RegistryProject) -> bool { project .services .values() - .any(|service| !service.credential_profiles.is_empty()) -} - -fn project_requires_notary_relay(project: &RegistryProject) -> bool { - let (requires_relay, requires_notary) = project_product_topology(project); - requires_relay && requires_notary + .any(|service| service.kind == ServiceKind::ConsultationApi) } fn is_script_runtime_released(capability: ReleasedScriptRuntime) -> bool { diff --git a/crates/registryctl/src/project_authoring/promotion_projection.rs b/crates/registryctl/src/project_authoring/promotion_projection.rs index 8d71dad58..1d15260b5 100644 --- a/crates/registryctl/src/project_authoring/promotion_projection.rs +++ b/crates/registryctl/src/project_authoring/promotion_projection.rs @@ -24,18 +24,12 @@ pub(crate) enum PromotionFieldPath { IntegrationCredentials, #[serde(rename = "/integrations/*/trust")] IntegrationTrust, - #[serde(rename = "/notary/callers/*")] - NotaryCaller, #[serde(rename = "/operations")] OperationalSettings, #[serde(rename = "/purposes/*")] Purpose, #[serde(rename = "/service_policy")] ServicePolicy, - #[serde(rename = "/notary/claims/*")] - Claim, - #[serde(rename = "/notary/disclosures/*")] - Disclosure, #[serde(rename = "/products/*")] ProductEnablement, #[serde(rename = "/integrations/*/capabilities/*")] @@ -57,28 +51,22 @@ pub(crate) enum PromotionChangeKind { Origin, CredentialBinding, Trust, - Caller, Operational, Purpose, ServicePolicy, - Claim, - Disclosure, ProductEnablement, CapabilityEnablement, IntegrationCeiling, } impl PromotionChangeKind { - pub(crate) const ALL: [Self; 12] = [ + pub(crate) const ALL: [Self; 9] = [ Self::Origin, Self::CredentialBinding, Self::Trust, - Self::Caller, Self::Operational, Self::Purpose, Self::ServicePolicy, - Self::Claim, - Self::Disclosure, Self::ProductEnablement, Self::CapabilityEnablement, Self::IntegrationCeiling, @@ -98,10 +86,6 @@ impl PromotionChangeKind { PromotionDocument::Environment, PromotionFieldPath::IntegrationTrust, ), - Self::Caller => ( - PromotionDocument::Environment, - PromotionFieldPath::NotaryCaller, - ), Self::Operational => ( PromotionDocument::Environment, PromotionFieldPath::OperationalSettings, @@ -123,8 +107,6 @@ impl PromotionChangeKind { PromotionDocument::Project, PromotionFieldPath::ServicePolicy, ), - Self::Claim => (PromotionDocument::Project, PromotionFieldPath::Claim), - Self::Disclosure => (PromotionDocument::Project, PromotionFieldPath::Disclosure), }; PromotionFieldAddress { document, path } } @@ -135,7 +117,6 @@ impl PromotionChangeKind { Self::Origin | Self::CredentialBinding | Self::Trust - | Self::Caller | Self::Operational | Self::ProductEnablement | Self::CapabilityEnablement @@ -149,12 +130,10 @@ impl PromotionChangeKind { pub(crate) const fn expected_classification(self) -> PromotionFieldClassification { match self { Self::CredentialBinding => PromotionFieldClassification::SecretReference, - Self::Origin | Self::Trust | Self::Caller => PromotionFieldClassification::Sensitive, - Self::Operational - | Self::Purpose - | Self::ServicePolicy - | Self::Claim - | Self::Disclosure => PromotionFieldClassification::Internal, + Self::Origin | Self::Trust => PromotionFieldClassification::Sensitive, + Self::Operational | Self::Purpose | Self::ServicePolicy => { + PromotionFieldClassification::Internal + } Self::ProductEnablement | Self::CapabilityEnablement | Self::IntegrationCeiling => { PromotionFieldClassification::Structural } @@ -189,7 +168,6 @@ pub(crate) enum ProjectPromotionProjectionSchemaVersion { #[serde(rename_all = "snake_case")] pub(crate) enum PromotionProjectedProduct { Relay, - Notary, } #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -321,7 +299,7 @@ mod tests { assert_eq!(revision, PROMOTION_FIELD_KNOWLEDGE_REVISION); let index = knowledge::published_field_knowledge_index().expect("knowledge indexes"); - assert_eq!(index.by_path().len(), 702); + assert_eq!(index.by_path().len(), 562); let mapped = index .by_path() .keys() diff --git a/crates/registryctl/src/project_authoring/report_contract.rs b/crates/registryctl/src/project_authoring/report_contract.rs index 6839edb50..416e5777f 100644 --- a/crates/registryctl/src/project_authoring/report_contract.rs +++ b/crates/registryctl/src/project_authoring/report_contract.rs @@ -159,7 +159,6 @@ pub struct ProjectFixtureReport { pub inputs: Vec, pub calls: Vec, pub outputs: Vec, - pub claims: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub outcome: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -378,7 +377,6 @@ pub struct ProjectSchemaRef { pub enum ImpactConsumer { RegistryctlAuthoring, RegistryRelay, - RegistryNotary, EditorTooling, DocsGenerator, BundleSigner, @@ -396,7 +394,6 @@ pub enum ImpactReviewClass { Privacy, Security, Relay, - Notary, Compatibility, Documentation, Testing, @@ -501,22 +498,18 @@ impl ProjectSemanticImpactReportV1 { #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SemanticDimension { - Claim, Integration, ServicePolicy, OperatorSecurity, - Disclosure, Compiler, } impl SemanticDimension { pub const fn as_str(self) -> &'static str { match self { - Self::Claim => "claim", Self::Integration => "integration", Self::ServicePolicy => "service_policy", Self::OperatorSecurity => "operator_security", - Self::Disclosure => "disclosure", Self::Compiler => "compiler", } } @@ -670,8 +663,6 @@ pub enum AffectedSubjectKind { Fixture, ServicePolicy, Consultation, - Claim, - Disclosure, ProductInput, GeneratedArtifact, } @@ -688,7 +679,6 @@ pub struct AffectedSubject { pub enum ProjectProduct { Registryctl, Relay, - Notary, Editor, Docs, } @@ -781,7 +771,6 @@ pub enum ArtifactClass { RuntimeConfig, ConsultationContract, SourcePlan, - ClaimConfiguration, DeploymentInput, ReviewRecord, Documentation, @@ -855,7 +844,6 @@ pub enum ArtifactAction { #[serde(rename_all = "snake_case")] pub enum ArtifactConsumer { RegistryRelay, - RegistryNotary, BundleSigner, DeploymentTooling, ProjectDocumentation, diff --git a/crates/registryctl/src/project_authoring/required_product_action.rs b/crates/registryctl/src/project_authoring/required_product_action.rs index cc48bef21..799a433ee 100644 --- a/crates/registryctl/src/project_authoring/required_product_action.rs +++ b/crates/registryctl/src/project_authoring/required_product_action.rs @@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize}; pub enum RequiredProductAction { RelayPublic, RelayConsultation, - Notary, } #[cfg(test)] @@ -24,10 +23,9 @@ mod tests { serde_json::to_value([ RequiredProductAction::RelayPublic, RequiredProductAction::RelayConsultation, - RequiredProductAction::Notary, ]) .expect("product action lanes serialize"), - serde_json::json!(["relay-public", "relay-consultation", "notary"]) + serde_json::json!(["relay-public", "relay-consultation"]) ); } } diff --git a/crates/registryctl/src/project_authoring/schema_authority.rs b/crates/registryctl/src/project_authoring/schema_authority.rs index f7ee8f65b..7e1343408 100644 --- a/crates/registryctl/src/project_authoring/schema_authority.rs +++ b/crates/registryctl/src/project_authoring/schema_authority.rs @@ -440,7 +440,7 @@ fn project_service_validation_instance_path(document: &Value) -> Option } for (service_id, service) in services { let branch = match service.get("kind").and_then(Value::as_str) { - Some("evidence") => "evidenceService", + Some("consultation_api") => "consultationService", Some("records_api") => "recordsService", Some(_) => { return Some(format!( @@ -1980,10 +1980,6 @@ mod schema_authority_tests { "/$defs/integrationRequestByteSize", "/$defs/integrationResponseByteSize", "/$defs/integrationSourceByteSize", - "/$defs/oid4vci/properties/registrar_clients", - "/$defs/oid4vci/properties/representative_issuance/properties/max_proof_age_seconds", - "/$defs/oid4vci/properties/tx_code/properties/required", - "/properties/issuance/properties/algorithm", ] ); assert!( @@ -2025,48 +2021,6 @@ mod schema_authority_tests { ); } - let issuance_omitted: IssuanceBinding = serde_json::from_value(json!({ - "issuer": "https://issuer.invalid", - "signing_key": {"secret": "ISSUER_KEY"}, - "signing_kid": "issuer-key", - "generation": 1 - })) - .expect("omitted issuance algorithm parses"); - let issuance_explicit: IssuanceBinding = serde_json::from_value(json!({ - "issuer": "https://issuer.invalid", - "signing_key": {"secret": "ISSUER_KEY"}, - "signing_kid": "issuer-key", - "algorithm": "EdDSA", - "generation": 1 - })) - .expect("explicit issuance algorithm parses"); - assert_eq!( - serde_json::to_value(issuance_omitted).expect("issuance serializes"), - serde_json::to_value(issuance_explicit).expect("issuance serializes") - ); - - let tx_omitted: Oid4vciTxCodeBinding = - serde_json::from_value(json!({})).expect("omitted tx-code default parses"); - let tx_explicit: Oid4vciTxCodeBinding = serde_json::from_value(json!({"required": true})) - .expect("explicit tx-code default parses"); - assert_eq!( - serde_json::to_value(tx_omitted).expect("tx code serializes"), - serde_json::to_value(tx_explicit).expect("tx code serializes") - ); - - let environment_schema: Value = - serde_json::from_str(ProjectSchemaKind::Environment.document()) - .expect("environment schema parses"); - let registrar_clients_default: Vec = serde_json::from_value( - environment_schema["$defs"]["oid4vci"]["properties"]["registrar_clients"]["default"] - .clone(), - ) - .expect("registrar client default parses"); - assert_eq!( - registrar_clients_default, - Vec::::default(), - "schema and serde use the same empty registrar-client default" - ); } fn collect_keyword_addresses( diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 647e7afd5..0e025b797 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -75,68 +75,6 @@ mod tests { assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); } - #[test] - fn evidence_subject_type_is_closed_and_omission_normalizes_to_person() { - let bytes = PROJECT_STARTERS - .get_file("bounded-http/registry-stack.yaml") - .expect("bounded HTTP project is embedded") - .contents(); - let omitted = - parse_current_authoring_document::(bytes).expect("project parses"); - let service = &omitted.services["person-verification"]; - assert_eq!(service.subject_type, None); - assert_eq!( - service.effective_subject_type(), - EvidenceSubjectType::Person - ); - assert!( - serde_json::to_value(&omitted) - .expect("project serializes") - .pointer("/services/person-verification/subject_type") - .is_none(), - "normalized omission must preserve authored-content serialization" - ); - - let mut invalid: Value = serde_norway::from_slice(bytes).expect("project YAML parses"); - invalid["services"]["person-verification"]["subject_type"] = json!("organisation"); - let invalid_bytes = - serde_norway::to_string(&invalid).expect("invalid subject project serializes"); - parse_current_authoring_document::(invalid_bytes.as_bytes()) - .expect_err("unknown evidence subject type must fail closed"); - - invalid["services"]["person-verification"]["subject_type"] = json!("project"); - let project_bytes = - serde_norway::to_string(&invalid).expect("project subject project serializes"); - let explicit = - parse_current_authoring_document::(project_bytes.as_bytes()) - .expect("project is an allowed subject type"); - assert_eq!( - explicit.services["person-verification"].subject_type, - Some(EvidenceSubjectType::Project) - ); - } - - #[test] - fn records_service_rejects_an_explicit_evidence_subject_type() { - let bytes = PROJECT_STARTERS - .get_file("spreadsheet/registry-stack.yaml") - .expect("spreadsheet project is embedded") - .contents(); - let mut value: Value = serde_norway::from_slice(bytes).expect("project YAML parses"); - value["services"]["projects-records"]["subject_type"] = json!("person"); - let invalid = serde_norway::to_string(&value).expect("records subject project serializes"); - parse_current_authoring_document::(invalid.as_bytes()) - .expect_err("public schema rejects evidence fields on records services"); - - let typed: RegistryProject = - serde_norway::from_str(&invalid).expect("presence-aware typed model retains the field"); - let error = validate_project_shape(&typed) - .expect_err("typed validation also rejects evidence fields on records services"); - assert!(error - .to_string() - .contains("records_api service cannot declare evidence-service fields")); - } - #[test] fn corrected_http_authoring_lowers_to_one_product_neutral_request() { let authored: AuthoredIntegrationDocument = serde_norway::from_str( @@ -437,90 +375,6 @@ items: .contains("require capability.script")); } - #[test] - fn authored_output_count_preserves_the_generic_non_notary_limit() { - fn boolean_output() -> AuthoredOutputDeclaration { - AuthoredOutputDeclaration::Scalar(AuthoredScalarOutputDeclaration { - output_type: AuthoredSchemaType::Single(AuthoredScalarType::Boolean), - format: None, - max_length: None, - minimum: None, - maximum: None, - source: None, - }) - } - - const { - assert!(MAX_OUTPUTS > registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1); - } - let outputs = |count| { - (0..count) - .map(|index| (format!("field_{index}"), boolean_output())) - .collect::>() - }; - validate_authored_outputs(&outputs( - registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 + 1, - )) - .expect("generic authoring retains more outputs than a Notary consultation"); - validate_authored_outputs(&outputs(MAX_OUTPUTS)) - .expect("the generic integration output limit validates"); - assert!(validate_authored_outputs(&outputs(MAX_OUTPUTS + 1)) - .expect_err("one output beyond the generic integration limit rejects") - .to_string() - .contains(&format!("between one and {MAX_OUTPUTS} fields"))); - } - - #[test] - fn notary_output_count_applies_only_to_evidence_consultations() { - let mut evidence = - load_registry_project(&project_golden("opencrvs"), None).expect("OpenCRVS project loads"); - let birth = evidence - .integrations - .get_mut("birth-record") - .expect("birth integration exists"); - let template = serde_json::to_value(&birth.document.outputs["sex"]) - .expect("scalar output serializes"); - while birth.document.outputs.len() - <= registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 - { - let index = birth.document.outputs.len(); - birth.document.outputs.insert( - format!("extra_{index}"), - serde_json::from_value(template.clone()).expect("scalar output clones"), - ); - } - validate_integration("birth-record", &birth.document) - .expect("the generic integration contract still accepts 33 outputs"); - let error = validate_service_integration_links(&evidence.project, &evidence.integrations) - .expect_err("an Evidence consultation cannot exceed the Notary output limit"); - assert!(error.to_string().contains(&format!( - "integration outputs must contain no more than {} entries", - registry_notary_core::MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1 - ))); - - let records_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("assets/project-starters/spreadsheet"); - let mut records = - load_registry_project(&records_root, None).expect("Records API project loads"); - records.project.integrations.insert( - "wide".to_string(), - IntegrationReference { - file: PathBuf::from("integrations/wide/integration.yaml"), - }, - ); - records.integrations.insert( - "wide".to_string(), - evidence - .integrations - .remove("birth-record") - .expect("wide integration remains available"), - ); - validate_project_shape(&records.project) - .expect("a Records API-only project may retain the generic integration limit"); - validate_service_integration_links(&records.project, &records.integrations) - .expect("Records API-only integrations are not narrowed to the Notary limit"); - } - #[test] fn structured_output_byte_caps_must_encode_a_non_null_value() { fn validate(source: &str) -> Result<()> { @@ -586,118 +440,6 @@ fields: .contains("outputs.record.fields.child.schema.max_bytes must be at least 2")); } - #[test] - fn structured_output_limits_reserve_the_notary_result_envelope() { - fn boolean_schema() -> AuthoredOutputSchema { - AuthoredOutputSchema::Scalar(AuthoredScalarOutputSchema { - output_type: AuthoredSchemaType::Single(AuthoredScalarType::Boolean), - format: None, - max_length: None, - minimum: None, - maximum: None, - }) - } - - fn boolean_output() -> AuthoredOutputDeclaration { - AuthoredOutputDeclaration::Scalar(AuthoredScalarOutputDeclaration { - output_type: AuthoredSchemaType::Single(AuthoredScalarType::Boolean), - format: None, - max_length: None, - minimum: None, - maximum: None, - source: None, - }) - } - - fn object_declaration(field_count: usize) -> AuthoredOutputObjectDeclaration { - AuthoredOutputObjectDeclaration { - output_type: AuthoredOutputObjectType::Object, - nullable: false, - max_bytes: 65_536, - fields: (0..field_count) - .map(|index| { - ( - format!("field_{index}"), - AuthoredOutputObjectField { - required: true, - schema: Box::new(boolean_schema()), - }, - ) - }) - .collect(), - } - } - - fn object_output(field_count: usize) -> AuthoredOutputDeclaration { - AuthoredOutputDeclaration::Object(object_declaration(field_count)) - } - - fn array_output(array_nodes: usize) -> AuthoredOutputDeclaration { - assert!(array_nodes > 0); - let mut items = boolean_schema(); - for _ in 1..array_nodes { - items = AuthoredOutputSchema::Array(AuthoredOutputArrayDeclaration { - output_type: AuthoredOutputArrayType::Array, - nullable: false, - max_bytes: 65_536, - max_items: 1, - items: Box::new(items), - }); - } - AuthoredOutputDeclaration::Array(AuthoredOutputArrayDeclaration { - output_type: AuthoredOutputArrayType::Array, - nullable: false, - max_bytes: 65_536, - max_items: 1, - items: Box::new(items), - }) - } - - let maximum_depth = BTreeMap::from([("nested".to_string(), array_output(5))]); - validate_authored_outputs(&maximum_depth) - .expect("five array nodes plus one leaf fit downstream depth eight"); - let excessive_depth = BTreeMap::from([("nested".to_string(), array_output(6))]); - assert!(validate_authored_outputs(&excessive_depth) - .expect_err("one more nested node exceeds downstream depth eight") - .to_string() - .contains("maximum depth of 8")); - - let node_budget = |extra_scalars| { - let mut outputs = (0..7) - .map(|index| (format!("wide_{index}"), object_output(32))) - .collect::>(); - for index in 0..(5 + extra_scalars) { - outputs.insert(format!("scalar_{index}"), boolean_output()); - } - outputs - }; - validate_authored_outputs(&node_budget(0)) - .expect("236 authored nodes plus the 20-node envelope fit exactly"); - assert!(validate_authored_outputs(&node_budget(1)) - .expect_err("237 authored nodes exceed the downstream node budget") - .to_string() - .contains("more than 256 nodes")); - - let expanded_output = |field_count| { - BTreeMap::from([( - "expanded".to_string(), - AuthoredOutputDeclaration::Array(AuthoredOutputArrayDeclaration { - output_type: AuthoredOutputArrayType::Array, - nullable: false, - max_bytes: 65_536, - max_items: 255, - items: Box::new(AuthoredOutputSchema::Object(object_declaration(field_count))), - }), - )]) - }; - validate_authored_outputs(&expanded_output(14)) - .expect("expanded schema remains below the downstream envelope-adjusted bound"); - assert!(validate_authored_outputs(&expanded_output(15)) - .expect_err("the result envelope pushes expanded nodes beyond 4096") - .to_string() - .contains("recursive expansion exceeds 4096 nodes")); - } - #[test] fn corrected_authoring_rejects_the_superseded_operation_graph() { serde_norway::from_str::( @@ -954,6 +696,164 @@ outputs: .join(name) } + fn relay_validation_project(kind: ServiceKind) -> RegistryProject { + let (kind, consultations) = match kind { + ServiceKind::ConsultationApi => ( + "consultation_api", + "consultations:\n check:\n integration: check\n input: {}", + ), + ServiceKind::RecordsApi => ("records_api", ""), + }; + serde_norway::from_str(&format!( + r#" +version: 1 +registry: {{ id: relay-validation }} +services: + api: + kind: {kind} + {consultations} +"# + )) + .expect("Relay validation project parses") + } + + fn relay_validation_environment( + allowed_clients: &[&str], + consultation: Option<(&str, &str)>, + local_api_keys: bool, + ) -> EnvironmentDocument { + EnvironmentDocument { + version: 1, + development: None, + integrations: BTreeMap::new(), + entities: BTreeMap::new(), + relay: Some(RelayBinding { + origin: "https://relay.invalid".to_string(), + issuer: "https://issuer.invalid".to_string(), + jwks_url: "https://issuer.invalid/.well-known/jwks.json".to_string(), + audience: "registry-relay".to_string(), + allowed_clients: allowed_clients + .iter() + .map(|client| (*client).to_string()) + .collect(), + consultation: consultation.map(|(client_id, principal_id)| { + RelayConsultationBinding { + client_id: client_id.to_string(), + principal_id: principal_id.to_string(), + } + }), + local_api_keys: local_api_keys.then(|| RelayLocalApiKeyBinding { + match_principal: "local-match".to_string(), + no_match_principal: "local-no-match".to_string(), + scopes: vec!["records:read".to_string()], + }), + }), + relay_state: None, + deployment: DeploymentBinding { + profile: DeploymentProfile::Local, + relay: Some(ServiceBinding { + service: "relay-validation".to_string(), + }), + }, + } + } + + #[test] + fn consultation_relay_requires_an_explicit_identity_even_with_local_api_keys() { + let project = relay_validation_project(ServiceKind::ConsultationApi); + let environment = relay_validation_environment(&[], None, true); + + let error = + validate_environment(&project, &BTreeMap::new(), &BTreeMap::new(), &environment) + .expect_err("local public API keys must not authorize consultation workloads"); + assert_eq!( + error.to_string(), + "a consultation_api service requires relay.consultation" + ); + } + + #[test] + fn consultation_relay_client_must_be_separate_from_the_public_oidc_allowlist() { + let project = relay_validation_project(ServiceKind::ConsultationApi); + let environment = relay_validation_environment( + &["shared-client"], + Some(("shared-client", "consultation-principal")), + false, + ); + + let error = + validate_environment(&project, &BTreeMap::new(), &BTreeMap::new(), &environment) + .expect_err("consultation client must not be admitted by the public Relay"); + assert_eq!( + error.to_string(), + "Relay consultation client_id must be separate from relay.allowed_clients" + ); + } + + #[test] + fn consultation_relay_accepts_a_distinct_workload_client() { + let project = relay_validation_project(ServiceKind::ConsultationApi); + let environment = relay_validation_environment( + &["public-client"], + Some(("consultation-client", "consultation-principal")), + false, + ); + + validate_environment(&project, &BTreeMap::new(), &BTreeMap::new(), &environment) + .expect("distinct public and consultation Relay clients are accepted"); + } + + #[test] + fn consultation_relay_validates_both_identity_tokens() { + let project = relay_validation_project(ServiceKind::ConsultationApi); + for (allowed_client, client_id, principal_id, expected_field) in [ + ( + "valid-client", + "invalid client", + "valid-principal", + "consultation client id", + ), + ( + "valid-client", + "valid-client", + "invalid principal", + "consultation principal id", + ), + ] { + let environment = relay_validation_environment( + &[allowed_client], + Some((client_id, principal_id)), + false, + ); + + let error = + validate_environment(&project, &BTreeMap::new(), &BTreeMap::new(), &environment) + .expect_err("invalid consultation identity token must fail closed"); + assert!( + error.to_string().contains(expected_field), + "unexpected consultation token diagnostic: {error:#}" + ); + } + } + + #[test] + fn records_only_relay_rejects_a_consultation_identity() { + let project = relay_validation_project(ServiceKind::RecordsApi); + let environment = relay_validation_environment( + &["records-client"], + Some(("records-client", "consultation-principal")), + false, + ); + + let error = + validate_environment(&project, &BTreeMap::new(), &BTreeMap::new(), &environment) + .expect_err("records-only projects must not bind a consultation workload"); + assert_eq!( + error.to_string(), + "relay.consultation is valid only with a consultation_api service" + ); + } + #[test] fn nia_userinfo_release_is_minimized_hash_covered_and_relay_valid() { let project = project_golden("nia-attribute-release"); @@ -1051,68 +951,6 @@ outputs: ); } - #[test] - fn normalized_subject_type_changes_claim_semantics_and_notary_compilation() { - let mut loaded = load_registry_project(&project_golden("custom-system"), Some("local")) - .expect("golden project loads"); - let baseline = loaded.semantic_digests.claim.clone(); - let service = loaded - .project - .services - .get_mut("household-eligibility") - .expect("evidence service exists"); - assert_eq!(service.subject_type, None); - service.subject_type = Some(EvidenceSubjectType::Person); - let explicit_person = semantic_digests( - &loaded.project, - &loaded.integrations, - &loaded.entities, - loaded.environment.as_ref(), - ) - .expect("explicit person digest compiles"); - assert_eq!( - explicit_person.claim, baseline, - "omitted and explicit person must share normalized Claim semantics" - ); - - loaded - .project - .services - .get_mut("household-eligibility") - .expect("evidence service exists") - .subject_type = Some(EvidenceSubjectType::Project); - let project_subject = semantic_digests( - &loaded.project, - &loaded.integrations, - &loaded.entities, - loaded.environment.as_ref(), - ) - .expect("project subject digest compiles"); - assert_ne!( - project_subject.claim, baseline, - "subject category changes must alter the Claim semantic digest" - ); - - let compiled = compile_project(&loaded, None).expect("project subject compiles"); - let notary: Value = serde_norway::from_slice( - compiled - .notary_private - .get(Path::new("config/notary.yaml")) - .expect("Notary config exists"), - ) - .expect("Notary config parses"); - assert!(notary["evidence"]["claims"] - .as_array() - .expect("generated claims are an array") - .iter() - .all(|claim| claim["subject_type"] == "project")); - assert_eq!( - fixture_subject_type(&loaded, "eligibility") - .expect("offline fixture derives the project subject"), - EvidenceSubjectType::Project - ); - } - #[test] fn attribute_release_purpose_must_be_header_safe_during_authoring() { let mut loaded = @@ -1399,122 +1237,64 @@ outputs: } #[test] - fn generated_public_and_consultation_relays_are_separate_and_production_validated() { + fn generated_public_and_consultation_relay_lanes_remain_separate() { let loaded = load_registry_project(&project_golden("custom-system"), Some("local")) - .expect("golden project loads"); - let compiled = compile_project(&loaded, None).expect("golden project compiles"); - let public_path = Path::new("config/relay.yaml"); - let consultation_path = Path::new("config/relay.yaml"); + .expect("Relay consultation project loads"); + let compiled = compile_project(&loaded, None).expect("Relay project compiles"); let public_bytes = compiled .relay_private - .get(public_path) + .get(Path::new("config/relay.yaml")) .expect("public Relay config exists"); let consultation_bytes = compiled .relay_consultation_private - .get(consultation_path) + .get(Path::new("config/relay.yaml")) .expect("consultation Relay config exists"); let public: Value = serde_norway::from_slice(public_bytes).expect("public Relay config parses"); - let consultation: Value = - serde_norway::from_slice(consultation_bytes).expect("consultation Relay config parses"); - - assert_eq!( - compiled - .relay_private - .keys() - .map(PathBuf::as_path) - .collect::>(), - BTreeSet::from([ - Path::new("config/relay.yaml"), - Path::new("descriptors/operations.json"), - Path::new("descriptors/secret-consumers.json"), - ]), - "the public Relay input contains only instance-applicable generated members" - ); - let referenced_artifacts = consultation["consultation"]["artifacts"] - .as_object() - .expect("consultation artifact closure exists") - .values() - .flat_map(|entries| { - entries - .as_array() - .expect("consultation artifact class is a list") - }) - .map(|entry| { - entry["path"] - .as_str() - .expect("consultation artifact has a path") - }) - .collect::>(); - let vendored_artifacts = compiled - .relay_consultation_private - .keys() - .filter_map(|path| path.strip_prefix("config").ok()) - .filter_map(|path| path.to_str()) - .filter(|path| path.starts_with("artifacts/")) - .collect::>(); - assert_eq!( - vendored_artifacts, referenced_artifacts, - "the consultation Relay input vendors exactly its selected artifacts" - ); + let consultation: Value = serde_norway::from_slice(consultation_bytes) + .expect("consultation Relay config parses"); assert!(public.get("consultation").is_none()); assert!(consultation.get("consultation").is_some()); - assert_eq!(public["instance"]["id"], "household-relay"); - assert_eq!( - consultation["instance"]["id"], - "household-relay-consultation" + assert_ne!(public["instance"]["id"], consultation["instance"]["id"]); + assert!( + public["auth"]["oidc"]["allowed_clients"] + .as_array() + .expect("public OIDC allowlist is an array") + .iter() + .any(|client| client == "household-relay-client"), + "the public Relay admits its public client" ); - assert_eq!( - public["auth"]["oidc"]["allowed_clients"], - json!(["household-relay-client"]) + assert!( + !public["auth"]["oidc"]["allowed_clients"] + .as_array() + .expect("public OIDC allowlist is an array") + .iter() + .any(|client| client == "household-consultation-client"), + "the public Relay must not admit the consultation workload client" ); assert_eq!( consultation["auth"]["oidc"]["allowed_clients"], - json!(["household-notary"]) + json!(["household-consultation-client"]), + "the consultation Relay admits only its bound workload client" ); assert_eq!( - consultation["auth"]["oidc"]["allow_dev_insecure_fetch_urls"], - false + consultation["consultation"]["authorized_workload"]["client_claim_selector"], + "azp" ); - let public_operations: Value = serde_json::from_slice( - compiled - .relay_private - .get(Path::new("descriptors/operations.json")) - .expect("public Relay operations descriptor exists"), - ) - .expect("public Relay operations descriptor parses"); - let consultation_operations: Value = serde_json::from_slice( - compiled - .relay_consultation_private - .get(Path::new("descriptors/operations.json")) - .expect("consultation Relay operations descriptor exists"), - ) - .expect("consultation Relay operations descriptor parses"); - assert_eq!(public_operations["service"], "household-relay"); - assert_eq!(public_operations["consultation_profiles"], 0); assert_eq!( - consultation_operations["service"], - "household-relay-consultation" + consultation["consultation"]["authorized_workload"]["client_value"], + "household-consultation-client" + ); + assert_eq!( + consultation["consultation"]["authorized_workload"]["principal_id"], + "household-consultation-principal" + ); + assert_ne!( + consultation["consultation"]["authorized_workload"]["client_value"], + consultation["consultation"]["authorized_workload"]["principal_id"], + "the azp client and sub principal remain distinct identities" ); - assert_eq!(consultation_operations["consultation_profiles"], 1); - - for (files, config) in [ - (&compiled.relay_private, &public), - (&compiled.relay_consultation_private, &consultation), - ] { - let descriptor: Value = serde_json::from_slice( - files - .get(Path::new("descriptors/secret-consumers.json")) - .expect("Relay secret-consumer descriptor exists"), - ) - .expect("Relay secret-consumer descriptor parses"); - assert_eq!( - descriptor, - secret_consumer_descriptor("registry-relay", config), - "each Relay instance describes only the secrets selected by its primary config" - ); - } assert_eq!( compiled.approval_state["generated_closure_digests"]["relay"], json!(closure_digest(&compiled.relay_private).expect("public Relay closure digests")) @@ -1524,11 +1304,6 @@ outputs: json!(closure_digest(&compiled.relay_consultation_private) .expect("consultation Relay closure digests")) ); - assert_ne!( - compiled.approval_state["generated_closure_digests"]["relay"], - compiled.approval_state["generated_closure_digests"]["relay_consultation"], - "the approval state independently binds both Relay instances" - ); validate_generated_relay(public_bytes, &compiled.relay_private, "config/relay.yaml") .expect("public Relay passes production loading"); validate_generated_relay( @@ -1540,116 +1315,40 @@ outputs: } #[test] - fn local_notary_add_on_uses_stable_issuer_and_mounted_jwks_file() { - let mut loaded = load_registry_project(&project_golden("custom-system"), Some("local")) - .expect("golden project loads"); - let environment = loaded.environment.as_mut().expect("environment exists"); - environment - .relay - .as_mut() - .expect("Relay binding exists") - .local_api_keys = Some(RelayLocalApiKeyBinding { - match_principal: "local_match".to_string(), - no_match_principal: "local_no_match".to_string(), - scopes: vec!["registry_relay:ops_read".to_string()], - }); - environment - .deployment - .notary - .as_mut() - .expect("Notary deployment exists") - .service = "registryctl-local-notary".to_string(); - - let unrelated = - compile_project(&loaded, None).expect("non-canonical local project compiles"); - let unrelated_consultation: Value = serde_norway::from_slice( - unrelated + fn generated_local_api_key_validation_preserves_refs_and_rejects_malformed_or_duplicate_keys() { + let project = + Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/project-starters/spreadsheet"); + let loaded = + load_registry_project(&project, Some("local")).expect("spreadsheet starter loads"); + let compiled = compile_project(&loaded, None).expect("spreadsheet starter compiles"); + let relay = compiled + .relay_private + .get(Path::new("config/relay.yaml")) + .expect("Relay config exists"); + let original: Value = serde_norway::from_slice(relay).expect("Relay config parses"); + let consultation: Value = serde_norway::from_slice( + compiled .relay_consultation_private .get(Path::new("config/relay.yaml")) .expect("consultation Relay config exists"), ) .expect("consultation Relay config parses"); - assert_eq!( - unrelated_consultation["auth"]["oidc"]["issuer"], - "https://workload-issuer.internal.invalid" - ); - assert_eq!( - unrelated_consultation["auth"]["oidc"]["jwks_url"], - "https://workload-issuer.internal.invalid/.well-known/jwks.json" - ); - assert_eq!( - unrelated_consultation["auth"]["oidc"]["allow_dev_insecure_fetch_urls"], - false - ); - - let binding = loaded - .environment - .as_mut() - .expect("environment exists") - .notary_relay - .as_mut() - .expect("Notary-to-Relay binding exists"); - binding.base_url = "http://10.89.0.4:8080".to_string(); - binding.workload_client_id = "registryctl-local-notary".to_string(); - binding.token_file = PathBuf::from("/run/secrets/relay-workload-token"); - loaded - .environment - .as_mut() - .expect("environment exists") - .relay - .as_mut() - .expect("Relay binding exists") - .issuer = "https://registryctl-local-notary.invalid".to_string(); - - let compiled = compile_project(&loaded, None).expect("local add-on project compiles"); - let public: Value = serde_norway::from_slice( - compiled - .relay_private - .get(Path::new("config/relay.yaml")) - .expect("public Relay config exists"), - ) - .expect("public Relay config parses"); - let consultation_bytes = compiled - .relay_consultation_private - .get(Path::new("config/relay.yaml")) - .expect("consultation Relay config exists"); - let consultation: Value = - serde_norway::from_slice(consultation_bytes).expect("consultation Relay config parses"); - assert_eq!(public["auth"]["mode"], "api_key"); + assert_eq!(original["auth"]["mode"], "api_key"); assert_eq!(consultation["auth"]["mode"], "oidc"); assert_eq!( - consultation["auth"]["oidc"]["issuer"], - "https://registryctl-local-notary.invalid" + consultation["auth"]["oidc"]["allowed_clients"], + json!(["public-works-consultation-client"]), + "public local API keys do not broaden consultation workload admission" ); assert_eq!( - consultation["auth"]["oidc"]["development_jwks_file"], - "/run/registry/dev-public/notary-workload-jwks.json" + consultation["consultation"]["authorized_workload"]["client_value"], + "public-works-consultation-client" ); - assert!(consultation["auth"]["oidc"].get("jwks_url").is_none()); - assert!(consultation["auth"]["oidc"] - .get("discovery_url") - .is_none()); assert_eq!( - consultation["auth"]["oidc"]["allow_dev_insecure_fetch_urls"], - false + consultation["consultation"]["authorized_workload"]["principal_id"], + "public-works-consultation-principal" ); - validate_generated_product_configs(&compiled) - .expect("local add-on product configs pass production validation"); - } - - #[test] - fn generated_local_api_key_validation_preserves_refs_and_rejects_malformed_or_duplicate_keys() { - let project = - Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/project-starters/spreadsheet"); - let loaded = - load_registry_project(&project, Some("local")).expect("spreadsheet starter loads"); - let compiled = compile_project(&loaded, None).expect("spreadsheet starter compiles"); - let relay = compiled - .relay_private - .get(Path::new("config/relay.yaml")) - .expect("Relay config exists"); - let original: Value = serde_norway::from_slice(relay).expect("Relay config parses"); validate_generated_relay(relay, &compiled.relay_private, "config/relay.yaml") .expect("temporary validation credentials satisfy production loading"); @@ -1804,75 +1503,6 @@ outputs: assert!(cel_member_roots("person.exists && 'unterminated").is_err()); } - #[test] - fn registry_cel_claims_reject_structured_members_and_constant_only_rules() { - let mut loaded = - load_registry_project(&project_golden("opencrvs"), None).expect("OpenCRVS project loads"); - let set_claim_expression = |loaded: &mut LoadedRegistryProject, expression: &str| { - let claim = loaded - .project - .services - .get_mut("birth-verification") - .and_then(|service| service.claims.get_mut("parents")) - .expect("structured parents claim exists"); - claim.output = None; - claim.cel = Some(expression.to_string()); - }; - set_claim_expression( - &mut loaded, - "birth.parents.exists(parent, parent.name != '')", - ); - - let error = validate_service_integration_links(&loaded.project, &loaded.integrations) - .expect_err("structured consultation members must not enter authored CEL claims"); - assert!(error.to_string().contains( - "service birth-verification claim parents CEL cannot reference structured consultation output birth.parents" - )); - - set_claim_expression(&mut loaded, r#"birth["parents"] != null"#); - let error = validate_service_integration_links(&loaded.project, &loaded.integrations) - .expect_err("registry-backed CEL index access must fail before generation"); - assert!(error - .to_string() - .contains("registry-backed CEL cannot use index access")); - - set_claim_expression(&mut loaded, "['literal-value'][0] == 'literal-value'"); - let error = validate_service_integration_links(&loaded.project, &loaded.integrations) - .expect_err("constant-only CEL cannot replace Registry evidence"); - assert!(error - .to_string() - .contains("every claim must derive from one declared Relay consultation")); - } - - #[test] - fn secret_descriptor_includes_named_environment_providers() { - let descriptor = secret_consumer_descriptor( - "registry-notary", - &json!({ - "authentication": { - "fingerprint": { "provider": "env", "name": "CALLER_TOKEN_HASH" }, - }, - "audit": { - "source": { - "provider": "environment", - "name": "AUDIT_PSEUDONYM_EPOCH_1", - }, - }, - }), - ); - let consumers = descriptor["consumers"] - .as_array() - .expect("descriptor consumers are present"); - assert!(consumers.iter().any(|consumer| { - consumer["locator"] == "CALLER_TOKEN_HASH" - && consumer["config_pointer"] == "/authentication/fingerprint/name" - })); - assert!(consumers.iter().any(|consumer| { - consumer["locator"] == "AUDIT_PSEUDONYM_EPOCH_1" - && consumer["config_pointer"] == "/audit/source/name" - })); - } - #[test] fn released_rhai_capability_identity_is_not_a_source_product() { assert!(is_script_runtime_released(ReleasedScriptRuntime::RhaiV1)); @@ -1882,177 +1512,21 @@ outputs: )); } - #[test] - fn duplicate_project_claim_ids_fail_before_generation() { - let project = project_golden("custom-system"); - let mut loaded = load_registry_project(&project, None).expect("golden project loads"); - let duplicate: ServiceDeclaration = serde_json::from_value( - serde_json::to_value(&loaded.project.services["household-eligibility"]) - .expect("service serializes"), - ) - .expect("service clones through its strict model"); - loaded - .project - .services - .insert("duplicate-service".to_string(), duplicate); - let error = validate_project_shape(&loaded.project) - .expect_err("duplicate project claim ids must fail closed"); - assert!(error - .to_string() - .contains("claim ids must be unique across project services")); - } - - #[test] - fn generated_notary_validation_rejects_empty_claim_formats() { - let loaded = load_registry_project(&project_golden("custom-system"), Some("local")) - .expect("golden project loads"); - let mut compiled = compile_project(&loaded, None).expect("golden project compiles"); - let config_path = Path::new("config/notary.yaml").to_path_buf(); - let mut notary: Value = serde_norway::from_slice( - compiled - .notary_private - .get(&config_path) - .expect("Notary config exists"), - ) - .expect("generated Notary config parses"); - notary["evidence"]["claims"][0]["formats"] = Value::Array(Vec::new()); - compiled.notary_private.insert( - config_path, - serde_norway::to_string(¬ary) - .expect("tampered Notary config serializes") - .into_bytes() - .into_boxed_slice(), - ); - - let error = validate_generated_notary(&compiled) - .expect_err("generated validation must reject an empty formats list"); - let diagnostic = format!("{error:#}"); - assert!(diagnostic.contains("formats must not be empty")); - assert!(diagnostic.contains("omit formats")); - } - - #[test] - fn generated_products_persist_audit_records_to_the_managed_volume() { - let loaded = load_registry_project(&project_golden("custom-system"), Some("local")) - .expect("golden project loads"); - let compiled = compile_project(&loaded, None).expect("golden project compiles"); - let product_configs = [ - ( - "Relay", - compiled - .relay_private - .get(Path::new("config/relay.yaml")) - .expect("Relay config exists"), - ), - ( - "consultation Relay", - compiled - .relay_consultation_private - .get(Path::new("config/relay.yaml")) - .expect("consultation Relay config exists"), - ), - ( - "Notary", - compiled - .notary_private - .get(Path::new("config/notary.yaml")) - .expect("Notary config exists"), - ), - ]; - - for (product, bytes) in product_configs { - let config: Value = - serde_norway::from_slice(bytes).expect("generated product config parses"); - assert_eq!( - config.pointer("/audit/sink"), - Some(&json!("file")), - "{product} must use a durable audit sink" - ); - assert_eq!( - config.pointer("/audit/path"), - Some(&json!("/var/lib/registry/audit/audit.jsonl")), - "{product} must write to the managed audit volume" - ); - } - } - - #[test] - fn disclosure_review_classes_are_directional() { - let loaded = load_registry_project(&project_golden("custom-system"), None) - .expect("golden project loads"); - let original = disclosure_review_profiles(&loaded.project); - let baseline = json!({ "disclosure_profiles": original }); - - let mut narrowed = disclosure_review_profiles(&loaded.project); - narrowed - .get_mut("household-eligibility") - .expect("service profile exists") - .insert( - "household-category".to_string(), - DisclosureReviewProfile { - default: DisclosureMode::Redacted, - allowed: BTreeSet::from([DisclosureMode::Redacted]), - }, - ); - assert_eq!( - disclosure_change_classes(&narrowed, Some(&baseline)), - (true, false) - ); - - let mut widened = disclosure_review_profiles(&loaded.project); - widened - .get_mut("household-eligibility") - .expect("service profile exists") - .insert( - "household-record-exists".to_string(), - DisclosureReviewProfile { - default: DisclosureMode::Value, - allowed: BTreeSet::from([DisclosureMode::Value, DisclosureMode::Redacted]), - }, - ); - assert_eq!( - disclosure_change_classes(&widened, Some(&baseline)), - (false, true) - ); - - let mut mixed = narrowed; - mixed - .get_mut("household-eligibility") - .expect("service profile exists") - .insert( - "household-record-exists".to_string(), - DisclosureReviewProfile { - default: DisclosureMode::Value, - allowed: BTreeSet::from([DisclosureMode::Value, DisclosureMode::Redacted]), - }, - ); - assert_eq!( - disclosure_change_classes(&mixed, Some(&baseline)), - (true, true) - ); - } - #[test] fn compiler_upgrade_is_reported_independently_of_authored_semantic_changes() { let loaded = load_registry_project(&project_golden("custom-system"), None) .expect("golden project loads"); - let disclosure_digest = format!("sha256:{}", "a".repeat(64)); let baseline = json!({ "compiler_version": "0.0.0", "semantic_digests": { - "claim": format!("sha256:{}", "0".repeat(64)), "integration": loaded.semantic_digests.integration.as_str(), "service_policy": loaded.semantic_digests.service_policy.as_str(), "operator_security": loaded.semantic_digests.operator_security.as_str(), }, - "disclosure_digest": disclosure_digest, }); assert_eq!( - semantic_change_records(&loaded, Some(&baseline), &disclosure_digest) - .into_iter() - .map(|change| change.dimension) - .collect::>(), - BTreeSet::from(["claim", "compiler"]), + changed_semantic_dimensions(&loaded, Some(&baseline)), + vec![SemanticDimension::Compiler], ); } @@ -2117,13 +1591,6 @@ outputs: .to_string() .contains("missing or unknown fields")); - let mut inconsistent_products = approval_state.clone(); - inconsistent_products["promotion_projection"]["products"] = json!(["relay"]); - assert!(validate_signed_approval_state(&inconsistent_products) - .expect_err("projection products must match signed generated closures") - .to_string() - .contains("product inventory disagrees")); - let mut missing_projection = approval_state.clone(); missing_projection .as_object_mut() diff --git a/crates/registryctl/src/release_lock.rs b/crates/registryctl/src/release_lock.rs index a90ef9750..884aa5083 100644 --- a/crates/registryctl/src/release_lock.rs +++ b/crates/registryctl/src/release_lock.rs @@ -37,8 +37,6 @@ const TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1"; const ANCHOR_TRANSITION_SCHEMA: &str = "registry.platform.anchor_transition@1.0"; const RELAY_CONFIG_SCHEMA: &str = "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json"; -const NOTARY_CONFIG_SCHEMA: &str = - "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; const PRODUCT_BUNDLE_TARGET: &str = "/run/registry/bundle"; const PRODUCT_ANCHOR_TARGET: &str = "/run/registry/anchor"; const PRODUCT_STATE_TARGET: &str = "/var/lib/registry/state"; @@ -49,16 +47,12 @@ const POSTGRESQL_DATA_TARGET: &str = "/var/lib/postgresql/data"; // authorize the exact reviewed script, not a shell command that contains a // few expected fragments. const POSTGRESQL_BOOTSTRAP_SCRIPT_SHA256: &str = - "cbad443afb9700702df52be6513cf8afd95b97747d75a0a417df4fd079a2e79c"; -const POSTGRESQL_BOOTSTRAP_KEYS: [&str; 8] = [ + "02515ab47034a241554bc13f616de00c14b42a36139d6d07a1a53e52c6c28f0e"; +const POSTGRESQL_BOOTSTRAP_KEYS: [&str; 4] = [ "REGISTRY_RELAY_MIGRATOR_PASSWORD", "REGISTRY_RELAY_RUNTIME_PASSWORD", "REGISTRY_RELAY_MAINTENANCE_PASSWORD", "REGISTRY_RELAY_READER_PASSWORD", - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - "REGISTRY_NOTARY_READER_PASSWORD", ]; /// The strict, self-contained wire envelope shipped as @@ -163,7 +157,6 @@ pub struct LockedOciImageV1 { #[serde(deny_unknown_fields)] pub struct LockedManagedImagesV1 { pub relay: LockedOciImageV1, - pub notary: LockedOciImageV1, pub postgresql_state_plane: LockedOciImageV1, } @@ -225,8 +218,6 @@ pub enum LockedOperatorFileFormatV1 { Dotenv, PemCertificate, PemPrivateKey, - JsonWebKey, - CompactJwt, Opaque, } @@ -265,7 +256,6 @@ pub struct LockedPostgresqlRecipeV1 { pub struct LockedRuntimeRecipesV1 { pub relay_public: LockedProductRecipeV1, pub relay_consultation: LockedProductRecipeV1, - pub notary: LockedProductRecipeV1, pub postgresql_state_plane: LockedPostgresqlRecipeV1, pub operator_files: Vec, } @@ -278,7 +268,6 @@ pub struct SupportedContractsV1 { pub trust_anchor_schema: String, pub anchor_transition_schema: String, pub relay_config_schema: String, - pub notary_config_schema: String, } #[derive(Clone, Deserialize, Serialize)] @@ -317,8 +306,6 @@ impl RetainedVerifiedEnvelope { pub struct VerifiedManagedImagesV1 { relay: String, relay_platform: OciPlatformV1, - notary: String, - notary_platform: OciPlatformV1, postgresql_state_plane: String, postgresql_state_plane_platform: OciPlatformV1, } @@ -332,14 +319,6 @@ impl VerifiedManagedImagesV1 { self.relay_platform } - pub fn notary(&self) -> &str { - &self.notary - } - - pub fn notary_platform(&self) -> OciPlatformV1 { - self.notary_platform - } - pub fn postgresql_state_plane(&self) -> &str { &self.postgresql_state_plane } @@ -468,7 +447,6 @@ impl VerifiedPostgresqlRuntimeV1 { pub struct VerifiedRuntimeMappingV1 { relay_public: VerifiedProductRuntimeV1, relay_consultation: VerifiedProductRuntimeV1, - notary: VerifiedProductRuntimeV1, postgresql_state_plane: VerifiedPostgresqlRuntimeV1, operator_files: Vec, } @@ -482,10 +460,6 @@ impl VerifiedRuntimeMappingV1 { &self.relay_consultation } - pub fn notary(&self) -> &VerifiedProductRuntimeV1 { - &self.notary - } - pub fn postgresql_state_plane(&self) -> &VerifiedPostgresqlRuntimeV1 { &self.postgresql_state_plane } @@ -554,8 +528,6 @@ impl VerifiedReleaseLockV1 { VerifiedManagedImagesV1 { relay: self.lock.images.relay.identity.clone(), relay_platform: self.lock.images.relay.platforms[0].platform, - notary: self.lock.images.notary.identity.clone(), - notary_platform: self.lock.images.notary.platforms[0].platform, postgresql_state_plane: self.lock.images.postgresql_state_plane.identity.clone(), postgresql_state_plane_platform: self.lock.images.postgresql_state_plane.platforms[0] .platform, @@ -566,7 +538,6 @@ impl VerifiedReleaseLockV1 { VerifiedRuntimeMappingV1 { relay_public: self.lock.runtime.relay_public.clone().into(), relay_consultation: self.lock.runtime.relay_consultation.clone().into(), - notary: self.lock.runtime.notary.clone().into(), postgresql_state_plane: self.lock.runtime.postgresql_state_plane.clone().into(), operator_files: self.lock.runtime.operator_files.clone(), } @@ -602,9 +573,10 @@ impl From for VerifiedProductRuntimeV1 { } } -/// Verify a package lock without network access or adopter-supplied trust -/// material. A newer Registryctl 1.x may inspect and deploy an older signed 1.x -/// package, so this boundary intentionally does not bind the running binary. +/// Verify a Relay-only package lock without network access or adopter-supplied +/// trust material. This boundary intentionally does not bind the running +/// binary, but retired Notary-bearing payloads are outside the closed schema +/// and must be rebuilt before deployment. pub fn verify_release_lock_for_package(bytes: &[u8]) -> Result { let verified = verify_release_lock_material(bytes)?; if release_major(verified.product_version())? != 1 { @@ -856,7 +828,6 @@ impl LockedReleaseIdentityV1 { impl LockedManagedImagesV1 { fn validate(&self) -> Result<()> { self.relay.validate("Relay")?; - self.notary.validate("Notary")?; self.postgresql_state_plane .validate("PostgreSQL state plane") } @@ -894,7 +865,6 @@ impl LockedRuntimeRecipesV1 { fn validate(&self) -> Result<()> { self.relay_public.validate("Relay public")?; self.relay_consultation.validate("Relay consultation")?; - self.notary.validate("Notary")?; self.postgresql_state_plane .validate("PostgreSQL state plane")?; validate_operator_files(self)?; @@ -1041,7 +1011,6 @@ fn validate_product_recipe_shape(recipe: &LockedProductRecipeV1, label: &str) -> let id = match label { "Relay public" => "relay-public", "Relay consultation" => "relay-consultation", - "Notary" => "notary", _ => bail!("product runtime recipe label is unsupported"), }; let environment = format!("{id}-environment"); @@ -1087,11 +1056,6 @@ fn validate_product_recipe_shape(recipe: &LockedProductRecipeV1, label: &str) -> let serve_secrets: &[&str] = match id { "relay-public" => &[], "relay-consultation" => &["postgresql-tls-certificate"], - "notary" => &[ - "postgresql-tls-certificate", - "notary-relay-workload-credential", - "notary-signing-key", - ], _ => unreachable!(), }; validate_action_shape( @@ -1234,10 +1198,9 @@ fn validate_mount_access( } fn validate_product_recipe_commands(recipe: &LockedProductRecipeV1, label: &str) -> Result<()> { - let (product, lane) = match label { - "Relay public" => ("registry-relay", Some("relay-public")), - "Relay consultation" => ("registry-relay", Some("relay-consultation")), - "Notary" => ("registry-notary", None), + let lane = match label { + "Relay public" => "relay-public", + "Relay consultation" => "relay-consultation", _ => bail!("product runtime recipe label is unsupported"), }; for (name, action) in [ @@ -1248,11 +1211,7 @@ fn validate_product_recipe_commands(recipe: &LockedProductRecipeV1, label: &str) ("accept_state", &recipe.accept_state), ("verify_state", &recipe.verify_state), ] { - let expected = if let Some(lane) = lane { - vec!["product-action", lane, name] - } else { - vec!["product-action", name] - }; + let expected = ["product-action", lane, name]; validate_exact_command( &action.command, &expected, @@ -1267,31 +1226,21 @@ fn validate_product_recipe_commands(recipe: &LockedProductRecipeV1, label: &str) ("initialize_state", &recipe.development_initialize_state), ("serve", &recipe.development_serve), ] { - let expected = if let Some(lane) = lane { - vec!["development-action", lane, name] - } else { - vec!["development-action", name] - }; + let expected = ["development-action", lane, name]; validate_exact_command( &action.command, &expected, &format!("{label} development {name} command"), )?; } - let health_binary = format!("/usr/local/bin/{product}"); - let health_url = if product == "registry-notary" { - "http://127.0.0.1:8081/ready" - } else { - "http://127.0.0.1:8080/ready" - }; validate_exact_command( &recipe.health_probe, &[ "CMD", - health_binary.as_str(), + "/usr/local/bin/registry-relay", "healthcheck", "--url", - health_url, + "http://127.0.0.1:8080/ready", ], &format!("{label} health probe"), ) @@ -1415,11 +1364,7 @@ fn validate_operator_files(runtime: &LockedRuntimeRecipesV1) -> Result<()> { .map(|projection| projection.file_id.clone()), ); }; - for product in [ - &runtime.relay_public, - &runtime.relay_consultation, - &runtime.notary, - ] { + for product in [&runtime.relay_public, &runtime.relay_consultation] { collect(&product.prepare_state_store); collect(&product.initialize_state); collect(&product.preview_state); @@ -1441,23 +1386,11 @@ fn validate_operator_files(runtime: &LockedRuntimeRecipesV1) -> Result<()> { fn validate_operator_file_contract(file: &LockedOperatorFileV1) -> Result<()> { let (format, allowed_owners, required_keys): (LockedOperatorFileFormatV1, &[&str], &[&str]) = match file.id.as_str() { - "relay-public-environment" - | "relay-consultation-environment" - | "notary-environment" => ( + "relay-public-environment" | "relay-consultation-environment" => ( LockedOperatorFileFormatV1::Dotenv, &["root:root", "65532:65532"], &[], ), - "notary-signing-key" => ( - LockedOperatorFileFormatV1::JsonWebKey, - &["root:root", "65532:65532"], - &[], - ), - "notary-relay-workload-credential" => ( - LockedOperatorFileFormatV1::CompactJwt, - &["root:root", "65532:65532"], - &[], - ), "postgresql-tls-certificate" => ( LockedOperatorFileFormatV1::PemCertificate, &["root:root", "65532:65532", "999:999"], @@ -1528,7 +1461,6 @@ impl SupportedContractsV1 { self.trust_anchor_schema.as_str(), self.anchor_transition_schema.as_str(), self.relay_config_schema.as_str(), - self.notary_config_schema.as_str(), ]; let expected = [ CONFIG_BUNDLE_SCHEMA, @@ -1536,7 +1468,6 @@ impl SupportedContractsV1 { TRUST_ANCHOR_SCHEMA, ANCHOR_TRANSITION_SCHEMA, RELAY_CONFIG_SCHEMA, - NOTARY_CONFIG_SCHEMA, ]; if actual != expected { bail!("release lock supported-contract roster is unsupported"); @@ -1712,11 +1643,6 @@ mod tests { } fn command_recipe(product: &str, lane: Option<&str>) -> LockedProductRecipeV1 { - let health_url = if product == "registry-notary" { - "http://127.0.0.1:8081/ready" - } else { - "http://127.0.0.1:8080/ready" - }; let action = |name: &str| { let mut command = vec!["product-action"]; if let Some(lane) = lane { @@ -1748,7 +1674,7 @@ mod tests { format!("/usr/local/bin/{product}"), "healthcheck".to_string(), "--url".to_string(), - health_url.to_string(), + "http://127.0.0.1:8080/ready".to_string(), ], } } @@ -1762,7 +1688,6 @@ mod tests { "registry-relay", Some("relay-consultation"), ), - ("Notary", "registry-notary", None), ] { let recipe = command_recipe(product, lane); assert_eq!( @@ -1772,11 +1697,7 @@ mod tests { format!("/usr/local/bin/{product}"), "healthcheck".to_string(), "--url".to_string(), - if product == "registry-notary" { - "http://127.0.0.1:8081/ready".to_string() - } else { - "http://127.0.0.1:8080/ready".to_string() - }, + "http://127.0.0.1:8080/ready".to_string(), ] ); validate_product_recipe_commands(&recipe, label) @@ -1932,22 +1853,8 @@ mod tests { environment.required_keys.push("DATABASE_URL".to_string()); assert!(validate_operator_file_contract(&environment).is_err()); - let mut notary_signing_key = LockedOperatorFileV1 { - id: "notary-signing-key".to_string(), - format: LockedOperatorFileFormatV1::JsonWebKey, - mode: "0600".to_string(), - allowed_owners: vec!["root:root".to_string(), "65532:65532".to_string()], - required_keys: Vec::new(), - }; - validate_operator_file_contract(¬ary_signing_key) - .expect("the Notary-only signing-key projection is accepted"); - notary_signing_key - .allowed_owners - .push("999:999".to_string()); - assert!(validate_operator_file_contract(¬ary_signing_key).is_err()); - let listener_certificate = LockedOperatorFileV1 { - id: "notary-tls-certificate".to_string(), + id: "unsupported-tls-certificate".to_string(), format: LockedOperatorFileFormatV1::PemCertificate, mode: "0600".to_string(), allowed_owners: vec!["root:root".to_string(), "65532:65532".to_string()], diff --git a/crates/registryctl/src/trust.rs b/crates/registryctl/src/trust.rs index 33011503e..a6141fa59 100644 --- a/crates/registryctl/src/trust.rs +++ b/crates/registryctl/src/trust.rs @@ -204,6 +204,11 @@ fn rotate_trust_anchor_with_resolver( mut resolve: impl FnMut(&KeyLocator) -> Result>, ) -> Result { let current = load_trust_anchor_input(&options.current_anchor)?; + validate_selected_lane( + ¤t.acceptance_identity, + current.acceptance_identity.lane, + ) + .context("current trust anchor is not a supported Relay lane")?; if current.acceptance_identity.trust_domain != ProductTrustDomainV1::Governed { bail!("current trust anchor must use the governed trust domain"); } @@ -333,7 +338,7 @@ fn sign_product_bundle_with_resolver( let (sequence, previous_config_hash, anchor_history) = if let Some(preceding_set) = &options.preceding_approved_set { - let lane = crate::ApprovedLaneV1::from_acceptance_lane(options.lane); + let lane = crate::ApprovedLaneV1::try_from_acceptance_lane(options.lane)?; let preceding = crate::approved_set::verify_approved_lane_from_set(preceding_set, lane) .context("failed to verify preceding approved lane before signing")?; if preceding.acceptance_identity() != &marker.acceptance_identity { @@ -560,13 +565,11 @@ fn validate_selected_lane( if identity.lane != selected { bail!("selected signing lane does not match the signing-input acceptance identity"); } - let expected_product = match selected { - ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => { - ProductAcceptanceProductV1::RegistryRelay - } - ProductAcceptanceLaneV1::Notary => ProductAcceptanceProductV1::RegistryNotary, - }; - if identity.product != expected_product { + if !matches!( + selected, + ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation + ) || identity.product != ProductAcceptanceProductV1::RegistryRelay + { bail!("selected signing lane does not match the acceptance-identity product"); } if identity.trust_domain != ProductTrustDomainV1::Governed { @@ -812,12 +815,13 @@ fn primary_config_path( lane: ProductAcceptanceLaneV1, files: &[SigningInputFile], ) -> Result { - let expected = match lane { - ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => { - "config/relay.yaml" - } - ProductAcceptanceLaneV1::Notary => "config/notary.yaml", - }; + if !matches!( + lane, + ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation + ) { + bail!("signing-input lane is not supported by registryctl"); + } + let expected = "config/relay.yaml"; if !files.iter().any(|file| file.relative_path == expected) { bail!("signing-input closure lacks the selected lane's primary product configuration"); } @@ -1192,18 +1196,12 @@ mod tests { project: "civil-registry".to_string(), environment: "production".to_string(), lane, - product: match lane { - ProductAcceptanceLaneV1::RelayPublic - | ProductAcceptanceLaneV1::RelayConsultation => { - ProductAcceptanceProductV1::RegistryRelay - } - ProductAcceptanceLaneV1::Notary => ProductAcceptanceProductV1::RegistryNotary, - }, + product: ProductAcceptanceProductV1::RegistryRelay, stream: "civil-registry".to_string(), instance: match lane { ProductAcceptanceLaneV1::RelayPublic => "relay".to_string(), ProductAcceptanceLaneV1::RelayConsultation => "relay-consultation".to_string(), - ProductAcceptanceLaneV1::Notary => "notary".to_string(), + _ => "unsupported".to_string(), }, } } @@ -1220,12 +1218,7 @@ mod tests { } fn write_signing_input(root: &Path, identity: ProductAcceptanceIdentityV1) { - let config = match identity.lane { - ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => { - "config/relay.yaml" - } - ProductAcceptanceLaneV1::Notary => "config/notary.yaml", - }; + let config = "config/relay.yaml"; fs::create_dir_all(root.join("config")).expect("config directory creates"); fs::write(root.join(config), b"instance:\n id: synthetic\n").expect("config writes"); let marker = SigningInputMarkerV1::governed(identity).expect("marker is valid"); @@ -1238,12 +1231,7 @@ mod tests { fn write_review_evidence(root: &Path, lane: ProductAcceptanceLaneV1) { fs::create_dir_all(root.join("approval")).unwrap(); - let config = match lane { - ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => { - "config/relay.yaml" - } - ProductAcceptanceLaneV1::Notary => "config/notary.yaml", - }; + let config = "config/relay.yaml"; let config_digest = registry_platform_config::sha256_uri(&fs::read(root.join(config)).unwrap()); let lane_closure = serde_json::json!([{ @@ -1254,7 +1242,7 @@ mod tests { registry_platform_config::sha256_uri(&canonicalize_json(&lane_closure).unwrap()); let consultations = match lane { ProductAcceptanceLaneV1::RelayPublic => serde_json::json!({}), - ProductAcceptanceLaneV1::RelayConsultation | ProductAcceptanceLaneV1::Notary => { + ProductAcceptanceLaneV1::RelayConsultation => { serde_json::json!({ "evidence.lookup": { "profile_id": "profile", @@ -1263,6 +1251,7 @@ mod tests { } }) } + _ => serde_json::json!({}), }; fs::write( root.join("approval/review.json"), @@ -1276,7 +1265,7 @@ mod tests { (match lane { ProductAcceptanceLaneV1::RelayPublic => "relay", ProductAcceptanceLaneV1::RelayConsultation => "relay_consultation", - ProductAcceptanceLaneV1::Notary => "notary", + _ => "unsupported", }): lane_digest, }, })) @@ -1304,7 +1293,8 @@ mod tests { #[test] fn signing_input_marker_is_canonical_and_binds_every_identity_dimension() { let marker = - SigningInputMarkerV1::governed(identity(ProductAcceptanceLaneV1::Notary)).unwrap(); + SigningInputMarkerV1::governed(identity(ProductAcceptanceLaneV1::RelayConsultation)) + .unwrap(); let first = canonical_signing_input_marker(&marker).unwrap(); let second = canonical_signing_input_marker(&marker).unwrap(); assert_eq!(first, second); @@ -1315,21 +1305,21 @@ mod tests { "trust_domain": "governed", "project": "civil-registry", "environment": "production", - "lane": "notary", - "product": "registry-notary", + "lane": "relay-consultation", + "product": "registry-relay", "stream": "civil-registry", - "instance": "notary", + "instance": "relay-consultation", }) ); } #[test] - fn signing_rejects_swapped_lane_and_every_identity_mismatch_without_key_resolution() { + fn signing_rejects_swapped_lane_and_identity_mismatches_without_key_resolution() { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - write_signing_input(&input, identity(ProductAcceptanceLaneV1::Notary)); + write_signing_input(&input, identity(ProductAcceptanceLaneV1::RelayConsultation)); let anchor_path = temp.path().join("anchor.json"); - let base = identity(ProductAcceptanceLaneV1::Notary); + let base = identity(ProductAcceptanceLaneV1::RelayConsultation); write_anchor(&anchor_path, base.clone()); let cases = [ @@ -1349,11 +1339,6 @@ mod tests { value }), ("lane", identity(ProductAcceptanceLaneV1::RelayPublic)), - ("product", { - let mut value = base.clone(); - value.product = ProductAcceptanceProductV1::RegistryRelay; - value - }), ("stream", { let mut value = base.clone(); value.stream.push_str("-other"); @@ -1381,7 +1366,7 @@ mod tests { let calls = Cell::new(0); let error = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: input.clone(), anchor: changed_anchor, preceding_approved_set: None, @@ -1421,7 +1406,7 @@ mod tests { fn anchor_create_sorts_keys_and_refuses_overwrite() { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - write_signing_input(&input, identity(ProductAcceptanceLaneV1::Notary)); + write_signing_input(&input, identity(ProductAcceptanceLaneV1::RelayConsultation)); let public = signer().jwk; let first = temp.path().join("first.jwk"); let second = temp.path().join("second.jwk"); @@ -1429,7 +1414,7 @@ mod tests { fs::write(&second, serde_json::to_vec(&public).unwrap()).unwrap(); let output = temp.path().join("anchor.json"); let error = create_trust_anchor(&TrustAnchorCreateOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: input.clone(), public_keys: vec![second, first], threshold: 1, @@ -1441,7 +1426,7 @@ mod tests { let public_path = temp.path().join("public.jwk"); fs::write(&public_path, serde_json::to_vec(&public).unwrap()).unwrap(); create_trust_anchor(&TrustAnchorCreateOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: input.clone(), public_keys: vec![public_path.clone()], threshold: 1, @@ -1450,7 +1435,7 @@ mod tests { .expect("initial anchor creates"); let before = fs::read(&output).unwrap(); create_trust_anchor(&TrustAnchorCreateOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input, public_keys: vec![public_path], threshold: 1, @@ -1464,7 +1449,7 @@ mod tests { fn bundle_signing_enforces_threshold_distinctness_and_self_verifies() { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - let identity = identity(ProductAcceptanceLaneV1::Notary); + let identity = identity(ProductAcceptanceLaneV1::RelayConsultation); write_signing_input(&input, identity.clone()); let anchor_path = temp.path().join("anchor.json"); let mut enabled_signers = vec![ @@ -1488,7 +1473,7 @@ mod tests { let calls = Cell::new(0); let error = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: input.clone(), anchor: anchor_path.clone(), preceding_approved_set: None, @@ -1507,7 +1492,7 @@ mod tests { let duplicate_output = temp.path().join("duplicate"); let error = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: input.clone(), anchor: anchor_path.clone(), preceding_approved_set: None, @@ -1526,7 +1511,7 @@ mod tests { let output = temp.path().join("signed"); let report = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input, anchor: anchor_path, preceding_approved_set: None, @@ -1561,14 +1546,14 @@ mod tests { fn approved_lane_verifier_rejects_signed_arbitrary_bundle_id() { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - let acceptance_identity = identity(ProductAcceptanceLaneV1::Notary); + let acceptance_identity = identity(ProductAcceptanceLaneV1::RelayConsultation); write_signing_input(&input, acceptance_identity.clone()); fs::create_dir_all(input.join("approval")).unwrap(); let config_digest = registry_platform_config::sha256_uri( - &fs::read(input.join("config/notary.yaml")).unwrap(), + &fs::read(input.join("config/relay.yaml")).unwrap(), ); let lane_closure = serde_json::json!([{ - "path": "config/notary.yaml", + "path": "config/relay.yaml", "sha256": config_digest, }]); let lane_digest = @@ -1581,7 +1566,7 @@ mod tests { fs::write( input.join("approval/project-state.json"), canonical_json_line(&serde_json::json!({ - "generated_closure_digests": { "notary": lane_digest }, + "generated_closure_digests": { "relay_consultation": lane_digest }, })) .unwrap(), ) @@ -1591,7 +1576,7 @@ mod tests { let output = temp.path().join("signed"); sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input, anchor: anchor_path, preceding_approved_set: None, @@ -1629,7 +1614,7 @@ mod tests { .expect("signature-valid arbitrary bundle_id passes platform verification"); let error = crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::Notary, + crate::ApprovedLaneV1::RelayConsultation, &output, ) .expect_err("approved-lane verifier must recompute the exact closure digest"); @@ -1644,9 +1629,9 @@ mod tests { for lane in [ ProductAcceptanceLaneV1::RelayPublic, ProductAcceptanceLaneV1::RelayConsultation, - ProductAcceptanceLaneV1::Notary, ] { - let lane_label = crate::ApprovedLaneV1::from_acceptance_lane(lane).to_string(); + let approved_lane = crate::ApprovedLaneV1::try_from_acceptance_lane(lane).unwrap(); + let lane_label = approved_lane.to_string(); let input = temporary.path().join(format!("{lane_label}-input")); let lane_identity = identity(lane); write_signing_input(&input, lane_identity.clone()); @@ -1667,21 +1652,16 @@ mod tests { ) .unwrap(); verified_lanes.insert( - crate::ApprovedLaneV1::from_acceptance_lane(lane), - crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::from_acceptance_lane(lane), - &signed, - ) - .unwrap(), + approved_lane, + crate::approved_set::verify_signed_lane_directory(approved_lane, &signed).unwrap(), ); - lane_paths.insert(crate::ApprovedLaneV1::from_acceptance_lane(lane), signed); + lane_paths.insert(approved_lane, signed); } let set_file = temporary.path().join("approved-set.json"); crate::approved_set::assemble_initial_approved_set( &crate::approved_set::InitialApprovedSetInputs { relay_public: lane_paths[&crate::ApprovedLaneV1::RelayPublic].clone(), relay_consultation: lane_paths[&crate::ApprovedLaneV1::RelayConsultation].clone(), - notary: lane_paths[&crate::ApprovedLaneV1::Notary].clone(), }, &set_file, |request| { @@ -1692,21 +1672,21 @@ mod tests { ) .unwrap(); - let update_input = temporary.path().join("notary-update-input"); - let notary_identity = identity(ProductAcceptanceLaneV1::Notary); - write_signing_input(&update_input, notary_identity); + let update_input = temporary.path().join("consultation-update-input"); + let consultation_identity = identity(ProductAcceptanceLaneV1::RelayConsultation); + write_signing_input(&update_input, consultation_identity); fs::write( - update_input.join("config/notary.yaml"), + update_input.join("config/relay.yaml"), b"instance:\n id: changed\n", ) .unwrap(); - write_review_evidence(&update_input, ProductAcceptanceLaneV1::Notary); - let update_output = temporary.path().join("notary-updated"); + write_review_evidence(&update_input, ProductAcceptanceLaneV1::RelayConsultation); + let update_output = temporary.path().join("consultation-updated"); sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: update_input, - anchor: lane_paths[&crate::ApprovedLaneV1::Notary].join("anchor.json"), + anchor: lane_paths[&crate::ApprovedLaneV1::RelayConsultation].join("anchor.json"), preceding_approved_set: Some(set_file.clone()), keys: vec!["op://vault/item/key".to_string()], output_dir: update_output.clone(), @@ -1721,8 +1701,8 @@ mod tests { .unwrap(); assert_eq!(updated.manifest.sequence, 2); let preceding = registry_platform_config::verify_config_bundle( - lane_paths[&crate::ApprovedLaneV1::Notary].join("bundle"), - lane_paths[&crate::ApprovedLaneV1::Notary].join("anchor.json"), + lane_paths[&crate::ApprovedLaneV1::RelayConsultation].join("bundle"), + lane_paths[&crate::ApprovedLaneV1::RelayConsultation].join("anchor.json"), ) .unwrap(); assert_eq!( @@ -1731,7 +1711,7 @@ mod tests { ); let verified_update = crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::Notary, + crate::ApprovedLaneV1::RelayConsultation, &update_output, ) .unwrap(); @@ -1740,12 +1720,12 @@ mod tests { crate::approved_set::assemble_updated_approved_set( &set_file, &crate::ReviewedBuildUpdateV1 { - notary: Some(verified_update.entry().reviewed_binding()), + relay_consultation: Some(verified_update.entry().reviewed_binding()), ..Default::default() }, &[], &crate::approved_set::AffectedLaneReplacements { - notary: Some(update_output.clone()), + relay_consultation: Some(update_output.clone()), ..Default::default() }, &set_two, @@ -1767,18 +1747,21 @@ mod tests { |_| Ok(Zeroizing::new(TEST_PRIVATE_JWK.to_string())), ) .unwrap(); - let rotated_input = temporary.path().join("notary-rotated-input"); - write_signing_input(&rotated_input, identity(ProductAcceptanceLaneV1::Notary)); + let rotated_input = temporary.path().join("consultation-rotated-input"); + write_signing_input( + &rotated_input, + identity(ProductAcceptanceLaneV1::RelayConsultation), + ); fs::write( - rotated_input.join("config/notary.yaml"), + rotated_input.join("config/relay.yaml"), b"instance:\n id: rotated-two\n", ) .unwrap(); - write_review_evidence(&rotated_input, ProductAcceptanceLaneV1::Notary); - let rotated_output = temporary.path().join("notary-rotated"); + write_review_evidence(&rotated_input, ProductAcceptanceLaneV1::RelayConsultation); + let rotated_output = temporary.path().join("consultation-rotated"); sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: rotated_input, anchor: rotation_two.join("anchor.json"), preceding_approved_set: Some(set_two.clone()), @@ -1795,7 +1778,7 @@ mod tests { .join("anchor-history/0000.transition.json") .is_file()); let verified_rotated = crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::Notary, + crate::ApprovedLaneV1::RelayConsultation, &rotated_output, ) .unwrap(); @@ -1803,12 +1786,12 @@ mod tests { crate::approved_set::assemble_updated_approved_set( &set_two, &crate::ReviewedBuildUpdateV1 { - notary: Some(verified_rotated.entry().reviewed_binding()), + relay_consultation: Some(verified_rotated.entry().reviewed_binding()), ..Default::default() }, - &[crate::ApprovedLaneV1::Notary], + &[crate::ApprovedLaneV1::RelayConsultation], &crate::approved_set::AffectedLaneReplacements { - notary: Some(rotated_output.clone()), + relay_consultation: Some(rotated_output.clone()), ..Default::default() }, &set_three, @@ -1828,21 +1811,24 @@ mod tests { |_| Ok(Zeroizing::new(TEST_PRIVATE_JWK.to_string())), ) .unwrap(); - let rotated_again_input = temporary.path().join("notary-rotated-again-input"); + let rotated_again_input = temporary.path().join("consultation-rotated-again-input"); write_signing_input( &rotated_again_input, - identity(ProductAcceptanceLaneV1::Notary), + identity(ProductAcceptanceLaneV1::RelayConsultation), ); fs::write( - rotated_again_input.join("config/notary.yaml"), + rotated_again_input.join("config/relay.yaml"), b"instance:\n id: rotated-three\n", ) .unwrap(); - write_review_evidence(&rotated_again_input, ProductAcceptanceLaneV1::Notary); - let rotated_again_output = temporary.path().join("notary-rotated-again"); + write_review_evidence( + &rotated_again_input, + ProductAcceptanceLaneV1::RelayConsultation, + ); + let rotated_again_output = temporary.path().join("consultation-rotated-again"); sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input: rotated_again_input, anchor: rotation_three.join("anchor.json"), preceding_approved_set: Some(set_three), @@ -1853,7 +1839,7 @@ mod tests { ) .unwrap(); let verified_twice_rotated = crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::Notary, + crate::ApprovedLaneV1::RelayConsultation, &rotated_again_output, ) .expect("every historical transition verifies oldest to terminal anchor"); @@ -1872,7 +1858,7 @@ mod tests { ) .unwrap(); let error = crate::approved_set::verify_signed_lane_directory( - crate::ApprovedLaneV1::Notary, + crate::ApprovedLaneV1::RelayConsultation, &rotated_again_output, ) .expect_err("tampering any historical transition must fail closed"); @@ -1941,7 +1927,10 @@ mod tests { fn rotation_writes_fresh_verified_transition_and_wrong_predecessor_fails() { let temp = tempfile::tempdir().unwrap(); let current_path = temp.path().join("current.json"); - let current = write_anchor(¤t_path, identity(ProductAcceptanceLaneV1::Notary)); + let current = write_anchor( + ¤t_path, + identity(ProductAcceptanceLaneV1::RelayConsultation), + ); let public_path = temp.path().join("current.jwk"); fs::write( &public_path, @@ -1992,7 +1981,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - let acceptance_identity = identity(ProductAcceptanceLaneV1::Notary); + let acceptance_identity = identity(ProductAcceptanceLaneV1::RelayConsultation); write_signing_input(&input, acceptance_identity.clone()); let anchor_path = temp.path().join("anchor.json"); write_anchor(&anchor_path, acceptance_identity); @@ -2014,7 +2003,7 @@ mod tests { let calls = Cell::new(0); let error = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input, anchor: anchor_path, preceding_approved_set: None, @@ -2038,7 +2027,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - let acceptance_identity = identity(ProductAcceptanceLaneV1::Notary); + let acceptance_identity = identity(ProductAcceptanceLaneV1::RelayConsultation); write_signing_input(&input, acceptance_identity.clone()); let anchor_path = temp.path().join("anchor.json"); write_anchor(&anchor_path, acceptance_identity); @@ -2062,7 +2051,7 @@ mod tests { let calls = Cell::new(0); let error = sign_product_bundle_with_resolver( &ProductBundleSignOptions { - lane: ProductAcceptanceLaneV1::Notary, + lane: ProductAcceptanceLaneV1::RelayConsultation, input, anchor: anchor_path, preceding_approved_set: None, @@ -2105,7 +2094,7 @@ mod tests { fn signing_input_closure_enforces_directory_depth_cap() { let temp = tempfile::tempdir().unwrap(); let input = temp.path().join("input"); - write_signing_input(&input, identity(ProductAcceptanceLaneV1::Notary)); + write_signing_input(&input, identity(ProductAcceptanceLaneV1::RelayConsultation)); let mut nested = input; for index in 0..=MAX_SIGNING_INPUT_DEPTH { nested = nested.join(format!("d{index}")); diff --git a/crates/registryctl/tests/anchor_rotation_journey.rs b/crates/registryctl/tests/anchor_rotation_journey.rs index 1dc908c74..babad3ef0 100644 --- a/crates/registryctl/tests/anchor_rotation_journey.rs +++ b/crates/registryctl/tests/anchor_rotation_journey.rs @@ -153,15 +153,6 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() ¤t_public_key, ¤t_private_key, ); - let notary = create_initial_lane( - "notary", - &signing_inputs, - &anchors, - &handoff, - ¤t_public_key, - ¤t_private_key, - ); - let approved_one = handoff.join("approved-one.json"); successful(vec![ "-C".to_string(), @@ -175,8 +166,6 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() relay_public.display().to_string(), "--relay-consultation".to_string(), relay_consultation.display().to_string(), - "--notary".to_string(), - notary.display().to_string(), "--output-file".to_string(), approved_one.display().to_string(), ]); @@ -190,33 +179,33 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "--against".to_string(), approved_one.display().to_string(), "--rotate-anchor".to_string(), - "notary".to_string(), + "relay-consultation".to_string(), ]); - let rotated_input = signing_inputs.join("notary"); + let rotated_input = signing_inputs.join("relay-consultation"); assert!(rotated_input.is_dir()); assert!(!signing_inputs.join("relay-public").exists()); - assert!(!signing_inputs.join("relay-consultation").exists()); assert_eq!( - fs::read(rotated_input.join("config/notary.yaml")).expect("rotated input config reads"), - fs::read(notary.join("bundle/config/notary.yaml")).expect("preceding config reads"), + fs::read(rotated_input.join("config/relay.yaml")).expect("rotated input config reads"), + fs::read(relay_consultation.join("bundle/config/relay.yaml")) + .expect("preceding config reads"), "an anchor-only build must not manufacture a configuration change" ); for reviewed_file in ["approval/review.json", "approval/project-state.json"] { assert_eq!( fs::read(rotated_input.join(reviewed_file)).expect("rotated reviewed input reads"), - fs::read(notary.join("bundle").join(reviewed_file)) + fs::read(relay_consultation.join("bundle").join(reviewed_file)) .expect("preceding reviewed input reads"), "an anchor-only build must retain the signed {reviewed_file}" ); } - let rotation = handoff.join("notary-rotation"); + let rotation = handoff.join("relay-consultation-rotation"); successful(vec![ "trust".to_string(), "anchor".to_string(), "rotate".to_string(), "--current-anchor".to_string(), - notary.join("anchor.json").display().to_string(), + relay_consultation.join("anchor.json").display().to_string(), "--next-public-key".to_string(), current_public_key.display().to_string(), "--next-public-key".to_string(), @@ -229,17 +218,17 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() rotation.display().to_string(), ]); - let same_anchor = handoff.join("notary-same-anchor"); + let same_anchor = handoff.join("relay-consultation-same-anchor"); successful(vec![ "trust".to_string(), "bundle".to_string(), "sign".to_string(), "--lane".to_string(), - "notary".to_string(), + "relay-consultation".to_string(), "--input".to_string(), rotated_input.display().to_string(), "--anchor".to_string(), - notary.join("anchor.json").display().to_string(), + relay_consultation.join("anchor.json").display().to_string(), "--against".to_string(), approved_one.display().to_string(), "--key".to_string(), @@ -258,7 +247,7 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "local".to_string(), "--from".to_string(), approved_one.display().to_string(), - "--notary".to_string(), + "--relay-consultation".to_string(), same_anchor.display().to_string(), "--output-file".to_string(), handoff @@ -269,13 +258,13 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "retained its preceding anchor", ); - let rotated_notary = handoff.join("notary-rotated"); + let rotated_consultation = handoff.join("relay-consultation-rotated"); successful(vec![ "trust".to_string(), "bundle".to_string(), "sign".to_string(), "--lane".to_string(), - "notary".to_string(), + "relay-consultation".to_string(), "--input".to_string(), rotated_input.display().to_string(), "--anchor".to_string(), @@ -285,7 +274,7 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "--key".to_string(), format!("file:{}", next_private_key.display()), "--output-dir".to_string(), - rotated_notary.display().to_string(), + rotated_consultation.display().to_string(), ]); let approved_two = handoff.join("approved-two.json"); successful(vec![ @@ -298,26 +287,31 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "local".to_string(), "--from".to_string(), approved_one.display().to_string(), - "--notary".to_string(), - rotated_notary.display().to_string(), + "--relay-consultation".to_string(), + rotated_consultation.display().to_string(), "--output-file".to_string(), approved_two.display().to_string(), ]); - let verified_one = - verify_config_bundle(notary.join("bundle"), notary.join("anchor.json")).unwrap(); + let verified_one = verify_config_bundle( + relay_consultation.join("bundle"), + relay_consultation.join("anchor.json"), + ) + .unwrap(); let verified_two = verify_config_bundle( - rotated_notary.join("bundle"), - rotated_notary.join("anchor.json"), + rotated_consultation.join("bundle"), + rotated_consultation.join("anchor.json"), ) .unwrap(); let candidate_one = VerifiedAcceptanceStateV1::from_verified_bundle(&verified_one).unwrap(); let candidate_two = VerifiedAcceptanceStateV1::from_verified_bundle(&verified_two).unwrap(); - let current_anchor = load_trust_anchor(¬ary.join("anchor.json")).unwrap(); + let current_anchor = load_trust_anchor(&relay_consultation.join("anchor.json")).unwrap(); let transition = - load_anchor_transition(&rotated_notary.join("anchor-history/0000.transition.json")) + load_anchor_transition(&rotated_consultation.join("anchor-history/0000.transition.json")) .unwrap(); - let state_path = temporary.path().join("notary-anti-rollback.json"); + let state_path = temporary + .path() + .join("relay-consultation-anti-rollback.json"); let store = FileAntiRollbackStore::new(&state_path); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -353,15 +347,15 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "--against".to_string(), approved_two.display().to_string(), "--rotate-anchor".to_string(), - "notary".to_string(), + "relay-consultation".to_string(), ]); - let stale_rotation = handoff.join("notary-stale-rotation"); + let stale_rotation = handoff.join("relay-consultation-stale-rotation"); successful(vec![ "trust".to_string(), "anchor".to_string(), "rotate".to_string(), "--current-anchor".to_string(), - notary.join("anchor.json").display().to_string(), + relay_consultation.join("anchor.json").display().to_string(), "--next-public-key".to_string(), current_public_key.display().to_string(), "--next-threshold".to_string(), @@ -377,7 +371,7 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "bundle".to_string(), "sign".to_string(), "--lane".to_string(), - "notary".to_string(), + "relay-consultation".to_string(), "--input".to_string(), rotated_input.display().to_string(), "--anchor".to_string(), @@ -387,7 +381,10 @@ fn unchanged_anchor_rotation_is_explicit_authenticated_and_runtime_acceptable() "--key".to_string(), format!("file:{}", current_private_key.display()), "--output-dir".to_string(), - handoff.join("notary-rejected-next").display().to_string(), + handoff + .join("relay-consultation-rejected-next") + .display() + .to_string(), ], "selected lane anchor is not the next authenticated anchor", ); diff --git a/crates/registryctl/tests/approved_set_assembly.rs b/crates/registryctl/tests/approved_set_assembly.rs index 701aa3f9b..f7a6ede30 100644 --- a/crates/registryctl/tests/approved_set_assembly.rs +++ b/crates/registryctl/tests/approved_set_assembly.rs @@ -38,7 +38,7 @@ fn initial_assembly_emits_one_deterministic_value_free_entry_for_each_fixed_lane assert_eq!( first_report.affected_lanes, ApprovedLaneV1::ALL, - "initial review requires all three fixed lanes" + "initial review requires both fixed Relay lanes" ); assert_eq!( first_report.approved_set.schema_id, @@ -48,28 +48,6 @@ fn initial_assembly_emits_one_deterministic_value_free_entry_for_each_fixed_lane first_report.approved_set.schema_version, APPROVED_BASELINE_SET_SCHEMA_VERSION ); - assert_eq!( - first_report - .approved_set - .lanes - .relay_consultation - .interfaces - .consultation_relay_notary, - first_report - .approved_set - .lanes - .notary - .interfaces - .consultation_relay_notary - ); - assert!(first_report - .approved_set - .lanes - .relay_public - .interfaces - .consultation_relay_notary - .is_none()); - let json = String::from_utf8(std::fs::read(&first).expect("set reads")).expect("set is UTF-8"); for forbidden in [ "example-project", @@ -91,7 +69,7 @@ fn initial_assembly_emits_one_deterministic_value_free_entry_for_each_fixed_lane .keys() .cloned() .collect::>(), - vec!["notary", "relay-consultation", "relay-public"] + vec!["relay-consultation", "relay-public"] ); assert!(parsed["lanes"]["relay-public"].get("lane").is_none()); assert!(parsed["lanes"]["relay-public"] @@ -116,12 +94,10 @@ fn update_reverifies_every_preceding_lane_and_only_replaces_reviewed_affected_la let reviewed = ReviewedBuildUpdateV1 { relay_public: None, relay_consultation: Some(reviewed_binding(ApprovedLaneV1::RelayConsultation)), - notary: Some(reviewed_binding(ApprovedLaneV1::Notary)), }; let replacements = AffectedLaneReplacements { relay_public: None, relay_consultation: Some(temporary.path().join("consultation-next")), - notary: Some(temporary.path().join("notary-next")), }; let requests = RefCell::new(Vec::new()); @@ -148,7 +124,7 @@ fn update_reverifies_every_preceding_lane_and_only_replaces_reviewed_affected_la .expect("governed update assembles"); let requests = requests.into_inner(); - assert_eq!(requests.len(), 5, "three preceding and two replacements"); + assert_eq!(requests.len(), 3, "two preceding and one replacement"); assert_eq!( requests .iter() @@ -157,11 +133,11 @@ fn update_reverifies_every_preceding_lane_and_only_replaces_reviewed_affected_la LaneVerificationSourceV1::PrecedingApprovedEntry { .. } )) .count(), - 3 + 2 ); assert_eq!( report.affected_lanes, - vec![ApprovedLaneV1::RelayConsultation, ApprovedLaneV1::Notary] + vec![ApprovedLaneV1::RelayConsultation] ); assert_eq!( report.approved_set.lanes.relay_public, @@ -171,21 +147,6 @@ fn update_reverifies_every_preceding_lane_and_only_replaces_reviewed_affected_la report.approved_set.lanes.relay_consultation, preceding.lanes.relay_consultation ); - assert_ne!(report.approved_set.lanes.notary, preceding.lanes.notary); - assert_eq!( - report - .approved_set - .lanes - .relay_consultation - .interfaces - .consultation_relay_notary, - report - .approved_set - .lanes - .notary - .interfaces - .consultation_relay_notary - ); assert_eq!( load_approved_baseline_set_structure(&next_file).expect("updated set round trips"), report.approved_set diff --git a/crates/registryctl/tests/approved_set_rejections.rs b/crates/registryctl/tests/approved_set_rejections.rs index a32a67100..e7e748102 100644 --- a/crates/registryctl/tests/approved_set_rejections.rs +++ b/crates/registryctl/tests/approved_set_rejections.rs @@ -34,7 +34,7 @@ fn initial_assembly_rejects_mixed_project_identity_before_output_creation() { let temporary = tempfile::tempdir().expect("temporary directory creates"); let output = temporary.path().join("mixed.json"); let error = assemble_initial_approved_set(&path_set(temporary.path()), &output, |request| { - Ok(if request.lane == ApprovedLaneV1::Notary { + Ok(if request.lane == ApprovedLaneV1::RelayConsultation { verified( request.lane, "other-project", @@ -44,7 +44,6 @@ fn initial_assembly_rejects_mixed_project_identity_before_output_creation() { None, 'c', 'f', - Some('c'), ) } else { initial_lane(request.lane) @@ -68,54 +67,25 @@ fn independent_verification_rejects_development_trust_for_an_approved_lane() { 1, digest('a'), None, - entry(lane, "approved", 'a', 'd', None), + entry(lane, "approved", 'a', 'd'), ) .expect_err("development trust must not enter a governed approved set"); assert!(format!("{error:#}").contains("governed trust domain")); } -#[test] -fn initial_assembly_rejects_mixed_cross_lane_contract_before_output_creation() { - let temporary = tempfile::tempdir().expect("temporary directory creates"); - let output = temporary.path().join("mixed-interface.json"); - let error = assemble_initial_approved_set(&path_set(temporary.path()), &output, |request| { - Ok(if request.lane == ApprovedLaneV1::Notary { - verified( - request.lane, - "example-project", - "approved", - 1, - 'c', - None, - 'c', - 'f', - Some('d'), - ) - } else { - initial_lane(request.lane) - }) - }) - .expect_err("mixed interface must fail"); - - assert!(format!("{error:#}").contains("interface digests do not match")); - assert!(!output.exists()); -} - #[test] fn update_requires_exact_replacements_and_never_carries_an_unverified_lane() { let temporary = tempfile::tempdir().expect("temporary directory creates"); let (preceding_file, _) = initial_set(&temporary); let output = temporary.path().join("next.json"); let reviewed = ReviewedBuildUpdateV1 { - relay_public: None, + relay_public: Some(reviewed_binding(ApprovedLaneV1::RelayPublic)), relay_consultation: Some(reviewed_binding(ApprovedLaneV1::RelayConsultation)), - notary: Some(reviewed_binding(ApprovedLaneV1::Notary)), }; let missing = AffectedLaneReplacements { relay_public: None, relay_consultation: Some(temporary.path().join("consultation-next")), - notary: None, }; let mut verification_called = false; let error = @@ -129,9 +99,8 @@ fn update_requires_exact_replacements_and_never_carries_an_unverified_lane() { assert!(!output.exists()); let complete = AffectedLaneReplacements { - relay_public: None, + relay_public: Some(temporary.path().join("public-next")), relay_consultation: Some(temporary.path().join("consultation-next")), - notary: Some(temporary.path().join("notary-next")), }; let planted_canary = "CANARY_PRIVATE_PRECEDING_PATH"; let error = assemble_updated_approved_set( @@ -167,12 +136,10 @@ fn update_rejects_non_successor_lineage_and_reviewed_closure_mismatch() { let reviewed = ReviewedBuildUpdateV1 { relay_public: None, relay_consultation: Some(reviewed_binding(ApprovedLaneV1::RelayConsultation)), - notary: Some(reviewed_binding(ApprovedLaneV1::Notary)), }; let replacements = AffectedLaneReplacements { relay_public: None, relay_consultation: Some(temporary.path().join("consultation-next")), - notary: Some(temporary.path().join("notary-next")), }; let error = assemble_updated_approved_set( @@ -197,7 +164,6 @@ fn update_rejects_non_successor_lineage_and_reviewed_closure_mismatch() { Some('b'), '7', '8', - Some('9'), )) } LaneVerificationSourceV1::LaneDirectory(_) => Ok(replacement_lane(request.lane)), @@ -237,17 +203,17 @@ fn update_requires_explicit_anchor_rotation_and_rejects_selected_same_anchor() { let (preceding_file, _) = initial_set(&temporary); let output = temporary.path().join("next.json"); let reviewed = ReviewedBuildUpdateV1 { - notary: Some(reviewed_binding(ApprovedLaneV1::Notary)), + relay_consultation: Some(reviewed_binding(ApprovedLaneV1::RelayConsultation)), ..Default::default() }; let replacements = AffectedLaneReplacements { - notary: Some(temporary.path().join("notary-next")), + relay_consultation: Some(temporary.path().join("consultation-next")), ..Default::default() }; let same_anchor = assemble_updated_approved_set( &preceding_file, &reviewed, - &[ApprovedLaneV1::Notary], + &[ApprovedLaneV1::RelayConsultation], &replacements, &output, |request| match request.source { diff --git a/crates/registryctl/tests/approved_set_support/mod.rs b/crates/registryctl/tests/approved_set_support/mod.rs index 5045f107e..c2f7529e3 100644 --- a/crates/registryctl/tests/approved_set_support/mod.rs +++ b/crates/registryctl/tests/approved_set_support/mod.rs @@ -59,9 +59,9 @@ use std::path::Path; use anyhow::Result; use approved_set::{ - ApprovedLaneEntryV1, ApprovedLaneLocatorsV1, ApprovedLaneV1, CrossLaneInterfaceDigestsV1, - LaneVerificationRequestV1, LaneVerificationSourceV1, PortableArtifactLocator, - ReviewedLaneBindingV1, VerifiedApprovedLaneV1, + ApprovedLaneEntryV1, ApprovedLaneLocatorsV1, ApprovedLaneV1, LaneVerificationRequestV1, + LaneVerificationSourceV1, PortableArtifactLocator, ReviewedLaneBindingV1, + VerifiedApprovedLaneV1, }; use registry_platform_config::{ ProductAcceptanceIdentityV1, ProductAcceptanceProductV1, ProductTrustDomainV1, @@ -80,7 +80,6 @@ pub fn entry( generation: &str, reviewed_digest: char, signing_digest: char, - interface_digest: Option, ) -> ApprovedLaneEntryV1 { let lane_name = lane.to_string(); let root = format!("{generation}/{lane_name}"); @@ -94,23 +93,17 @@ pub fn entry( signed_manifest_digest: digest(match lane { ApprovedLaneV1::RelayPublic => '1', ApprovedLaneV1::RelayConsultation => '2', - ApprovedLaneV1::Notary => '3', }), bundle_digest: digest(match lane { ApprovedLaneV1::RelayPublic => '4', ApprovedLaneV1::RelayConsultation => '5', - ApprovedLaneV1::Notary => '6', }), anchor_digest: digest(match lane { ApprovedLaneV1::RelayPublic => '7', ApprovedLaneV1::RelayConsultation => '8', - ApprovedLaneV1::Notary => '9', }), lane_scoped_reviewed_input_digest: digest(reviewed_digest), signing_input_closure_digest: digest(signing_digest), - interfaces: CrossLaneInterfaceDigestsV1 { - consultation_relay_notary: interface_digest.map(digest), - }, } } @@ -120,12 +113,7 @@ pub fn identity(lane: ApprovedLaneV1, project: &str) -> ProductAcceptanceIdentit project: project.to_string(), environment: "production".to_string(), lane: lane.acceptance_lane(), - product: match lane { - ApprovedLaneV1::RelayPublic | ApprovedLaneV1::RelayConsultation => { - ProductAcceptanceProductV1::RegistryRelay - } - ApprovedLaneV1::Notary => ProductAcceptanceProductV1::RegistryNotary, - }, + product: ProductAcceptanceProductV1::RegistryRelay, stream: format!("{project}-stream"), instance: format!("{project}-{lane}"), } @@ -141,7 +129,6 @@ pub fn verified( previous_config_digest: Option, reviewed_digest: char, signing_digest: char, - interface_digest: Option, ) -> VerifiedApprovedLaneV1 { VerifiedApprovedLaneV1::from_independent_verification( lane, @@ -149,22 +136,15 @@ pub fn verified( sequence, digest(config_digest), previous_config_digest.map(digest), - entry( - lane, - generation, - reviewed_digest, - signing_digest, - interface_digest, - ), + entry(lane, generation, reviewed_digest, signing_digest), ) .expect("test lane evidence is structurally verified") } pub fn initial_lane(lane: ApprovedLaneV1) -> VerifiedApprovedLaneV1 { - let (reviewed, signing, interface) = match lane { - ApprovedLaneV1::RelayPublic => ('a', 'd', None), - ApprovedLaneV1::RelayConsultation => ('b', 'e', Some('c')), - ApprovedLaneV1::Notary => ('c', 'f', Some('c')), + let (reviewed, signing) = match lane { + ApprovedLaneV1::RelayPublic => ('a', 'd'), + ApprovedLaneV1::RelayConsultation => ('b', 'e'), }; verified( lane, @@ -174,20 +154,17 @@ pub fn initial_lane(lane: ApprovedLaneV1) -> VerifiedApprovedLaneV1 { match lane { ApprovedLaneV1::RelayPublic => 'a', ApprovedLaneV1::RelayConsultation => 'b', - ApprovedLaneV1::Notary => 'c', }, None, reviewed, signing, - interface, ) } pub fn replacement_lane(lane: ApprovedLaneV1) -> VerifiedApprovedLaneV1 { - let (reviewed, signing, interface, previous) = match lane { - ApprovedLaneV1::RelayPublic => ('7', '8', None, 'a'), - ApprovedLaneV1::RelayConsultation => ('7', '8', Some('9'), 'b'), - ApprovedLaneV1::Notary => ('8', '9', Some('9'), 'c'), + let (reviewed, signing, previous) = match lane { + ApprovedLaneV1::RelayPublic => ('7', '8', 'a'), + ApprovedLaneV1::RelayConsultation => ('8', '9', 'b'), }; verified( lane, @@ -197,12 +174,10 @@ pub fn replacement_lane(lane: ApprovedLaneV1) -> VerifiedApprovedLaneV1 { match lane { ApprovedLaneV1::RelayPublic => 'd', ApprovedLaneV1::RelayConsultation => 'e', - ApprovedLaneV1::Notary => 'f', }, Some(previous), reviewed, signing, - interface, ) } @@ -222,6 +197,5 @@ pub fn path_set(root: &Path) -> approved_set::InitialApprovedSetInputs { approved_set::InitialApprovedSetInputs { relay_public: root.join("relay-public"), relay_consultation: root.join("relay-consultation"), - notary: root.join("notary"), } } diff --git a/crates/registryctl/tests/cli_contract.rs b/crates/registryctl/tests/cli_contract.rs index ef9f82498..4faaf1916 100644 --- a/crates/registryctl/tests/cli_contract.rs +++ b/crates/registryctl/tests/cli_contract.rs @@ -152,6 +152,7 @@ fn removed_pre_1_0_roots_and_aliases_are_usage_errors() { "authoring", "project", "bruno", + "__registryctl-cel-worker-v1", ] { let output = run(&[root]); assert_eq!( @@ -220,6 +221,48 @@ fn stable_flags_have_strict_values_and_documented_meanings() { assert_eq!(run(&["check", "--format", "jsonl"]).status.code(), Some(2)); } +#[test] +fn trust_lane_selectors_are_relay_only() { + let anchor_help = stdout(&["trust", "anchor", "create", "--help"]); + assert!( + anchor_help.contains("possible values: relay-public, relay-consultation"), + "{anchor_help}" + ); + assert!(!anchor_help.contains("notary"), "{anchor_help}"); + + let approved_set_help = stdout(&["trust", "approved-set", "assemble", "--help"]); + assert!( + approved_set_help.contains("--relay-public"), + "{approved_set_help}" + ); + assert!( + approved_set_help.contains("--relay-consultation"), + "{approved_set_help}" + ); + assert!(!approved_set_help.contains("notary"), "{approved_set_help}"); + + assert_eq!( + run(&[ + "trust", + "anchor", + "create", + "--lane", + "notary", + "--input", + "unused", + "--public-key", + "unused", + "--threshold", + "1", + "--output-file", + "unused", + ]) + .status + .code(), + Some(2) + ); +} + #[test] fn check_explain_adds_the_classifier_safe_review_to_human_output() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -252,7 +295,8 @@ fn check_explain_adds_the_classifier_safe_review_to_human_output() { "Explanation: registry.project.explanation.v1 for fictional-citizen-registry in local", "integration person-record", "[authored, effective]", - "", + "environment local /relay/consultation/client_id = ", + "", "Full provenance and constraint metadata: rerun with --format json.", ] { assert!( @@ -531,7 +575,6 @@ fn trace_renders_the_selected_synthetic_fixture_in_human_output() { "inputs: person_id", "calls:", "outputs: active", - "claims: person-active, person-record-exists", "outcome: match", ] { assert!( diff --git a/crates/registryctl/tests/cli_trust_journey.rs b/crates/registryctl/tests/cli_trust_journey.rs index 3eb93f782..52d80d8a2 100644 --- a/crates/registryctl/tests/cli_trust_journey.rs +++ b/crates/registryctl/tests/cli_trust_journey.rs @@ -65,7 +65,7 @@ fn sign_verify_and_assemble_share_one_signed_artifact_root() { let signing_inputs = project.join(".registry-stack/build/local/signing-inputs"); let mut lane_roots = Vec::new(); - for lane in ["relay-public", "relay-consultation", "notary"] { + for lane in ["relay-public", "relay-consultation"] { let input = signing_inputs.join(lane); let anchor = anchors.join(format!("{lane}.json")); successful(vec![ @@ -159,8 +159,6 @@ fn sign_verify_and_assemble_share_one_signed_artifact_root() { lane_roots[0].display().to_string(), "--relay-consultation".to_string(), lane_roots[1].display().to_string(), - "--notary".to_string(), - lane_roots[2].display().to_string(), "--output-file".to_string(), approved_set.display().to_string(), ]); diff --git a/crates/registryctl/tests/deployment_seams.rs b/crates/registryctl/tests/deployment_seams.rs index b66f1ef1c..3d1f58b5f 100644 --- a/crates/registryctl/tests/deployment_seams.rs +++ b/crates/registryctl/tests/deployment_seams.rs @@ -42,31 +42,6 @@ use sha2::{Digest as _, Sha256}; static PROCESS_PATH_LOCK: Mutex<()> = Mutex::new(()); -fn shell_fence_after_heading<'a>(markdown: &'a str, heading: &str, occurrence: usize) -> &'a str { - let section = markdown - .split_once(heading) - .unwrap_or_else(|| panic!("missing Markdown heading {heading}")) - .1; - let section = section - .split_once("\n## ") - .map_or(section, |(current, _next)| current); - let mut remainder = section; - for index in 1..=occurrence { - remainder = remainder - .split_once("```sh\n") - .unwrap_or_else(|| panic!("missing shell fence {occurrence} after {heading}")) - .1; - let (block, after) = remainder - .split_once("\n```") - .unwrap_or_else(|| panic!("unterminated shell fence after {heading}")); - if index == occurrence { - return block; - } - remainder = after; - } - unreachable!("shell fence occurrence is one-based") -} - struct ProcessPathGuard(Option); impl ProcessPathGuard { @@ -98,8 +73,6 @@ fn plan() -> DeploymentPlanV1 { DeploymentPlanV1::managed_single_node(&ManagedTopologyImagesV1 { relay: image("registry-relay", 'a'), relay_platform: OciPlatformV1::LinuxAmd64, - notary: image("registry-notary", 'b'), - notary_platform: OciPlatformV1::LinuxAmd64, postgresql_state_plane: image("postgresql", 'c'), postgresql_state_plane_platform: OciPlatformV1::LinuxAmd64, }) @@ -187,23 +160,6 @@ fn product_runtime(product: &str, lane: &str) -> LockedProductRuntimeV1 { vec![database_ca.clone()], vec![database_ca.clone()], ), - "notary" => ( - vec![database_ca.clone()], - vec![], - vec![ - database_ca, - secret( - "notary-relay-workload-credential", - "/run/secrets/relay-workload-token", - "65532", - ), - secret( - "notary-signing-key", - "/run/secrets/notary-signing-key.jwk", - "65532", - ), - ], - ), _ => unreachable!(), }; let command = |name: &str| vec![format!("/{product}"), name.to_string()]; @@ -255,10 +211,6 @@ fn operator_files() -> Vec { "REGISTRY_RELAY_RUNTIME_PASSWORD", "REGISTRY_RELAY_MAINTENANCE_PASSWORD", "REGISTRY_RELAY_READER_PASSWORD", - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - "REGISTRY_NOTARY_READER_PASSWORD", ]; deployment::OPERATOR_FILE_IDS .iter() @@ -269,10 +221,6 @@ fn operator_files() -> Vec { LockedOperatorFileFormatV1::PemCertificate } else if id.ends_with("-private-key") { LockedOperatorFileFormatV1::PemPrivateKey - } else if *id == "notary-signing-key" { - LockedOperatorFileFormatV1::JsonWebKey - } else if *id == "notary-relay-workload-credential" { - LockedOperatorFileFormatV1::CompactJwt } else { LockedOperatorFileFormatV1::Opaque }; @@ -306,7 +254,6 @@ fn runtime() -> LockedRuntimeMappingV1 { LockedRuntimeMappingV1 { relay_public: product_runtime("registry-relay", "relay-public"), relay_consultation: product_runtime("registry-relay", "relay-consultation"), - notary: product_runtime("registry-notary", "notary"), postgresql_state_plane: LockedPostgresqlRuntimeV1 { serve: LockedRuntimeActionV1 { command: vec!["/postgresql-state-plane".to_string()], @@ -385,21 +332,13 @@ fn write_source_tree(root: &Path, lane: ApprovedLaneV1) { fs::create_dir_all(bundle_dir.join("config")).unwrap(); fs::create_dir_all(bundle_dir.join("descriptors")).unwrap(); fs::create_dir_all(&anchor_dir).unwrap(); - let config = if lane == ApprovedLaneV1::Notary { - "config/notary.yaml" - } else { - "config/relay.yaml" - }; + let config = "config/relay.yaml"; fs::write(bundle_dir.join(config), "value-free: true\n").unwrap(); let environment_key = format!( "REGISTRY_{}_TEST_SECRET", lane_id.replace('-', "_").to_ascii_uppercase() ); - let product = if lane == ApprovedLaneV1::Notary { - "registry-notary" - } else { - "registry-relay" - }; + let product = "registry-relay"; fs::write( bundle_dir.join("descriptors/secret-consumers.json"), serde_json::to_vec(&json!({ @@ -446,21 +385,12 @@ fn package_fixture() -> PackageFixture { "approved", 'a', 'd', - None, ), relay_consultation: approved_set_support::entry( ApprovedLaneV1::RelayConsultation, "approved", 'b', 'e', - Some('c'), - ), - notary: approved_set_support::entry( - ApprovedLaneV1::Notary, - "approved", - 'c', - 'f', - Some('c'), ), }, }; @@ -619,6 +549,23 @@ fn initialization_effective(fixture: &PackageFixture) -> Value { .unwrap() } +#[test] +fn generated_deployment_surface_is_relay_only() { + let fixture = package_fixture(); + for relative in [ + "generated/deployment-plan.v1.json", + "generated/compose.yaml", + "generated/compose.initialize.yaml", + "generated/RUNBOOK.md", + ] { + let rendered = fs::read_to_string(fixture.package.join(relative)).unwrap(); + assert!( + !rendered.to_ascii_lowercase().contains("notary"), + "{relative} retained a Notary service, action, secret, or volume:\n{rendered}" + ); + } +} + #[test] fn deployment_plan_round_trips_the_closed_topology() { let plan = plan(); @@ -633,11 +580,7 @@ fn deployment_plan_round_trips_the_closed_topology() { }; assert_eq!(product("relay-public")["secret_consumers"], json!([])); assert_eq!(product("relay-consultation")["secret_consumers"], json!([])); - assert_eq!( - product("notary")["secret_consumers"], - json!(["notary-relay-workload-credential", "notary-signing-key"]) - ); - for id in ["relay-public", "relay-consultation", "notary"] { + for id in ["relay-public", "relay-consultation"] { assert!(!product(id)["mount_roles"] .as_array() .unwrap() @@ -924,7 +867,7 @@ fn edited_generated_file_is_invalid_and_requires_a_new_output() { fn hard_invariant_changes_are_invalid() { let fixture = package_fixture(); let mut ordinary = fixture.models.ordinary.clone(); - ordinary["services"]["registry-notary"]["command"] = json!(["/operator-command"]); + ordinary["services"]["registry-relay-public"]["command"] = json!(["/operator-command"]); let effective = EffectiveComposeModelsV1 { standalone_ordinary: ordinary, initialization: initialization_effective(&fixture), @@ -949,8 +892,8 @@ fn supporting_entrypoints_and_required_dependencies_are_hard_invariants() { ); let mut ordinary = fixture.models.ordinary.clone(); ordinary["services"]["registry-postgres"]["entrypoint"] = json!(["/operator-entrypoint"]); - ordinary["services"]["registry-notary"]["depends_on"]["registry-postgres"]["required"] = - json!(false); + ordinary["services"]["registry-relay-consultation"]["depends_on"]["registry-postgres"] + ["required"] = json!(false); let effective = EffectiveComposeModelsV1 { standalone_ordinary: ordinary, initialization: initialization_effective(&fixture), @@ -964,7 +907,8 @@ fn supporting_entrypoints_and_required_dependencies_are_hard_invariants() { assert!(report .violations .iter() - .any(|violation| violation.contains("registry-notary changed its locked depends_on"))); + .any(|violation| violation + .contains("registry-relay-consultation changed its locked depends_on"))); } #[test] @@ -973,8 +917,7 @@ fn binding_contains_only_locators_not_secret_values() { binding.validate().unwrap(); let rendered = serde_norway::to_string(&binding).unwrap(); assert!(!rendered.contains("edge_network_name")); - assert!(rendered.contains("operator/secrets/notary-signing-key")); - for lane in ["relay-public", "relay-consultation", "notary"] { + for lane in ["relay-public", "relay-consultation"] { assert!(rendered.contains(&format!("operator/secrets/{lane}-environment"))); assert!(!rendered.contains(&format!("{lane}-serve-environment"))); assert!(!rendered.contains(&format!("{lane}-prepare-environment"))); @@ -992,9 +935,10 @@ fn binding_rejects_shared_or_aliased_operator_file_locators() { .get("relay-public-environment") .unwrap() .clone(); - shared - .secret_files - .insert("notary-environment".to_string(), relay_environment); + shared.secret_files.insert( + "relay-consultation-environment".to_string(), + relay_environment, + ); let error = shared.validate().unwrap_err(); assert!(error .to_string() @@ -1002,8 +946,8 @@ fn binding_rejects_shared_or_aliased_operator_file_locators() { let mut aliased = binding(); aliased.secret_files.insert( - "notary-environment".to_string(), - "operator//secrets/notary-environment".to_string(), + "relay-consultation-environment".to_string(), + "operator//secrets/relay-consultation-environment".to_string(), ); let error = aliased.validate().unwrap_err(); assert!(error @@ -1028,12 +972,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { assert_eq!( delta_services.keys().cloned().collect::>(), vec![ - "registry-notary-accept-state", - "registry-notary-actions-stage-secrets", - "registry-notary-initialize", - "registry-notary-prepare-state", - "registry-notary-preview-state", - "registry-notary-verify-state", "registry-postgres", "registry-postgres-bootstrap", "registry-postgresql-actions-stage-secrets", @@ -1059,10 +997,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { "registry-relay-consultation-initialize", "registry-relay-consultation-actions-stage-secrets", ), - ( - "registry-notary-prepare-state", - "registry-notary-actions-stage-secrets", - ), ( "registry-postgres-bootstrap", "registry-postgresql-actions-stage-secrets", @@ -1079,7 +1013,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { for service_name in [ "registry-relay-consultation-prepare-state", "registry-relay-consultation-initialize", - "registry-notary-prepare-state", ] { assert_eq!( delta_services[service_name]["depends_on"]["registry-postgres"], @@ -1094,7 +1027,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { for service_name in [ "registry-relay-public-prepare-state", "registry-relay-public-initialize", - "registry-notary-initialize", ] { assert!(delta_services[service_name]["depends_on"] .as_object() @@ -1106,7 +1038,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { for service in [ "registry-relay-public-prepare-state", "registry-relay-consultation-prepare-state", - "registry-notary-prepare-state", ] { let service = &delta_services[service]; let targets = service["volumes"] @@ -1123,7 +1054,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { for service in [ "registry-relay-public-initialize", "registry-relay-consultation-initialize", - "registry-notary-initialize", ] { let service = &delta_services[service]; assert!(service.get("volumes").is_some()); @@ -1143,12 +1073,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { true, false, ), - ( - "registry-notary-preview-state", - &expected_runtime.notary.preview_state, - true, - false, - ), ( "registry-relay-public-verify-state", &expected_runtime.relay_public.verify_state, @@ -1161,12 +1085,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { true, false, ), - ( - "registry-notary-verify-state", - &expected_runtime.notary.verify_state, - true, - false, - ), ( "registry-relay-public-accept-state", &expected_runtime.relay_public.accept_state, @@ -1179,12 +1097,6 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { false, true, ), - ( - "registry-notary-accept-state", - &expected_runtime.notary.accept_state, - false, - true, - ), ] { let service = &delta_services[service_name]; assert_eq!(service["command"], json!(action.command)); @@ -1263,7 +1175,7 @@ fn initialization_is_a_delta_and_prepare_state_has_no_acceptance_authority() { fn secret_staging_is_isolated_and_consumers_receive_only_read_only_volumes() { let fixture = package_fixture(); let services = fixture.models.ordinary["services"].as_object().unwrap(); - assert_eq!(services.len(), 7); + assert_eq!(services.len(), 5); let expected_stagers = [ ( "registry-postgresql-stage-secrets", @@ -1285,18 +1197,6 @@ fn secret_staging_is_isolated_and_consumers_receive_only_read_only_volumes() { )], vec!["postgresql-tls-certificate"], ), - ( - "registry-notary-stage-secrets", - vec![( - "registry-operator-files-notary-serve", - "/registryctl-stage/output/notary-serve", - )], - vec![ - "notary-relay-workload-credential", - "notary-signing-key", - "postgresql-tls-certificate", - ], - ), ]; assert!(services.get("registry-runtime-stage-secrets").is_none()); for (service_name, expected_outputs, expected_sources) in expected_stagers { @@ -1361,14 +1261,6 @@ fn secret_staging_is_isolated_and_consumers_receive_only_read_only_volumes() { ], vec!["postgresql-tls-certificate"], ), - ( - "registry-notary-actions-stage-secrets", - vec![( - "registry-operator-files-notary-prepare", - "/registryctl-stage/output/notary-prepare", - )], - vec!["postgresql-tls-certificate"], - ), ]; for (service_name, expected_outputs, expected_sources) in expected_action_stagers { assert!( @@ -1413,17 +1305,12 @@ fn secret_staging_is_isolated_and_consumers_receive_only_read_only_volumes() { "registry-postgres", "registry-relay-public", "registry-relay-consultation", - "registry-notary", ] { let service = &services[service_name]; assert!(service.get("cap_add").is_none()); assert!(service.get("secrets").is_none()); } - for service_name in [ - "registry-postgres", - "registry-relay-consultation", - "registry-notary", - ] { + for service_name in ["registry-postgres", "registry-relay-consultation"] { let secret_mount = services[service_name]["volumes"] .as_array() .unwrap() @@ -1462,15 +1349,6 @@ fn secret_staging_is_isolated_and_consumers_receive_only_read_only_volumes() { .unwrap()["source"], "registry-operator-files-relay-consultation-serve" ); - assert_eq!( - services["registry-notary"]["volumes"] - .as_array() - .unwrap() - .iter() - .find(|mount| mount["target"] == "/run/secrets") - .unwrap()["source"], - "registry-operator-files-notary-serve" - ); } #[test] @@ -1479,12 +1357,12 @@ fn secret_stager_cannot_gain_cross_owner_inputs_or_outputs() { let mut ordinary = fixture.models.ordinary.clone(); let stager = &mut ordinary["services"]["registry-relay-consultation-stage-secrets"]; stager["secrets"].as_array_mut().unwrap().push(json!({ - "source": "registry-notary-signing-key", - "target": "notary-signing-key" + "source": "registry-postgresql-admin-password", + "target": "postgresql-admin-password" })); stager["volumes"].as_array_mut().unwrap().push(json!({ "type": "volume", - "source": "registry-operator-files-notary-serve", + "source": "registry-operator-files-postgresql-serve", "target": "/registryctl-stage/cross-action", "read_only": false })); @@ -1531,7 +1409,6 @@ fn package_normalizes_locators_and_mounts_digest_specific_lane_inputs() { let service = match lane { ApprovedLaneV1::RelayPublic => "registry-relay-public", ApprovedLaneV1::RelayConsultation => "registry-relay-consultation", - ApprovedLaneV1::Notary => "registry-notary", }; let bundle_mount = fixture.models.ordinary["services"][service]["volumes"] .as_array() @@ -1556,11 +1433,9 @@ fn package_normalizes_locators_and_mounts_digest_specific_lane_inputs() { fs::read(consultation_anchors.join("transition.json")).unwrap(), fs::read(consultation_anchors.join("history/0001.transition.json")).unwrap() ); - for lane in ["relay-public", "notary"] { - let anchors = fixture.package.join("generated/anchors").join(lane); - assert!(!anchors.join("previous-anchor.json").exists()); - assert!(!anchors.join("transition.json").exists()); - } + let public_anchors = fixture.package.join("generated/anchors/relay-public"); + assert!(!public_anchors.join("previous-anchor.json").exists()); + assert!(!public_anchors.join("transition.json").exists()); } #[test] @@ -1616,15 +1491,10 @@ fn ordinary_networking_publishes_only_public_applications_on_loopback() { assert!(services["registry-relay-consultation"] .get("ports") .is_none()); - assert_eq!( - services["registry-notary"]["ports"], - published_port("127.0.0.1", 4255, 8081) - ); assert!(services["registry-postgres"].get("ports").is_none()); for service_name in [ "registry-relay-public", "registry-relay-consultation", - "registry-notary", "registry-postgres", ] { assert_eq!( @@ -1645,13 +1515,10 @@ fn ordinary_networking_publishes_only_public_applications_on_loopback() { .collect::>(); assert_eq!( published, - vec![ - ("registry-notary", published_port("127.0.0.1", 4255, 8081),), - ( - "registry-relay-public", - published_port("127.0.0.1", 4242, 8080), - ), - ] + vec![( + "registry-relay-public", + published_port("127.0.0.1", 4242, 8080), + )] ); for service in services.as_object().unwrap().values() { assert!(service.get("ipc").is_none()); @@ -1665,7 +1532,6 @@ fn ordinary_networking_publishes_only_public_applications_on_loopback() { "registry-postgres-bootstrap", "registry-relay-consultation-prepare-state", "registry-relay-consultation-initialize", - "registry-notary-prepare-state", ] { assert_eq!( initialization[service_name]["networks"], @@ -1676,16 +1542,12 @@ fn ordinary_networking_publishes_only_public_applications_on_loopback() { for service_name in [ "registry-relay-public-prepare-state", "registry-relay-public-initialize", - "registry-notary-initialize", "registry-relay-public-preview-state", "registry-relay-consultation-preview-state", - "registry-notary-preview-state", "registry-relay-public-accept-state", "registry-relay-consultation-accept-state", - "registry-notary-accept-state", "registry-relay-public-verify-state", "registry-relay-consultation-verify-state", - "registry-notary-verify-state", ] { assert!(initialization[service_name].get("networks").is_none()); assert_eq!(initialization[service_name]["network_mode"], "none"); @@ -1703,8 +1565,6 @@ fn durable_volumes_are_stable_and_scratch_volumes_are_project_scoped() { "registry-relay-public-audit", "registry-relay-consultation-state", "registry-relay-consultation-audit", - "registry-notary-state", - "registry-notary-audit", ] { assert_eq!( volumes[name], @@ -1731,7 +1591,6 @@ fn generated_services_use_fixed_bounded_local_logging() { for service_name in [ "registry-relay-public", "registry-relay-consultation", - "registry-notary", "registry-postgres", ] { assert_eq!( @@ -1745,17 +1604,12 @@ fn generated_services_use_fixed_bounded_local_logging() { "registry-relay-public-initialize", "registry-relay-consultation-prepare-state", "registry-relay-consultation-initialize", - "registry-notary-prepare-state", - "registry-notary-initialize", "registry-relay-public-preview-state", "registry-relay-consultation-preview-state", - "registry-notary-preview-state", "registry-relay-public-accept-state", "registry-relay-consultation-accept-state", - "registry-notary-accept-state", "registry-relay-public-verify-state", "registry-relay-consultation-verify-state", - "registry-notary-verify-state", ] { assert_eq!( fixture.models.initialization["services"][service_name]["logging"], @@ -1765,7 +1619,6 @@ fn generated_services_use_fixed_bounded_local_logging() { for service_name in [ "registry-postgresql-stage-secrets", "registry-relay-consultation-stage-secrets", - "registry-notary-stage-secrets", ] { assert!(fixture.models.ordinary["services"][service_name] .get("logging") @@ -1796,8 +1649,6 @@ fn top_level_secrets_exclude_environment_files() { assert_eq!( secrets.keys().map(String::as_str).collect::>(), vec![ - "registry-notary-relay-workload-credential", - "registry-notary-signing-key", "registry-postgresql-admin-password", "registry-postgresql-tls-certificate", "registry-postgresql-tls-private-key", @@ -1806,7 +1657,6 @@ fn top_level_secrets_exclude_environment_files() { for environment in [ "relay-public-environment", "relay-consultation-environment", - "notary-environment", "postgresql-bootstrap-environment", ] { assert!(!secrets.contains_key(&format!("registry-{environment}"))); @@ -1820,7 +1670,7 @@ fn exact_mount_source_type_access_and_lane_are_hard_invariants() { ( "source", json!( - "./bundles/notary/ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + "./bundles/relay-consultation/ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" ), ), ("type", json!("volume")), @@ -2112,7 +1962,7 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { &fs::read(fixture.package.join("generated/operator-files.v1.json")).unwrap(), ) .unwrap(); - for lane in ["relay-public", "relay-consultation", "notary"] { + for lane in ["relay-public", "relay-consultation"] { let expected_key = format!( "REGISTRY_{}_TEST_SECRET", lane.replace('-', "_").to_ascii_uppercase() @@ -2156,7 +2006,6 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { for service in [ "registry-relay-public-verify-state", "registry-relay-consultation-verify-state", - "registry-notary-verify-state", ] { assert!(runbook.contains(service)); } @@ -2171,7 +2020,6 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { } let serving_stages = [ "registry-relay-consultation-stage-secrets", - "registry-notary-stage-secrets", "registry-postgresql-stage-secrets", ]; for stage in serving_stages { @@ -2185,7 +2033,6 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { assert!(!runbook.contains("registry-relay-public-stage-secrets")); let action_stages = [ "registry-relay-consultation-actions-stage-secrets", - "registry-notary-actions-stage-secrets", "registry-postgresql-actions-stage-secrets", ]; for stage in action_stages { @@ -2209,23 +2056,20 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { assert!(runbook.contains(&format!( "{first_install_config}\n{action_staging_sequence}\n{action_compose} run --rm registry-postgres-bootstrap" ))); - let initialize_notary = runbook.find("registry-notary-initialize").unwrap(); - let first_verify_public = runbook[initialize_notary..] + let initialize_consultation = runbook + .find("registry-relay-consultation-initialize") + .unwrap(); + let first_verify_public = runbook[initialize_consultation..] .find("registry-relay-public-verify-state") - .map(|offset| initialize_notary + offset) + .map(|offset| initialize_consultation + offset) .unwrap(); let first_verify_consultation = runbook[first_verify_public..] .find("registry-relay-consultation-verify-state") .map(|offset| first_verify_public + offset) .unwrap(); - let first_verify_notary = runbook[first_verify_consultation..] - .find("registry-notary-verify-state") - .map(|offset| first_verify_consultation + offset) - .unwrap(); assert!( - initialize_notary < first_verify_public + initialize_consultation < first_verify_public && first_verify_public < first_verify_consultation - && first_verify_consultation < first_verify_notary ); assert!(runbook.contains(&format!( "{serving_staging_sequence}\n{ordinary_compose} up --detach --wait --wait-timeout 120" @@ -2249,27 +2093,23 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { let preview_consultation = runbook .find("registry-relay-consultation-preview-state") .unwrap(); - let preview_notary = runbook.find("registry-notary-preview-state").unwrap(); - let stop = runbook[preview_notary..] + let stop = runbook[preview_consultation..] .find("generated/compose.yaml stop") - .map(|offset| preview_notary + offset) + .map(|offset| preview_consultation + offset) .unwrap(); let accept_public = runbook.find("registry-relay-public-accept-state").unwrap(); let accept_consultation = runbook .find("registry-relay-consultation-accept-state") .unwrap(); - let accept_notary = runbook.find("registry-notary-accept-state").unwrap(); assert!( preview_public < preview_consultation - && preview_consultation < preview_notary - && preview_notary < stop + && preview_consultation < stop && stop < accept_public && accept_public < accept_consultation - && accept_consultation < accept_notary ); - let update_start = runbook[accept_notary..] + let update_start = runbook[accept_consultation..] .find("generated/compose.yaml up --detach --wait --wait-timeout 120") - .map(|offset| accept_notary + offset) + .map(|offset| accept_consultation + offset) .unwrap(); let post_start_verify_public = runbook[update_start..] .find("registry-relay-public-verify-state") @@ -2279,14 +2119,9 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { .find("registry-relay-consultation-verify-state") .map(|offset| post_start_verify_public + offset) .unwrap(); - let post_start_verify_notary = runbook[post_start_verify_consultation..] - .find("registry-notary-verify-state") - .map(|offset| post_start_verify_consultation + offset) - .unwrap(); assert!( update_start < post_start_verify_public && post_start_verify_public < post_start_verify_consultation - && post_start_verify_consultation < post_start_verify_notary ); assert!(runbook .contains("Do not stop any service or accept any lane unless every preview succeeds")); @@ -2294,39 +2129,10 @@ fn runbook_covers_first_install_start_update_and_recovery_without_reset() { assert!(!runbook.contains("registry-runtime-stage-secrets")); assert!(!runbook.contains("registry-relay-public-serve-stage-secrets")); assert!(!runbook.contains("registry-relay-consultation-serve-stage-secrets")); - assert!(!runbook.contains("registry-notary-serve-stage-secrets")); assert!(!runbook.contains("registry-postgresql-serve-stage-secrets")); assert!(!runbook.contains("--force")); } -#[test] -fn public_operator_command_blocks_match_the_generated_runbook() { - let fixture = package_fixture(); - let runbook = fs::read_to_string(fixture.package.join("generated/RUNBOOK.md")).unwrap(); - let standalone = include_str!( - "../../../docs/site/src/content/docs/operate/single-node-compose-behind-proxy.mdx" - ); - let update = - include_str!("../../../docs/site/src/content/docs/operate/upgrade-and-rollback.mdx"); - - assert_eq!( - shell_fence_after_heading(standalone, "## Initialize each product once", 2), - shell_fence_after_heading(&runbook, "## First installation only", 1), - ); - assert_eq!( - shell_fence_after_heading(standalone, "## Run the package standalone", 1), - shell_fence_after_heading(&runbook, "## Ordinary start and stop", 1), - ); - assert_eq!( - shell_fence_after_heading( - update, - "## Preview, accept, verify, and start the candidate", - 1, - ), - shell_fence_after_heading(&runbook, "## Product or image update", 2), - ); -} - #[test] fn production_verifier_succeeds_without_docker() { let fixture = package_fixture(); @@ -2417,43 +2223,6 @@ fn managed_package_rejects_project_local_file_datasets() { .contains("cannot package a project-local file dataset")); } -#[test] -fn managed_package_rejects_notary_loopback_but_accepts_private_service_http() { - let fixture = package_fixture(); - fs::remove_dir_all(&fixture.package).unwrap(); - let notary_config = fixture - .approved_set_file - .parent() - .unwrap() - .join("approved/notary/bundle/config/notary.yaml"); - fs::write( - ¬ary_config, - "evidence:\n relay:\n base_url: http://127.0.0.1:8080\n", - ) - .unwrap(); - let request = DeploymentGenerateRequestV1 { - approved_set_file: fixture.approved_set_file.clone(), - output_dir: fixture.package.clone(), - binding_file: None, - }; - assert!(generate_deployment_package_with_test_inputs( - request.clone(), - fixture.verified_inputs.clone(), - None, - ) - .unwrap_err() - .to_string() - .contains("loopback is not shared across workloads")); - - fs::write( - notary_config, - "evidence:\n relay:\n base_url: http://registry-relay-consultation:8080\n allow_insecure_private_network: true\n", - ) - .unwrap(); - generate_deployment_package_with_test_inputs(request, fixture.verified_inputs, None) - .expect("private service HTTP package renders"); -} - #[test] fn high_level_generation_derives_a_safe_binding_from_signed_identity() { let fixture = package_fixture(); @@ -2729,10 +2498,6 @@ fn ipv6_loopback_uses_unambiguous_compose_port_syntax() { report.models.ordinary["services"]["registry-relay-public"]["ports"], published_port("::1", 4242, 8080) ); - assert_eq!( - report.models.ordinary["services"]["registry-notary"]["ports"], - published_port("::1", 4255, 8081) - ); } #[test] @@ -2827,8 +2592,8 @@ fn python_release_lock_runtime_renders_compose_conformance() { for (label, source, replacement, diagnostic) in [ ( "product command drift", - r#""command":["product-action","serve"]"#, - r#""command":["product-action","drift"]"#, + r#""command":["product-action","relay-public","serve"]"#, + r#""command":["product-action","relay-public","drift"]"#, "exact supported command", ), ( @@ -2867,13 +2632,13 @@ fn python_release_lock_runtime_renders_compose_conformance() { json!(["relay-public-environment"]), ), ( - "verify serving credential", - "notary", + "verify credential", + "relay_public", "verify_state", "secret_files", json!([{ - "file_id": "notary-signing-key", - "target": "/run/secrets/notary-signing-key.jwk", + "file_id": "postgresql-tls-certificate", + "target": "/run/secrets/postgresql-ca.pem", "mode": "0400", "uid": "65532", "gid": "65532" @@ -2934,7 +2699,7 @@ fn python_release_lock_runtime_renders_compose_conformance() { let compose_path = package.join("generated/compose.yaml"); let mut compose: Value = serde_norway::from_slice(&fs::read(&compose_path).unwrap()).unwrap(); - compose["services"]["registry-notary"]["command"] = json!(["runtime-drift"]); + compose["services"]["registry-relay-public"]["command"] = json!(["runtime-drift"]); fs::write(&compose_path, serde_norway::to_string(&compose).unwrap()).unwrap(); let drift = runtime_parity_checker(&package, &payload); assert!(!drift.status.success(), "Compose drift was not detected"); diff --git a/crates/registryctl/tests/dev_runtime_core.rs b/crates/registryctl/tests/dev_runtime_core.rs index b83ec2999..3b9b3f974 100644 --- a/crates/registryctl/tests/dev_runtime_core.rs +++ b/crates/registryctl/tests/dev_runtime_core.rs @@ -77,9 +77,6 @@ mod release_lock { pub fn relay(&self) -> &str { unreachable!() } - pub fn notary(&self) -> &str { - unreachable!() - } pub fn postgresql_state_plane(&self) -> &str { unreachable!() } @@ -140,9 +137,6 @@ mod release_lock { pub fn relay_consultation(&self) -> &ProductRuntime { unreachable!() } - pub fn notary(&self) -> &ProductRuntime { - unreachable!() - } pub fn postgresql_state_plane(&self) -> &PostgresqlRuntime { unreachable!() } @@ -221,29 +215,20 @@ mod dev_credentials { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PreparedDevCredentialFiles { pub root: PathBuf, - pub caller_token: PathBuf, pub relay_match_token: Option, pub relay_no_match_token: Option, - pub workload_token: PathBuf, - pub workload_public_jwk: PathBuf, - pub workload_jwks: PathBuf, pub relay_public_prepare: PreparedDevActionCredentialFile, pub relay_public_initialize: PreparedDevActionCredentialFile, pub relay_public_serve: PreparedDevActionCredentialFile, pub relay_consultation_prepare: PreparedDevActionCredentialFile, pub relay_consultation_initialize: PreparedDevActionCredentialFile, pub relay_consultation_serve: PreparedDevActionCredentialFile, - pub notary_prepare: PreparedDevActionCredentialFile, - pub notary_initialize: PreparedDevActionCredentialFile, - pub notary_serve: PreparedDevActionCredentialFile, pub postgres_bootstrap: PreparedDevActionCredentialFile, pub postgres_admin_password: PathBuf, - pub notary_signing_key: PathBuf, pub postgres_tls_certificate: PathBuf, pub postgres_tls_private_key: PathBuf, pub source: Option, - pub issuance_public_jwk: Option, - pub lane_public_jwks: [PathBuf; 3], + pub lane_public_jwks: [PathBuf; 2], } impl PreparedDevCredentialClosure { @@ -347,34 +332,24 @@ mod dev_credentials { }); PreparedDevCredentialFiles { root: root.to_path_buf(), - caller_token: root.join("caller-token"), relay_match_token: self.relay_api_keys.then(|| root.join("relay-match-token")), relay_no_match_token: self .relay_api_keys .then(|| root.join("relay-no-match-token")), - workload_token: root.join("notary-relay-token"), - workload_public_jwk: root.join("notary-workload-public.jwk"), - workload_jwks: root.join("notary-workload-jwks.json"), relay_public_prepare: action("relay-public-prepare.env"), relay_public_initialize: action("relay-public-initialize.env"), relay_public_serve: action("relay-public-serve.env"), relay_consultation_prepare: action("relay-consultation-prepare.env"), relay_consultation_initialize: action("relay-consultation-initialize.env"), relay_consultation_serve: action("relay-consultation-serve.env"), - notary_prepare: action("notary-prepare.env"), - notary_initialize: action("notary-initialize.env"), - notary_serve: action("notary-serve.env"), postgres_bootstrap: action("postgres-bootstrap.env"), postgres_admin_password: root.join("postgres-admin-password"), - notary_signing_key: root.join("notary-signing-key.jwk"), postgres_tls_certificate: root.join("postgres-tls.crt"), postgres_tls_private_key: root.join("postgres-tls.key"), source, - issuance_public_jwk: None, lane_public_jwks: [ root.join("relay-public-lane.jwk"), root.join("relay-consultation-lane.jwk"), - root.join("notary-lane.jwk"), ], } } @@ -391,27 +366,18 @@ mod dev_credentials { fs::set_permissions(root, fs::Permissions::from_mode(0o700))?; } let mut paths = vec![ - (&files.caller_token, "test-caller-token"), - (&files.workload_token, "test-workload-token"), - (&files.workload_public_jwk, "{}"), - (&files.workload_jwks, "{\"keys\":[]}"), (&files.relay_public_prepare.host_path, "A=1\n"), (&files.relay_public_initialize.host_path, "A=1\n"), (&files.relay_public_serve.host_path, "A=1\n"), (&files.relay_consultation_prepare.host_path, "A=1\n"), (&files.relay_consultation_initialize.host_path, "A=1\n"), (&files.relay_consultation_serve.host_path, "A=1\n"), - (&files.notary_prepare.host_path, "A=1\n"), - (&files.notary_initialize.host_path, "A=1\n"), - (&files.notary_serve.host_path, "A=1\n"), (&files.postgres_bootstrap.host_path, "A=1\n"), (&files.postgres_admin_password, "postgres-admin-password"), - (&files.notary_signing_key, "{}"), (&files.postgres_tls_certificate, "certificate"), (&files.postgres_tls_private_key, "private-key"), (&files.lane_public_jwks[0], "{}"), (&files.lane_public_jwks[1], "{}"), - (&files.lane_public_jwks[2], "{}"), ]; if let Some(path) = &files.relay_match_token { paths.push((path, "test-relay-match-token")); @@ -485,9 +451,7 @@ mod project_authoring { pub relay_public_anchor: PathBuf, pub relay_consultation_bundle: PathBuf, pub relay_consultation_anchor: PathBuf, - pub notary_bundle: PathBuf, - pub notary_anchor: PathBuf, - pub lane_config_digests: [String; 3], + pub lane_config_digests: [String; 2], } pub struct ProjectBuildOptions { @@ -575,10 +539,6 @@ fn verified_release() -> VerifiedDevReleaseProjection { "ghcr.io/registrystack/registry-relay@sha256:{}", "a".repeat(64) ), - format!( - "ghcr.io/registrystack/registry-notary@sha256:{}", - "b".repeat(64) - ), format!("docker.io/library/postgres@sha256:{}", "c".repeat(64)), "2.24.0".to_string(), ) @@ -607,22 +567,12 @@ fn create_artifacts_generation(root: &Path, generation: &str) -> DevRuntimeArtif fs::write(&compose_file, "services: {}\n").unwrap(); let relay_public_bundle = bundles.join("relay-public"); let relay_consultation_bundle = bundles.join("relay-consultation"); - let notary_bundle = bundles.join("notary"); - for path in [ - &relay_public_bundle, - &relay_consultation_bundle, - ¬ary_bundle, - ] { + for path in [&relay_public_bundle, &relay_consultation_bundle] { fs::create_dir(path).unwrap(); } let relay_public_anchor = anchors.join("relay-public.json"); let relay_consultation_anchor = anchors.join("relay-consultation.json"); - let notary_anchor = anchors.join("notary.json"); - for path in [ - &relay_public_anchor, - &relay_consultation_anchor, - ¬ary_anchor, - ] { + for path in [&relay_public_anchor, &relay_consultation_anchor] { assert!(!path.exists()); } write_development_trust_material( @@ -639,21 +589,12 @@ fn create_artifacts_generation(root: &Path, generation: &str) -> DevRuntimeArtif ProductAcceptanceProductV1::RegistryRelay, "relay-consultation", ); - write_development_trust_material( - ¬ary_bundle, - ¬ary_anchor, - ProductAcceptanceLaneV1::Notary, - ProductAcceptanceProductV1::RegistryNotary, - "notary", - ); DevRuntimeArtifactInputs { compose_file, relay_public_bundle, relay_public_anchor, relay_consultation_bundle, relay_consultation_anchor, - notary_bundle, - notary_anchor, } } @@ -771,7 +712,7 @@ fn scenario(provider: DevSourceProvider, oauth_profile: DevOAuthProfile) -> Auth .then_some(SyntheticOAuthResponseCase::Valid), oauth_request: (oauth_profile != DevOAuthProfile::None).then(|| { AuthoredSyntheticOauthRequest { - audience: Some("registry-notary".to_string()), + audience: Some("registry-relay".to_string()), scope: Some("registry.read".to_string()), resource: Some("registry-source".to_string()), } @@ -782,115 +723,6 @@ fn scenario(provider: DevSourceProvider, oauth_profile: DevOAuthProfile) -> Auth } } -fn authorized_claim_result() -> serde_json::Value { - serde_json::json!({ - "evaluation_id": "evaluation-1", - "claim_id": "eligibility", - "claim_version": "1", - "subject_type": "Person", - "target_ref": { - "type": "Person", - "handle": "rnref:v1:test", - "identifier_schemes": [] - }, - "value": true, - "satisfied": true, - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json", - "issued_at": "2026-07-31T00:00:00Z", - "expires_at": null, - "provenance": { - "schema_version": "registry-notary-claim-provenance/v2", - "generated_by": { - "type": "claim_evaluation", - "service_id": "registry-notary", - "evaluation_id": "evaluation-1", - "claim_id": "eligibility", - "claim_version": "1" - }, - "used": {"relay_consultation_count": 1}, - "derived_from": [] - } - }) -} - -#[test] -fn authorized_evaluation_requires_the_committed_claim_outcome() { - let temporary = tempfile::tempdir().unwrap(); - let mut plan = DevRuntimePlan::derive(plan_input( - temporary.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - )) - .unwrap(); - let valid = serde_json::json!({"results": [authorized_claim_result()]}); - let valid_bytes = serde_json::to_vec(&valid).unwrap(); - assert_eq!( - validate_authorized_evaluation_response(&plan, &valid_bytes).unwrap(), - ["eligibility"] - ); - - for (field, unexpected) in [ - ("value", serde_json::json!(false)), - ("satisfied", serde_json::json!(false)), - ("disclosure", serde_json::json!("value")), - ] { - let mut changed = valid.clone(); - changed["results"][0][field] = unexpected; - assert!( - validate_authorized_evaluation_response(&plan, &serde_json::to_vec(&changed).unwrap()) - .is_err(), - "{field} mismatch must fail smoke" - ); - } - - let mut missing = valid.clone(); - missing["results"][0] - .as_object_mut() - .unwrap() - .remove("satisfied"); - assert!( - validate_authorized_evaluation_response(&plan, &serde_json::to_vec(&missing).unwrap()) - .is_err() - ); - - let duplicate = serde_json::json!({ - "results": [authorized_claim_result(), authorized_claim_result()] - }); - assert!(validate_authorized_evaluation_response( - &plan, - &serde_json::to_vec(&duplicate).unwrap() - ) - .is_err()); - - plan.scenario.expected_claim_results_sha256 = - dev_claim_results_commitment(vec![DevClaimResultExpectation { - claim_id: "eligibility".to_string(), - value: serde_json::Value::Null, - satisfied: None, - disclosure: "redacted".to_string(), - }]) - .unwrap(); - let mut redacted = valid; - redacted["results"][0]["value"] = serde_json::Value::Null; - redacted["results"][0]["satisfied"] = serde_json::Value::Null; - redacted["results"][0]["disclosure"] = serde_json::json!("redacted"); - assert!(validate_authorized_evaluation_response( - &plan, - &serde_json::to_vec(&redacted).unwrap() - ) - .is_ok()); - for field in ["value", "satisfied"] { - let mut missing = redacted.clone(); - missing["results"][0].as_object_mut().unwrap().remove(field); - assert!( - validate_authorized_evaluation_response(&plan, &serde_json::to_vec(&missing).unwrap()) - .is_err(), - "nullable {field} must still be present" - ); - } -} - fn plan_input( root: &Path, provider: DevSourceProvider, @@ -911,10 +743,6 @@ fn plan_input_generation( let build_manifest_bytes = b"{\"schema_version\":\"registry.project.artifact_manifest.v1\"}\n"; fs::write(&build_manifest_path, build_manifest_bytes).unwrap(); let relay_port = free_port(); - let mut notary_port = free_port(); - while notary_port == relay_port { - notary_port = free_port(); - } DevRuntimePlanInput { project_root: root, project_id: "citizen-registry".to_string(), @@ -933,7 +761,6 @@ fn plan_input_generation( default_fixture: "passing-default".to_string(), operator_source_binding_present: false, relay_port: Some(relay_port), - notary_port: Some(notary_port), }, scenarios: vec![scenario(provider, oauth_profile)], records_request: None, @@ -968,6 +795,39 @@ fn local_snapshot_plan_input(root: &Path) -> DevRuntimePlanInput { input } +#[test] +fn disposable_runtime_plan_and_credentials_are_relay_only() { + let temporary = tempfile::tempdir().unwrap(); + let input = plan_input( + temporary.path(), + DevSourceProvider::Http, + DevOAuthProfile::None, + ); + let credential_root = temporary.path().join("credential-inventory"); + input + .credentials + .materialize_owner_only(&credential_root) + .unwrap(); + let credential_names = fs::read_dir(&credential_root) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + let plan = DevRuntimePlan::derive(input).unwrap(); + + assert!( + plan.workloads + .iter() + .all(|workload| !workload.id.compose_service().contains("notary")), + "the disposable runtime plan must not contain a Notary workload" + ); + assert!( + credential_names + .iter() + .all(|name| !name.contains("notary") && !name.contains("workload")), + "the disposable credential inventory must not contain Notary material: {credential_names:?}" + ); +} + #[test] fn generic_plan_uses_locked_images_loopback_development_identity_and_exact_source_command() { for provider in [ @@ -1058,176 +918,6 @@ fn generic_plan_uses_locked_images_loopback_development_identity_and_exact_sourc } } -#[test] -fn credential_mount_inventory_is_lane_and_action_specific() { - let temp = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(plan_input( - temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::Oauth2Bearer, - )) - .unwrap(); - let notary = plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::Notary) - .unwrap(); - assert!(notary - .mounts - .iter() - .any(|mount| mount.container_path == "/run/secrets/relay-workload-token")); - let relay_consultation = plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::RelayConsultation) - .unwrap(); - assert!(!relay_consultation - .mounts - .iter() - .any(|mount| mount.container_path == "/run/secrets/relay-workload-token")); - let secret_targets = |action: &DevProductActionPlan| { - action - .mounts - .iter() - .filter(|mount| mount.container_path.starts_with("/run/secrets/")) - .map(|mount| mount.container_path.clone()) - .collect::>() - }; - let environment_inputs = |mounts: &[DevWorkloadMount]| { - mounts - .iter() - .filter(|mount| { - mount - .host_path - .extension() - .is_some_and(|extension| extension == "env") - }) - .map(|mount| { - mount - .host_path - .file_name() - .unwrap() - .to_string_lossy() - .into_owned() - }) - .collect::>() - }; - let public = plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::RelayPublic) - .unwrap(); - assert_eq!( - environment_inputs(&public.prepare_state_store.as_ref().unwrap().mounts), - BTreeSet::from(["relay-public-prepare.env".to_string()]) - ); - assert_eq!( - environment_inputs(&public.initialize_state.as_ref().unwrap().mounts), - BTreeSet::from(["relay-public-initialize.env".to_string()]) - ); - assert_eq!( - environment_inputs(&public.mounts), - BTreeSet::from(["relay-public-serve.env".to_string()]) - ); - assert_eq!( - environment_inputs( - &relay_consultation - .prepare_state_store - .as_ref() - .unwrap() - .mounts - ), - BTreeSet::from(["relay-consultation-prepare.env".to_string()]) - ); - assert_eq!( - environment_inputs(&relay_consultation.initialize_state.as_ref().unwrap().mounts), - BTreeSet::from(["relay-consultation-initialize.env".to_string()]) - ); - assert_eq!( - environment_inputs(&relay_consultation.mounts), - BTreeSet::from(["relay-consultation-serve.env".to_string()]) - ); - assert_eq!( - environment_inputs(¬ary.prepare_state_store.as_ref().unwrap().mounts), - BTreeSet::from(["notary-prepare.env".to_string()]) - ); - assert_eq!( - environment_inputs(¬ary.initialize_state.as_ref().unwrap().mounts), - BTreeSet::from(["notary-initialize.env".to_string()]) - ); - assert_eq!( - environment_inputs(¬ary.mounts), - BTreeSet::from(["notary-serve.env".to_string()]) - ); - assert!(secret_targets(public.prepare_state_store.as_ref().unwrap()).is_empty()); - assert!(secret_targets(public.initialize_state.as_ref().unwrap()).is_empty()); - let database_ca = BTreeSet::from(["/run/secrets/postgresql-ca.pem".to_string()]); - assert_eq!( - secret_targets(relay_consultation.prepare_state_store.as_ref().unwrap()), - database_ca - ); - assert_eq!( - secret_targets(relay_consultation.initialize_state.as_ref().unwrap()), - database_ca - ); - assert_eq!( - secret_targets(notary.prepare_state_store.as_ref().unwrap()), - database_ca - ); - assert_eq!( - secret_targets(notary.initialize_state.as_ref().unwrap()), - database_ca - ); - assert_eq!( - notary - .mounts - .iter() - .filter(|mount| mount.container_path.starts_with("/run/secrets/")) - .map(|mount| mount.container_path.clone()) - .collect::>(), - BTreeSet::from([ - "/run/secrets/notary-signing-key.jwk".to_string(), - "/run/secrets/postgresql-ca.pem".to_string(), - "/run/secrets/relay-workload-token".to_string(), - ]) - ); - assert!(relay_consultation.mounts.iter().any(|mount| { - mount.container_path == "/run/registry/dev-public/notary-workload-jwks.json" - && mount.read_only - && mount.kind == DevWorkloadMountKind::Bind - })); - for workload in plan - .workloads - .iter() - .filter(|workload| workload.id != DevWorkloadId::RelayConsultation) - { - assert!(workload.mounts.iter().all(|mount| { - mount.container_path != "/run/registry/dev-public/notary-workload-jwks.json" - })); - } - for action in plan - .workloads - .iter() - .flat_map(|workload| { - [ - workload.prepare_state_store.as_ref(), - workload.initialize_state.as_ref(), - ] - }) - .flatten() - { - assert!(action.mounts.iter().any(|mount| mount - .host_path - .extension() - .is_some_and(|value| value == "env"))); - assert!(!action.mounts.iter().any(|mount| { - mount - .container_path - .starts_with("/run/registry/synthetic-source-secrets") - })); - } -} - #[test] fn local_snapshot_mounts_one_bounded_workbook_only_into_relay_serve_workloads() { let temporary = tempfile::tempdir().unwrap(); @@ -1293,235 +983,11 @@ fn local_snapshot_cannot_shadow_runtime_owned_mounts() { let temporary = tempfile::tempdir().unwrap(); let mut input = local_snapshot_plan_input(temporary.path()); input.local_snapshot.as_mut().unwrap().container_path = - "/run/registry/dev-public/notary-workload-jwks.json".to_string(); + "/run/registry/dev-public/relay-public-signing-public.jwk".to_string(); assert!(DevRuntimePlan::derive(input).is_err()); } -#[test] -fn local_snapshot_rejects_changed_extra_escaped_and_oversized_project_files() { - let changed = tempfile::tempdir().unwrap(); - let changed_plan = DevRuntimePlan::derive(local_snapshot_plan_input(changed.path())).unwrap(); - let mut changed_controller = DevRuntimeController::new(FakeBackend::default()); - changed_controller.start(&changed_plan, true).unwrap(); - fs::write(changed.path().join("data.xlsx"), b"changed workbook").unwrap(); - assert!(validate_local_snapshot(&changed_plan).is_err()); - assert_eq!( - changed_controller - .status(&changed_plan) - .unwrap_err() - .category, - DevFailureCategory::ProjectBinding - ); - changed_controller.down(&changed_plan).unwrap(); - - let extra = tempfile::tempdir().unwrap(); - let mut extra_plan = DevRuntimePlan::derive(local_snapshot_plan_input(extra.path())).unwrap(); - let mount = extra_plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::RelayPublic) - .unwrap() - .mounts - .iter() - .find(|mount| mount.kind == DevWorkloadMountKind::ProjectFile) - .unwrap() - .clone(); - extra_plan - .workloads - .iter_mut() - .find(|workload| workload.id == DevWorkloadId::Notary) - .unwrap() - .mounts - .push(mount); - assert!(validate_local_snapshot(&extra_plan).is_err()); - - let escaped = tempfile::tempdir().unwrap(); - let external = tempfile::NamedTempFile::new().unwrap(); - let mut escaped_input = local_snapshot_plan_input(escaped.path()); - escaped_input.local_snapshot.as_mut().unwrap().host_path = - fs::canonicalize(external.path()).unwrap(); - escaped_input.local_snapshot.as_mut().unwrap().digest = - registry_platform_config::sha256_uri(&fs::read(external.path()).unwrap()); - assert!(DevRuntimePlan::derive(escaped_input).is_err()); - - let oversized = tempfile::tempdir().unwrap(); - let mut oversized_input = local_snapshot_plan_input(oversized.path()); - std::fs::OpenOptions::new() - .write(true) - .open(oversized.path().join("data.xlsx")) - .unwrap() - .set_len(MAX_LOCAL_SNAPSHOT_BYTES + 1) - .unwrap(); - oversized_input.local_snapshot.as_mut().unwrap().digest = - registry_platform_config::sha256_uri(b""); - assert!(DevRuntimePlan::derive(oversized_input).is_err()); -} - -#[cfg(unix)] -#[test] -fn local_snapshot_rejects_a_symlink_swap_after_planning() { - use std::os::unix::fs::symlink; - - let temporary = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(local_snapshot_plan_input(temporary.path())).unwrap(); - let external = tempfile::NamedTempFile::new().unwrap(); - fs::remove_file(temporary.path().join("data.xlsx")).unwrap(); - symlink(external.path(), temporary.path().join("data.xlsx")).unwrap(); - assert!(validate_local_snapshot(&plan).is_err()); -} - -#[test] -fn compose_networks_close_synthetic_mode_and_scope_operator_egress_and_secrets() { - let synthetic_temp = tempfile::tempdir().unwrap(); - let synthetic = DevRuntimePlan::derive(plan_input( - synthetic_temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - )) - .unwrap(); - let synthetic_compose = synthetic_temp - .path() - .join(".registry-stack/build/local/dev/synthetic-compose.json"); - render_closed_compose( - &synthetic_compose, - &synthetic.workloads, - DevSourceMode::Synthetic, - ) - .unwrap(); - let value: serde_json::Value = - serde_json::from_slice(&fs::read(synthetic_compose).unwrap()).unwrap(); - assert!(value["networks"].get("registry_egress").is_none()); - assert_eq!( - value["networks"]["registry_private"]["ipam"]["config"][0]["subnet"], - "10.89.0.0/24" - ); - assert_eq!( - value["services"]["registry-synthetic-source"]["networks"]["registry_private"] - ["ipv4_address"], - "10.89.0.3" - ); - assert_eq!( - value["services"]["registry-relay-consultation"]["networks"]["registry_private"] - ["ipv4_address"], - "10.89.0.4" - ); - let consultation_mounts = value["services"]["registry-relay-consultation"]["volumes"] - .as_array() - .unwrap(); - assert!(consultation_mounts.iter().any(|mount| { - mount["target"] == "/run/registry/dev-public/synthetic-source-tls.crt" - && mount["read_only"] == true - })); - assert!(consultation_mounts.iter().any(|mount| { - mount["target"] == "/run/registry/dev-public/notary-workload-jwks.json" - && mount["read_only"] == true - })); - let notary_mounts = value["services"]["registry-notary"]["volumes"] - .as_array() - .unwrap(); - assert!(notary_mounts.iter().all(|mount| { - mount["target"] != "/run/secrets/relay-consultation-ca.pem" - && mount["target"] != "/run/registry/dev-public/notary-workload-jwks.json" - })); - #[cfg(unix)] - { - let expected_user = format!( - "{}:{}", - rustix::process::geteuid().as_raw(), - rustix::process::getegid().as_raw() - ); - for (service, document) in value["services"].as_object().unwrap() { - match service.as_str() { - "registry-postgres" | "registry-postgres-bootstrap" => { - assert_eq!(document["user"], "999:999"); - } - "registry-postgres-stage-secrets" => { - assert_eq!(document["user"], "0:0"); - assert_eq!(document["network_mode"], "none"); - assert_eq!( - document["cap_add"], - serde_json::json!(["CHOWN", "DAC_READ_SEARCH"]) - ); - } - _ => { - assert_eq!(document["user"], expected_user); - assert_ne!(document["user"], "0:0"); - } - } - } - } - let postgres = &value["services"]["registry-postgres"]; - assert_eq!(postgres["read_only"], true); - assert_eq!(postgres["cap_drop"], serde_json::json!(["ALL"])); - assert_eq!( - postgres["environment"], - serde_json::json!([ - "POSTGRES_USER=registry_stack_bootstrap", - "POSTGRES_DB=postgres", - "POSTGRES_PASSWORD_FILE=/run/secrets/postgresql-admin-password", - "POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=peer" - ]) - ); - let stage_command = value["services"]["registry-postgres-stage-secrets"]["command"] - .as_array() - .unwrap() - .iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - .join("\n"); - assert!(stage_command.contains("/usr/bin/install -m 0400")); - assert!(stage_command.contains("/usr/bin/chown 999:999")); - assert!(!stage_command.contains("postgres-admin-password")); - assert_eq!( - value["services"]["registry-postgres-bootstrap"]["networks"]["registry_private"], - serde_json::json!({}) - ); - - let operator_temp = tempfile::tempdir().unwrap(); - let mut input = plan_input( - operator_temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - ); - input.development.source_mode = DevSourceMode::OperatorBound; - input.development.operator_source_binding_present = true; - input.scenarios[0].synthetic_source = None; - input.credentials = PreparedDevCredentialClosure::operator_bound(); - input.operator_source_secret_env = vec!["FICTIONAL_REGISTRY_TOKEN".to_string()]; - let operator = DevRuntimePlan::derive(input).unwrap(); - let operator_compose = operator_temp - .path() - .join(".registry-stack/build/local/dev/operator-compose.json"); - render_closed_compose( - &operator_compose, - &operator.workloads, - DevSourceMode::OperatorBound, - ) - .unwrap(); - let value: serde_json::Value = - serde_json::from_slice(&fs::read(operator_compose).unwrap()).unwrap(); - assert!(value["networks"].get("registry_egress").is_some()); - for (service, document) in value["services"].as_object().unwrap() { - if document.get("network_mode").is_some() { - assert_eq!(service, "registry-postgres-stage-secrets"); - assert_eq!(document["network_mode"], "none"); - continue; - } - let networks = document["networks"].as_object().unwrap(); - let has_egress = networks.contains_key("registry_egress"); - assert_eq!(has_egress, service == "registry-relay-consultation"); - if service == "registry-relay-consultation" { - assert_eq!( - document["environment"], - serde_json::json!(["FICTIONAL_REGISTRY_TOKEN"]) - ); - } else if service != "registry-postgres" { - assert!(document.get("environment").is_none()); - } - } -} - #[test] fn supporting_services_wait_for_health_before_product_actions() { assert_eq!( @@ -1574,40 +1040,166 @@ fn compose_health_requires_the_exact_complete_healthy_workload_set() { ); } -#[test] -fn operator_bound_smoke_reports_unobserved_source_counters() { - let temp = tempfile::tempdir().unwrap(); - let mut input = plan_input(temp.path(), DevSourceProvider::Http, DevOAuthProfile::None); - input.development.source_mode = DevSourceMode::OperatorBound; - input.development.operator_source_binding_present = true; - input.scenarios[0].synthetic_source = None; - input.credentials = PreparedDevCredentialClosure::operator_bound(); - input.operator_source_secret_env = vec!["FICTIONAL_REGISTRY_TOKEN".to_string()]; - let plan = DevRuntimePlan::derive(input).unwrap(); - let mut controller = DevRuntimeController::new(FakeBackend::default()); - controller.start(&plan, true).unwrap(); - let report = controller.smoke(&plan).unwrap(); - assert!(report.results.iter().all(|result| { - result.token_counter_delta.is_none() && result.source_counter_delta.is_none() - })); +#[derive(Default)] +struct FakeBackend { + calls: Vec, + running: bool, + health: Option, + down_calls: usize, + fail_down: bool, + doctor_report: Option, } -#[test] -fn lifecycle_refuses_compose_content_changed_after_plan_derivation() { - let temp = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(plan_input( - temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - )) - .unwrap(); - fs::write(&plan.lifecycle.compose_file, "services:\n replaced: {}\n").unwrap(); - let error = DevRuntimeController::new(FakeBackend::default()) - .start(&plan, true) - .unwrap_err(); - assert_eq!(error.category, DevFailureCategory::ProjectBinding); - assert!(!plan.paths.root.exists()); -} +impl DevRuntimeBackend for FakeBackend { + fn doctor(&mut self, _plan: &DevRuntimePlan) -> DevRuntimeResult { + self.calls.push("doctor".to_string()); + Ok(self.doctor_report.clone().unwrap_or(DevDoctorReport { + docker_installed: true, + daemon_available: true, + compose_supported: true, + })) + } + + fn image_availability(&mut self, image: &str) -> DevRuntimeResult { + self.calls.push(format!("inspect:{image}")); + Ok(DevImageAvailability::Local) + } + + fn pull_image(&mut self, image: &str) -> DevRuntimeResult<()> { + self.calls.push(format!("pull:{image}")); + Ok(()) + } + + fn health(&mut self, _state: &DevRuntimeStateV1) -> DevRuntimeResult { + Ok(self.health.unwrap_or(if self.running { + DevRuntimeHealth::Running + } else { + DevRuntimeHealth::Stopped + })) + } + + fn start( + &mut self, + _plan: &DevRuntimePlan, + _state: &DevRuntimeStateV1, + detach: bool, + ) -> DevRuntimeResult<()> { + self.calls.push(format!("start:{detach}")); + self.running = true; + Ok(()) + } + + fn attach( + &mut self, + _plan: &DevRuntimePlan, + _state: &DevRuntimeStateV1, + ) -> DevRuntimeResult<()> { + self.calls.push("attach".to_string()); + Ok(()) + } + + fn status( + &mut self, + plan: &DevRuntimePlan, + _state: &DevRuntimeStateV1, + ) -> DevRuntimeResult> { + Ok(plan + .lifecycle + .status_services + .iter() + .map(|workload| DevWorkloadStatus { + workload: *workload, + state: DevRuntimeHealthWire::Running, + }) + .collect()) + } + + fn logs( + &mut self, + plan: &DevRuntimePlan, + _state: &DevRuntimeStateV1, + ) -> DevRuntimeResult> { + Ok(plan + .lifecycle + .log_services + .iter() + .map(|workload| DevProductLogSummary { + workload: *workload, + available: true, + }) + .collect()) + } + + fn smoke( + &mut self, + plan: &DevRuntimePlan, + _state: &DevRuntimeStateV1, + ) -> DevRuntimeResult { + let results = plan + .records_request_digest + .as_ref() + .map(|_| { + vec![ + DevSmokeScenarioResult { + scenario_id: plan.lifecycle.smoke_denial_scenario.clone(), + status: DevSmokeStatus::Denied, + token_counter_delta: None, + source_counter_delta: None, + minimized_claim_ids: Vec::new(), + passed: true, + }, + DevSmokeScenarioResult { + scenario_id: plan.lifecycle.smoke_authorized_scenario.clone(), + status: DevSmokeStatus::Authorized, + token_counter_delta: None, + source_counter_delta: None, + minimized_claim_ids: Vec::new(), + passed: true, + }, + ] + }) + .unwrap_or_default(); + Ok(DevSmokeReportV1 { + schema_version: DEV_SMOKE_REPORT_SCHEMA_V1.to_string(), + project: plan.binding.project.clone(), + environment: plan.binding.environment.clone(), + results, + passed: true, + }) + } + + fn down(&mut self, _state: &DevRuntimeStateV1, timeout_seconds: u16) -> DevRuntimeResult<()> { + assert_eq!(timeout_seconds, 15); + if self.fail_down { + return Err(DevRuntimeError::new( + DevFailureCategory::DockerUnavailable, + "injected down failure", + "retry", + )); + } + self.calls.push("down".to_string()); + self.down_calls += 1; + self.running = false; + Ok(()) + } +} + +#[test] +fn lifecycle_refuses_compose_content_changed_after_plan_derivation() { + let temp = tempfile::tempdir().unwrap(); + let plan = DevRuntimePlan::derive(plan_input( + temp.path(), + DevSourceProvider::Http, + DevOAuthProfile::None, + )) + .unwrap(); + fs::write(&plan.lifecycle.compose_file, "services:\n replaced: {}\n").unwrap(); + let error = DevRuntimeController::new(FakeBackend::default()) + .start(&plan, true) + .unwrap_err(); + assert_eq!(error.category, DevFailureCategory::ProjectBinding); + assert!(!plan.paths.root.exists()); +} #[test] fn attached_flow_returns_report_before_wait_and_cleans_up_normally() { @@ -1620,9 +1212,7 @@ fn attached_flow_returns_report_before_wait_and_cleans_up_normally() { .unwrap(); let mut controller = DevRuntimeController::new(FakeBackend::default()); let report = controller.start(&plan, false).unwrap(); - assert!(report - .evidence_request_command - .starts_with("curl --config ")); + assert!(report.relay_api_url.starts_with("http://127.0.0.1:")); assert!(plan.paths.state_file.exists()); controller.attach(&plan).unwrap(); let backend = controller.into_backend(); @@ -1738,457 +1328,6 @@ fn unsigned_or_tampered_development_bundle_is_refused() { assert!(error.summary.contains("disposable development identity")); } -#[test] -fn release_projection_and_oauth_profiles_fail_closed() { - assert_eq!( - VerifiedDevReleaseProjection::test_only( - "release-v1.0.0".to_string(), - "v1.0.0".to_string(), - "ghcr.io/registrystack/registry-relay:v1.0.0".to_string(), - format!( - "ghcr.io/registrystack/registry-notary@sha256:{}", - "b".repeat(64) - ), - format!("docker.io/library/postgres@sha256:{}", "c".repeat(64)), - "2.24.0".to_string(), - ) - .unwrap_err() - .category, - DevFailureCategory::InvalidImageLock - ); - - let temp = tempfile::tempdir().unwrap(); - let mut no_oauth = plan_input(temp.path(), DevSourceProvider::Rhai, DevOAuthProfile::None); - no_oauth.scenarios[0] - .synthetic_source - .as_mut() - .unwrap() - .oauth_response_case = Some(SyntheticOAuthResponseCase::Valid); - let error = DevRuntimePlan::derive(no_oauth).unwrap_err(); - assert!(error.summary.contains("non-OAuth source")); - - let temp = tempfile::tempdir().unwrap(); - let mut forbidden_body = - plan_input(temp.path(), DevSourceProvider::Http, DevOAuthProfile::None); - forbidden_body.scenarios[0] - .synthetic_source - .as_mut() - .unwrap() - .scenario = SyntheticSourceScenario::SourceTimeout; - let error = DevRuntimePlan::derive(forbidden_body).unwrap_err(); - assert!(error.summary.contains("response_body presence")); - - let temp = tempfile::tempdir().unwrap(); - let mut reserved_request = - plan_input(temp.path(), DevSourceProvider::Http, DevOAuthProfile::None); - reserved_request.scenarios[0] - .synthetic_source - .as_mut() - .unwrap() - .source_request - .path = "/oauth/token".to_string(); - let error = DevRuntimePlan::derive(reserved_request).unwrap_err(); - assert!(error.summary.contains("closed authored operation")); -} - -#[derive(Default)] -struct FakeBackend { - calls: Vec, - absent_images: BTreeSet, - running: bool, - health: Option, - down_calls: usize, - fail_down: bool, - fail_start: bool, - doctor_report: Option, -} - -impl DevRuntimeBackend for FakeBackend { - fn doctor(&mut self, _plan: &DevRuntimePlan) -> DevRuntimeResult { - self.calls.push("doctor".to_string()); - Ok(self.doctor_report.clone().unwrap_or(DevDoctorReport { - docker_installed: true, - daemon_available: true, - compose_supported: true, - })) - } - - fn image_availability(&mut self, image: &str) -> DevRuntimeResult { - self.calls.push(format!("inspect:{image}")); - Ok(if self.absent_images.contains(image) { - DevImageAvailability::Absent - } else { - DevImageAvailability::Local - }) - } - - fn pull_image(&mut self, image: &str) -> DevRuntimeResult<()> { - self.calls.push(format!("pull:{image}")); - Ok(()) - } - - fn health(&mut self, _state: &DevRuntimeStateV1) -> DevRuntimeResult { - Ok(self.health.unwrap_or(if self.running { - DevRuntimeHealth::Running - } else { - DevRuntimeHealth::Stopped - })) - } - - fn start( - &mut self, - _plan: &DevRuntimePlan, - _state: &DevRuntimeStateV1, - detach: bool, - ) -> DevRuntimeResult<()> { - self.calls.push(format!("start:{detach}")); - if self.fail_start { - return Err(DevRuntimeError::new( - DevFailureCategory::Startup, - "injected startup failure", - "retry", - )); - } - self.running = true; - Ok(()) - } - - fn attach( - &mut self, - _plan: &DevRuntimePlan, - _state: &DevRuntimeStateV1, - ) -> DevRuntimeResult<()> { - self.calls.push("attach".to_string()); - Ok(()) - } - - fn status( - &mut self, - plan: &DevRuntimePlan, - _state: &DevRuntimeStateV1, - ) -> DevRuntimeResult> { - Ok(plan - .lifecycle - .status_services - .iter() - .map(|workload| DevWorkloadStatus { - workload: *workload, - state: DevRuntimeHealthWire::Running, - }) - .collect()) - } - - fn logs( - &mut self, - plan: &DevRuntimePlan, - _state: &DevRuntimeStateV1, - ) -> DevRuntimeResult> { - Ok(plan - .lifecycle - .log_services - .iter() - .map(|workload| DevProductLogSummary { - workload: *workload, - available: true, - }) - .collect()) - } - - fn smoke( - &mut self, - plan: &DevRuntimePlan, - _state: &DevRuntimeStateV1, - ) -> DevRuntimeResult { - let token_delta = if plan.scenario.oauth_profile == DevOAuthProfile::None { - 0 - } else { - 1 - }; - let observed = plan.source_mode == DevSourceMode::Synthetic; - Ok(DevSmokeReportV1 { - schema_version: DEV_SMOKE_REPORT_SCHEMA_V1.to_string(), - project: plan.binding.project.clone(), - environment: plan.binding.environment.clone(), - results: vec![ - DevSmokeScenarioResult { - scenario_id: plan.scenario.denial_scenario_id.clone(), - status: DevSmokeStatus::Denied, - token_counter_delta: observed.then_some(0), - source_counter_delta: observed.then_some(0), - minimized_claim_ids: Vec::new(), - passed: true, - }, - DevSmokeScenarioResult { - scenario_id: plan.scenario.authorized_scenario_id.clone(), - status: DevSmokeStatus::Authorized, - token_counter_delta: observed.then_some(token_delta), - source_counter_delta: observed.then_some(1), - minimized_claim_ids: plan.scenario.minimized_claim_ids.clone(), - passed: true, - }, - ], - passed: true, - }) - } - - fn down(&mut self, _state: &DevRuntimeStateV1, timeout_seconds: u16) -> DevRuntimeResult<()> { - assert_eq!(timeout_seconds, 15); - if self.fail_down { - return Err(DevRuntimeError::new( - DevFailureCategory::DockerUnavailable, - "injected down failure", - "retry", - )); - } - self.calls.push("down".to_string()); - self.down_calls += 1; - self.running = false; - Ok(()) - } -} - -#[test] -fn lifecycle_is_project_bound_bounded_owner_only_and_value_free() { - let temp = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(plan_input( - temp.path(), - DevSourceProvider::Rhai, - DevOAuthProfile::Oauth2BearerNoExpiry, - )) - .unwrap(); - let generated_artifacts = plan.artifacts.compose_file.parent().unwrap().to_path_buf(); - let mut backend = FakeBackend::default(); - backend - .absent_images - .insert(plan.workloads[0].image.clone()); - let mut controller = DevRuntimeController::new(backend); - let startup = controller.start(&plan, true).unwrap(); - assert_eq!(startup.source_mode, DevSourceMode::Synthetic); - assert!(startup.disposable_notice.contains("not production inputs")); - assert!(startup.evidence_request_command.contains("curl --config")); - assert!(!format!("{startup:?}").contains(RESPONSE_CANARY)); - assert!(!format!("{startup:?}").contains(REQUEST_CANARY)); - - let request_config = fs::read_to_string(&plan.paths.request_config).unwrap(); - assert!(request_config.contains("/v1/evaluations")); - assert!(request_config.contains("url = \"http://127.0.0.1:")); - assert!(!request_config.contains("url = \"https://")); - assert!(!request_config.contains("cacert = ")); - assert!(startup.relay_api_url.starts_with("http://127.0.0.1:")); - assert!(startup.evidence_api_url.starts_with("http://127.0.0.1:")); - for obsolete_listener_credential in [ - "relay-public-tls.crt", - "relay-public-tls.key", - "relay-consultation-tls.crt", - "relay-consultation-tls.key", - "notary-tls.crt", - "notary-tls.key", - ] { - assert!(!plan - .paths - .credentials - .join(obsolete_listener_credential) - .exists()); - } - let caller_token = fs::read_to_string(plan.paths.credentials.join("caller-token")).unwrap(); - assert!(request_config.contains(&caller_token)); - assert!(!startup.evidence_request_command.contains(&caller_token)); - assert!(fs::read_to_string(&plan.paths.synthetic_source_plan) - .unwrap() - .contains(RESPONSE_CANARY)); - let source_plan: serde_json::Value = - serde_json::from_slice(&fs::read(&plan.paths.synthetic_source_plan).unwrap()).unwrap(); - assert_eq!( - source_plan["version"], - "registry.relay.synthetic-source-plan.v1" - ); - assert_eq!(source_plan["scenario"], "authored_response"); - assert_eq!(source_plan["source_request"]["method"], "get"); - assert_eq!( - source_plan["source_request"]["path"], - "/people/example-person" - ); - assert_eq!( - source_plan["source_request"]["query"]["expand"], - "eligibility" - ); - assert!(source_plan.get("routes").is_none()); - assert!(source_plan.get("path").is_none()); - assert_eq!( - source_plan["oauth"]["response_profile"], - "oauth2_bearer_no_expiry" - ); - assert_eq!(source_plan["request_encoding"], "form"); - assert_eq!( - source_plan["oauth"]["request"]["audience"], - "registry-notary" - ); - assert_eq!(source_plan["oauth"]["request"]["scope"], "registry.read"); - assert_eq!( - source_plan["oauth"]["request"]["resource"], - "registry-source" - ); - assert_eq!( - source_plan["secrets"]["control_token"]["file"], - "control-token" - ); - assert_eq!(source_plan["secrets"]["control_token"]["generation"], 1); - let same_runtime = controller.start(&plan, true).unwrap(); - assert_eq!( - same_runtime.evidence_request_command, - startup.evidence_request_command - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - for path in [ - &plan.paths.state_file, - &plan.paths.request_config, - &plan.paths.request_body, - &plan.paths.synthetic_source_plan, - ] { - assert_eq!( - fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - assert_eq!( - fs::metadata(&plan.paths.credentials) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o700 - ); - } - - let status = controller.status(&plan).unwrap(); - assert_eq!(status.schema_version, DEV_STATUS_REPORT_SCHEMA_V1); - assert_eq!( - status.evidence_request_command, - startup.evidence_request_command - ); - assert_eq!(status.workloads.len(), plan.lifecycle.status_services.len()); - let status_json = serde_json::to_value(&status).unwrap(); - assert_eq!(status_json["schema_version"], DEV_STATUS_REPORT_SCHEMA_V1); - assert_eq!(status_json.as_object().unwrap().len(), 9); - let serialized_status = serde_json::to_string(&status).unwrap(); - assert!(!serialized_status.contains(&caller_token)); - assert!(!serialized_status.contains(RESPONSE_CANARY)); - let logs = controller.logs(&plan).unwrap(); - assert_eq!(logs.schema_version, DEV_LOGS_REPORT_SCHEMA_V1); - assert_eq!(logs.products.len(), plan.lifecycle.log_services.len()); - let logs_json = serde_json::to_value(&logs).unwrap(); - assert_eq!(logs_json["schema_version"], DEV_LOGS_REPORT_SCHEMA_V1); - assert_eq!(logs_json.as_object().unwrap().len(), 3); - let serialized_logs = serde_json::to_string(&logs).unwrap(); - assert!(!serialized_logs.contains(&caller_token)); - assert!(!serialized_logs.contains(RESPONSE_CANARY)); - let smoke = controller.smoke(&plan).unwrap(); - assert!(smoke.passed); - assert_eq!(smoke.results[0].token_counter_delta, Some(0)); - assert_eq!(smoke.results[0].source_counter_delta, Some(0)); - assert_eq!(smoke.results[1].token_counter_delta, Some(1)); - assert_eq!(smoke.results[1].source_counter_delta, Some(1)); - - controller.down(&plan).unwrap(); - assert!(!plan.paths.root.exists()); - assert!(!generated_artifacts.exists()); - let backend = controller.into_backend(); - assert_eq!(backend.down_calls, 1); - assert_eq!( - backend - .calls - .iter() - .filter(|call| call.starts_with("start:")) - .count(), - 1 - ); - assert_eq!(backend.calls.first().unwrap(), "doctor"); - assert!(backend.calls.iter().any(|call| call.starts_with("pull:"))); -} - -#[test] -fn records_requests_are_owner_only_minimal_and_credential_separated() { - let temp = tempfile::tempdir().unwrap(); - let mut input = plan_input( - temp.path(), - DevSourceProvider::Spreadsheet, - DevOAuthProfile::None, - ); - input.records_request = Some(project_authoring::AuthoredRecordsRequest { - dataset_id: "projects".to_string(), - entity_id: "projects".to_string(), - record_id: "pw_001".to_string(), - purpose: "public-works-case-management".to_string(), - }); - input.credentials = PreparedDevCredentialClosure::synthetic_records(); - let plan = DevRuntimePlan::derive(input).unwrap(); - let mut controller = DevRuntimeController::new(FakeBackend::default()); - let startup = controller.start(&plan, true).unwrap(); - - assert!(startup.relay_api_url.starts_with("http://127.0.0.1:")); - assert!(startup - .records_denied_command - .as_ref() - .unwrap() - .contains("records-denied.curl")); - assert!(startup - .records_request_command - .as_ref() - .unwrap() - .contains("records-request.curl")); - assert!(!startup.evidence_request_command.contains("records")); - - let authorized = fs::read_to_string(&plan.paths.records_request_config).unwrap(); - let denied = fs::read_to_string(&plan.paths.records_denied_config).unwrap(); - let match_token = fs::read_to_string(plan.paths.credentials.join("relay-match-token")).unwrap(); - let no_match_token = - fs::read_to_string(plan.paths.credentials.join("relay-no-match-token")).unwrap(); - let caller_token = fs::read_to_string(plan.paths.credentials.join("caller-token")).unwrap(); - assert!(authorized.contains("/v1/datasets/projects/entities/projects/records/pw_001")); - assert!(authorized.contains("url = \"http://127.0.0.1:")); - assert!(!authorized.contains("cacert = ")); - assert!(authorized.contains("header = \"Data-Purpose: public-works-case-management\"")); - assert!(authorized.contains(&match_token)); - assert!(authorized.contains("fail\n")); - assert!(!authorized.contains(&no_match_token)); - assert!(!authorized.contains(&caller_token)); - assert!(denied.contains("include\n")); - assert!(denied.contains("silent\n")); - assert!(denied.contains("show-error\n")); - assert!(!denied.contains("Authorization")); - assert!(!denied.contains("fail\n")); - for token in [&match_token, &no_match_token, &caller_token] { - assert!(!denied.contains(token)); - assert!(!startup - .records_request_command - .as_ref() - .unwrap() - .contains(token)); - } - let status = controller.status(&plan).unwrap(); - let status_json = serde_json::to_string(&status).unwrap(); - for token in [&match_token, &no_match_token, &caller_token] { - assert!(!status_json.contains(token)); - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - for path in [ - &plan.paths.records_request_config, - &plan.paths.records_denied_config, - ] { - assert_eq!( - fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } -} - #[test] fn records_request_percent_encodes_each_path_segment_without_encoding_separators() { let temp = tempfile::tempdir().unwrap(); @@ -2216,69 +1355,6 @@ fn records_request_percent_encodes_each_path_segment_without_encoding_separators assert!(!request.contains("/datasets/pro/jects/")); } -#[test] -fn healthy_unchanged_start_rebinds_candidate_attach_to_retained_runtime() { - let temp = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(plan_input( - temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - )) - .unwrap(); - let old_artifacts = plan.artifacts.compose_file.parent().unwrap().to_path_buf(); - let relay_port = plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::RelayPublic) - .and_then(|workload| workload.host_endpoint) - .unwrap() - .port(); - let notary_port = plan - .workloads - .iter() - .find(|workload| workload.id == DevWorkloadId::Notary) - .and_then(|workload| workload.host_endpoint) - .unwrap() - .port(); - let mut controller = DevRuntimeController::new(FakeBackend::default()); - controller.start(&plan, true).unwrap(); - - let mut input = plan_input_generation( - temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - "0000000000000002", - ); - input.development.relay_port = Some(relay_port); - input.development.notary_port = Some(notary_port); - let candidate = DevRuntimePlan::derive(input).unwrap(); - let candidate_artifacts = candidate - .artifacts - .compose_file - .parent() - .unwrap() - .to_path_buf(); - controller.start(&candidate, true).unwrap(); - - assert!(plan.paths.root.exists()); - assert!(old_artifacts.exists()); - assert!(!candidate_artifacts.exists()); - controller.attach(&candidate).unwrap(); - assert!(!plan.paths.root.exists()); - assert!(!old_artifacts.exists()); - let backend = controller.into_backend(); - assert_eq!(backend.down_calls, 1); - assert_eq!( - backend - .calls - .iter() - .filter(|call| call.starts_with("start:")) - .count(), - 1 - ); - assert!(backend.calls.iter().any(|call| call == "attach")); -} - #[test] fn stopped_degraded_and_changed_running_starts_replace_without_orphan_generations() { for (health, changed) in [ @@ -2383,55 +1459,6 @@ fn terminal_errors_include_stable_public_category_code() { ); } -#[test] -fn bound_plan_loader_is_read_only_and_rebinds_exact_request_body() { - fn snapshot(root: &Path) -> BTreeSet<(String, Vec)> { - fn collect(root: &Path, directory: &Path, out: &mut BTreeSet<(String, Vec)>) { - for entry in fs::read_dir(directory).unwrap() { - let entry = entry.unwrap(); - let path = entry.path(); - if entry.file_type().unwrap().is_dir() { - collect(root, &path, out); - } else { - out.insert(( - path.strip_prefix(root) - .unwrap() - .to_string_lossy() - .into_owned(), - fs::read(path).unwrap(), - )); - } - } - } - let mut files = BTreeSet::new(); - collect(root, root, &mut files); - files - } - - let temp = tempfile::tempdir().unwrap(); - let plan = DevRuntimePlan::derive(plan_input( - temp.path(), - DevSourceProvider::Http, - DevOAuthProfile::None, - )) - .unwrap(); - let mut controller = DevRuntimeController::new(FakeBackend::default()); - controller.start(&plan, true).unwrap(); - let before = snapshot(&plan.paths.root); - - let loaded = load_bound_dev_runtime_plan(temp.path(), "local").unwrap(); - assert_eq!(loaded.request_digest, plan.request_digest); - assert_eq!( - loaded.evidence_request_command(), - plan.evidence_request_command() - ); - assert_eq!(snapshot(&plan.paths.root), before); - - fs::write(&plan.paths.request_body, br#"{"subject":"tampered"}"#).unwrap(); - let error = load_bound_dev_runtime_plan(temp.path(), "local").unwrap_err(); - assert_eq!(error.category, DevFailureCategory::ProjectBinding); -} - #[test] fn doctor_distinguishes_missing_docker_from_an_unavailable_daemon() { for (report, expected) in [ diff --git a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml index aa22b926b..827f2d780 100644 --- a/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring-journeys.yaml @@ -5,7 +5,7 @@ workspaces: summary: One fixed bounded HTTP request with a closed response projection. source: crates/registryctl/assets/project-starters/bounded-http classification: maintained - topology: combined + topology: relay-only starter: http project_dir: registry-project focused_fixture_file: active.yaml @@ -15,10 +15,10 @@ workspaces: - id: custom-system label: Custom HTTP conformance workspace - summary: Combined HTTP acquisition and registry-backed evidence coverage. + summary: Bounded HTTP acquisition and registry-backed consultation coverage. source: crates/registryctl/tests/fixtures/project-authoring/custom-system classification: maintained - topology: combined + topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/custom-system focused_fixture_file: source-approved.yaml steps: [trace, watch, test, check, build] @@ -30,7 +30,7 @@ workspaces: summary: Conformance coverage for the bounded Script adapter surface. source: crates/registryctl/tests/fixtures/project-authoring/dhis2-script classification: conformance-only - topology: combined + topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/dhis2-script steps: [test, check, build] environment: local @@ -38,10 +38,10 @@ workspaces: - id: dhis2-tracker label: DHIS2 Tracker - summary: Bounded DHIS2 Tracker acquisition normalized into reusable health evidence for consumer-owned decisions. + summary: Bounded DHIS2 Tracker acquisition normalized into reusable consultation outputs for consumer-owned decisions. source: crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker classification: maintained - topology: combined + topology: relay-only starter: dhis2-tracker project_dir: dhis2-project focused_fixture_file: match.yaml @@ -54,7 +54,7 @@ workspaces: summary: A product-neutral script adapter with bounded FHIR R4 search-set parsing. source: crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active classification: maintained - topology: combined + topology: relay-only starter: fhir-r4 project_dir: fhir-project focused_fixture_file: match.yaml @@ -70,7 +70,7 @@ workspaces: focus: solmara topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release - steps: [check] + steps: [check, build] environment: local check_explain: true @@ -79,7 +79,7 @@ workspaces: summary: A product-neutral script adapter with the signed DCI search verification profile. source: crates/registryctl/tests/fixtures/project-authoring/opencrvs classification: maintained - topology: combined + topology: relay-only starter: opencrvs-dci project_dir: opencrvs-project focused_fixture_file: match.yaml @@ -92,7 +92,7 @@ workspaces: summary: A maintained synthetic case study for a bounded Events API-shaped search using generic OAuth client credentials and Rhai. source: crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api classification: maintained - topology: combined + topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api focused_fixture_file: match.yaml steps: [trace, watch, test, check, build] @@ -104,7 +104,7 @@ workspaces: summary: Country-owned DCI mapping coverage for match, no-match, and ambiguity. source: crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant classification: maintained - topology: combined + topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant focused_fixture_file: match.yaml steps: [trace, watch, test, check, build] @@ -116,7 +116,7 @@ workspaces: summary: Offline fixture, configuration validation, and build coverage for a synthetic OpenSPP exact lookup. source: crates/registryctl/tests/fixtures/project-authoring/openspp-exact classification: maintained - topology: combined + topology: relay-only evidence: offline-fixture-validation project_dir: crates/registryctl/tests/fixtures/project-authoring/openspp-exact focused_fixture_file: match.yaml @@ -131,18 +131,18 @@ workspaces: classification: maintained topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/relay-only-materialization - steps: [test, check] + steps: [test, check, build] environment: local check_explain: true - id: relay-only-records label: Relay-only records API - summary: Fixtureless Relay records configuration with no Notary inputs. + summary: Fixtureless Relay records configuration with no consultation inputs. source: crates/registryctl/tests/fixtures/project-authoring/relay-only-records classification: maintained topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/relay-only-records - steps: [test, check] + steps: [test, check, build] environment: local check_explain: true @@ -151,7 +151,7 @@ workspaces: summary: An exact lookup over one immutable local materialization. source: crates/registryctl/tests/fixtures/project-authoring/snapshot-exact classification: maintained - topology: combined + topology: relay-only starter: snapshot project_dir: snapshot-project focused_fixture_file: match.yaml @@ -161,10 +161,10 @@ workspaces: - id: snapshot-with-records label: Snapshot with records API - summary: Match and no-match evidence sharing one authorized Relay materialization with records. + summary: Match and no-match consultations sharing one authorized Relay materialization with records. source: crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records classification: maintained - topology: combined + topology: relay-only project_dir: crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records focused_fixture_file: match.yaml steps: [trace, watch, test, check, build] diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/environments/local.yaml index eebb3ad8f..12f1fae9f 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/environments/local.yaml @@ -7,28 +7,14 @@ integrations: username: { secret: HOUSEHOLD_USERNAME } password: { secret: HOUSEHOLD_PASSWORD } generation: 1 -issuance: - issuer: did:web:household-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - benefits-service: - api_key_fingerprint: { secret: BENEFITS_CLIENT_TOKEN_HASH } - scopes: ["evidence:household:read"] relay: origin: https://household-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [household-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: household-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: household-consultation-client, principal_id: household-consultation-principal } deployment: profile: local relay: { service: household-relay } - notary: { service: household-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/ambiguous.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/ambiguous.yaml index b2edb5150..f8e1af3bb 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/ambiguous.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/ambiguous.yaml @@ -7,4 +7,4 @@ interactions: path: /households/HH-AB12CD34 query: { fields: "approved,category" } respond: { status: 409, body: {} } -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml index bb62583e9..dff12dd9a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/no-match.yaml @@ -10,7 +10,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - household-record-exists: false - household-category: null - source-household-approval-decision: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml index 4b55b1614..f59610f5a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/integrations/eligibility/fixtures/source-approved.yaml @@ -1,13 +1,5 @@ name: source-approved-household classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: household_reference, value: HH-AB12CD34 }] - claims: [household-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: household-support-screening input: { household_reference: HH-AB12CD34 } interactions: - expect: @@ -20,7 +12,3 @@ interactions: expect: outcome: match outputs: { approved: true, category: PRIORITY } - claims: - household-record-exists: true - household-category: PRIORITY - source-household-approval-decision: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml index 21f03bd9b..f63e17e3a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/custom-system/registry-stack.yaml @@ -9,32 +9,13 @@ integrations: services: household-eligibility: - kind: evidence + kind: consultation_api version: 1 purpose: household-support-screening legal_basis: public-service-delivery consent: not_required - access: - scopes: ["evidence:household:read"] consultations: household: integration: eligibility input: household_reference: request.target.identifiers.household_reference - claims: - household-record-exists: - cel: household.matched - disclosure: predicate - household-category: - output: household.category - disclosure: value - source-household-approval-decision: - cel: >- - household.matched && household.approved != null ? household.approved : null - disclosure: predicate - credential_profiles: - household-eligibility: - format: dc+sd-jwt - type: https://credentials.invalid/household-eligibility/v1 - validity: 5m - claims: [household-record-exists, household-category, source-household-approval-decision] diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/environments/local.yaml index ca640b2d6..44e5ac10e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/environments/local.yaml @@ -7,28 +7,14 @@ integrations: username: { secret: HEALTH_REGISTRY_USERNAME } password: { secret: HEALTH_REGISTRY_PASSWORD } generation: 1 -issuance: - issuer: did:web:health-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - programme-verifier: - api_key_fingerprint: { secret: PROGRAMME_VERIFIER_TOKEN_HASH } - scopes: ["evidence:health:read"] relay: origin: https://health-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [health-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: health-registry-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: health-consultation-client, principal_id: health-consultation-principal } deployment: profile: local relay: { service: health-registry-relay } - notary: { service: health-registry-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml index 527479d79..fe920907c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/match.yaml @@ -1,15 +1,5 @@ name: complete-child-health-evidence classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: dhis2_tracked_entity, value: A0000000001 }] - attributes: { include_inactive: true } - variables: { as_of_date: 2026-01-01 } - claims: [child-program-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: programme-enrollment-verification input: { tracked_entity: A0000000001, include_inactive: true } variables: { as_of_date: 2026-01-01 } interactions: @@ -54,16 +44,3 @@ expect: bcg_birth_dose_recorded: true opv_birth_dose_recorded: true measles_dose_recorded: true - claims: - tracked-entity-first-name: Nia - tracked-entity-last-name: Example - child-program-active: true - child-age-band: 5-17 - programme-code: DEMO_CHILD_PROGRAM - reconciliation-reference: redacted - maternal-postnatal-care-active: true - child-health-visit-recorded: true - tb-program-active: false - bcg-birth-dose-recorded: true - opv-birth-dose-recorded: true - measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml index 62f8bf449..f7e4c464c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-enrollment.yaml @@ -31,16 +31,3 @@ expect: bcg_birth_dose_recorded: null opv_birth_dose_recorded: null measles_dose_recorded: null - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: null - child-age-band: null - programme-code: null - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: null - opv-birth-dose-recorded: null - measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml index ded4cda16..75598b415 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/no-match.yaml @@ -13,16 +13,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: null - child-age-band: null - programme-code: null - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: null - opv-birth-dose-recorded: null - measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml index 006970494..6cd7b1ce7 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/partial.yaml @@ -35,16 +35,3 @@ expect: bcg_birth_dose_recorded: false opv_birth_dose_recorded: null measles_dose_recorded: true - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: false - child-age-band: null - programme-code: DEMO_CHILD_PROGRAM - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: false - opv-birth-dose-recorded: null - measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/source-rejected.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/source-rejected.yaml index 91142e881..24f6faf96 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/source-rejected.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/source-rejected.yaml @@ -10,4 +10,4 @@ interactions: fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] includeDeleted: true respond: { status: 500, body: {} } -expect: { error: source.status_rejected, outputs: {}, claims: {} } +expect: { error: source.status_rejected, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/subject-mismatch.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/subject-mismatch.yaml index 3c46d8d62..ab9b55815 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/subject-mismatch.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/integrations/health-record/fixtures/subject-mismatch.yaml @@ -12,4 +12,4 @@ interactions: respond: status: 200 body: { trackedEntity: B0000000002, attributes: [], enrollments: [] } -expect: { error: failure.subject_mismatch, outputs: {}, claims: {} } +expect: { error: failure.subject_mismatch, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml index 7496cebe9..5d68828df 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-script/registry-stack.yaml @@ -4,12 +4,11 @@ integrations: health-record: { file: integrations/health-record/integration.yaml } services: health-verification: - kind: evidence + kind: consultation_api version: 1 purpose: programme-enrollment-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:health:read"] } variables: as_of_date: { from: request.variables.as_of_date, type: date } consultations: @@ -18,44 +17,3 @@ services: input: tracked_entity: request.target.identifiers.dhis2_tracked_entity include_inactive: request.target.attributes.include_inactive - claims: - tracked-entity-first-name: { output: health.first_name, disclosure: value } - tracked-entity-last-name: { output: health.last_name, disclosure: value } - child-program-active: { output: health.child_program_active, disclosure: predicate } - child-age-band: - cel: >- - health.matched && health.date_of_birth != null - ? (date.age_on(health.date_of_birth, as_of_date) < 5 - ? "0-4" - : (date.age_on(health.date_of_birth, as_of_date) < 18 ? "5-17" : "18+")) - : null - disclosure: value - programme-code: { output: health.programme_code, disclosure: value } - reconciliation-reference: { output: health.reconciliation_reference, disclosure: redacted } - maternal-postnatal-care-active: { output: health.maternal_postnatal_active, disclosure: predicate } - child-health-visit-recorded: { output: health.child_health_visit_recorded, disclosure: predicate } - tb-program-active: { output: health.tb_program_active, disclosure: predicate } - bcg-birth-dose-recorded: { output: health.bcg_birth_dose_recorded, disclosure: predicate } - opv-birth-dose-recorded: { output: health.opv_birth_dose_recorded, disclosure: predicate } - measles-dose-recorded: { output: health.measles_dose_recorded, disclosure: predicate } - credential_profiles: - health-status: - format: dc+sd-jwt - type: https://credentials.invalid/health-status/v1 - validity: 10m - claims: [maternal-postnatal-care-active, child-health-visit-recorded, tb-program-active] - child-program: - format: dc+sd-jwt - type: https://credentials.invalid/child-program/v1 - validity: 10m - claims: [tracked-entity-first-name, tracked-entity-last-name, child-program-active, child-age-band] - programme-participation: - format: dc+sd-jwt - type: https://credentials.invalid/programme-participation/v1 - validity: 10m - claims: [programme-code, reconciliation-reference, child-program-active] - child-health-evidence: - format: dc+sd-jwt - type: https://credentials.invalid/child-health-evidence/v1 - validity: 10m - claims: [child-program-active, bcg-birth-dose-recorded, opv-birth-dose-recorded, measles-dose-recorded] diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md index 92f1d010a..1fb8939be 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md @@ -1,4 +1,4 @@ -# DHIS2 child-health evidence Registry Stack project +# DHIS2 child-health consultation Registry Stack project This starter demonstrates the product-neutral `script` capability with a synthetic DHIS2 Tracker wire shape. Product and version metadata do not select @@ -28,19 +28,20 @@ The authored layers remain separate: declared identity, date, programme, reconciliation, and health-status outputs. Nullable programme and stage booleans keep `true`, `false`, and `null` distinct, including BCG, OPV, and measles dose evidence. -2. Notary discloses those outputs as atomic evidence claims. It does not decide - outreach, follow-up priority, eligibility, entitlement, or case action. -3. In this example, a public-health programme is both the evidence consumer and +2. Relay exposes those outputs through the bounded consultation API. It does + not decide outreach, follow-up priority, eligibility, entitlement, or case + action. +3. In this example, a public-health programme is both the consultation consumer and decision owner. It might first route any `null` evidence to resolution, then - derive `outreach_required` only from known enrollment and dose evidence. + derive `outreach_required` only from known enrollment and dose outputs. That downstream rule is illustrative and is not part of this Registry Stack project. For a matched tracked entity, a completed DHIS2 programme-stage event maps to `true`, an existing non-completed stage event maps to `false`, and an absent enrollment or stage maps to `null`. A 404 is a no-match, not negative health -evidence. An upstream rejection and an echoed-subject mismatch are failures -and produce no claims. Ambiguity is explicitly not applicable because the +source data. An upstream rejection and an echoed-subject mismatch are failures +and produce no outputs. Ambiguity is explicitly not applicable because the adapter uses DHIS2's singleton tracked-entity resource. The demo programme and stage UIDs in `adapter.rhai` are project-owned mappings. @@ -52,6 +53,6 @@ Record any live compatibility result through the repository root's deterministic offline fixtures to reflect a transient live server result. The `include_inactive` boolean is a bounded, typed target attribute supplied by -the evaluation caller and forwarded through Notary and Relay. It is request +the consultation caller and forwarded through Relay. It is request context only. It is not an authenticated identity or a substitute for the `dhis2_tracked_entity` identifier used to select the record. diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/environments/local.yaml index ca640b2d6..44e5ac10e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/environments/local.yaml @@ -7,28 +7,14 @@ integrations: username: { secret: HEALTH_REGISTRY_USERNAME } password: { secret: HEALTH_REGISTRY_PASSWORD } generation: 1 -issuance: - issuer: did:web:health-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - programme-verifier: - api_key_fingerprint: { secret: PROGRAMME_VERIFIER_TOKEN_HASH } - scopes: ["evidence:health:read"] relay: origin: https://health-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [health-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: health-registry-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: health-consultation-client, principal_id: health-consultation-principal } deployment: profile: local relay: { service: health-registry-relay } - notary: { service: health-registry-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml index 527479d79..fe920907c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/match.yaml @@ -1,15 +1,5 @@ name: complete-child-health-evidence classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: dhis2_tracked_entity, value: A0000000001 }] - attributes: { include_inactive: true } - variables: { as_of_date: 2026-01-01 } - claims: [child-program-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: programme-enrollment-verification input: { tracked_entity: A0000000001, include_inactive: true } variables: { as_of_date: 2026-01-01 } interactions: @@ -54,16 +44,3 @@ expect: bcg_birth_dose_recorded: true opv_birth_dose_recorded: true measles_dose_recorded: true - claims: - tracked-entity-first-name: Nia - tracked-entity-last-name: Example - child-program-active: true - child-age-band: 5-17 - programme-code: DEMO_CHILD_PROGRAM - reconciliation-reference: redacted - maternal-postnatal-care-active: true - child-health-visit-recorded: true - tb-program-active: false - bcg-birth-dose-recorded: true - opv-birth-dose-recorded: true - measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml index 62f8bf449..f7e4c464c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-enrollment.yaml @@ -31,16 +31,3 @@ expect: bcg_birth_dose_recorded: null opv_birth_dose_recorded: null measles_dose_recorded: null - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: null - child-age-band: null - programme-code: null - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: null - opv-birth-dose-recorded: null - measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml index ded4cda16..75598b415 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/no-match.yaml @@ -13,16 +13,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: null - child-age-band: null - programme-code: null - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: null - opv-birth-dose-recorded: null - measles-dose-recorded: null diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml index 006970494..6cd7b1ce7 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/partial.yaml @@ -35,16 +35,3 @@ expect: bcg_birth_dose_recorded: false opv_birth_dose_recorded: null measles_dose_recorded: true - claims: - tracked-entity-first-name: null - tracked-entity-last-name: null - child-program-active: false - child-age-band: null - programme-code: DEMO_CHILD_PROGRAM - reconciliation-reference: redacted - maternal-postnatal-care-active: null - child-health-visit-recorded: null - tb-program-active: null - bcg-birth-dose-recorded: false - opv-birth-dose-recorded: null - measles-dose-recorded: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/source-rejected.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/source-rejected.yaml index 91142e881..24f6faf96 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/source-rejected.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/source-rejected.yaml @@ -10,4 +10,4 @@ interactions: fields: trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]] includeDeleted: true respond: { status: 500, body: {} } -expect: { error: source.status_rejected, outputs: {}, claims: {} } +expect: { error: source.status_rejected, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/subject-mismatch.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/subject-mismatch.yaml index 3c46d8d62..ab9b55815 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/subject-mismatch.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/integrations/health-record/fixtures/subject-mismatch.yaml @@ -12,4 +12,4 @@ interactions: respond: status: 200 body: { trackedEntity: B0000000002, attributes: [], enrollments: [] } -expect: { error: failure.subject_mismatch, outputs: {}, claims: {} } +expect: { error: failure.subject_mismatch, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml index aedc9360e..f2007fce6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/registry-stack.yaml @@ -2,18 +2,17 @@ version: 1 starter: id: dhis2-tracker release: 0.16.3 - content_digest: sha256:be906adb76e387518906b1db31a435139d132c4471ea1f4387bcaf6c0de456c3 + content_digest: sha256:ea1b8635519636c3076e55209a6f234608b3fd5b99c5b280d409ca7024f3a402 registry: { id: fictional-health-registry } integrations: health-record: { file: integrations/health-record/integration.yaml } services: health-verification: - kind: evidence + kind: consultation_api version: 1 purpose: programme-enrollment-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:health:read"] } variables: as_of_date: { from: request.variables.as_of_date, type: date } consultations: @@ -22,44 +21,3 @@ services: input: tracked_entity: request.target.identifiers.dhis2_tracked_entity include_inactive: request.target.attributes.include_inactive - claims: - tracked-entity-first-name: { output: health.first_name, disclosure: value } - tracked-entity-last-name: { output: health.last_name, disclosure: value } - child-program-active: { output: health.child_program_active, disclosure: predicate } - child-age-band: - cel: >- - health.matched && health.date_of_birth != null - ? (date.age_on(health.date_of_birth, as_of_date) < 5 - ? "0-4" - : (date.age_on(health.date_of_birth, as_of_date) < 18 ? "5-17" : "18+")) - : null - disclosure: value - programme-code: { output: health.programme_code, disclosure: value } - reconciliation-reference: { output: health.reconciliation_reference, disclosure: redacted } - maternal-postnatal-care-active: { output: health.maternal_postnatal_active, disclosure: predicate } - child-health-visit-recorded: { output: health.child_health_visit_recorded, disclosure: predicate } - tb-program-active: { output: health.tb_program_active, disclosure: predicate } - bcg-birth-dose-recorded: { output: health.bcg_birth_dose_recorded, disclosure: predicate } - opv-birth-dose-recorded: { output: health.opv_birth_dose_recorded, disclosure: predicate } - measles-dose-recorded: { output: health.measles_dose_recorded, disclosure: predicate } - credential_profiles: - health-status: - format: dc+sd-jwt - type: https://credentials.invalid/health-status/v1 - validity: 10m - claims: [maternal-postnatal-care-active, child-health-visit-recorded, tb-program-active] - child-program: - format: dc+sd-jwt - type: https://credentials.invalid/child-program/v1 - validity: 10m - claims: [tracked-entity-first-name, tracked-entity-last-name, child-program-active, child-age-band] - programme-participation: - format: dc+sd-jwt - type: https://credentials.invalid/programme-participation/v1 - validity: 10m - claims: [programme-code, reconciliation-reference, child-program-active] - child-health-evidence: - format: dc+sd-jwt - type: https://credentials.invalid/child-health-evidence/v1 - validity: 10m - claims: [child-program-active, bcg-birth-dose-recorded, opv-birth-dose-recorded, measles-dose-recorded] diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/environments/local.yaml index 672120d40..d46e3168f 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/environments/local.yaml @@ -6,28 +6,14 @@ integrations: credential: token: { secret: FHIR_ACCESS_TOKEN } generation: 1 -issuance: - issuer: did:web:fhir-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - coverage-service: - api_key_fingerprint: { secret: COVERAGE_SERVICE_TOKEN_HASH } - scopes: ["evidence:coverage:read"] relay: origin: https://fhir-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [fhir-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: fhir-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: fhir-consultation-client, principal_id: fhir-consultation-principal } deployment: profile: local relay: { service: fhir-relay } - notary: { service: fhir-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-anchor.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-anchor.yaml index 3a03e11d1..c2c900f84 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-anchor.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-anchor.yaml @@ -15,4 +15,4 @@ interactions: entry: - { search: { mode: match }, resource: { resourceType: Patient, id: patient-1 } } - { search: { mode: match }, resource: { resourceType: Patient, id: patient-2 } } -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-relation.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-relation.yaml index 32b91ea35..7b2bda218 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-relation.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/ambiguous-relation.yaml @@ -32,4 +32,4 @@ interactions: entry: - { search: { mode: match }, resource: { resourceType: Coverage, status: active } } - { search: { mode: match }, resource: { resourceType: Coverage, status: cancelled } } -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/match.yaml index c342baad6..98425cfa3 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/match.yaml @@ -1,16 +1,5 @@ name: coverage-active classification: synthetic -request: - target: - type: Person - identifiers: - - { scheme: birthdate, value: 2018-05-12 } - - { scheme: family, value: Example } - - { scheme: given, value: Ada } - claims: [coverage-active] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: coverage-verification input: { birthdate: 2018-05-12, family: Example, given: Ada } interactions: - expect: @@ -58,4 +47,3 @@ interactions: expect: outcome: match outputs: { coverage_status: active, insurer_name: Synthetic Health Fund } - claims: { patient-record-exists: true, coverage-active: true, insurer-name: Synthetic Health Fund } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/no-match.yaml index 5dcd2cb7c..3a34cfdff 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/no-match.yaml @@ -12,4 +12,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: { patient-record-exists: false, coverage-active: false, insurer-name: null } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-bounded.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-bounded.yaml index 37df00431..8665e80d0 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-bounded.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-bounded.yaml @@ -32,4 +32,4 @@ interactions: id: patient-1 birthDate: 2018-05-12 name: [{ family: Example, given: [Ada] }] -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-match.yaml index ed982d596..d08b7667e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/pagination-match.yaml @@ -52,4 +52,3 @@ interactions: expect: outcome: match outputs: { coverage_status: active, insurer_name: Synthetic Health Fund } - claims: { patient-record-exists: true, coverage-active: true, insurer-name: Synthetic Health Fund } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/subject-mismatch.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/subject-mismatch.yaml index 076264f56..47c960f71 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/subject-mismatch.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/integrations/coverage/fixtures/subject-mismatch.yaml @@ -19,4 +19,4 @@ interactions: id: patient-other birthDate: 2016-09-03 name: [{ family: Different, given: [Person] }] -expect: { error: failure.subject_mismatch, outputs: {}, claims: {} } +expect: { error: failure.subject_mismatch, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml index 459ed6847..654f4bdd5 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/registry-stack.yaml @@ -2,18 +2,17 @@ version: 1 starter: id: fhir-r4 release: 0.16.3 - content_digest: sha256:6eef001b4c172831a33abf13484c6389195fc2aca787f55750555bed1850a0ea + content_digest: sha256:db68aeeaa207bd7a1f2d5f87557e45d888097b6b82379ce14c2acc424ee4d1ac registry: { id: fictional-fhir-registry } integrations: coverage: { file: integrations/coverage/integration.yaml } services: coverage-verification: - kind: evidence + kind: consultation_api version: 1 purpose: coverage-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:coverage:read"] } consultations: coverage: integration: coverage @@ -21,18 +20,3 @@ services: birthdate: request.target.identifiers.birthdate family: request.target.identifiers.family given: request.target.identifiers.given - claims: - patient-record-exists: { cel: "coverage.matched", disclosure: predicate } - coverage-active: - cel: >- - coverage.coverage_status != null - ? coverage.matched && coverage.coverage_status == "active" - : false - disclosure: predicate - insurer-name: { output: coverage.insurer_name, disclosure: value } - credential_profiles: - coverage-status: - format: dc+sd-jwt - type: https://credentials.invalid/coverage-status/v1 - validity: 5m - claims: [patient-record-exists, coverage-active, insurer-name] diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/environments/local.yaml index 3cdca67cc..34082cdd0 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/environments/local.yaml @@ -15,28 +15,14 @@ integrations: origin: https://trust.provincial-civil-registry.invalid path: /.well-known/jwks.json generation: 1 -issuance: - issuer: did:web:provincial-civil-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - provincial-verifier: - api_key_fingerprint: { secret: PROVINCIAL_VERIFIER_TOKEN_HASH } - scopes: ["evidence:provincial-birth:read"] relay: origin: https://provincial-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [provincial-civil-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: provincial-civil-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: provincial-civil-consultation-client, principal_id: provincial-civil-consultation-principal } deployment: profile: local relay: { service: provincial-civil-relay } - notary: { service: provincial-civil-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/ambiguous.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/ambiguous.yaml index de40d66b9..ac8d71984 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/ambiguous.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/ambiguous.yaml @@ -42,4 +42,4 @@ interactions: - { field: identifier_value, operator: eq, value: BR-ZZ-0000001 } pagination: { page_size: 2, page_number: 1 } respond: { status: 200, body: { file: bodies/ambiguous.json } } -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/match.yaml index 0dafc20f1..8852a510f 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/match.yaml @@ -1,14 +1,5 @@ name: provincial-birth-match classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: birth_registration_number, value: BR-ZZ-0000001 }] - variables: { as_of_date: 2026-01-01 } - claims: [birth-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: provincial-birth-attribute-verification input: { registration_number: BR-ZZ-0000001 } variables: { as_of_date: 2026-01-01 } interactions: @@ -59,12 +50,3 @@ expect: child_given_name: Camille child_family_name: Exemple place_of_birth: Fictional Province - claims: - birth-record-exists: true - date-of-birth: 2016-03-11 - sex: X - child-given-name: Camille - child-family-name: Exemple - child-birth-date: 2016-03-11 - child-place-of-birth: Fictional Province - age-band: 5-17 diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/no-match.yaml index 8d13b2bb9..2c788f57d 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/integrations/birth-record/fixtures/no-match.yaml @@ -45,4 +45,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: { birth-record-exists: false, date-of-birth: null, sex: null, child-given-name: null, child-family-name: null, child-birth-date: null, child-place-of-birth: null, age-band: null } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/registry-stack.yaml index a607ea21d..ffff6e3b9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-country-variant/registry-stack.yaml @@ -4,12 +4,11 @@ integrations: birth-record: { file: integrations/birth-record/integration.yaml } services: provincial-birth-verification: - kind: evidence + kind: consultation_api version: 1 purpose: provincial-birth-attribute-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:provincial-birth:read"] } variables: as_of_date: { from: request.variables.as_of_date, type: date } consultations: @@ -17,30 +16,3 @@ services: integration: birth-record input: registration_number: request.target.identifiers.birth_registration_number - claims: - birth-record-exists: { cel: "birth.matched", disclosure: predicate } - date-of-birth: { output: birth.date_of_birth, disclosure: value } - sex: { output: birth.sex, disclosure: value } - child-given-name: { output: birth.child_given_name, disclosure: value } - child-family-name: { output: birth.child_family_name, disclosure: value } - child-birth-date: { output: birth.date_of_birth, disclosure: value } - child-place-of-birth: { output: birth.place_of_birth, disclosure: value } - age-band: - cel: >- - birth.matched && birth.date_of_birth != null - ? (date.age_on(birth.date_of_birth, as_of_date) < 5 - ? "0-4" - : (date.age_on(birth.date_of_birth, as_of_date) < 18 ? "5-17" : "18+")) - : null - disclosure: value - credential_profiles: - birth-summary: - format: dc+sd-jwt - type: https://credentials.invalid/provincial-birth-summary/v1 - validity: 10m - claims: [birth-record-exists, date-of-birth, sex, age-band] - birth-attributes: - format: dc+sd-jwt - type: https://credentials.invalid/provincial-birth-attributes/v1 - validity: 10m - claims: [child-given-name, child-family-name, child-birth-date, child-place-of-birth] diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/environments/local.yaml index 620ea2256..607843568 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/environments/local.yaml @@ -18,24 +18,14 @@ integrations: path: /oauth/token generation: 1 -callers: - synthetic-case-client: - api_key_fingerprint: { secret: SYNTHETIC_CASE_CLIENT_TOKEN_HASH } - scopes: ["evidence:birth-event:read"] - relay: origin: https://relay.opencrvs.invalid issuer: https://workload-issuer.opencrvs.invalid jwks_url: https://workload-issuer.opencrvs.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [synthetic-opencrvs-relay-client] - -notary_relay: - base_url: http://registry-relay-consultation:8080 - workload_client_id: synthetic-opencrvs-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: synthetic-opencrvs-consultation-client, principal_id: synthetic-opencrvs-consultation-principal } deployment: profile: local relay: { service: synthetic-opencrvs-relay } - notary: { service: synthetic-opencrvs-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/ambiguous.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/ambiguous.yaml index e053ffeb6..ecc618347 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/ambiguous.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/ambiguous.yaml @@ -32,4 +32,3 @@ interactions: expect: outcome: ambiguous outputs: {} - claims: {} diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/match.yaml index ef9885978..97544b7e0 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/match.yaml @@ -1,14 +1,5 @@ name: birth-event-match classification: synthetic -request: - target: - type: Person - identifiers: - - { scheme: opencrvs_tracking_id, value: TRK-SYNTH000001 } - claims: [birth-event-found, birth-event-registered] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: birth-event-registration-verification input: { tracking_id: TRK-SYNTH000001 } interactions: - expect: @@ -51,6 +42,3 @@ expect: outputs: event_type: birth registered: true - claims: - birth-event-found: true - birth-event-registered: true diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/no-match.yaml index 8a34cf7eb..14405da7a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/no-match.yaml @@ -28,6 +28,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - birth-event-found: false - birth-event-registered: false diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-expiry.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-expiry.yaml index d27ba86ad..fd8bb87e9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-expiry.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-expiry.yaml @@ -14,4 +14,4 @@ interactions: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer expires_in: 300 -expect: { error: source.response_malformed, outputs: {}, claims: {} } +expect: { error: source.response_malformed, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-extra-member.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-extra-member.yaml index 0d7c88b42..0a96cb72c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-extra-member.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-extra-member.yaml @@ -14,4 +14,4 @@ interactions: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: Bearer unexpected: rejected -expect: { error: source.response_malformed, outputs: {}, claims: {} } +expect: { error: source.response_malformed, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-media-type.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-media-type.yaml index f6d093deb..3c71fe464 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-media-type.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-media-type.yaml @@ -11,4 +11,4 @@ interactions: status: 200 headers: { Content-Type: text/plain } body: { access_token: SYNTHETIC_FIXTURE_TOKEN, token_type: Bearer } -expect: { error: source.response_malformed, outputs: {}, claims: {} } +expect: { error: source.response_malformed, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-redirect.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-redirect.yaml index 788832488..79b4b250c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-redirect.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-redirect.yaml @@ -11,4 +11,4 @@ interactions: status: 302 headers: { Location: https://redirect.opencrvs.invalid/oauth/token } body: {} -expect: { error: source.status_rejected, outputs: {}, claims: {} } +expect: { error: source.status_rejected, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-token-type.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-token-type.yaml index 99b2ba786..2af0b65f2 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-token-type.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/oauth-token-type.yaml @@ -13,4 +13,4 @@ interactions: body: access_token: SYNTHETIC_FIXTURE_TOKEN token_type: bearer -expect: { error: source.response_malformed, outputs: {}, claims: {} } +expect: { error: source.response_malformed, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-malformed.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-malformed.yaml index 6917af9dd..0b71925a1 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-malformed.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-malformed.yaml @@ -25,4 +25,4 @@ interactions: respond: status: 200 body: { total: one, results: [] } -expect: { error: source.status_rejected, outputs: {}, claims: {} } +expect: { error: source.status_rejected, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-rejected.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-rejected.yaml index c2aa9a714..8f286c612 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-rejected.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-rejected.yaml @@ -23,4 +23,4 @@ interactions: limit: 2 offset: 0 respond: { status: 503, body: {} } -expect: { error: source.status_rejected, outputs: {}, claims: {} } +expect: { error: source.status_rejected, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-timeout.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-timeout.yaml index 728735613..fdb27880b 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-timeout.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/source-timeout.yaml @@ -23,4 +23,4 @@ interactions: limit: 2 offset: 0 respond: { timeout: 10s } -expect: { error: source.deadline_exceeded, outputs: {}, claims: {} } +expect: { error: source.deadline_exceeded, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/subject-mismatch.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/subject-mismatch.yaml index c66b3f852..0e497bbb9 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/subject-mismatch.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/integrations/birth-event-search/fixtures/subject-mismatch.yaml @@ -31,4 +31,4 @@ interactions: type: birth status: REGISTERED trackingId: TRK-SYNTH999999 -expect: { error: failure.subject_mismatch, outputs: {}, claims: {} } +expect: { error: failure.subject_mismatch, outputs: {} } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/registry-stack.yaml index e5bcc94de..33891d5cb 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs-events-api/registry-stack.yaml @@ -8,25 +8,13 @@ integrations: services: birth-event-verification: - kind: evidence + kind: consultation_api version: 1 purpose: birth-event-registration-verification legal_basis: public-service-delivery consent: not_required - access: - scopes: ["evidence:birth-event:read"] consultations: event: integration: birth-event-search input: tracking_id: request.target.identifiers.opencrvs_tracking_id - claims: - birth-event-found: - cel: event.matched - disclosure: predicate - birth-event-registered: - cel: >- - event.matched - && event.event_type == "birth" - && event.registered - disclosure: predicate diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md index 6331f9676..29a055740 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md @@ -26,5 +26,5 @@ it constructs each released parent object field by field and releases only `type`, `name`, and `identifier`. The closed `parents` output permits at most two objects and caps both the array and each item by canonical serialized bytes. The adapter never spreads or returns a whole source parent record. -`parents` is one top-level credential claim and is disclosed or withheld as a -whole unit; this project does not declare nested selective disclosure. +`parents` is one bounded top-level consultation output and is returned as a +whole unit; the project does not expose the source-only reference. diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/environments/local.yaml index 2c1a2a315..9b32d2102 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/environments/local.yaml @@ -15,28 +15,14 @@ integrations: origin: https://trust.civil-registry.invalid path: /.well-known/jwks.json generation: 1 -issuance: - issuer: did:web:civil-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - birth-verifier: - api_key_fingerprint: { secret: BIRTH_VERIFIER_TOKEN_HASH } - scopes: ["evidence:birth:read"] relay: origin: https://civil-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [civil-registry-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: civil-registry-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: civil-registry-consultation-client, principal_id: civil-registry-consultation-principal } deployment: profile: local relay: { service: civil-registry-relay } - notary: { service: civil-registry-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/ambiguous.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/ambiguous.yaml index d2fbb3bd9..3a5f4b0e2 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/ambiguous.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/ambiguous.yaml @@ -48,4 +48,3 @@ interactions: expect: outcome: ambiguous outputs: {} - claims: {} diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/match.yaml index 906bd764d..4722d292d 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/match.yaml @@ -1,14 +1,5 @@ name: birth-record-match classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: UIN, value: "0000000001" }] - variables: { as_of_date: 2026-01-01 } - claims: [birth-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: birth-record-verification input: { uin: "0000000001" } variables: { as_of_date: 2026-01-01 } interactions: @@ -65,15 +56,3 @@ expect: parents: - { type: mother, name: Mira Example, identifier: PARENT-0001 } - { type: father, name: Noah Example, identifier: PARENT-0002 } - claims: - birth-record-exists: true - date-of-birth: 2018-05-12 - sex: female - child-given-name: Ada - child-family-name: Example - child-birth-date: 2018-05-12 - child-place-of-birth: Fictional District - parents: - - { type: mother, name: Mira Example, identifier: PARENT-0001 } - - { type: father, name: Noah Example, identifier: PARENT-0002 } - age-band: 5-17 diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/no-match.yaml index fedb9e81c..38b3b5e88 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/integrations/birth-record/fixtures/no-match.yaml @@ -45,4 +45,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: { birth-record-exists: false, date-of-birth: null, sex: null, child-given-name: null, child-family-name: null, child-birth-date: null, child-place-of-birth: null, parents: null, age-band: null } diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml index 26c71bde0..66c9d5e23 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/registry-stack.yaml @@ -2,18 +2,17 @@ version: 1 starter: id: opencrvs-dci release: 0.16.3 - content_digest: sha256:b99132e497b63e1f77a1f5cde8e52f1a293e6bca88f912e5a2ba70a8e1c46f8c + content_digest: sha256:7eb3f0e32f11a4a6e6a5c36f6107292a8fe4d9a853acbcf57cc30b5942c7d268 registry: { id: fictional-civil-registry } integrations: birth-record: { file: integrations/birth-record/integration.yaml } services: birth-verification: - kind: evidence + kind: consultation_api version: 1 purpose: birth-record-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:birth:read"] } variables: as_of_date: from: request.variables.as_of_date @@ -23,31 +22,3 @@ services: integration: birth-record input: uin: request.target.identifiers.UIN - claims: - birth-record-exists: { cel: "birth.matched", disclosure: predicate } - date-of-birth: { output: birth.date_of_birth, disclosure: value } - sex: { output: birth.sex, disclosure: value } - child-given-name: { output: birth.child_given_name, disclosure: value } - child-family-name: { output: birth.child_family_name, disclosure: value } - child-birth-date: { output: birth.date_of_birth, disclosure: value } - child-place-of-birth: { output: birth.place_of_birth, disclosure: value } - parents: { output: birth.parents, disclosure: value } - age-band: - cel: >- - birth.matched && birth.date_of_birth != null - ? (date.age_on(birth.date_of_birth, as_of_date) < 5 - ? "0-4" - : (date.age_on(birth.date_of_birth, as_of_date) < 18 ? "5-17" : "18+")) - : null - disclosure: value - credential_profiles: - birth-summary: - format: dc+sd-jwt - type: https://credentials.invalid/birth-summary/v1 - validity: 10m - claims: [birth-record-exists, date-of-birth, sex, age-band] - birth-attributes: - format: dc+sd-jwt - type: https://credentials.invalid/birth-attributes/v1 - validity: 10m - claims: [child-given-name, child-family-name, child-birth-date, child-place-of-birth, parents] diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md index adf64d0d7..d2c084bfd 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/README.md @@ -38,13 +38,12 @@ Use a private copy of this workspace from one tagged Registry Stack release that contains both [PR #355](https://github.com/registrystack/registry-stack/pull/355) and [PR #364](https://github.com/registrystack/registry-stack/pull/364). -Record the tag and use the matching `registryctl`, Relay image, Notary image, -and published image digests. +Record the tag and use the matching `registryctl`, Relay image, and published +image digest. Atomically pin the candidate adopter deployment to those artifacts. Do not use a source build, a branch image, the retired monorepo `lab/`, shared -Relay or Notary state, or a hand-edited generated YAML file. -Each Relay authority needs its own dedicated Notary and Notary-owned PostgreSQL -state. +Relay state, or a hand-edited generated YAML file. +Each Relay authority needs its own dedicated runtime and state. Select and record one exact OpenSPP version and one read-only operation before editing the workspace. @@ -55,23 +54,22 @@ Replace every fictional assumption in the private copy: | `integrations/individual/integration.yaml` | Replace `id` and the `source.versions.unverified` fixture label with the reviewed integration identity and exact selected OpenSPP version. Classify that version under `source.versions.tested` only after the owner evidence is accepted. | | `integrations/individual/integration.yaml` | Replace the input name, type, length, pattern, HTTP method, relative path, query, no-match statuses, authentication type, response format, and response-size bound with the selected read-only operation's contract. | | `integrations/individual/integration.yaml` | Replace output names, types, lengths, and `x-registry-source` pointers. Reassess the `ambiguity` and `subject_mismatch` not-applicable rationales. Add fixtures when either outcome is applicable. | -| `integrations/individual/fixtures/*.yaml` | Replace the synthetic selector, request expectation, source response, normalized outputs, outcome, and claims. Keep all retained fixture data synthetic. | -| `registry-stack.yaml` | Replace `registry.id`, the service id, `purpose`, `legal_basis`, `consent`, `access.scopes`, the consultation input mapping, claim ids and declarations, disclosure modes, and credential profile with reviewed owner decisions. | +| `integrations/individual/fixtures/*.yaml` | Replace the synthetic selector, request expectation, source response, normalized outputs, and outcome. Keep all retained fixture data synthetic. | +| `registry-stack.yaml` | Replace `registry.id`, the service id, `purpose`, `legal_basis`, `consent`, and the consultation input mapping with reviewed owner decisions. | | `environments/.yaml` | Replace `integrations.individual.source.origin`, `integrations.individual.source.credential.token.secret`, and `integrations.individual.source.credential.generation` with the private OpenSPP source binding. | -| `environments/.yaml` | Replace every `issuance` field and the `callers.programme-service` map key, API-key fingerprint secret reference, and scopes with candidate-owned values. | -| `environments/.yaml` | Replace every `relay`, `notary_relay`, and `deployment` field. Add the candidate-required state bindings. Keep all deployment values outside public evidence. | +| `environments/.yaml` | Replace every `relay` and `deployment` field. Add the candidate-required state bindings. Keep all deployment values outside public evidence. | Use bounded one-request HTTP authoring when it expresses the selected operation. Use reviewed Rhai only when the operation requires project-owned traversal or normalization. -Do not add OpenSPP-specific Rust dispatch, restore a Notary source connector, -add an integration sidecar, or edit generated runtime configuration. +Do not add OpenSPP-specific Rust dispatch, add an integration sidecar, or edit +generated runtime configuration. Rerun the focused trace, complete offline fixture suite, check, and build after every contract change. For the private owner environment, replace `local` in the check and build commands with the exact environment filename without `.yaml`. -Activate the generated Relay and Notary Config Bundle inputs through the +Activate the generated Relay Config Bundle inputs through the documented path for the selected tagged candidate, without modifying generated files. @@ -81,18 +79,17 @@ Before asking to close GH#357, record: - [ ] Exact OpenSPP version and read-only operation, including which country-specific mapping files changed. -- [ ] Exact Registry Stack tag, `registryctl` version, adopter commit, Relay and - Notary image digests, and per-authority Notary and PostgreSQL topology. +- [ ] Exact Registry Stack tag, `registryctl` version, adopter commit, Relay + image digest, and per-authority Relay state topology. - [ ] The commands and pass or fail outcomes for focused trace, watch, complete offline fixtures, check, build, and separately owned activation evidence. - [ ] Match and no-match outcomes, plus applicable ambiguity or subject-mismatch behavior or reviewed reasons that they are not applicable. - [ ] Authorization denial before source access, bounded failure, disclosure, redaction, and source-backed provenance outcomes. -- [ ] Confirmation that generated Relay and Notary files were activated - unchanged. -- [ ] Confirmation that no OpenSPP-specific Registry Stack Rust, Notary source - connector, integration sidecar, or direct registry test path was needed. +- [ ] Confirmation that generated Relay files were activated unchanged. +- [ ] Confirmation that no OpenSPP-specific Registry Stack Rust, integration + sidecar, or direct registry test path was needed. - [ ] Confirmation that changing the country mapping required only reviewed project-authored files and fixtures. - [ ] Any gap fixed in the generic authoring or runtime model, or recorded as an @@ -100,7 +97,7 @@ Before asking to close GH#357, record: Before retaining or publishing evidence: -- [ ] Remove Notary and OpenSPP credentials, secret values, private origins, +- [ ] Remove OpenSPP credentials, secret values, private origins, private network details, raw selectors, subject identifiers, source rows, source response bodies, and deployment-specific file paths. - [ ] Do not retain shell history, environment dumps, packet captures, verbose diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/environments/local.yaml index a2e09fb5b..7547e709a 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/environments/local.yaml @@ -6,28 +6,14 @@ integrations: credential: token: { secret: SOCIAL_REGISTRY_TOKEN } generation: 1 -issuance: - issuer: did:web:social-registry-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - programme-service: - api_key_fingerprint: { secret: PROGRAMME_SERVICE_TOKEN_HASH } - scopes: ["evidence:social-registry:read"] relay: origin: https://social-registry-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [social-registry-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: social-registry-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: social-registry-consultation-client, principal_id: social-registry-consultation-principal } deployment: profile: local relay: { service: social-registry-relay } - notary: { service: social-registry-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/match.yaml index c215fc1fa..86516d624 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/match.yaml @@ -1,13 +1,5 @@ name: social-registry-match classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: openspp_individual_id, value: IND-AB12CD34 }] - claims: [social-registry-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: social-programme-verification input: { individual_id: IND-AB12CD34 } interactions: - expect: @@ -19,4 +11,3 @@ interactions: body: { active: true, programme_code: SUPPORT, household_reference: HH-0001 } expect: outputs: { active: true, programme_code: SUPPORT, household_reference: HH-0001 } - claims: { social-registry-record-exists: true, social-registry-active: true, programme-code: SUPPORT, household-reference: redacted } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/no-match.yaml index 307da4e21..117f684dc 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/integrations/individual/fixtures/no-match.yaml @@ -10,4 +10,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: { social-registry-record-exists: false, social-registry-active: null, programme-code: null, household-reference: redacted } diff --git a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/registry-stack.yaml index c608cf71c..bcd200fe6 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/openspp-exact/registry-stack.yaml @@ -4,25 +4,13 @@ integrations: individual: { file: integrations/individual/integration.yaml } services: social-registry-verification: - kind: evidence + kind: consultation_api version: 1 purpose: social-programme-verification legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:social-registry:read"] } consultations: individual: integration: individual input: individual_id: request.target.identifiers.openspp_individual_id - claims: - social-registry-record-exists: { cel: individual.matched, disclosure: predicate } - social-registry-active: { output: individual.active, disclosure: predicate } - programme-code: { output: individual.programme_code, disclosure: value } - household-reference: { output: individual.household_reference, disclosure: redacted } - credential_profiles: - social-registry-status: - format: dc+sd-jwt - type: https://credentials.invalid/social-registry-status/v1 - validity: 10m - claims: [social-registry-record-exists, social-registry-active, programme-code] diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md index 4c423971b..118a0e12e 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md @@ -20,10 +20,9 @@ Add a records service only when the project intentionally publishes the entity through Relay's governed records API. Relay normalizes the source fields as `registration_status` and -`residency_confirmed`. Notary exposes the reusable -`population-registration-status` and `residency-confirmed` evidence claims. -The evidence consumer, not this project, determines how those claims are used. +`residency_confirmed` and exposes them through the bounded consultation API. +The consultation consumer, not this project, determines how those outputs are used. The decision owner remains accountable for eligibility, qualification, prioritization, approval, payment, workflow, and action rules. A no-match keeps -both evidence values unknown rather than silently converting missing evidence +both output values unknown rather than silently converting missing source data to a negative fact. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml index ff8f18ee5..d378fac7c 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/environments/local.yaml @@ -12,31 +12,14 @@ entities: guardian_id: guardian_key source_revision: population-export-v1 generation: 2026-07-12 -issuance: - issuer: did:web:population-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - benefits-service: - api_key_fingerprint: { secret: BENEFITS_SERVICE_TOKEN_HASH } - scopes: ["evidence:population:read"] - emergency-service: - api_key_fingerprint: { secret: EMERGENCY_SERVICE_TOKEN_HASH } - scopes: ["evidence:population:emergency"] relay: origin: https://population-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [population-relay-client] - -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: population-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: population-consultation-client, principal_id: population-consultation-principal } deployment: profile: local relay: { service: population-relay } - notary: { service: population-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml index 3d21e9777..ec853caab 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/match.yaml @@ -1,13 +1,5 @@ name: snapshot-match classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: population_person_id, value: PER-00000001 }] - claims: [population-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: benefits-eligibility input: { person_id: PER-00000001 } interactions: - expect: { method: GET, path: /snapshot } @@ -15,9 +7,3 @@ interactions: expect: outcome: match outputs: { registration_status: active, residency_confirmed: true } - claims: - population-record-exists: true - population-registration-status: active - residency-confirmed: true - emergency-record-exists: true - emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml index 55a1e18a6..51c104130 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/integrations/person-snapshot/fixtures/no-match.yaml @@ -7,9 +7,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - population-record-exists: false - population-registration-status: null - residency-confirmed: null - emergency-record-exists: false - emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml index deb26553a..8b9fa486f 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/registry-stack.yaml @@ -2,7 +2,7 @@ version: 1 starter: id: snapshot release: 0.16.3 - content_digest: sha256:868e6ecd834c078f56bd38c8b9f3c8a5d39baa9e6a44ddf94e153276dd588314 + content_digest: sha256:15d4fc03d45d1b622812aa5900f413609b325b32f3d967993c9fd396c51f06df registry: { id: fictional-population-registry } integrations: person-snapshot: { file: integrations/person-snapshot/integration.yaml } @@ -10,43 +10,22 @@ entities: people: { file: entities/people.yaml } services: benefits-eligibility: - kind: evidence + kind: consultation_api version: 1 purpose: benefits-eligibility legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:population:read"] } consultations: person: integration: person-snapshot input: { person_id: request.target.identifiers.population_person_id } - claims: - population-record-exists: { cel: person.matched, disclosure: predicate } - population-registration-status: { output: person.registration_status, disclosure: value } - residency-confirmed: { output: person.residency_confirmed, disclosure: predicate } - credential_profiles: - population-evidence: - format: dc+sd-jwt - type: https://credentials.invalid/population-evidence/v1 - validity: 10m - claims: [population-record-exists, population-registration-status, residency-confirmed] emergency-assistance: - kind: evidence + kind: consultation_api version: 1 purpose: emergency-assistance legal_basis: vital-interests consent: not_required - access: { scopes: ["evidence:population:emergency"] } consultations: person: integration: person-snapshot input: { person_id: request.target.identifiers.population_person_id } - claims: - emergency-record-exists: { cel: person.matched, disclosure: predicate } - emergency-status: { output: person.registration_status, disclosure: redacted } - credential_profiles: - emergency-status: - format: dc+sd-jwt - type: https://credentials.invalid/emergency-status/v1 - validity: 5m - claims: [emergency-record-exists, emergency-status] diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml index 1fa460442..c831732e1 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/environments/local.yaml @@ -12,29 +12,13 @@ entities: guardian_id: guardian_key source_revision: population-export-v1 generation: 2026-07-13 -issuance: - issuer: did:web:population-notary.invalid - signing_kid: project-issuer-key - signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK } - generation: 1 -callers: - benefits-service: - api_key_fingerprint: { secret: BENEFITS_SERVICE_TOKEN_HASH } - scopes: ["evidence:population:read"] - emergency-service: - api_key_fingerprint: { secret: EMERGENCY_SERVICE_TOKEN_HASH } - scopes: ["evidence:population:emergency"] relay: origin: https://population-relay.internal.invalid issuer: https://workload-issuer.internal.invalid jwks_url: https://workload-issuer.internal.invalid/.well-known/jwks.json audience: registry-relay allowed_clients: [population-records-client] -notary_relay: - base_url: http://127.0.0.1:8080 - workload_client_id: population-notary - token_file: /run/secrets/relay-workload-token + consultation: { client_id: population-records-consultation-client, principal_id: population-consultation-principal } deployment: profile: local relay: { service: population-relay } - notary: { service: population-notary } diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml index 3d21e9777..ec853caab 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/match.yaml @@ -1,13 +1,5 @@ name: snapshot-match classification: synthetic -request: - target: - type: Person - identifiers: [{ scheme: population_person_id, value: PER-00000001 }] - claims: [population-record-exists] - disclosure: predicate - format: application/vnd.registry-notary.claim-result+json - purpose: benefits-eligibility input: { person_id: PER-00000001 } interactions: - expect: { method: GET, path: /snapshot } @@ -15,9 +7,3 @@ interactions: expect: outcome: match outputs: { registration_status: active, residency_confirmed: true } - claims: - population-record-exists: true - population-registration-status: active - residency-confirmed: true - emergency-record-exists: true - emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml index 55a1e18a6..51c104130 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/integrations/person-snapshot/fixtures/no-match.yaml @@ -7,9 +7,3 @@ interactions: expect: outcome: no_match outputs: {} - claims: - population-record-exists: false - population-registration-status: null - residency-confirmed: null - emergency-record-exists: false - emergency-status: redacted diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml index 444941552..7559cb94b 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-with-records/registry-stack.yaml @@ -22,43 +22,22 @@ services: required_principal_filters: [person_id] standards: { ogc_features: false, sp_dci: false } benefits-eligibility: - kind: evidence + kind: consultation_api version: 1 purpose: benefits-eligibility legal_basis: public-service-delivery consent: not_required - access: { scopes: ["evidence:population:read"] } consultations: person: integration: person-snapshot input: { person_id: request.target.identifiers.population_person_id } - claims: - population-record-exists: { cel: person.matched, disclosure: predicate } - population-registration-status: { output: person.registration_status, disclosure: value } - residency-confirmed: { output: person.residency_confirmed, disclosure: predicate } - credential_profiles: - population-evidence: - format: dc+sd-jwt - type: https://credentials.invalid/population-evidence/v1 - validity: 10m - claims: [population-record-exists, population-registration-status, residency-confirmed] emergency-assistance: - kind: evidence + kind: consultation_api version: 1 purpose: emergency-assistance legal_basis: vital-interests consent: not_required - access: { scopes: ["evidence:population:emergency"] } consultations: person: integration: person-snapshot input: { person_id: request.target.identifiers.population_person_id } - claims: - emergency-record-exists: { cel: person.matched, disclosure: predicate } - emergency-status: { output: person.registration_status, disclosure: redacted } - credential_profiles: - emergency-status: - format: dc+sd-jwt - type: https://credentials.invalid/emergency-status/v1 - validity: 5m - claims: [emergency-record-exists, emergency-status] diff --git a/crates/registryctl/tests/fixtures/project-reports/registry.project.capability_inventory.v1.json b/crates/registryctl/tests/fixtures/project-reports/registry.project.capability_inventory.v1.json index c37d58bfa..dc2396f28 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registry.project.capability_inventory.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registry.project.capability_inventory.v1.json @@ -13,7 +13,7 @@ "installed_evidence": "embedded_compiler", "project_declaration": "declared", "environment_enablement": "enabled", - "used_by": {"services": 1, "consultations": 1, "claims": 0, "total": 2}, + "used_by": {"services": 1, "consultations": 1, "total": 2}, "disposition": "used" }, { @@ -26,7 +26,7 @@ "installed_evidence": "embedded_compiler", "project_declaration": "declared", "environment_enablement": "enabled", - "used_by": {"services": 0, "consultations": 1, "claims": 1, "total": 2}, + "used_by": {"services": 0, "consultations": 1, "total": 1}, "disposition": "used" }, { @@ -39,7 +39,7 @@ "installed_evidence": "embedded_compiler", "project_declaration": "declared", "environment_enablement": "not_enabled", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, + "used_by": {"services": 0, "consultations": 0, "total": 0}, "disposition": "declared_inactive" }, { @@ -52,7 +52,7 @@ "installed_evidence": "linked_crate", "project_declaration": "not_applicable", "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 1, "claims": 1, "total": 2}, + "used_by": {"services": 0, "consultations": 1, "total": 1}, "disposition": "used" }, { @@ -65,7 +65,7 @@ "installed_evidence": "linked_crate", "project_declaration": "not_applicable", "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 1, "claims": 1, "total": 2}, + "used_by": {"services": 0, "consultations": 1, "total": 1}, "disposition": "used" }, { @@ -78,20 +78,7 @@ "installed_evidence": "linked_crate", "project_declaration": "declared", "environment_enablement": "enabled", - "used_by": {"services": 1, "consultations": 2, "claims": 0, "total": 3}, - "disposition": "used" - }, - { - "capability": "registry_notary_product", - "kind": "product", - "owner": "registry_notary", - "maturity": "release_gated", - "supported_versions": ["registry_notary_config_v1"], - "installed_release": "compiled", - "installed_evidence": "linked_crate", - "project_declaration": "declared", - "environment_enablement": "enabled", - "used_by": {"services": 0, "consultations": 0, "claims": 1, "total": 1}, + "used_by": {"services": 1, "consultations": 2, "total": 3}, "disposition": "used" }, { @@ -104,20 +91,7 @@ "installed_evidence": "linked_product_validator", "project_declaration": "not_applicable", "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, - "disposition": "installed_unused" - }, - { - "capability": "registry_notary_validator", - "kind": "product_validator", - "owner": "registry_notary", - "maturity": "release_gated", - "supported_versions": ["registry_notary_config_v1"], - "installed_release": "compiled", - "installed_evidence": "linked_product_validator", - "project_declaration": "not_applicable", - "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, + "used_by": {"services": 0, "consultations": 0, "total": 0}, "disposition": "installed_unused" }, { @@ -130,7 +104,7 @@ "installed_evidence": "embedded_schema", "project_declaration": "not_applicable", "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, + "used_by": {"services": 0, "consultations": 0, "total": 0}, "disposition": "installed_unused" }, { @@ -143,20 +117,7 @@ "installed_evidence": "embedded_schema", "project_declaration": "not_applicable", "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, - "disposition": "installed_unused" - }, - { - "capability": "registry_notary_config_schema", - "kind": "schema", - "owner": "registry_notary", - "maturity": "release_gated", - "supported_versions": ["registry_notary_config_v1"], - "installed_release": "compiled", - "installed_evidence": "embedded_schema", - "project_declaration": "not_applicable", - "environment_enablement": "not_applicable", - "used_by": {"services": 0, "consultations": 0, "claims": 0, "total": 0}, + "used_by": {"services": 0, "consultations": 0, "total": 0}, "disposition": "installed_unused" } ], @@ -166,15 +127,11 @@ {"component": "snapshot_materialization_worker", "kind": "worker", "owner": "registry_relay", "state": "missing", "evidence": "explicitly_missing", "required_by": ["source_snapshot"]}, {"component": "rhai_xw_protocol_helper", "kind": "protocol_helper", "owner": "registry_relay", "state": "available", "evidence": "linked_crate", "required_by": ["source_script", "rhai_abi"]}, {"component": "registry_relay_product", "kind": "product", "owner": "registry_relay", "state": "available", "evidence": "linked_crate", "required_by": ["source_http", "source_script", "source_snapshot", "registry_relay_product"]}, - {"component": "registry_notary_product", "kind": "product", "owner": "registry_notary", "state": "available", "evidence": "linked_crate", "required_by": ["registry_notary_product"]}, {"component": "registry_relay_validator", "kind": "product_validator", "owner": "registry_relay", "state": "available", "evidence": "linked_product_validator", "required_by": ["registry_relay_product"]}, - {"component": "registry_notary_validator", "kind": "product_validator", "owner": "registry_notary", "state": "available", "evidence": "linked_product_validator", "required_by": ["registry_notary_product"]}, {"component": "project_authoring_schema", "kind": "schema", "owner": "registryctl", "state": "available", "evidence": "embedded_schema", "required_by": ["source_http", "source_script", "source_snapshot"]}, {"component": "registry_relay_config_schema", "kind": "schema", "owner": "registry_relay", "state": "available", "evidence": "embedded_schema", "required_by": ["registry_relay_product"]}, - {"component": "registry_notary_config_schema", "kind": "schema", "owner": "registry_notary", "state": "available", "evidence": "embedded_schema", "required_by": ["registry_notary_product"]}, {"component": "registryctl_distribution", "kind": "distribution", "owner": "release_engineering", "state": "available", "evidence": "release_metadata", "required_by": ["source_http", "source_script", "source_snapshot", "project_authoring_schemas"]}, - {"component": "registry_relay_image", "kind": "image", "owner": "release_engineering", "state": "not_evaluated", "evidence": "no_evidence", "required_by": ["registry_relay_product"]}, - {"component": "registry_notary_image", "kind": "image", "owner": "release_engineering", "state": "not_evaluated", "evidence": "no_evidence", "required_by": ["registry_notary_product"]} + {"component": "registry_relay_image", "kind": "image", "owner": "release_engineering", "state": "not_evaluated", "evidence": "no_evidence", "required_by": ["registry_relay_product"]} ], "missing_support": [ {"component": "snapshot_materialization_worker", "kind": "worker", "state": "missing", "required_by": ["source_snapshot"]} diff --git a/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.no-target.v1.json b/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.no-target.v1.json index 728da2c70..976ef25d5 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.no-target.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.no-target.v1.json @@ -5,7 +5,6 @@ "evidence_scope": "offline_synthetic", "compatibility_claim": "none", "live_compatibility": "not_evaluated", - "governed_request_evidence": "per_consultation_authored_request_witness_evaluation", "targets": [], "summary": { "target_set_state": "no_targets", diff --git a/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.v1.json b/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.v1.json index c7698234d..78eb6b7d4 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registry.project.fixture_coverage.v1.json @@ -5,7 +5,6 @@ "evidence_scope": "offline_synthetic", "compatibility_claim": "none", "live_compatibility": "not_evaluated", - "governed_request_evidence": "per_consultation_authored_request_witness_evaluation", "targets": [ { "identity": { @@ -16,29 +15,23 @@ "source_operation_count": 1, "reviewed_not_applicable": [ "subject_mismatch" - ], - "registry_backed_consultations": [ - { - "service_id": "person-verification", - "consultation_id": "person_record" - } ] }, "fixture_set_state": "fixture_bearing", "compiled_contract": { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" }, "fixture_inventory": [ { "evidence": { "kind": "authored_fixture", "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3", + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7", "expectation": { "kind": "outcome", "outcome": "match" @@ -51,24 +44,9 @@ "output_ids": [ "active" ], - "claim_ids": [ - "person-active", - "person-record-exists" - ], "exercised_status_mappings": [], "classification": "synthetic", - "pass_state": "passed", - "request_to_consultation_binding": { - "state": "passed", - "consultations": [ - { - "service_id": "person-verification", - "consultation_id": "person_record" - } - ], - "actual_relay_consultations": 1, - "safe_error_code": null - } + "pass_state": "passed" }, { "evidence": { @@ -88,7 +66,6 @@ "person_id" ], "output_ids": [], - "claim_ids": [], "exercised_status_mappings": [ { "outcome": "ambiguous", @@ -98,36 +75,26 @@ } ], "classification": "synthetic", - "pass_state": "passed", - "request_to_consultation_binding": { - "state": "not_authored", - "consultations": [], - "actual_relay_consultations": null, - "safe_error_code": null - } + "pass_state": "passed" }, { "evidence": { "kind": "authored_fixture", "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5", + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e", "expectation": { "kind": "outcome", "outcome": "no_match" }, - "semantic_null": true, + "semantic_null": false, "interaction_count": 1, "input_ids": [ "person_id" ], "output_ids": [], - "claim_ids": [ - "person-active", - "person-record-exists" - ], "exercised_status_mappings": [ { "outcome": "no_match", @@ -137,13 +104,7 @@ } ], "classification": "synthetic", - "pass_state": "passed", - "request_to_consultation_binding": { - "state": "not_authored", - "consultations": [], - "actual_relay_consultations": null, - "safe_error_code": null - } + "pass_state": "passed" } ], "generated_cases": [ @@ -151,7 +112,7 @@ "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/request_authority/v1", - "digest": "sha256:691232c9fff919e9dd9f361be7b141a07cd9fc56a0864d40a9c5decf6719ed85" + "digest": "sha256:8c44e8e564c6cebafcf72f7c61e076d399592c64ee65c4ce0a95fe8eaeda08f4" }, "recipe": { "id": "request_authority", @@ -159,7 +120,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -167,14 +128,13 @@ "mutation_target_class": "request_path_authority", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": "fixture.request_mismatch", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/request_order/v1", - "digest": "sha256:1b714bff4811578892f09bd3fe3fe79c634b2fed0c470984f25d373d679cce8c" + "digest": "sha256:012fee1fcd5caae106e4848043828ed670ffb03413b994fd94d6dcbe0bd5c8ab" }, "recipe": { "id": "request_order", @@ -182,7 +142,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "not_applicable", @@ -192,14 +152,13 @@ "mutation_target_class": "request_interaction_order", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/status_rejection/v1", - "digest": "sha256:365e50a447ad14ef0ad14689f569acc4f8c935e03fa85a6fe18f07f64666c451" + "digest": "sha256:83893efd85d32733666d7140c6ebc9cac40bb61890b14540c312338b0e9dcf13" }, "recipe": { "id": "status_rejection", @@ -207,7 +166,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -215,14 +174,13 @@ "mutation_target_class": "source_status", "expected_safe_code": "source.status_rejected", "actual_safe_code": "source.status_rejected", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/malformed_decode/v1", - "digest": "sha256:00a9b0010878b27345c9ce26b7d2089c21312749ccedfaa98849d97ced748765" + "digest": "sha256:53963adf443166b4fd580d1b6020f98ebd3383a21f682d75c695ce4df82d3a5d" }, "recipe": { "id": "malformed_decode", @@ -230,7 +188,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -238,14 +196,13 @@ "mutation_target_class": "response_body_decoding", "expected_safe_code": "source.response_malformed", "actual_safe_code": "source.response_malformed", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/byte_ceiling/v1", - "digest": "sha256:1111c0c5d0542692d8c3efe821e719ee1235654102af5a399ee1da2b4167691e" + "digest": "sha256:d93707b5e8aaea86d7e9fc014b649fdc1702f4281a36b6de15544827f4ed5448" }, "recipe": { "id": "byte_ceiling", @@ -253,7 +210,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -261,14 +218,13 @@ "mutation_target_class": "declared_response_byte_count", "expected_safe_code": "source.response_too_large", "actual_safe_code": "source.response_too_large", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/timeout/v1", - "digest": "sha256:b491ba87e856a5f652fdef4077861a1cc2c95cca45029856eb72e28a6b0c06e1" + "digest": "sha256:74babb893caf18e9adb01c49998839f64f1388fc8f984b6836985c7380bcd307" }, "recipe": { "id": "timeout", @@ -276,7 +232,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -284,14 +240,13 @@ "mutation_target_class": "source_deadline", "expected_safe_code": "source.deadline_exceeded", "actual_safe_code": "source.deadline_exceeded", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/protocol_verification/v1", - "digest": "sha256:891f622340293bffcd8ef3b6f46e45657b194b5b3be228f37054f57655f38b0c" + "digest": "sha256:9a6d0a087c11819145848b435aaa1e1c1304d23be9c97f1ea137824956d82a41" }, "recipe": { "id": "protocol_verification", @@ -299,7 +254,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "not_applicable", @@ -309,41 +264,13 @@ "mutation_target_class": "protocol_response_envelope", "expected_safe_code": "source.response_malformed", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null - }, - { - "evidence": { - "kind": "generated_case", - "id": "target/person-record/fixture/active-person/generated/authorization_before_source/v1", - "digest": "sha256:2d5485575a39c37edec432a39f1d2ba08be39c45cc38e55c1dd1e4a75dccb74a" - }, - "recipe": { - "id": "authorization_before_source", - "version": "v1" - }, - "source_fixture": { - "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" - }, - "applicability": { - "state": "applicable" - }, - "mutation_target_class": "authorization_gate", - "expected_safe_code": "authorization.denied", - "actual_safe_code": "authorization.denied", - "pass_state": "passed", - "source_access_assertion": { - "expected_source_calls": "zero", - "actual_source_calls": 0, - "passed": true - } + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/output_minimization/v1", - "digest": "sha256:dc7c3bbc4489276a37f90f03591ce9cd3e568ad11d2244e1fcb2b3381463c8f0" + "digest": "sha256:d0d93d063b0f678c8e2047228ec2b279aa9cf34cc25b6c8f8efb96b2d03614e7" }, "recipe": { "id": "output_minimization", @@ -351,7 +278,7 @@ }, "source_fixture": { "fixture_id": "active-person", - "fixture_digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "fixture_digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, "applicability": { "state": "applicable" @@ -359,14 +286,13 @@ "mutation_target_class": "unselected_response_member", "expected_safe_code": null, "actual_safe_code": null, - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/request_authority/v1", - "digest": "sha256:37104336a5fc0b48e66157167df95bd3be54a5c83ecb7a2fce1c4a62223974a7" + "digest": "sha256:6e5061bf9d74c931f852610aaf782a0f46f2d3d1c56a961c3d189f1aed6899dc" }, "recipe": { "id": "request_authority", @@ -382,14 +308,13 @@ "mutation_target_class": "request_path_authority", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": "fixture.request_mismatch", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/request_order/v1", - "digest": "sha256:db7f52d696a31e99b6fb7770b67af97658e13d433e8be67a4e17a7a803b8ae00" + "digest": "sha256:7888258af86a191e16be021b1187140bd98b377dc31b491c424990a662028376" }, "recipe": { "id": "request_order", @@ -407,14 +332,13 @@ "mutation_target_class": "request_interaction_order", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/status_rejection/v1", - "digest": "sha256:17c91679f0bcafb1d73a632361167111fe3adc821d7362b5f73d57cc8e646aec" + "digest": "sha256:ae9bfc729a67431d7c745cc48557dd2cebe20ac7107c36ffa6478cc05d1ca173" }, "recipe": { "id": "status_rejection", @@ -430,14 +354,13 @@ "mutation_target_class": "source_status", "expected_safe_code": "source.status_rejected", "actual_safe_code": "source.status_rejected", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/malformed_decode/v1", - "digest": "sha256:16358fa165c4d86fb69c57c9fbb1d47db083b933ac7c449a47ac03a2b3a722a1" + "digest": "sha256:4cdd8cd238507c7fb322242bec787887814bcfe9d8313002fd86deae7d1d5331" }, "recipe": { "id": "malformed_decode", @@ -453,14 +376,13 @@ "mutation_target_class": "response_body_decoding", "expected_safe_code": "source.response_malformed", "actual_safe_code": "source.response_malformed", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/byte_ceiling/v1", - "digest": "sha256:cdfe77bfe9fa27c36bc01f6e9635a1dd593990153c8010657f87fbf8760a36f2" + "digest": "sha256:19730f51c1b5e8fe1c23d3622e978eef5eaaf73f7f706a68d588ffcc1375142a" }, "recipe": { "id": "byte_ceiling", @@ -476,14 +398,13 @@ "mutation_target_class": "declared_response_byte_count", "expected_safe_code": "source.response_too_large", "actual_safe_code": "source.response_too_large", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/timeout/v1", - "digest": "sha256:aa73c4fe1667260942dd69968f60973cd0a19dbdde38b991fafb89ef288fbc01" + "digest": "sha256:b695dd1f303ef4d1190a1b4b2d90fe818d2f54100b0df8a0674173e1ab725ae3" }, "recipe": { "id": "timeout", @@ -499,14 +420,13 @@ "mutation_target_class": "source_deadline", "expected_safe_code": "source.deadline_exceeded", "actual_safe_code": "source.deadline_exceeded", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/protocol_verification/v1", - "digest": "sha256:5a891e5db4eaa349bc1f03f1548fa6759078b953629eea15e2821350d26cf945" + "digest": "sha256:28dc026d68b0c00774970c32ec8e9eddf7ec61f50fdd7e4c5d785d56a479df7c" }, "recipe": { "id": "protocol_verification", @@ -524,41 +444,13 @@ "mutation_target_class": "protocol_response_envelope", "expected_safe_code": "source.response_malformed", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null - }, - { - "evidence": { - "kind": "generated_case", - "id": "target/person-record/fixture/ambiguous-person/generated/authorization_before_source/v1", - "digest": "sha256:9c96a3b1b8bb205e135572dee4d2f82d689482ef642eb83f498d8e14c115ad4f" - }, - "recipe": { - "id": "authorization_before_source", - "version": "v1" - }, - "source_fixture": { - "fixture_id": "ambiguous-person", - "fixture_digest": "sha256:6a375aa916a0b6b8dba04702b50cd7b1ec600063073c64881a9b62c31f16f232" - }, - "applicability": { - "state": "applicable" - }, - "mutation_target_class": "authorization_gate", - "expected_safe_code": "authorization.denied", - "actual_safe_code": "authorization.denied", - "pass_state": "passed", - "source_access_assertion": { - "expected_source_calls": "zero", - "actual_source_calls": 0, - "passed": true - } + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/output_minimization/v1", - "digest": "sha256:2b5e0499735227031d349d9c8ba7b2ccf070228e2ecbdfc97f1632b2352eb7c2" + "digest": "sha256:9f9aae65b7c31c99e000362684f513d66aeaa116a82fdab1ac8d2645c22a2086" }, "recipe": { "id": "output_minimization", @@ -574,14 +466,13 @@ "mutation_target_class": "unselected_response_member", "expected_safe_code": null, "actual_safe_code": null, - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/request_authority/v1", - "digest": "sha256:e7ae1794d2cca59818de6b3baf66439dca503d79b6f3196e887956f552ee4881" + "digest": "sha256:ba58ab903af746603b9550de6b854b9b87db6c6fc2d80a0cf15e71f1850429ab" }, "recipe": { "id": "request_authority", @@ -589,7 +480,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -597,14 +488,13 @@ "mutation_target_class": "request_path_authority", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": "fixture.request_mismatch", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/request_order/v1", - "digest": "sha256:547b5db1d96930b17a4d855f9c699cddac23120d9c44c86b9ea9d829f91fd315" + "digest": "sha256:26a118a2fbb97e5df78f16cc96e2389c4a75754f2fe5d47f3cd0745e055c16a8" }, "recipe": { "id": "request_order", @@ -612,7 +502,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "not_applicable", @@ -622,14 +512,13 @@ "mutation_target_class": "request_interaction_order", "expected_safe_code": "fixture.request_mismatch", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/status_rejection/v1", - "digest": "sha256:8bd1e632702cdf10d2841bce8527e2593858cdc0e46611a86f2199737e9829ae" + "digest": "sha256:4bb8bc4b23e22f4f95ae88dbb093181b1313deac6d6fab40fac4203035e8c200" }, "recipe": { "id": "status_rejection", @@ -637,7 +526,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -645,14 +534,13 @@ "mutation_target_class": "source_status", "expected_safe_code": "source.status_rejected", "actual_safe_code": "source.status_rejected", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/malformed_decode/v1", - "digest": "sha256:560c723752d29a94f56203a1593bad292684cc7bde7420185da4191c0a400c26" + "digest": "sha256:f665f20904c17eec0b90a5064b2422c9ddc714ec19751c712bb5977711ca3a64" }, "recipe": { "id": "malformed_decode", @@ -660,7 +548,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -668,14 +556,13 @@ "mutation_target_class": "response_body_decoding", "expected_safe_code": "source.response_malformed", "actual_safe_code": "source.response_malformed", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/byte_ceiling/v1", - "digest": "sha256:9413c3591793f33e81592d852cea1e4e5b52407457d642f95b6b8d9366ac7510" + "digest": "sha256:eaceee28ed492dc7126feccac3e726e59bd4035391081b610c21cf69f39859a1" }, "recipe": { "id": "byte_ceiling", @@ -683,7 +570,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -691,14 +578,13 @@ "mutation_target_class": "declared_response_byte_count", "expected_safe_code": "source.response_too_large", "actual_safe_code": "source.response_too_large", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/timeout/v1", - "digest": "sha256:43b0454a34a0c5cdce6d6003d609004047ecfa7308fce2fcdfcb2b1a98fa8516" + "digest": "sha256:6461781fc86e21b82b1c31db604038b1740e7069be14b2bc9d1bd32c778c503e" }, "recipe": { "id": "timeout", @@ -706,7 +592,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -714,14 +600,13 @@ "mutation_target_class": "source_deadline", "expected_safe_code": "source.deadline_exceeded", "actual_safe_code": "source.deadline_exceeded", - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/protocol_verification/v1", - "digest": "sha256:023a5c16d404902ae161f2d76688c74841afc120790831ccb6f6ec2e9c6f0a13" + "digest": "sha256:630e9188053f6cd0a735effd8e93088bf3614486c2cf5656bce8a40de3d3f875" }, "recipe": { "id": "protocol_verification", @@ -729,7 +614,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "not_applicable", @@ -739,41 +624,13 @@ "mutation_target_class": "protocol_response_envelope", "expected_safe_code": "source.response_malformed", "actual_safe_code": null, - "pass_state": "not_executed", - "source_access_assertion": null - }, - { - "evidence": { - "kind": "generated_case", - "id": "target/person-record/fixture/no-person/generated/authorization_before_source/v1", - "digest": "sha256:32f15ede7285f1271d842c7d9a1cd1e9e5906fb0c5a16d1173a49849b117737e" - }, - "recipe": { - "id": "authorization_before_source", - "version": "v1" - }, - "source_fixture": { - "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" - }, - "applicability": { - "state": "applicable" - }, - "mutation_target_class": "authorization_gate", - "expected_safe_code": "authorization.denied", - "actual_safe_code": "authorization.denied", - "pass_state": "passed", - "source_access_assertion": { - "expected_source_calls": "zero", - "actual_source_calls": 0, - "passed": true - } + "pass_state": "not_executed" }, { "evidence": { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/output_minimization/v1", - "digest": "sha256:ba70efd09852298f5635cf140ecc7ea8ea077b54c83620a57d486ab2f0b2a19f" + "digest": "sha256:72b3b2e274b054ede9e4432b49c84b595f107bdce1b323d678956574e39ddd52" }, "recipe": { "id": "output_minimization", @@ -781,7 +638,7 @@ }, "source_fixture": { "fixture_id": "no-person", - "fixture_digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "fixture_digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" }, "applicability": { "state": "applicable" @@ -789,8 +646,7 @@ "mutation_target_class": "unselected_response_member", "expected_safe_code": null, "actual_safe_code": null, - "pass_state": "passed", - "source_access_assertion": null + "pass_state": "passed" } ], "platform_cases": [], @@ -801,14 +657,6 @@ "output_ids": [ "active" ], - "claim_ids": [ - "person-active", - "person-record-exists" - ], - "disclosure_modes": [ - "predicate", - "value" - ], "status_mappings": [ { "outcome": "ambiguous", @@ -841,11 +689,6 @@ "output_ids": [ "active" ], - "claim_ids": [ - "person-active", - "person-record-exists" - ], - "disclosure_modes": [], "status_mappings": [ { "outcome": "ambiguous", @@ -876,7 +719,7 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" } ] }, @@ -887,7 +730,7 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" } ] }, @@ -910,24 +753,13 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" - } - ] - }, - { - "state": "covered", - "requirement": "semantic_null", - "evidence": [ - { - "kind": "authored_fixture", - "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, { "state": "missing", - "requirement": "authorization_denial", + "requirement": "semantic_null", "reason": "required_evidence_missing", "evidence": [] }, @@ -944,28 +776,17 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/request_authority/v1", - "digest": "sha256:691232c9fff919e9dd9f361be7b141a07cd9fc56a0864d40a9c5decf6719ed85" + "digest": "sha256:8c44e8e564c6cebafcf72f7c61e076d399592c64ee65c4ce0a95fe8eaeda08f4" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/request_authority/v1", - "digest": "sha256:37104336a5fc0b48e66157167df95bd3be54a5c83ecb7a2fce1c4a62223974a7" + "digest": "sha256:6e5061bf9d74c931f852610aaf782a0f46f2d3d1c56a961c3d189f1aed6899dc" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/request_authority/v1", - "digest": "sha256:e7ae1794d2cca59818de6b3baf66439dca503d79b6f3196e887956f552ee4881" - } - ] - }, - { - "state": "covered", - "requirement": "request_to_consultation_binding", - "evidence": [ - { - "kind": "authored_fixture", - "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "digest": "sha256:ba58ab903af746603b9550de6b854b9b87db6c6fc2d80a0cf15e71f1850429ab" } ] }, @@ -976,7 +797,7 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" }, { "kind": "authored_fixture", @@ -986,7 +807,7 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" } ] }, @@ -998,7 +819,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1009,43 +830,10 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" + "digest": "sha256:df873d90889c90ccc30132c5b911e555d1a9816ba409bc9cb37504a6466650b7" } ] }, - { - "state": "covered", - "requirement": "claims", - "evidence": [ - { - "kind": "authored_fixture", - "id": "target/person-record/fixture/active-person", - "digest": "sha256:4f0b0afa2cd1ac597e2d55a6bef4accee4549e7b74520fd23df10dde2fc02fc3" - }, - { - "kind": "authored_fixture", - "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" - } - ] - }, - { - "state": "covered", - "requirement": "declared_disclosure_modes", - "evidence": [ - { - "kind": "compiled_contract", - "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" - } - ] - }, - { - "state": "missing", - "requirement": "exercised_disclosure_modes", - "reason": "runtime_dimension_not_observed", - "evidence": [] - }, { "state": "not_applicable", "requirement": "script_branches", @@ -1054,7 +842,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1066,7 +854,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1082,7 +870,7 @@ { "kind": "authored_fixture", "id": "target/person-record/fixture/no-person", - "digest": "sha256:f7968b31cee08af79d6160e1a3bb99fdc4962fcfc7ccd13b92fe35f0d87367a5" + "digest": "sha256:415ac27bc7e212e2a92b46e51c9e7282bddd9722aeaf4530557061f10d89b33e" } ] }, @@ -1094,7 +882,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1106,28 +894,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" - } - ] - }, - { - "state": "covered", - "requirement": "authorization_before_source", - "evidence": [ - { - "kind": "generated_case", - "id": "target/person-record/fixture/active-person/generated/authorization_before_source/v1", - "digest": "sha256:2d5485575a39c37edec432a39f1d2ba08be39c45cc38e55c1dd1e4a75dccb74a" - }, - { - "kind": "generated_case", - "id": "target/person-record/fixture/ambiguous-person/generated/authorization_before_source/v1", - "digest": "sha256:9c96a3b1b8bb205e135572dee4d2f82d689482ef642eb83f498d8e14c115ad4f" - }, - { - "kind": "generated_case", - "id": "target/person-record/fixture/no-person/generated/authorization_before_source/v1", - "digest": "sha256:32f15ede7285f1271d842c7d9a1cd1e9e5906fb0c5a16d1173a49849b117737e" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1138,17 +905,17 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/malformed_decode/v1", - "digest": "sha256:00a9b0010878b27345c9ce26b7d2089c21312749ccedfaa98849d97ced748765" + "digest": "sha256:53963adf443166b4fd580d1b6020f98ebd3383a21f682d75c695ce4df82d3a5d" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/malformed_decode/v1", - "digest": "sha256:16358fa165c4d86fb69c57c9fbb1d47db083b933ac7c449a47ac03a2b3a722a1" + "digest": "sha256:4cdd8cd238507c7fb322242bec787887814bcfe9d8313002fd86deae7d1d5331" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/malformed_decode/v1", - "digest": "sha256:560c723752d29a94f56203a1593bad292684cc7bde7420185da4191c0a400c26" + "digest": "sha256:f665f20904c17eec0b90a5064b2422c9ddc714ec19751c712bb5977711ca3a64" } ] }, @@ -1159,7 +926,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1171,7 +938,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1182,17 +949,17 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/byte_ceiling/v1", - "digest": "sha256:1111c0c5d0542692d8c3efe821e719ee1235654102af5a399ee1da2b4167691e" + "digest": "sha256:d93707b5e8aaea86d7e9fc014b649fdc1702f4281a36b6de15544827f4ed5448" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/byte_ceiling/v1", - "digest": "sha256:cdfe77bfe9fa27c36bc01f6e9635a1dd593990153c8010657f87fbf8760a36f2" + "digest": "sha256:19730f51c1b5e8fe1c23d3622e978eef5eaaf73f7f706a68d588ffcc1375142a" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/byte_ceiling/v1", - "digest": "sha256:9413c3591793f33e81592d852cea1e4e5b52407457d642f95b6b8d9366ac7510" + "digest": "sha256:eaceee28ed492dc7126feccac3e726e59bd4035391081b610c21cf69f39859a1" } ] }, @@ -1204,7 +971,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1216,7 +983,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1228,7 +995,7 @@ { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1239,17 +1006,17 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/timeout/v1", - "digest": "sha256:b491ba87e856a5f652fdef4077861a1cc2c95cca45029856eb72e28a6b0c06e1" + "digest": "sha256:74babb893caf18e9adb01c49998839f64f1388fc8f984b6836985c7380bcd307" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/timeout/v1", - "digest": "sha256:aa73c4fe1667260942dd69968f60973cd0a19dbdde38b991fafb89ef288fbc01" + "digest": "sha256:b695dd1f303ef4d1190a1b4b2d90fe818d2f54100b0df8a0674173e1ab725ae3" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/timeout/v1", - "digest": "sha256:43b0454a34a0c5cdce6d6003d609004047ecfa7308fce2fcdfcb2b1a98fa8516" + "digest": "sha256:6461781fc86e21b82b1c31db604038b1740e7069be14b2bc9d1bd32c778c503e" } ] }, @@ -1261,22 +1028,22 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/timeout/v1", - "digest": "sha256:b491ba87e856a5f652fdef4077861a1cc2c95cca45029856eb72e28a6b0c06e1" + "digest": "sha256:74babb893caf18e9adb01c49998839f64f1388fc8f984b6836985c7380bcd307" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/timeout/v1", - "digest": "sha256:aa73c4fe1667260942dd69968f60973cd0a19dbdde38b991fafb89ef288fbc01" + "digest": "sha256:b695dd1f303ef4d1190a1b4b2d90fe818d2f54100b0df8a0674173e1ab725ae3" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/timeout/v1", - "digest": "sha256:43b0454a34a0c5cdce6d6003d609004047ecfa7308fce2fcdfcb2b1a98fa8516" + "digest": "sha256:6461781fc86e21b82b1c31db604038b1740e7069be14b2bc9d1bd32c778c503e" }, { "kind": "compiled_contract", "id": "target/person-record/compiled-contract/v1", - "digest": "sha256:f1b7c3c4a2ece745e634cfdc0c4507f7058fd7650fba342b8f8463e12dd8aea8" + "digest": "sha256:9a0c69f212881f7793f7cac6aef779f2504c7b8d4e54a478b8139b73aa8c77f6" } ] }, @@ -1287,17 +1054,17 @@ { "kind": "generated_case", "id": "target/person-record/fixture/active-person/generated/output_minimization/v1", - "digest": "sha256:dc7c3bbc4489276a37f90f03591ce9cd3e568ad11d2244e1fcb2b3381463c8f0" + "digest": "sha256:d0d93d063b0f678c8e2047228ec2b279aa9cf34cc25b6c8f8efb96b2d03614e7" }, { "kind": "generated_case", "id": "target/person-record/fixture/ambiguous-person/generated/output_minimization/v1", - "digest": "sha256:2b5e0499735227031d349d9c8ba7b2ccf070228e2ecbdfc97f1632b2352eb7c2" + "digest": "sha256:9f9aae65b7c31c99e000362684f513d66aeaa116a82fdab1ac8d2645c22a2086" }, { "kind": "generated_case", "id": "target/person-record/fixture/no-person/generated/output_minimization/v1", - "digest": "sha256:ba70efd09852298f5635cf140ecc7ea8ea077b54c83620a57d486ab2f0b2a19f" + "digest": "sha256:72b3b2e274b054ede9e4432b49c84b595f107bdce1b323d678956574e39ddd52" } ] }, @@ -1313,12 +1080,6 @@ "reason": "comparison_input_absent", "evidence": [] }, - { - "state": "not_evaluated", - "requirement": "changed_claim_affected_fixtures", - "reason": "comparison_input_absent", - "evidence": [] - }, { "state": "not_evaluated", "requirement": "changed_source_contract_affected_fixtures", @@ -1334,11 +1095,11 @@ "fixture_bearing_target_count": 1, "fixtureless_target_count": 0, "requirements": { - "covered": 17, - "missing": 7, + "covered": 12, + "missing": 6, "not_applicable": 7, - "not_evaluated": 4, - "total": 35 + "not_evaluated": 3, + "total": 28 } } } diff --git a/crates/registryctl/tests/fixtures/project-reports/registryctl.fixture_error_reference.v1.json b/crates/registryctl/tests/fixtures/project-reports/registryctl.fixture_error_reference.v1.json index 8b1349174..58b7f1c7d 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registryctl.fixture_error_reference.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registryctl.fixture_error_reference.v1.json @@ -1,24 +1,6 @@ { "schema_version": "registryctl.fixture_error_reference.v1", "entries": [ - { - "family": "fixture_execution", - "code": "authorization.denied", - "owner": "registryctl", - "product": "registryctl_relay_offline_harness", - "phase": "offline_execution", - "safe_meaning": "Authorization denied fixture execution before source access.", - "rule": "authorization_before_source", - "safe_remediation": "Align the fixture identity and authorization expectation with the compiled policy.", - "field_address_pattern": null, - "evidence_scope": "offline synthetic fixture execution", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/fixture/#registryctl_relay_offline_harness--authorization.denied", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "Offline synthetic evidence does not prove live source compatibility." - }, { "family": "fixture_execution", "code": "failure.subject_mismatch", diff --git a/crates/registryctl/tests/fixtures/project-reports/registryctl.operator_error_reference.v1.json b/crates/registryctl/tests/fixtures/project-reports/registryctl.operator_error_reference.v1.json index d8750d557..63a8af07b 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registryctl.operator_error_reference.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registryctl.operator_error_reference.v1.json @@ -73,330 +73,6 @@ "stability": "pre1_stable_code", "evidence_limitation": "The category does not disclose parser messages, local paths, or supplied values." }, - { - "family": "notary_activation", - "code": "notary.cel.worker_unavailable", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary CEL worker is unavailable", - "rule": "Every configured CEL worker must complete the bounded product protocol probe before listeners serve", - "safe_remediation": "verify that the adjacent CEL worker artifact is present and executable, confirm the supported platform and configured resource ceilings, then retry activation", - "field_address_pattern": null, - "evidence_scope": "CEL worker packaging, protocol responsiveness, supported platform, and bounded startup capacity", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--cel-worker-unavailable", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.configuration.invalid", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "configuration_activation", - "safe_meaning": "Registry Notary runtime configuration is invalid", - "rule": "Runtime activation requires valid product configuration, supported features, and resolvable secret and provider bindings", - "safe_remediation": "run registry-notary doctor, correct the reviewed configuration or binding, and retry activation", - "field_address_pattern": null, - "evidence_scope": "Notary configuration, provider bindings, and compiled feature support", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--configuration-invalid", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.deployment.gate_failed", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "deployment_activation", - "safe_meaning": "Registry Notary deployment gates refused startup", - "rule": "Every startup-failing deployment gate must pass before activation", - "safe_remediation": "run registry-notary doctor for the selected deployment profile and resolve its startup-failing findings", - "field_address_pattern": null, - "evidence_scope": "selected deployment profile and startup-failing gate results", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--deployment-gate-failed", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.activation_failed", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay consultation activation failed", - "rule": "Registry-backed claims require the reviewed Relay consultation client to activate before Notary serves", - "safe_remediation": "check the Notary configuration and startup environment", - "field_address_pattern": null, - "evidence_scope": "Notary Relay consultation client activation lifecycle", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-activation-failed", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.configuration_invalid", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay consultation configuration is invalid", - "rule": "The Relay destination, activation plan, and activation lifecycle must form one valid reviewed configuration", - "safe_remediation": "check the evidence.relay connection and Registry-backed consultation configuration", - "field_address_pattern": null, - "evidence_scope": "Relay destination, activation plan, and consultation configuration", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-configuration-invalid", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.credential_unavailable", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay workload credential is unavailable", - "rule": "A current non-empty workload credential must be available before a live Relay consultation", - "safe_remediation": "mount a current readable workload JWT at evidence.relay.token_file", - "field_address_pattern": null, - "evidence_scope": "configured Relay workload credential availability", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-credential-unavailable", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.credentials_rejected", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay rejected the configured workload credential", - "rule": "Relay must accept the configured Notary workload binding, scope, and validity window", - "safe_remediation": "rotate the workload JWT and verify that Relay recognizes its workload binding, required scope, and validity window", - "field_address_pattern": null, - "evidence_scope": "Relay workload binding, scope, and validity acceptance", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-credentials-rejected", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.profile_mismatch", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay consultation profile does not match the configured contract pin", - "rule": "The active Relay profile must match the reviewed Notary consultation contract pin", - "safe_remediation": "reconcile the Notary profile id and contract hash with the reviewed Relay consultation contract", - "field_address_pattern": null, - "evidence_scope": "reviewed Notary profile pin and active Relay consultation contract", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-profile-mismatch", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.profile_not_found", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay consultation profile was not found", - "rule": "Every Registry-backed Notary consultation must resolve to an active Relay profile", - "safe_remediation": "deploy the configured Relay profile id, then retry the live check", - "field_address_pattern": null, - "evidence_scope": "configured consultation profile resolution in Relay", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-profile-not-found", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.relay.unavailable", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "relay_activation", - "safe_meaning": "Relay consultation service is unavailable", - "rule": "The reviewed Relay destination must be reachable through the configured transport policy", - "safe_remediation": "check Relay reachability, TLS, destination policy, and service health", - "field_address_pattern": null, - "evidence_scope": "reviewed Relay destination, transport policy, and service availability", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--relay-unavailable", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.runtime.activation_failed", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary runtime activation failed", - "rule": "Audit, state, sensitive-state, and other runtime dependencies must activate successfully before listeners serve", - "safe_remediation": "restore the governed runtime dependency or integrity condition, then retry activation", - "field_address_pattern": null, - "evidence_scope": "governed audit, state, sensitive-state, and runtime dependencies", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--runtime-activation-failed", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.runtime.activation_required", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary runtime activation is required before serving", - "rule": "Routers may be built only after the governed audit and state activation lifecycle completes", - "safe_remediation": "run the compiled Registry Notary runtime activation step before building or serving routers", - "field_address_pattern": null, - "evidence_scope": "router assembly and governed audit and state activation lifecycle", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--runtime-activation-required", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.database_read_only", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL state database is read-only or recovering", - "rule": "Serving requires a writable PostgreSQL primary for correctness-state transactions", - "safe_remediation": "restore a writable PostgreSQL primary, run registry-notary state doctor, and retry activation", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL writeability and recovery posture", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-database-read-only", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.database_unavailable", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL state database is unavailable", - "rule": "Serving requires a reachable PostgreSQL service with an accepted TLS trust chain", - "safe_remediation": "check PostgreSQL reachability, TLS trust, and service health, then run registry-notary state doctor", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL transport, TLS trust, and service availability", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-database-unavailable", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.database_unsupported", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL server major is unsupported", - "rule": "Serving requires a PostgreSQL major covered by the Registry Notary compatibility contract", - "safe_remediation": "move the state database to a supported PostgreSQL major, run registry-notary state doctor, and retry activation", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL server-major compatibility", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-database-unsupported", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.durability_unsafe", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL durability settings are unsafe", - "rule": "Serving requires the documented PostgreSQL durability settings for correctness state", - "safe_remediation": "restore the required PostgreSQL durability settings, run registry-notary state doctor, and retry activation", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL durability posture", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-durability-unsafe", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.role_incompatible", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL runtime role contract is incompatible", - "rule": "Serving requires the documented restricted runtime role and its attested schema binding", - "safe_remediation": "restore the documented runtime grants and role binding, run registry-notary state doctor, and retry activation", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL runtime-role attributes, grants, and schema binding", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-role-incompatible", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, - { - "family": "notary_activation", - "code": "notary.state.postgresql.schema_incompatible", - "owner": "registry_notary", - "product": "registry_notary", - "phase": "runtime_activation", - "safe_meaning": "Registry Notary PostgreSQL state schema contract is incompatible", - "rule": "Serving requires the exact product-owned schema, catalog, fingerprint, and privilege contract", - "safe_remediation": "restore or install the matching Registry Notary state schema, run registry-notary state doctor, and retry activation", - "field_address_pattern": null, - "evidence_scope": "PostgreSQL state schema, catalog, fingerprint, and privilege contract", - "secret_sensitive_value_policy": "no_runtime_values", - "docs_anchor": "/reference/diagnostics/operator/#registry_notary--postgresql-schema-incompatible", - "lifecycle": "unreleased", - "introduced_in": null, - "stability": "pre1_stable_code", - "evidence_limitation": "The category confirms only the failed activation boundary; it does not disclose paths, URLs, hashes, credentials, identifiers, parser text, authored values, source responses, or country values." - }, { "family": "operator_preflight", "code": "registryctl.preflight.product_validator_not_checked", diff --git a/crates/registryctl/tests/fixtures/project-reports/registryctl.project_command.v1.json b/crates/registryctl/tests/fixtures/project-reports/registryctl.project_command.v1.json index 5d6bcc59a..5ea74674a 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registryctl.project_command.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registryctl.project_command.v1.json @@ -16,9 +16,6 @@ "outputs": [ "active" ], - "claims": [ - "is_active" - ], "outcome": "matched", "source_access": false, "passed": true diff --git a/crates/registryctl/tests/fixtures/project-reports/registryctl.project_preflight.v1.json b/crates/registryctl/tests/fixtures/project-reports/registryctl.project_preflight.v1.json index 1b772ffb6..a078bcab8 100644 --- a/crates/registryctl/tests/fixtures/project-reports/registryctl.project_preflight.v1.json +++ b/crates/registryctl/tests/fixtures/project-reports/registryctl.project_preflight.v1.json @@ -75,11 +75,6 @@ "product": "registry_relay", "capability": "configuration_validation", "state": "locally_available" - }, - { - "product": "registry_notary", - "capability": "configuration_validation", - "state": "locally_available" } ], "secret_checks": [ @@ -114,11 +109,11 @@ "state": "available" }, { - "kind": "notary_to_relay_token", + "kind": "relay_state_root_certificate", "addresses": [ { "file": "environments/production.yaml", - "pointer": "/notary_relay/token_file" + "pointer": "/relay_state/postgresql/root_certificate_path" } ], "generation": "not_declared", diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index 69b29b990..ba7d3c08c 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -5,17 +5,16 @@ use std::path::{Path, PathBuf}; use registry_platform_config::ProductAcceptanceLaneV1; use registryctl::{ - build_registry_project_with_baselines_and_context, build_registry_project_with_context, - check_registry_project_with_context, create_trust_anchor, init_registry_project, - inspect_project_capabilities, preflight_registry_project, render_project_authoring_diagnostics, - setup_registry_project_editor, sign_product_bundle, + build_registry_project_with_context, check_registry_project_with_context, create_trust_anchor, + init_registry_project, inspect_project_capabilities, preflight_registry_project, + render_project_authoring_diagnostics, setup_registry_project_editor, sign_product_bundle, test_registry_project_selected_with_context, test_registry_project_with_context, verify_config_bundle_cli, ClassifierSafeReportedValue, InitSource, ProductBundleSignOptions, - ProjectAuthoringDiagnostics, ProjectBuildBaselineSetOptions, ProjectBuildOptions, - ProjectCapabilityOptions, ProjectCheckOptions, ProjectEditorSetupOptions, - ProjectExecutionContext, ProjectExplanationReportV1, ProjectFieldAddress, - ProjectFieldExplanation, ProjectInitOptions, ProjectPreflightOptions, ProjectSchemaKind, - ProjectStarter, ProjectTestOptions, ProjectTestSelection, TrustAnchorCreateOptions, + ProjectAuthoringDiagnostics, ProjectBuildOptions, ProjectCapabilityOptions, + ProjectCheckOptions, ProjectEditorSetupOptions, ProjectExecutionContext, + ProjectExplanationReportV1, ProjectFieldAddress, ProjectFieldExplanation, ProjectInitOptions, + ProjectPreflightOptions, ProjectSchemaKind, ProjectStarter, ProjectTestOptions, + ProjectTestSelection, TrustAnchorCreateOptions, }; use serde::Deserialize; use sha2::{Digest as _, Sha256}; @@ -309,103 +308,6 @@ fn assert_authoring_diagnostic(error: &anyhow::Error, code: &str) { ); } -#[test] -fn project_check_aggregates_script_host_call_and_environment_diagnostics_safely() { - const ARGUMENT_MARKER: &str = "argument-marker-383"; - const ENVIRONMENT_MARKER: &str = "environment-secret-marker-383"; - const FIXTURE_MARKER: &str = "fixture-value-marker-383"; - const RESPONSE_MARKER: &str = "source-response-marker-383"; - - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("dhis2-script", temporary.path()); - let script_path = project.join("integrations/health-record/adapter.rhai"); - std::fs::write( - &script_path, - format!( - "fn consult(ctx) {{\n let response = source.gett(\"{ARGUMENT_MARKER}\");\n result.no_match()\n}}\n" - ), - ) - .expect("invalid Script writes"); - - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["integrations"]["health-record"]["source"]["credential"]["generation"] = - serde_norway::Value::Number(0.into()); - environment["integrations"]["health-record"]["source"]["credential"]["username"]["secret"] = - serde_norway::Value::String(ENVIRONMENT_MARKER.to_string()); - write_yaml(&environment_path, &environment); - - let fixture_path = project.join("integrations/health-record/fixtures/match.yaml"); - let mut fixture = read_yaml(&fixture_path); - fixture["variables"]["diagnostic_marker"] = - serde_norway::Value::String(FIXTURE_MARKER.to_string()); - fixture["interactions"][0]["respond"]["body"]["diagnostic_marker"] = - serde_norway::Value::String(RESPONSE_MARKER.to_string()); - write_yaml(&fixture_path, &fixture); - - let report = authoring_diagnostics(&project); - assert_eq!(report.status, "invalid"); - assert_eq!(report.diagnostics.len(), 2, "{report:#?}"); - let script = report - .diagnostics - .iter() - .find(|diagnostic| diagnostic.code == "registryctl.authoring.script.unknown_function") - .expect("one Script diagnostic"); - assert_eq!(script.file, "integrations/health-record/adapter.rhai"); - assert_eq!(script.field, Some("capability.script.file")); - assert_eq!((script.line, script.column), (Some(2), Some(20))); - assert_eq!( - script.suggestion, - Some("source.get(target: string) -> response") - ); - assert_eq!( - report - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.code.starts_with("registryctl.authoring.script.")) - .count(), - 1 - ); - assert_eq!( - report - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.code == "registryctl.authoring.environment.invalid") - .count(), - 1 - ); - assert!(!project.join(".registry-stack/build").exists()); - - let human = render_project_authoring_diagnostics(&report); - let json = serde_json::to_string_pretty(&report).expect("diagnostics serialize"); - let debug = format!("{report:#?}"); - assert_eq!( - human - .matches("registryctl.authoring.script.unknown_function") - .count(), - 1, - "{human}" - ); - for rendered in [&human, &json, &debug] { - for forbidden in [ - ARGUMENT_MARKER, - ENVIRONMENT_MARKER, - FIXTURE_MARKER, - RESPONSE_MARKER, - "https://health-registry.invalid", - "HEALTH_REGISTRY_PASSWORD", - "Engine", - "EvalAltResult", - &project.display().to_string(), - ] { - assert!( - !rendered.contains(forbidden), - "leaked {forbidden}: {rendered}" - ); - } - } -} - #[test] fn project_check_keeps_script_probe_stable_across_metadata_and_ignores_non_calls() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -543,10 +445,7 @@ fn project_check_orders_independent_fixture_errors_and_caps_deterministically() &second_project.join("integrations/eligibility/integration.yaml"), &["outputs"], ); - reverse_yaml_mapping( - &second_project.join("registry-stack.yaml"), - &["services", "household-eligibility", "claims"], - ); + reverse_yaml_mapping(&second_project.join("registry-stack.yaml"), &[]); let first = authoring_diagnostics(&first_project); let repeated = authoring_diagnostics(&first_project); let second = authoring_diagnostics(&second_project); @@ -575,7 +474,14 @@ fn project_check_orders_independent_fixture_errors_and_caps_deterministically() fn project_check_collects_separate_integration_and_fixture_yaml_errors() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); - duplicate_project_integration(&project, "eligibility", "secondary"); + let project_path = project.join("registry-stack.yaml"); + let mut authored = read_yaml(&project_path); + authored["integrations"]["secondary"] = + serde_norway::from_str("file: integrations/secondary/integration.yaml") + .expect("secondary integration reference parses"); + write_yaml(&project_path, &authored); + std::fs::create_dir_all(project.join("integrations/secondary")) + .expect("secondary integration directory creates"); std::fs::write( project.join("integrations/secondary/integration.yaml"), "version: [\n", @@ -1014,10 +920,7 @@ fn project_authoring_catalog_classifies_every_golden_and_only_five_starters() { journey.id ); assert!( - matches!( - journey.topology.as_str(), - "combined" | "relay-only" | "notary-only" - ), + matches!(journey.topology.as_str(), "relay-only"), "{} topology", journey.id ); @@ -1058,44 +961,29 @@ fn project_authoring_catalog_classifies_every_golden_and_only_five_starters() { let services = project["services"] .as_mapping() .expect("catalog workspace services are a mapping"); - let has_notary = services - .values() - .any(|service| service["kind"].as_str() == Some("evidence")); let has_relay = has_integrations || has_entities || services .values() .any(|service| service["kind"].as_str() == Some("records_api")); - let derived_topology = match (has_relay, has_notary) { - (true, true) => "combined", - (true, false) => "relay-only", - (false, true) => "notary-only", - (false, false) => panic!("{} has no product topology", journey.id), - }; + assert!(has_relay, "{} has a Relay topology", journey.id); + let derived_topology = "relay-only"; assert_eq!( journey.topology, derived_topology, "{} topology", journey.id ); - if derived_topology == "combined" { - assert!( - journey.steps.contains(&"build".to_string()), - "{} combined governed build", - journey.id - ); - } else { + assert!( + journey.steps.contains(&"build".to_string()), + "{} Relay topology has a governed build", + journey.id + ); + if journey.classification == "maintained" { assert!( - !journey.steps.contains(&"build".to_string()), - "{} partial topology must not advertise a governed build", + journey.steps.contains(&"test".to_string()), + "{} maintained Relay topology keeps offline test", journey.id ); - if journey.classification == "maintained" { - assert!( - journey.steps.contains(&"test".to_string()), - "{} maintained partial topology keeps offline test", - journey.id - ); - } } let has_authored_fixtures = catalog_has_authored_fixtures(journey, &project); @@ -1296,9 +1184,14 @@ fn every_cataloged_supported_project_authoring_command_is_automated() { }) .unwrap_or_else(|error| panic!("{} offline test failed: {error:#}", journey.id)); assert_eq!(report.status, "passed", "{} test", journey.id); - if journey.topology == "combined" { - assert!(!report.fixtures.is_empty(), "{} fixtures", journey.id); - } + let authored = read_yaml(&project.join("registry-stack.yaml")); + let has_authored_fixtures = catalog_has_authored_fixtures(&journey, &authored); + assert_eq!( + report.fixtures.is_empty(), + !has_authored_fixtures, + "{} fixture inventory", + journey.id + ); assert!( report.fixtures.iter().all(|fixture| fixture.passed), "{} fixtures", @@ -1317,21 +1210,6 @@ fn every_cataloged_supported_project_authoring_command_is_automated() { assert_eq!(check.status, "valid", "{} check", journey.id); assert!(check.explanation.is_some(), "{} explanation", journey.id); - if !journey.steps.contains(&"build".to_string()) { - let error = build_registry_project(&ProjectBuildOptions { - project_directory: project, - environment: journey.environment.clone(), - against: None, - anchor: None, - }) - .expect_err("partial product topology must not publish a governed build"); - let message = format!("{error:#}"); - assert!(message.contains("project test"), "{message}"); - assert!(message.contains("project check"), "{message}"); - assert!(message.contains("before project build"), "{message}"); - continue; - } - let build = build_registry_project(&ProjectBuildOptions { project_directory: project.clone(), environment: journey.environment.clone(), @@ -1341,45 +1219,23 @@ fn every_cataloged_supported_project_authoring_command_is_automated() { .unwrap_or_else(|error| panic!("{} build failed: {error:#}", journey.id)); assert_eq!(build.status, "built", "{} build", journey.id); let output = resolve_build_output(&project, build.output.expect("catalog build output")); - let relay = output.join("private/relay-public"); - let notary = output.join("private/notary"); - match journey.topology.as_str() { - "relay-only" => { - assert!(relay.is_dir(), "{} Relay inputs", journey.id); - assert!(!notary.exists(), "{} Notary inputs", journey.id); - } - "notary-only" => { - assert!(notary.is_dir(), "{} Notary inputs", journey.id); - assert!(!relay.exists(), "{} Relay inputs", journey.id); - } - "combined" => { - assert!(relay.is_dir(), "{} Relay inputs", journey.id); - assert!(notary.is_dir(), "{} Notary inputs", journey.id); - let notary_config = read_yaml(¬ary.join("config/notary.yaml")); - assert_eq!( - notary_config["state"]["storage"].as_str(), - Some("in_memory"), - "{} Notary correctness state", - journey.id - ); - assert!( - notary_config["evidence"]["relay"].is_mapping(), - "{} compiler-pinned Relay consultation", - journey.id - ); - let rendered = serde_norway::to_string(¬ary_config) - .expect("generated Notary config serializes"); - assert!( - rendered.contains("contract_hash:"), - "{} compiler-pinned consultation hash", - journey.id - ); - for forbidden in ["redis:", "direct_source:", "source_credential:"] { - assert!(!rendered.contains(forbidden), "{} {forbidden}", journey.id); - } - } - _ => unreachable!("catalog topology is validated"), - } + assert!( + output.join("private/relay-public").is_dir(), + "{} public Relay inputs", + journey.id + ); + let project_document = read_yaml(&project.join("registry-stack.yaml")); + let has_consultation_service = project_document["services"] + .as_mapping() + .expect("catalog project services are a mapping") + .values() + .any(|service| service["kind"].as_str() == Some("consultation_api")); + assert_eq!( + output.join("private/relay-consultation").is_dir(), + has_consultation_service, + "{} consultation Relay inputs follow the authored service topology", + journey.id + ); } } @@ -1430,31 +1286,16 @@ fn fhir_r4_coverage_active_passes_the_closed_bundle_matrix() { .expect("FHIR R4 Coverage-active golden passes"); assert_eq!(report.status, "passed"); assert!( - report.fixtures.len() >= 5, - "the five authored journeys and their derived security cases must execute" + report.fixtures.len() >= 7, + "the authored fixture matrix and derived Relay cases must execute" ); assert!(report .fixtures .iter() .any(|fixture| fixture.fixture.ends_with("::derived/request_authority"))); - assert!(report.fixtures.iter().any(|fixture| fixture - .fixture - .ends_with("::derived/authorization_before_source"))); assert!(report.fixtures.iter().all(|fixture| fixture.passed)); } -#[test] -fn approved_opencrvs_and_dhis2_claim_sets_execute_offline() { - for project in ["opencrvs", "opencrvs-country-variant", "dhis2-tracker"] { - let report = test_registry_project(&ProjectTestOptions { - project_directory: golden(project), - environment: None, - }) - .unwrap_or_else(|error| panic!("{project} approved claims failed: {error:#}")); - assert!(report.fixtures.iter().all(|fixture| fixture.passed)); - } -} - #[test] fn synthetic_opencrvs_events_api_executes_the_closed_offline_matrix() { let project = golden("opencrvs-events-api"); @@ -1508,7 +1349,6 @@ fn synthetic_opencrvs_events_api_executes_the_closed_offline_matrix() { .unwrap_or_else(|| panic!("missing {fixture_name}")); assert_eq!(fixture.expected_error.as_deref(), Some(safe_code)); assert!(fixture.outputs.is_empty()); - assert!(fixture.claims.is_empty()); if fixture_name.starts_with("oauth-token-") { assert_eq!( fixture.calls.len(), @@ -1524,17 +1364,13 @@ fn synthetic_opencrvs_events_api_executes_the_closed_offline_matrix() { .find(|fixture| fixture.fixture.as_str() == "birth-event-match") .expect("exact-selector match fixture"); assert_eq!(matched.outputs, ["event_type", "registered"]); - assert_eq!( - matched.claims, - ["birth-event-found", "birth-event-registered"] - ); assert_eq!(matched.calls.len(), 2); for (recipe, safe_code) in [ ("malformed_decode", Some("source.response_malformed")), ("byte_ceiling", Some("source.response_too_large")), ("timeout", Some("source.deadline_exceeded")), - ("authorization_before_source", Some("authorization.denied")), + ("request_authority", Some("fixture.request_mismatch")), ("output_minimization", None), ] { let fixture_id = format!("birth-event-match::derived/{recipe}"); @@ -1545,7 +1381,7 @@ fn synthetic_opencrvs_events_api_executes_the_closed_offline_matrix() { .unwrap_or_else(|| panic!("missing {fixture_id}")); assert_eq!(fixture.expected_error.as_deref(), safe_code); assert!(fixture.passed); - if recipe == "authorization_before_source" { + if recipe == "request_authority" { assert_eq!(fixture.source_access, Some(false)); assert!(fixture.calls.is_empty()); } @@ -1644,160 +1480,10 @@ fn synthetic_opencrvs_events_api_executes_the_closed_offline_matrix() { service["consultations"]["event"]["input"]["tracking_id"].as_str(), Some("request.target.identifiers.opencrvs_tracking_id") ); - for claim in ["birth-event-found", "birth-event-registered"] { - assert!( - service["claims"][claim]["cel"] - .as_str() - .expect("claim CEL is a string") - .contains("event."), - "{claim} must derive from the single Relay consultation" - ); - } -} - -#[test] -fn dhis2_health_evidence_journey_preserves_distinct_results() { - let project = golden("dhis2-tracker"); - let report = test_registry_project(&ProjectTestOptions { - project_directory: project.clone(), - environment: None, - }) - .expect("DHIS2 health evidence journey passes offline"); - assert_eq!(report.status, "passed"); - - let expected_outputs = [ - "bcg_birth_dose_recorded", - "child_health_visit_recorded", - "child_program_active", - "date_of_birth", - "first_name", - "last_name", - "maternal_postnatal_active", - "measles_dose_recorded", - "opv_birth_dose_recorded", - "programme_code", - "reconciliation_reference", - "tb_program_active", - ] - .map(String::from); - let expected_claims = [ - "bcg-birth-dose-recorded", - "child-age-band", - "child-health-visit-recorded", - "child-program-active", - "maternal-postnatal-care-active", - "measles-dose-recorded", - "opv-birth-dose-recorded", - "programme-code", - "reconciliation-reference", - "tb-program-active", - "tracked-entity-first-name", - "tracked-entity-last-name", - ] - .map(String::from); - - for fixture_name in [ - "complete-child-health-evidence", - "partial-child-health-evidence", - "no-child-program-enrollment", - ] { - let fixture = report - .fixtures - .iter() - .find(|fixture| fixture.fixture == fixture_name) - .unwrap_or_else(|| panic!("missing {fixture_name}")); - assert_eq!(fixture.outcome.as_deref(), Some("match")); - assert_eq!(fixture.outputs, expected_outputs); - assert_eq!(fixture.claims, expected_claims); - assert!(fixture.passed, "{fixture:#?}"); - } - - let no_match = report - .fixtures - .iter() - .find(|fixture| fixture.fixture == "health-no-match") - .expect("no-match fixture report"); - assert_eq!(no_match.outcome.as_deref(), Some("no_match")); - assert!(no_match.outputs.is_empty()); - assert_eq!(no_match.claims, expected_claims); - assert!(no_match.passed, "{no_match:#?}"); - - for (fixture_name, expected_error) in [ - ("health-source-rejected", "source.status_rejected"), - ("health-subject-mismatch", "failure.subject_mismatch"), - ] { - let fixture = report - .fixtures - .iter() - .find(|fixture| fixture.fixture == fixture_name) - .unwrap_or_else(|| panic!("missing {fixture_name}")); - assert_eq!(fixture.expected_error.as_deref(), Some(expected_error)); - assert_eq!(fixture.source_access, Some(true)); - assert!(fixture.outputs.is_empty()); - assert!(fixture.claims.is_empty()); - assert!(fixture.passed, "{fixture:#?}"); - } - - let malformed = report - .fixtures - .iter() - .find(|fixture| fixture.fixture.ends_with("::derived/malformed_decode")) - .expect("derived malformed-source fixture report"); - assert_eq!( - malformed.expected_error.as_deref(), - Some("source.response_malformed") - ); - assert_eq!(malformed.source_access, Some(true)); - assert!(malformed.passed, "{malformed:#?}"); - - let fixtures = project.join("integrations/health-record/fixtures"); - let complete = read_yaml(&fixtures.join("match.yaml")); - for claim in [ - "child-program-active", - "bcg-birth-dose-recorded", - "opv-birth-dose-recorded", - "measles-dose-recorded", - ] { - assert_eq!(complete["expect"]["claims"][claim].as_bool(), Some(true)); - } - - let partial = read_yaml(&fixtures.join("partial.yaml")); - assert_eq!( - partial["expect"]["claims"]["child-program-active"].as_bool(), - Some(false) - ); - assert_eq!( - partial["expect"]["claims"]["bcg-birth-dose-recorded"].as_bool(), - Some(false) - ); - assert!(partial["expect"]["claims"]["opv-birth-dose-recorded"].is_null()); - assert_eq!( - partial["expect"]["claims"]["measles-dose-recorded"].as_bool(), - Some(true) - ); - - for fixture_name in ["no-enrollment.yaml", "no-match.yaml"] { - let fixture = read_yaml(&fixtures.join(fixture_name)); - for claim in [ - "child-program-active", - "bcg-birth-dose-recorded", - "opv-birth-dose-recorded", - "measles-dose-recorded", - ] { - assert!( - fixture["expect"]["claims"][claim].is_null(), - "{fixture_name} must keep {claim} unknown" - ); - } - } - - let authored = read_yaml(&project.join("registry-stack.yaml")); - assert!(!yaml_contains_string(&authored, "eligible")); - assert!(!yaml_contains_string(&authored, "outreach")); } #[test] -fn successful_negative_fixtures_report_the_closed_denial_assertion() { +fn successful_negative_fixtures_report_closed_source_access_assertions() { let report = test_registry_project(&ProjectTestOptions { project_directory: golden("custom-system"), environment: None, @@ -1807,21 +1493,17 @@ fn successful_negative_fixtures_report_the_closed_denial_assertion() { assert!(!serialized.contains("HH-AB12CD34")); assert!(!serialized.contains("synthetic-key-1")); - let denied_before_access = report + let rejected_before_access = report .fixtures .iter() - .find(|fixture| { - fixture - .fixture - .ends_with("::derived/authorization_before_source") - }) - .expect("derived authorization fixture report"); - assert!(denied_before_access.passed); + .find(|fixture| fixture.fixture.ends_with("::derived/request_authority")) + .expect("derived request-authority fixture report"); + assert!(rejected_before_access.passed); assert_eq!( - denied_before_access.expected_error.as_deref(), - Some("authorization.denied") + rejected_before_access.expected_error.as_deref(), + Some("fixture.request_mismatch") ); - assert_eq!(denied_before_access.source_access, Some(false)); + assert_eq!(rejected_before_access.source_access, Some(false)); let denied_after_access = report .fixtures @@ -2130,7 +1812,7 @@ fn signed_dci_rejects_wrong_jwks_algorithm_and_key_use() { } #[test] -fn partial_relay_project_tests_and_checks_but_cannot_ship_a_governed_build() { +fn records_project_tests_checks_and_builds_only_the_public_relay_lane() { let relay_root = tempfile::tempdir().expect("Relay-only temporary directory"); let relay = copy_project("relay-only-records", relay_root.path()); test_registry_project(&ProjectTestOptions { @@ -2147,17 +1829,16 @@ fn partial_relay_project_tests_and_checks_but_cannot_ship_a_governed_build() { }) .expect("Relay-only project explains"); let relay_build = build_registry_project(&ProjectBuildOptions { - project_directory: relay, + project_directory: relay.clone(), environment: "local".to_string(), against: None, anchor: None, }) - .expect_err("Relay-only project cannot ship a partial governed signed set"); - let relay_message = format!("{relay_build:#}"); - assert!(relay_message.contains("governed build requires")); - assert!(relay_message.contains("project test")); - assert!(relay_message.contains("project check")); - assert!(relay_message.contains("add deployment.notary before project build")); + .expect("Relay-only project builds"); + assert_eq!(relay_build.status, "built"); + let output = resolve_build_output(&relay, relay_build.output.expect("Relay build output")); + assert!(output.join("private/relay-public").is_dir()); + assert!(!output.join("private/relay-consultation").exists()); } #[test] @@ -2386,66 +2067,6 @@ fn rhai_conformance_controls_are_code_only_and_deny_ambient_capabilities() { } } -#[test] -fn production_cel_worker_evaluates_project_date_policy() { - let mut config = - registry_notary_server::cel_worker::CelWorkerConfig::for_current_exe_subcommand(); - config.command = env!("CARGO_BIN_EXE_registryctl").into(); - config.command_args = vec!["__registryctl-cel-worker-v1".into()]; - config.startup_timeout = std::time::Duration::from_secs(10); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime builds"); - let worker = registry_notary_server::cel_worker::CelWorker::lazy(config); - let value = runtime - .block_on(worker.evaluate( - "health.exists && health.date_of_birth != null\n ? date.age_on(health.date_of_birth, as_of_date)\n : null", - serde_json::json!({ - "health": { - "exists": true, - "first_name": "Nia", - "last_name": "Example", - "date_of_birth": "2017-06-15", - "child_program_active": true, - "programme_code": "CHILD", - "reconciliation_reference": "REF-0001", - "maternal_postnatal_active": true, - "child_health_visit_recorded": true, - "tb_program_active": false - }, - "as_of_date": "2026-01-01" - }), - )) - .expect("production CEL worker evaluates the project date policy"); - assert_eq!(value, serde_json::json!(8)); - - let age_band = runtime - .block_on(worker.evaluate( - "health.exists && health.date_of_birth != null\n ? (date.age_on(health.date_of_birth, as_of_date) < 5\n ? \"0-4\"\n : (date.age_on(health.date_of_birth, as_of_date) < 18 ? \"5-17\" : \"18+\"))\n : null", - serde_json::json!({ - "health": { - "exists": true, - "date_of_birth": "2017-06-15" - }, - "as_of_date": "2026-01-01" - }), - )) - .expect("production CEL worker evaluates the approved age band"); - assert_eq!(age_band, serde_json::json!("5-17")); - - let absent = runtime - .block_on(worker.evaluate( - "health.exists && health.date_of_birth != null\n ? date.age_on(health.date_of_birth, as_of_date)\n : null", - serde_json::json!({ - "health": { "exists": false, "date_of_birth": null }, - "as_of_date": "2026-01-01" - }), - )) - .expect("production CEL worker preserves a successful null result"); - assert_eq!(absent, serde_json::Value::Null); -} - #[test] fn all_advertised_starters_initialize_and_test_without_source_access() { for starter in [ @@ -2520,12 +2141,12 @@ fn spreadsheet_starter_builds_sensitive_projected_fields_without_emitting_projec .expect("spreadsheet starter preflight executes"); assert_eq!( preflight.status, - registryctl::PreflightStatus::NotReady, - "authoring and deterministic build remain available without pretending missing local runtime inputs are ready" + registryctl::PreflightStatus::Ready, + "the maintained spreadsheet starter carries its local workbook and requires no retired Notary inputs" ); assert!( - !preflight.diagnostics.is_empty(), - "offline preflight must explain the missing local runtime inputs" + preflight.diagnostics.is_empty(), + "ready offline preflight must not invent missing local runtime inputs" ); let repeated_preflight = preflight_registry_project(&ProjectPreflightOptions { project_directory: project.clone(), @@ -2884,7 +2505,7 @@ fn spreadsheet_project_file_rejects_traversal_and_symlink_components() { } #[test] -fn typed_target_attribute_executes_through_the_offline_notary_journey() { +fn typed_target_attribute_executes_through_the_offline_relay_journey() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = temporary.path().join("typed-target-attribute"); copy_tree( @@ -3416,7 +3037,7 @@ fn check_explain_reports_environment_binding_without_origin_value() { } #[test] -fn offline_preflight_reports_missing_local_requirements_without_leaking_references() { +fn offline_preflight_reports_missing_source_secret_without_leaking_references() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = temporary.path().join("registry-project"); init_registry_project(&ProjectInitOptions { @@ -3432,23 +3053,16 @@ fn offline_preflight_reports_missing_local_requirements_without_leaking_referenc .expect("offline preflight produces a bounded report"); assert_eq!(report.status, registryctl::PreflightStatus::NotReady); assert_eq!(report.static_checks.len(), 4); - assert_eq!(report.product_validators.len(), 2); + assert_eq!(report.product_validators.len(), 1); assert!(!report.secret_checks.is_empty()); - assert!(!report.runtime_files.is_empty()); + assert!(report.runtime_files.is_empty()); assert_eq!( report.execution, registryctl::PreflightExecutionBoundary::default() ); let serialized = serde_json::to_string(&report).expect("preflight report serializes"); - for forbidden in [ - "FICTIONAL_REGISTRY_TOKEN", - "REGISTRY_NOTARY_ISSUER_JWK", - "EVIDENCE_CLIENT_TOKEN_HASH", - "/run/secrets/relay-workload-token", - "citizen-registry.invalid", - "fictional-registry-notary", - ] { + for forbidden in ["FICTIONAL_REGISTRY_TOKEN", "citizen-registry.invalid"] { assert!( !serialized.contains(forbidden), "preflight report must not contain {forbidden}" @@ -3498,18 +3112,13 @@ fn capability_inventory_separates_static_support_from_runtime_and_image_evidence report.runtime_activation, registryctl::RuntimeActivationEvaluation::NotEvaluated ); - for image in [ - registryctl::SupportComponent::RegistryRelayImage, - registryctl::SupportComponent::RegistryNotaryImage, - ] { - let support = report - .support - .iter() - .find(|entry| entry.component == image) - .expect("image support is represented"); - assert_eq!(support.state, registryctl::SupportState::NotEvaluated); - assert_eq!(support.evidence, registryctl::SupportEvidence::NoEvidence); - } + let support = report + .support + .iter() + .find(|entry| entry.component == registryctl::SupportComponent::RegistryRelayImage) + .expect("Relay image support is represented"); + assert_eq!(support.state, registryctl::SupportState::NotEvaluated); + assert_eq!(support.evidence, registryctl::SupportEvidence::NoEvidence); let report_value = serde_json::to_value(&report).expect("capability report serializes"); let schema = serde_json::from_str(include_str!( @@ -3656,6 +3265,11 @@ not_applicable: "#, ) .expect("adapted integration writes"); + replace_in_file( + &project.join("registry-stack.yaml"), + "person_id: request.target.identifiers.registry_person_id", + "municipal_reference: request.target.identifiers.registry_person_id", + ); let fixture_directory = project.join("integrations/person-record/fixtures"); for entry in std::fs::read_dir(&fixture_directory).expect("starter fixtures") { let path = entry.expect("fixture entry").path(); @@ -3679,7 +3293,6 @@ interactions: expect: outcome: match outputs: { status: ACTIVE, category: RESIDENT } - claims: { person-record-exists: true, person-status: ACTIVE } "#, ) .expect("adapted fixture writes"); @@ -3694,40 +3307,10 @@ interactions: path: /municipal/registry/lookup query: { reference: AB-123456, include: "status,category" } respond: { status: 409, body: {} } -expect: { outcome: ambiguous, outputs: {}, claims: {} } +expect: { outcome: ambiguous, outputs: {} } "#, ) .expect("adapted ambiguity fixture writes"); - let project_file = project.join("registry-stack.yaml"); - let mut project_document = read_yaml(&project_file); - let service = &mut project_document["services"]["person-verification"]; - service["purpose"] = serde_norway::Value::String("municipal-benefit-screening".to_string()); - service["consultations"]["person_record"]["input"] = serde_norway::from_str( - "municipal_reference: request.target.identifiers.registry_person_id\n", - ) - .expect("adapted consultation input"); - service["claims"] - .as_mapping_mut() - .expect("starter claims") - .remove(serde_norway::Value::String("person-active".to_string())); - service["claims"] - .as_mapping_mut() - .expect("starter claims") - .insert( - serde_norway::Value::String("person-status".to_string()), - serde_norway::from_str("output: person_record.status\ndisclosure: value\n") - .expect("adapted status claim"), - ); - service["credential_profiles"]["person-status"]["claims"] - .as_sequence_mut() - .expect("starter credential claims") - .iter_mut() - .for_each(|claim| { - if claim.as_str() == Some("person-active") { - *claim = serde_norway::Value::String("person-status".to_string()); - } - }); - write_yaml(&project_file, &project_document); let report = check_registry_project(&ProjectCheckOptions { project_directory: project, @@ -3741,10 +3324,6 @@ expect: { outcome: ambiguous, outputs: {}, claims: {} } .semantic_changes .iter() .any(|change| change.dimension == "integration")); - assert!(report - .semantic_changes - .iter() - .any(|change| change.dimension == "service_policy")); } #[test] @@ -3812,7 +3391,7 @@ fn source_product_is_metadata_not_runtime_dispatch() { against: None, anchor: None, }) - .expect("unknown product builds generic Relay and Notary inputs"); + .expect("unknown product builds generic Relay inputs"); assert_eq!(build.status, "built"); let metadata_free_root = tempfile::tempdir().expect("metadata-free temporary directory"); @@ -3890,11 +3469,6 @@ fn code_owned_rhai_conformance_uses_the_injected_worker_and_is_deterministic() { "{} outputs", expected.fixture ); - assert_eq!( - actual.claims, expected.claims, - "{} claims", - expected.fixture - ); assert_eq!( actual.outcome, expected.outcome, "{} outcome", @@ -4016,22 +3590,6 @@ fn pre_freeze_fact_authoring_keys_are_rejected_without_aliases() { assert!(rendered.contains("canonical schema validation")); assert!(!rendered.contains("facts")); - let claim_root = tempfile::tempdir().expect("claim-key temporary directory"); - let claim = copy_project("custom-system", claim_root.path()); - replace_in_file( - &claim.join("registry-stack.yaml"), - "output: household.category", - "fact: household.category", - ); - let error = test_registry_project(&ProjectTestOptions { - project_directory: claim, - environment: None, - }) - .expect_err("claim fact alias must be rejected"); - let rendered = format!("{error:#}"); - assert!(rendered.contains("canonical schema validation")); - assert!(!rendered.contains("fact:")); - let fixture_root = tempfile::tempdir().expect("fixture-key temporary directory"); let fixture = copy_project("custom-system", fixture_root.path()); let fixture_path = fixture.join("integrations/eligibility/fixtures/source-approved.yaml"); @@ -4600,111 +4158,24 @@ fn project_check_preserves_both_exact_sides_of_cross_file_failures() { } #[test] -fn project_check_points_to_representative_semantic_reference_and_value_failures() { - let assert_exact_pointer = - |project: &Path, cause: &str, expected_file: &str, expected_pointer: &str| { - let report = authoring_diagnostics(project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| diagnostic.cause == cause) - .unwrap_or_else(|| panic!("missing {cause}: {report:#?}")); - assert!( - diagnostic.addresses.iter().any(|address| { - address.file == expected_file && address.pointer == expected_pointer - }), - "missing {expected_file}#{expected_pointer}: {diagnostic:#?}" - ); - assert!( - diagnostic - .addresses - .iter() - .all(|address| !address.pointer.is_empty()), - "a precise semantic diagnostic degraded to a document-root address: {diagnostic:#?}" - ); - }; - - let integration_root = tempfile::tempdir().expect("integration temporary directory"); - let integration_project = copy_project("custom-system", integration_root.path()); - let project_path = integration_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["consultations"]["household"]["integration"] = - serde_norway::Value::String("missing-integration".to_string()); - write_yaml(&project_path, &project); - assert_exact_pointer( - &integration_project, - "A service consultation references an unknown integration.", - "registry-stack.yaml", - "/services/household-eligibility/consultations/household/integration", - ); - - let credential_root = tempfile::tempdir().expect("credential temporary directory"); - let credential_project = copy_project("custom-system", credential_root.path()); - let project_path = credential_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["claims"][0] = serde_norway::Value::String("missing-claim".to_string()); - write_yaml(&project_path, &project); - assert_exact_pointer( - &credential_project, - "A credential profile references an unknown claim.", - "registry-stack.yaml", - "/services/household-eligibility/credential_profiles/household-eligibility/claims/0", - ); - - let cel_root = tempfile::tempdir().expect("CEL temporary directory"); - let cel_project = copy_project("custom-system", cel_root.path()); - let project_path = cel_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["claims"]["household-record-exists"]["cel"] = - serde_norway::Value::String("missing_consultation.matched".to_string()); - project["services"]["household-eligibility"]["claims"]["household-record-exists"]["value"] = - serde_norway::from_str("{ type: boolean }").expect("claim value YAML parses"); - write_yaml(&project_path, &project); - assert_exact_pointer( - &cel_project, - "A claim evaluation does not resolve to a declared consultation.", - "registry-stack.yaml", - "/services/household-eligibility/claims/household-record-exists/cel", - ); - - let validity_root = tempfile::tempdir().expect("validity temporary directory"); - let validity_project = copy_project("custom-system", validity_root.path()); - let project_path = validity_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["validity"] = serde_norway::Value::String("ten-minutes".to_string()); - write_yaml(&project_path, &project); - assert_exact_pointer( - &validity_project, - "The YAML document does not satisfy its canonical authoring schema.", - "registry-stack.yaml", - "/services/household-eligibility/credential_profiles/household-eligibility/validity", - ); - - let disclosure_root = tempfile::tempdir().expect("disclosure temporary directory"); - let disclosure_project = copy_project("custom-system", disclosure_root.path()); - let project_path = disclosure_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["claims"]["household-record-exists"] - ["disclosure"] = serde_norway::Value::String("unsupported-mode".to_string()); - write_yaml(&project_path, &project); - assert_exact_pointer( - &disclosure_project, - "The YAML document does not satisfy its canonical authoring schema.", - "registry-stack.yaml", - "/services/household-eligibility/claims/household-record-exists/disclosure", - ); -} - -#[test] -fn project_check_moves_unknown_direct_outputs_into_exact_typed_diagnostics() { +fn project_check_moves_unknown_consultation_inputs_into_exact_typed_diagnostics() { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); let project_path = project.join("registry-stack.yaml"); let mut authored = read_yaml(&project_path); - authored["services"]["household-eligibility"]["claims"]["household-category"]["output"] = - serde_norway::Value::String("household.unknown-output".to_string()); + let input = authored["services"]["household-eligibility"]["consultations"]["household"] + ["input"] + .as_mapping_mut() + .expect("consultation input is a mapping"); + let value = input + .remove(serde_norway::Value::String( + "household_reference".to_string(), + )) + .expect("maintained consultation input exists"); + input.insert( + serde_norway::Value::String("unknown_input".to_string()), + value, + ); write_yaml(&project_path, &authored); let report = authoring_diagnostics(&project); @@ -4712,9 +4183,9 @@ fn project_check_moves_unknown_direct_outputs_into_exact_typed_diagnostics() { .diagnostics .iter() .find(|diagnostic| { - diagnostic.cause == "A direct claim references an unknown integration output." + diagnostic.cause == "A service consultation does not match its integration." }) - .unwrap_or_else(|| panic!("missing direct-output diagnostic: {report:#?}")); + .unwrap_or_else(|| panic!("missing consultation-input diagnostic: {report:#?}")); assert_eq!( diagnostic .addresses @@ -4722,10 +4193,10 @@ fn project_check_moves_unknown_direct_outputs_into_exact_typed_diagnostics() { .map(|address| (address.file.as_str(), address.pointer.as_str())) .collect::>(), BTreeSet::from([ - ("integrations/eligibility/integration.yaml", "/outputs"), + ("integrations/eligibility/integration.yaml", "/input"), ( "registry-stack.yaml", - "/services/household-eligibility/claims/household-category/output", + "/services/household-eligibility/consultations/household/input", ), ]) ); @@ -5021,338 +4492,6 @@ fn project_schema_accepts_only_bounded_scalar_target_attribute_mappings() { ); } -#[test] -fn environment_schema_tracks_local_loopback_signing_kid_and_postgresql_state() { - let schema: serde_json::Value = serde_json::from_slice( - &std::fs::read( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("schemas/project-authoring/environment.schema.json"), - ) - .expect("environment schema reads"), - ) - .expect("environment schema is JSON"); - let schema = jsonschema::JSONSchema::options() - .with_draft(jsonschema::Draft::Draft202012) - .compile(&schema) - .expect("environment schema compiles"); - let local = serde_json::json!({ - "version": 1, - "issuance": { - "issuer": "did:web:authority.invalid", - "signing_key": { "secret": "NOTARY_ISSUER_JWK" }, - "signing_kid": "did:web:authority.invalid#issuer-key-1", - "generation": 1, - }, - "relay": { - "origin": "HTTP://127.0.0.1:8080", - "issuer": "HTTP://[::1]:8090", - "jwks_url": "HTTP://127.0.0.1:8090/.well-known/jwks.json", - "audience": "registry-relay", - "allowed_clients": [], - }, - "notary_relay": { - "base_url": "HTTP://127.0.0.1:8080", - "workload_client_id": "authority-notary", - "token_file": "/run/secrets/authority-notary-relay-token", - }, - "relay_state": { - "postgresql": { - "root_certificate_path": "/run/secrets/relay-postgres-ca.pem", - }, - }, - "notary_state": { - "postgresql": { - "root_certificate_path": "/run/secrets/notary-postgres-ca.pem", - }, - }, - "notary_cel": { - "worker_memory_bytes": 1073741824, - }, - "deployment": { - "profile": "local", - "relay": { "service": "authority-relay" }, - "notary": { "service": "authority-notary" }, - }, - }); - assert!(schema.is_valid(&local)); - - let mut hosted_loopback = local.clone(); - hosted_loopback["deployment"]["profile"] = serde_json::json!("hosted_lab"); - assert!(!schema.is_valid(&hosted_loopback)); - - let mut private_network_http = local.clone(); - private_network_http["relay"]["origin"] = serde_json::json!("http://10.42.0.8:8080"); - assert!(!schema.is_valid(&private_network_http)); - - let mut relative_root = local.clone(); - relative_root["notary_state"]["postgresql"]["root_certificate_path"] = - serde_json::json!("notary-postgres-ca.pem"); - assert!(!schema.is_valid(&relative_root)); - - let mut relative_relay_root = local.clone(); - relative_relay_root["relay_state"]["postgresql"]["root_certificate_path"] = - serde_json::json!("relay-postgres-ca.pem"); - assert!(!schema.is_valid(&relative_relay_root)); - - let mut undersized_cel_worker = local.clone(); - undersized_cel_worker["notary_cel"]["worker_memory_bytes"] = serde_json::json!(33_554_431); - assert!(!schema.is_valid(&undersized_cel_worker)); - - let mut oversized_cel_worker = local.clone(); - oversized_cel_worker["notary_cel"]["worker_memory_bytes"] = - serde_json::json!(1_073_741_825_u64); - assert!(!schema.is_valid(&oversized_cel_worker)); - - let mut relay_only_cel_worker = local.clone(); - relay_only_cel_worker["deployment"] - .as_object_mut() - .expect("deployment is an object") - .remove("notary"); - assert!(!schema.is_valid(&relay_only_cel_worker)); - - let mut whitespace_kid = local.clone(); - whitespace_kid["issuance"]["signing_kid"] = - serde_json::json!("did:web:authority.invalid#bad kid"); - assert!(!schema.is_valid(&whitespace_kid)); -} - -#[test] -fn environment_schema_types_the_closed_oid4vci_authority_binding() { - let schema: serde_json::Value = serde_json::from_slice( - &std::fs::read( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("schemas/project-authoring/environment.schema.json"), - ) - .expect("environment schema reads"), - ) - .expect("environment schema is JSON"); - let schema = jsonschema::JSONSchema::options() - .with_draft(jsonschema::Draft::Draft202012) - .compile(&schema) - .expect("environment schema compiles"); - let environment = serde_json::json!({ - "version": 1, - "issuance": { - "issuer": "did:web:notary.example.invalid", - "signing_key": { "secret": "NOTARY_ISSUER_JWK" }, - "signing_kid": "did:web:notary.example.invalid#issuer-key-1", - "generation": 1, - }, - "notary_state": { - "postgresql": { - "root_certificate_path": "/run/secrets/notary-postgres-ca.pem", - }, - }, - "oid4vci": { - "public_base_url": "https://notary.example.invalid", - "credential": { - "service": "citizen-status", - "profile": "citizen-status", - }, - "authorization_server": { - "issuer": "https://esignet.example.invalid", - "jwks_url": "https://esignet.example.invalid/jwks.json", - "userinfo_url": "https://esignet.example.invalid/userinfo", - "authorize_url": "https://esignet-ui.example.invalid/authorize", - "token_url": "https://esignet.example.invalid/token", - }, - "client": { - "id": "citizen-wallet", - "signing_key": { "secret": "ESIGNET_CLIENT_JWK" }, - "signing_kid": "citizen-wallet-key-1", - }, - "access_token": { - "signing_key": { "secret": "NOTARY_ACCESS_TOKEN_JWK" }, - "signing_kid": "did:web:notary.example.invalid#access-token-key-1", - }, - "sensitive_state_key": { "secret": "NOTARY_SENSITIVE_STATE_KEY" }, - "subject": { - "token_claim": "individual_id", - "id_type": "solmara_uin", - }, - "redirect_uri": "https://notary.example.invalid/oid4vci/offer/callback", - "allowed_wallet_origins": ["https://wallet.example.invalid"], - "representative_issuance": { - "relationship": "parent", - "proof_claim": "parent-link", - "target_id_type": "solmara_uin", - }, - }, - "deployment": { - "profile": "hosted_lab", - "notary": { "service": "citizen-notary" }, - }, - }); - assert!(schema.is_valid(&environment)); - - let mut empty_callers = environment.clone(); - empty_callers["callers"] = serde_json::json!({}); - assert!(schema.is_valid(&empty_callers)); - - let mut with_callers = environment.clone(); - with_callers["callers"] = serde_json::json!({ - "portal": { - "api_key_fingerprint": { "secret": "PORTAL_KEY_HASH" }, - "scopes": ["evidence:read"], - }, - }); - assert!(schema.is_valid(&with_callers)); - - let mut authored_scope = environment.clone(); - authored_scope["oid4vci"]["credential"]["scope"] = serde_json::json!("credential:issue"); - assert!(!schema.is_valid(&authored_scope)); - - let mut missing_state = environment.clone(); - missing_state - .as_object_mut() - .expect("environment object") - .remove("notary_state"); - assert!(!schema.is_valid(&missing_state)); - - let mut relative_redirect = environment.clone(); - relative_redirect["oid4vci"]["redirect_uri"] = serde_json::json!("/oid4vci/offer/callback"); - assert!(!schema.is_valid(&relative_redirect)); - - let mut hosted_loopback = environment.clone(); - hosted_loopback["oid4vci"]["public_base_url"] = serde_json::json!("http://127.0.0.1:8081"); - assert!(!schema.is_valid(&hosted_loopback)); - - let mut stale_relationship_proof = environment.clone(); - stale_relationship_proof["oid4vci"]["representative_issuance"]["max_proof_age_seconds"] = - serde_json::json!(0); - assert!(!schema.is_valid(&stale_relationship_proof)); - - let mut unknown_key_field = environment; - unknown_key_field["oid4vci"]["access_token"]["value"] = serde_json::json!("secret-material"); - assert!(!schema.is_valid(&unknown_key_field)); -} - -#[test] -fn project_authoring_schemas_reject_incoherent_product_topologies() { - let schema_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/project-authoring"); - let compile = |schema_name: &str| { - let schema: serde_json::Value = serde_json::from_slice( - &std::fs::read(schema_root.join(schema_name)).expect("schema reads"), - ) - .expect("schema is JSON"); - jsonschema::JSONSchema::options() - .with_draft(jsonschema::Draft::Draft202012) - .compile(&schema) - .unwrap_or_else(|error| panic!("{schema_name} did not compile: {error}")) - }; - let project_schema = compile("project.schema.json"); - assert!(!project_schema.is_valid(&serde_json::json!({ - "version": 1, - "registry": { "id": "empty-registry" }, - "services": {}, - }))); - - let environment_schema = compile("environment.schema.json"); - let relay_binding = serde_json::json!({ - "origin": "https://relay.internal.invalid", - "issuer": "https://issuer.internal.invalid", - "jwks_url": "https://issuer.internal.invalid/.well-known/jwks.json", - "audience": "registry-relay", - "allowed_clients": ["registry-client"], - }); - let connection = serde_json::json!({ - "base_url": "http://127.0.0.1:8080", - "workload_client_id": "registry-notary", - "token_file": "/run/secrets/notary-relay-token", - }); - for (name, environment) in [ - ( - "Relay deployment without Relay bindings", - serde_json::json!({ - "version": 1, - "deployment": { "profile": "local", "relay": { "service": "relay" } }, - }), - ), - ( - "Notary-only deployment with Relay bindings", - serde_json::json!({ - "version": 1, - "relay": relay_binding.clone(), - "deployment": { "profile": "local", "notary": { "service": "notary" } }, - }), - ), - ( - "Relay-only deployment with a Notary-to-Relay connection", - serde_json::json!({ - "version": 1, - "relay": relay_binding.clone(), - "notary_relay": connection, - "deployment": { "profile": "local", "relay": { "service": "relay" } }, - }), - ), - ] { - assert!( - !environment_schema.is_valid(&environment), - "schema accepted {name}" - ); - } - assert!(environment_schema.is_valid(&serde_json::json!({ - "version": 1, - "relay": relay_binding, - "deployment": { - "profile": "local", - "relay": { "service": "relay" }, - "notary": { "service": "notary" }, - }, - }))); - assert!(!environment_schema.is_valid(&serde_json::json!({ - "version": 1, - "relay": { - "origin": "https://relay.internal.invalid", - "issuer": "https://issuer.internal.invalid", - "jwks_url": "https://issuer.internal.invalid/.well-known/jwks.json", - "audience": "registry-relay", - "workload_client_id": "obsolete-overloaded-client", - }, - "deployment": { "profile": "local", "relay": { "service": "relay" } }, - }))); -} - -#[test] -fn relay_authorization_bindings_follow_authored_service_topology() { - let missing_workload_root = tempfile::tempdir().expect("temporary directory"); - let missing_workload = copy_project("custom-system", missing_workload_root.path()); - let environment_path = missing_workload.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment - .as_mapping_mut() - .expect("environment mapping") - .remove(serde_norway::Value::String("notary_relay".to_string())); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: missing_workload, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("Relay consultation without a Notary workload must fail"); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); - - let missing_records_client_root = tempfile::tempdir().expect("temporary directory"); - let missing_records_client = - copy_project("relay-only-records", missing_records_client_root.path()); - let environment_path = missing_records_client.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["relay"]["allowed_clients"] = - serde_norway::from_str("[]\n").expect("empty allowed client list"); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: missing_records_client, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("records publication without an admitted client must fail"); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); -} - #[test] fn exact_selector_sizes_one_through_eight_compile_for_http_and_snapshot() { for size in 1..=8 { @@ -5360,7 +4499,7 @@ fn exact_selector_sizes_one_through_eight_compile_for_http_and_snapshot() { for golden_name in ["custom-system", "snapshot-exact"] { let project = copy_project(golden_name, temporary.path()); if golden_name == "custom-system" { - remove_custom_cel_claim(&project); + prepare_custom_selector_project(&project); } extend_exact_selector(&project, golden_name, size); check_registry_project(&ProjectCheckOptions { @@ -5381,7 +4520,7 @@ fn exact_selector_sizes_one_through_eight_compile_for_http_and_snapshot() { fn integration_input_bounds_match_the_production_compiler_limit() { let accepted_root = tempfile::tempdir().expect("accepted temporary directory"); let accepted = copy_project("custom-system", accepted_root.path()); - remove_custom_cel_claim(&accepted); + prepare_custom_selector_project(&accepted); replace_in_file( &accepted.join("integrations/eligibility/integration.yaml"), "maxLength: 18", @@ -5431,7 +4570,7 @@ fn integration_input_bounds_match_the_production_compiler_limit() { fn integration_input_names_match_the_wire_grammar() { let accepted_root = tempfile::tempdir().expect("accepted temporary directory"); let accepted = copy_project("custom-system", accepted_root.path()); - remove_custom_cel_claim(&accepted); + prepare_custom_selector_project(&accepted); let boundary_name = format!("a{}", "0".repeat(63)); rename_custom_input(&accepted, &boundary_name); let report = build_registry_project(&ProjectBuildOptions { @@ -5563,8 +4702,8 @@ fn exact_selector_authored_member_order_is_canonical() { let second_root = tempfile::tempdir().expect("second temporary directory"); let first = copy_project("custom-system", first_root.path()); let second = copy_project("custom-system", second_root.path()); - remove_custom_cel_claim(&first); - remove_custom_cel_claim(&second); + prepare_custom_selector_project(&first); + prepare_custom_selector_project(&second); extend_exact_selector(&first, "custom-system", 3); extend_exact_selector(&second, "custom-system", 3); @@ -5624,7 +4763,7 @@ fn api_key_interfaces_keep_values_environment_only_and_use_the_stable_auth_type( ] { let temporary = tempfile::tempdir().expect("temporary directory"); let project = copy_project("custom-system", temporary.path()); - remove_custom_cel_claim(&project); + prepare_custom_selector_project(&project); let integration = project.join("integrations/eligibility/integration.yaml"); let mut document = read_yaml(&integration); document["source"]["auth"] = serde_norway::from_str(&format!( @@ -5818,7 +4957,6 @@ fn opencrvs_composite_dci_uses_unified_exact_predicates_canonically() { .expect("composite ambiguous fixture executes"); assert_eq!(ambiguous.outcome.as_deref(), Some("ambiguous")); assert!(ambiguous.outputs.is_empty()); - assert!(ambiguous.claims.is_empty()); reverse_yaml_mapping( &second.join("integrations/birth-record/integration.yaml"), &["source", "protocol", "signed_dci", "selectors"], @@ -6051,20 +5189,14 @@ fn check_and_build_produce_deterministic_product_inputs() { }) .expect("golden project checks"); assert_eq!(check.status, "valid"); - assert_eq!(check.semantic_changes.len(), 5); + assert_eq!(check.semantic_changes.len(), 3); assert_eq!( check .semantic_changes .iter() .map(|change| change.dimension) .collect::>(), - BTreeSet::from([ - "claim", - "disclosure", - "integration", - "operator_security", - "service_policy", - ]) + BTreeSet::from(["integration", "operator_security", "service_policy"]) ); let explanation = check.explanation.expect("explanation is present"); assert_eq!( @@ -6075,21 +5207,6 @@ fn check_and_build_produce_deterministic_product_inputs() { )), &serde_json::json!("http") ); - assert_eq!( - public_explanation_value(project_explanation_field( - &explanation, - "/services/household-eligibility/consultation_count", - )), - &serde_json::json!(1) - ); - assert!(matches!( - project_explanation_field( - &explanation, - "/services/household-eligibility/claims/source-household-approval-decision/cel", - ) - .reported_value, - ClassifierSafeReportedValue::Redacted { .. } - )); assert!(matches!( environment_explanation_field( &explanation, @@ -6128,15 +5245,6 @@ fn check_and_build_produce_deterministic_product_inputs() { }; let first = build_registry_project(&options).expect("first build"); let output = resolve_build_output(&project, first.output.expect("build output")); - let notary_config = std::fs::read_to_string(output.join("private/notary/config/notary.yaml")) - .expect("generated Notary config"); - let notary_document: serde_norway::Value = - serde_norway::from_str(¬ary_config).expect("generated Notary config parses"); - assert_eq!( - notary_document["server"]["bind"], - serde_norway::Value::String("0.0.0.0:8081".to_string()), - "the governed Notary listener must be reachable through its container port" - ); for (lane, expected_bind) in [ ("relay-public", "0.0.0.0:8080"), ("relay-consultation", "0.0.0.0:8080"), @@ -6152,15 +5260,6 @@ fn check_and_build_produce_deterministic_product_inputs() { "the governed Relay listener must be reachable through its container port" ); } - assert!( - notary_document.get("cel").is_none(), - "absent authoring must preserve the Notary product default" - ); - assert!(notary_config.contains("type: consultation_output")); - assert!(notary_config.contains("consultation: household")); - assert!(notary_config.contains("output: category")); - assert!(!notary_config.contains("type: extract")); - assert!(!notary_config.contains("type: exists")); let public_contract: serde_json::Value = serde_json::from_slice( &std::fs::read(output.join( "private/relay-consultation/config/artifacts/consultation-contracts/household-eligibility-household.json", @@ -6179,11 +5278,6 @@ fn check_and_build_produce_deterministic_product_inputs() { let first_closure = directory_closure(&output); build_registry_project(&options).expect("second build"); assert_eq!(first_closure, directory_closure(&output)); - assert_eq!( - closure_digest(&first_closure), - "43ce7ef2d61edc9ab8c6f56d5bc2420d3cf39e64013807d69b82055b05e6bdbe", - "project output, including its deterministic manifest, must match the cross-machine golden digest" - ); } #[test] @@ -6300,23 +5394,10 @@ fn build_artifact_manifest_is_complete_relative_private_and_deterministic() { .as_array() .expect("artifact consumers"); assert!(!consumers.is_empty()); - if payload_relative.starts_with("private/relay-public/") - || payload_relative.starts_with("private/relay-consultation/") - { - assert!(!consumers - .iter() - .any(|consumer| consumer == "registry_notary")); - } - if payload_relative.starts_with("private/notary/") { - assert!(!consumers - .iter() - .any(|consumer| consumer == "registry_relay")); - } if matches!( payload_relative, "private/relay-public/config/relay.yaml" | "private/relay-consultation/config/relay.yaml" - | "private/notary/config/notary.yaml" ) { assert_eq!(artifact["sensitivity"], "topology_sensitive"); assert_eq!(artifact["publication"], "never_publish"); @@ -6362,289 +5443,6 @@ fn build_artifact_manifest_is_complete_relative_private_and_deterministic() { ); } -#[cfg(feature = "relay-contract-test-support")] -#[test] -fn generated_relay_contract_activates_through_notary_exactly_and_rejects_a_stale_pin() { - use registry_notary_core::{ClaimEvidenceMode, StandaloneRegistryNotaryConfig}; - - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let integration_path = project.join("integrations/eligibility/integration.yaml"); - let mut integration = read_yaml(&integration_path); - integration["limits"] = serde_norway::from_str("deadline: 20s\n") - .expect("reviewed deadline boundary is valid YAML"); - write_yaml(&integration_path, &integration); - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("combined project builds"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let contract_path = output.join( - "private/relay-consultation/config/artifacts/consultation-contracts/household-eligibility-household.json", - ); - let contract_bytes = std::fs::read(&contract_path).expect("Relay contract artifact reads"); - let contract: serde_json::Value = - serde_json::from_slice(&contract_bytes).expect("Relay contract artifact parses"); - assert_eq!(contract["spec"]["bounds"]["timeout_ms"], 20_000); - let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( - &std::fs::read(output.join("private/notary/config/notary.yaml")) - .expect("Notary config reads"), - ) - .expect("generated Notary config parses through its production model"); - let relay = notary - .evidence - .relay - .as_ref() - .expect("combined deployment has one Relay workload"); - let claim = notary - .evidence - .claims - .iter() - .find(|claim| claim.id == "household-category") - .expect("registry-backed claim"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - panic!("household category remains registry-backed"); - }; - let consultation = consultations - .values() - .next() - .expect("claim has one Relay consultation"); - let input_names = consultation.inputs.keys().cloned().collect::>(); - let purpose = claim.purpose.as_deref().expect("claim purpose is explicit"); - - assert!( - registry_notary_server::relay_contract_test_support::verifies_contract_artifact( - &contract_bytes, - &consultation.profile.contract_hash, - &consultation.profile.id, - &relay.workload_client_id, - purpose, - &input_names, - &consultation.outputs, - ), - "Notary must activate the exact compiler-produced contract and pin" - ); - - let mut mutated: serde_json::Value = - serde_json::from_slice(&contract_bytes).expect("contract artifact parses"); - mutated["spec"]["output"]["category"]["max_bytes"] = serde_json::json!(84); - let mutated = serde_json::to_vec(&mutated).expect("mutated envelope serializes"); - assert!( - !registry_notary_server::relay_contract_test_support::verifies_contract_artifact( - &mutated, - &consultation.profile.contract_hash, - &consultation.profile.id, - &relay.workload_client_id, - purpose, - &input_names, - &consultation.outputs, - ), - "a contract mutation cannot activate under the prior Notary pin" - ); -} - -#[cfg(feature = "relay-contract-test-support")] -#[test] -fn generated_snapshot_contracts_activate_through_notary_at_the_authoring_bound() { - use registry_notary_core::{ClaimEvidenceMode, StandaloneRegistryNotaryConfig}; - - for (authored_max_bytes, expected_max_bytes) in [ - ("256MiB", 256 * 1_024 * 1_024_u64), - ("512MiB", 512 * 1_024 * 1_024_u64), - ("1024MiB", 1_024 * 1_024 * 1_024_u64), - ] { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("snapshot-exact", temporary.path()); - let entity_path = project.join("entities/people.yaml"); - let mut entity = read_yaml(&entity_path); - entity["materialization"]["max_bytes"] = - serde_norway::Value::String(authored_max_bytes.to_string()); - write_yaml(&entity_path, &entity); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("snapshot project builds within the authored materialization bound"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let contract_bytes = std::fs::read(output.join( - "private/relay-consultation/config/artifacts/consultation-contracts/benefits-eligibility-person.json", - )) - .expect("snapshot Relay contract reads"); - let contract: serde_json::Value = - serde_json::from_slice(&contract_bytes).expect("snapshot Relay contract parses"); - assert_eq!( - contract["spec"]["materialization"]["footprint"]["max_source_bytes"].as_u64(), - Some(expected_max_bytes) - ); - - let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( - &std::fs::read(output.join("private/notary/config/notary.yaml")) - .expect("Notary config reads"), - ) - .expect("generated Notary config parses"); - let relay = notary.evidence.relay.as_ref().expect("Relay workload"); - let claim = notary - .evidence - .claims - .iter() - .find(|claim| claim.id == "population-registration-status") - .expect("registry-backed snapshot claim"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - panic!("snapshot claim remains registry-backed"); - }; - let consultation = consultations - .values() - .next() - .expect("one snapshot consultation"); - let input_names = consultation.inputs.keys().cloned().collect::>(); - let purpose = claim.purpose.as_deref().expect("claim purpose"); - assert!( - registry_notary_server::relay_contract_test_support::verifies_contract_artifact( - &contract_bytes, - &consultation.profile.contract_hash, - &consultation.profile.id, - &relay.workload_client_id, - purpose, - &input_names, - &consultation.outputs, - ), - "Notary must activate the {authored_max_bytes} compiler-produced snapshot contract" - ); - } -} - -#[cfg(feature = "relay-contract-test-support")] -#[test] -fn script_only_change_moves_the_relay_closure_without_forking_the_public_contract() { - use registry_notary_core::{ClaimEvidenceMode, StandaloneRegistryNotaryConfig}; - - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("dhis2-script", temporary.path()); - let options = ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }; - let first = build_registry_project(&options).expect("initial Script project builds"); - let first_output = resolve_build_output(&project, first.output.expect("initial build output")); - let contract_relative = - "private/relay-consultation/config/artifacts/consultation-contracts/health-verification-health.json"; - let pack_relative = - "private/relay-consultation/config/artifacts/integration-packs/health-record.json"; - let binding_relative = - "private/relay-consultation/config/artifacts/private-bindings/health-verification-health.json"; - let first_contract = - std::fs::read(first_output.join(contract_relative)).expect("initial contract reads"); - let first_pack = - std::fs::read(first_output.join(pack_relative)).expect("initial integration pack reads"); - let first_binding = - std::fs::read(first_output.join(binding_relative)).expect("initial private binding reads"); - let notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( - &std::fs::read(first_output.join("private/notary/config/notary.yaml")) - .expect("initial Notary config reads"), - ) - .expect("initial Notary config parses"); - let relay = notary.evidence.relay.as_ref().expect("Relay workload"); - let claim = notary - .evidence - .claims - .iter() - .find(|claim| claim.id == "tracked-entity-first-name") - .expect("registry-backed Script claim"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - panic!("Script claim remains registry-backed"); - }; - let consultation = consultations.values().next().expect("one consultation"); - let first_hash = consultation.profile.contract_hash.clone(); - let input_names = consultation.inputs.keys().cloned().collect::>(); - let purpose = claim.purpose.as_deref().expect("claim purpose"); - assert!( - registry_notary_server::relay_contract_test_support::verifies_contract_artifact( - &first_contract, - &first_hash, - &consultation.profile.id, - &relay.workload_client_id, - purpose, - &input_names, - &consultation.outputs, - ), - "Notary accepts the initial Script contract under its generated pin" - ); - - let script_path = project.join("integrations/health-record/adapter.rhai"); - let mut script = std::fs::read_to_string(&script_path).expect("Script reads"); - script.push_str("\n// reviewed script-only contract change\n"); - std::fs::write(&script_path, script).expect("Script change writes"); - let second = build_registry_project(&options).expect("changed Script project builds"); - let second_output = - resolve_build_output(&project, second.output.expect("changed build output")); - let second_contract = - std::fs::read(second_output.join(contract_relative)).expect("changed contract reads"); - let second_pack = - std::fs::read(second_output.join(pack_relative)).expect("changed integration pack reads"); - let second_binding = - std::fs::read(second_output.join(binding_relative)).expect("changed private binding reads"); - let second_notary: StandaloneRegistryNotaryConfig = serde_norway::from_slice( - &std::fs::read(second_output.join("private/notary/config/notary.yaml")) - .expect("changed Notary config reads"), - ) - .expect("changed Notary config parses"); - let second_claim = second_notary - .evidence - .claims - .iter() - .find(|claim| claim.id == "tracked-entity-first-name") - .expect("changed Script claim"); - let ClaimEvidenceMode::RegistryBacked { - consultations: second_consultations, - } = &second_claim.evidence_mode - else { - panic!("changed Script claim remains registry-backed"); - }; - let second_hash = &second_consultations - .values() - .next() - .expect("changed consultation") - .profile - .contract_hash; - assert_eq!( - first_hash.as_str(), - second_hash, - "a script-only implementation change must preserve an unchanged public semantic contract" - ); - assert_eq!( - first_contract, second_contract, - "the public consultation contract contains semantics, not Relay implementation bytes" - ); - assert_ne!( - first_pack, second_pack, - "reviewed Script bytes must remain hash-covered by the Relay integration pack" - ); - assert_ne!( - first_binding, second_binding, - "the Relay private binding must move with its hash-covered integration pack" - ); - assert!( - registry_notary_server::relay_contract_test_support::verifies_contract_artifact( - &second_contract, - &first_hash, - &consultation.profile.id, - &relay.workload_client_id, - purpose, - &input_names, - &consultation.outputs, - ), - "Notary verifies the unchanged public semantics while Relay verifies the changed private closure" - ); -} - #[test] fn records_and_snapshot_share_one_generated_materialization() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -6767,112 +5565,22 @@ fn materialization_size_boundary_accepts_integer_ceiling_and_rejects_human_above } #[test] -fn materialization_only_project_checks_but_emits_no_partial_governed_build() { +fn consultation_project_rejects_an_empty_relay_allowlist() { let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("relay-only-materialization", temporary.path()); - check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: true, - against: None, - anchor: None, - }) - .expect("materialization-only Relay project checks"); - let error = build_registry_project(&ProjectBuildOptions { + let project = copy_project("custom-system", temporary.path()); + let environment_path = project.join("environments/local.yaml"); + let mut environment = read_yaml(&environment_path); + environment["relay"]["allowed_clients"] = serde_norway::Value::Sequence(Vec::new()); + write_yaml(&environment_path, &environment); + + let error = check_registry_project(&ProjectCheckOptions { project_directory: project.clone(), environment: "local".to_string(), + explain: false, against: None, anchor: None, }) - .expect_err("materialization-only Relay project cannot emit a partial governed build"); - let message = format!("{error:#}"); - assert!(message.contains("governed build requires")); - assert!(message.contains("add deployment.notary before project build")); - assert!( - !project.join(".registry-stack/build/local").exists(), - "a rejected partial topology must not leave deployable-looking output" - ); -} - -#[test] -fn relay_oidc_clients_are_separate_from_the_notary_consultation_workload() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let inventory = inspect_project_capabilities(&ProjectCapabilityOptions { - project_directory: project.clone(), - environment: "local".to_string(), - }) - .expect("capability inventory builds"); - let public_relay = inventory - .capabilities - .iter() - .find(|record| record.capability == registryctl::CapabilityId::RegistryRelayProduct) - .expect("public Relay capability is inventoried"); - assert_eq!( - public_relay.disposition, - registryctl::CapabilityDisposition::DeclaredEnabledUnused - ); - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("combined project builds with separate Relay identities"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let relay = read_yaml(&output.join("private/relay-public/config/relay.yaml")); - let consultation_relay = - read_yaml(&output.join("private/relay-consultation/config/relay.yaml")); - let allowed_clients = relay["auth"]["oidc"]["allowed_clients"] - .as_sequence() - .expect("Relay OIDC allowed clients"); - assert!(allowed_clients - .iter() - .any(|client| client.as_str() == Some("household-relay-client"))); - assert!(!allowed_clients - .iter() - .any(|client| client.as_str() == Some("household-notary"))); - let consultation_allowed_clients = consultation_relay["auth"]["oidc"]["allowed_clients"] - .as_sequence() - .expect("consultation Relay OIDC allowed clients"); - assert_eq!( - consultation_allowed_clients - .iter() - .filter_map(serde_norway::Value::as_str) - .collect::>(), - vec!["household-notary"] - ); - assert_eq!( - consultation_relay["consultation"]["authorized_workload"]["client_value"].as_str(), - Some("household-notary") - ); - assert_eq!( - consultation_relay["consultation"]["authorized_workload"]["principal_id"].as_str(), - Some("household-notary") - ); - assert_ne!( - consultation_relay["consultation"]["authorized_workload"]["client_value"].as_str(), - Some("household-relay-client") - ); -} - -#[test] -fn evidence_only_project_rejects_an_empty_public_relay_allowlist() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["relay"]["allowed_clients"] = serde_norway::Value::Sequence(Vec::new()); - write_yaml(&environment_path, &environment); - - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("evidence-only project without a public Relay client must fail authoring"); + .expect_err("consultation project without a Relay client must fail authoring"); let report = error .downcast_ref::() .expect("error is a typed authoring diagnostics report"); @@ -6880,11 +5588,10 @@ fn evidence_only_project_rejects_an_empty_public_relay_allowlist() { .diagnostics .iter() .find(|diagnostic| diagnostic.field == Some("relay.allowed_clients")) - .expect("empty public Relay allowlist has a focused diagnostic"); - assert_eq!( - diagnostic.remediation, - "Add at least one intended public Relay client id. Keep the private Notary workload client only in notary_relay." - ); + .expect("empty Relay allowlist has a focused diagnostic"); + assert!(diagnostic + .remediation + .contains("Add at least one intended Relay client id.")); build_registry_project(&ProjectBuildOptions { project_directory: project.clone(), @@ -6892,1063 +5599,43 @@ fn evidence_only_project_rejects_an_empty_public_relay_allowlist() { against: None, anchor: None, }) - .expect_err("invalid evidence-only project must not build"); + .expect_err("invalid consultation project must not build"); assert!( !project.join(".registry-stack/build/local").exists(), - "rejected project must not leave build output" - ); -} - -#[test] -fn records_api_requires_an_explicit_public_relay_client() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("relay-only-records", temporary.path()); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["relay"]["allowed_clients"] = serde_norway::Value::Sequence(Vec::new()); - write_yaml(&environment_path, &environment); - - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("records API without a public Relay client must fail authoring"); - let report = error - .downcast_ref::() - .expect("error is a typed authoring diagnostics report"); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| diagnostic.field == Some("relay.allowed_clients")) - .expect("empty public Relay allowlist has a focused diagnostic"); - assert_eq!( - diagnostic.cause, - "The public Relay has no admitted OpenID Connect client." - ); - assert!(diagnostic.remediation.contains("Add at least one intended")); - assert!(diagnostic.remediation.contains("only in notary_relay")); -} - -#[test] -fn local_loopback_relay_topology_is_explicit_and_nonportable() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["relay"]["origin"] = - serde_norway::Value::String("HTTP://127.0.0.1:18080".to_string()); - environment["relay"]["issuer"] = - serde_norway::Value::String("HTTP://127.0.0.1:18090".to_string()); - environment["relay"]["jwks_url"] = - serde_norway::Value::String("HTTP://127.0.0.1:18090/jwks.json".to_string()); - environment["notary_relay"]["base_url"] = - serde_norway::Value::String("HTTP://127.0.0.1:18081".to_string()); - environment["notary_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", - ) - .expect("Notary state binding parses"); - environment["relay_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/relay-postgres-ca.pem\n", - ) - .expect("Relay state binding parses"); - environment["notary_cel"] = serde_norway::from_str("worker_memory_bytes: 1073741824\n") - .expect("Notary CEL binding parses"); - write_yaml(&environment_path, &environment); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("local IP-loopback Relay, issuer, and JWKS build"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let relay = read_yaml(&output.join("private/relay-public/config/relay.yaml")); - assert_eq!( - relay["auth"]["oidc"]["allow_dev_insecure_fetch_urls"].as_bool(), - Some(true) - ); - let consultation_relay = - read_yaml(&output.join("private/relay-consultation/config/relay.yaml")); - assert_eq!( - consultation_relay["consultation"]["state_plane"]["root_certificate_path"].as_str(), - Some("/run/secrets/relay-postgres-ca.pem") - ); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - assert_eq!(notary["state"]["storage"].as_str(), Some("postgresql")); - assert_eq!( - notary["state"]["postgresql"]["url_env"].as_str(), - Some("REGISTRY_NOTARY_POSTGRES_URL") - ); - assert!(notary["state"]["postgresql"] - .get("connect_timeout_ms") - .is_none()); - assert!(notary["state"]["postgresql"] - .get("operation_timeout_ms") - .is_none()); - assert!(notary["state"]["postgresql"] - .get("max_connections") - .is_none()); - assert_eq!( - notary["state"]["postgresql"]["root_certificate_path"].as_str(), - Some("/run/secrets/notary-postgres-ca.pem") - ); - assert_eq!( - notary["cel"]["worker_memory_bytes"].as_u64(), - Some(1_073_741_824) - ); - assert_eq!( - notary["evidence"]["relay"]["allow_insecure_localhost"].as_bool(), - Some(true) - ); - assert_eq!( - notary["evidence"]["relay"]["allow_insecure_private_network"].as_bool(), - Some(false) - ); - assert_eq!( - notary["evidence"]["relay"]["base_url"].as_str(), - Some("http://127.0.0.1:18081") - ); - - for (name, profile, origin, issuer, jwks_url, expected) in [ - ( - "hosted loopback", - "hosted_lab", - "http://127.0.0.1:18080", - "http://127.0.0.1:18090", - "http://127.0.0.1:18090/jwks.json", - "Relay origin must be an exact HTTPS origin", - ), - ( - "local private-network", - "local", - "http://10.42.0.8:18080", - "http://10.42.0.9:18090", - "http://10.42.0.9:18090/jwks.json", - "Relay origin must be an exact HTTPS origin", - ), - ] { - let rejected_root = tempfile::tempdir().expect("rejected temporary directory"); - let rejected = copy_project("custom-system", rejected_root.path()); - let environment_path = rejected.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["deployment"]["profile"] = serde_norway::Value::String(profile.to_string()); - environment["relay"]["origin"] = serde_norway::Value::String(origin.to_string()); - environment["relay"]["issuer"] = serde_norway::Value::String(issuer.to_string()); - environment["relay"]["jwks_url"] = serde_norway::Value::String(jwks_url.to_string()); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: rejected, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .unwrap_err(); - let _ = (name, expected); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); - } -} - -#[test] -fn hosted_notary_can_use_explicit_loopback_or_private_service_relay_connections() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["deployment"]["profile"] = serde_norway::Value::String("hosted_lab".to_string()); - environment["notary_relay"]["base_url"] = - serde_norway::Value::String("http://127.0.0.1:18080".to_string()); - environment["notary_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", - ) - .expect("hosted Notary state binding parses"); - write_yaml(&environment_path, &environment); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("hosted project builds with a private loopback Notary-to-Relay connection"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let relay = read_yaml(&output.join("private/relay-public/config/relay.yaml")); - assert_eq!( - relay["catalog"]["base_url"].as_str(), - Some("https://household-relay.internal.invalid") - ); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - assert_eq!( - notary["evidence"]["relay"]["base_url"].as_str(), - Some("http://127.0.0.1:18080") - ); - assert_eq!( - notary["evidence"]["relay"]["allow_insecure_localhost"].as_bool(), - Some(true) - ); - - let private_root = tempfile::tempdir().expect("private-service temporary directory"); - let private = copy_project("custom-system", private_root.path()); - let private_environment_path = private.join("environments/local.yaml"); - let mut private_environment = read_yaml(&private_environment_path); - private_environment["deployment"]["profile"] = - serde_norway::Value::String("production".to_string()); - private_environment["notary_relay"]["base_url"] = - serde_norway::Value::String("http://registry-relay-consultation:8080".to_string()); - private_environment["notary_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", - ) - .expect("private-service Notary state binding parses"); - private_environment["relay_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/relay-postgres-ca.pem\n", - ) - .expect("private-service Relay state binding parses"); - write_yaml(&private_environment_path, &private_environment); - let private_build = build_registry_project(&ProjectBuildOptions { - project_directory: private.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("production project builds with a signed private service Relay connection"); - let private_output = - resolve_build_output(&private, private_build.output.expect("build output")); - let private_notary = read_yaml(&private_output.join("private/notary/config/notary.yaml")); - assert_eq!( - private_notary["evidence"]["relay"]["allow_insecure_private_network"].as_bool(), - Some(true) - ); - assert_eq!( - private_notary["evidence"]["relay"]["allow_insecure_localhost"].as_bool(), - Some(false) - ); - - let rejected_root = tempfile::tempdir().expect("rejected temporary directory"); - let rejected = copy_project("custom-system", rejected_root.path()); - let rejected_environment_path = rejected.join("environments/local.yaml"); - let mut rejected_environment = read_yaml(&rejected_environment_path); - rejected_environment["notary_relay"]["base_url"] = - serde_norway::Value::String("http://10.42.0.8:8080".to_string()); - write_yaml(&rejected_environment_path, &rejected_environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: rejected, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("private IP cleartext Notary-to-Relay URL must fail authoring"); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); -} - -#[test] -fn issuance_accepts_a_full_verification_method_kid() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - let kid = "did:web:household-notary.invalid#issuer-key-1"; - environment["issuance"]["signing_kid"] = serde_norway::Value::String(kid.to_string()); - write_yaml(&environment_path, &environment); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("a full verification-method kid builds"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - assert_eq!( - notary["evidence"]["signing_keys"]["project-issuer"]["kid"].as_str(), - Some(kid) - ); - - let rejected_root = tempfile::tempdir().expect("rejected temporary directory"); - let rejected = copy_project("custom-system", rejected_root.path()); - let environment_path = rejected.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["issuance"]["signing_kid"] = - serde_norway::Value::String("did:web:issuer.invalid#bad kid".to_string()); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: rejected, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .unwrap_err(); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); -} - -#[test] -fn authored_oid4vci_binding_generates_the_complete_notary_owned_issuer() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["claims"] = serde_norway::from_str("[household-record-exists]") - .expect("single registry-backed credential claim"); - write_yaml(&project_path, &document); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - let baseline_build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("OID4VCI project without registrar clients remains compatible"); - let baseline_output = resolve_build_output( - &project, - baseline_build.output.expect("baseline build output"), - ); - let baseline_approval: serde_json::Value = serde_json::from_slice( - &std::fs::read(baseline_output.join("private/notary/approval/project-state.json")) - .expect("baseline approval state reads"), - ) - .expect("baseline approval state parses"); - merge_environment_yaml( - &project.join("environments/local.yaml"), - "oid4vci:\n registrar_clients: [benefits-service]\n", - ); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("typed OID4VCI authority project builds through the production validator"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - let approval: serde_json::Value = serde_json::from_slice( - &std::fs::read(output.join("private/notary/approval/project-state.json")) - .expect("registrar approval state reads"), - ) - .expect("registrar approval state parses"); - - assert_ne!( - baseline_approval["semantic_digests"]["operator_security"], - approval["semantic_digests"]["operator_security"], - "registrar trust must alter operator-security review semantics" - ); - assert_eq!( - baseline_approval["semantic_digests"]["claim"], approval["semantic_digests"]["claim"], - "registrar trust must not alter claim semantics" - ); - let baseline_trust = baseline_approval["promotion_projection"]["fields"] - .as_array() - .expect("baseline promotion fields are an array") - .iter() - .find(|field| field["kind"].as_str() == Some("trust")) - .expect("baseline trust promotion field exists"); - let registrar_trust = approval["promotion_projection"]["fields"] - .as_array() - .expect("registrar promotion fields are an array") - .iter() - .find(|field| field["kind"].as_str() == Some("trust")) - .expect("registrar trust promotion field exists"); - assert_ne!( - baseline_trust["digest"], registrar_trust["digest"], - "registrar clients must be projected as trust" - ); - assert_eq!( - registrar_trust["authority_members"] - .as_array() - .expect("registrar trust authority members") - .len(), - baseline_trust["authority_members"] - .as_array() - .expect("baseline trust authority members") - .len() - + 1 - ); - - assert_eq!( - notary["instance"]["public_base_url"].as_str(), - Some("https://notary.example.invalid") - ); - assert_eq!( - notary["evidence"]["api_base_url"].as_str(), - Some("https://notary.example.invalid") - ); - assert!(notary["auth"].get("mode").is_none()); - assert_eq!( - notary["auth"]["api_keys"][0]["id"].as_str(), - Some("benefits-service") - ); - assert_eq!( - notary["auth"]["oidc"]["issuer"].as_str(), - Some("https://esignet.example.invalid") - ); - assert_eq!( - notary["auth"]["oidc"]["audiences"], - serde_norway::from_str::( - "[example-wallet-client, https://notary.example.invalid]" - ) - .expect("OIDC audiences parse") - ); - assert_eq!( - notary["auth"]["oidc"]["allowed_clients"], - serde_norway::from_str::("[example-wallet-client, benefits-service]") - .expect("OIDC clients parse") - ); - assert_eq!( - notary["auth"]["oidc"]["allowed_token_types"], - serde_norway::from_str::("[JWT]") - .expect("OIDC access-token types parse") - ); - assert_eq!( - notary["auth"]["access_token_signing"]["signing_key_id"].as_str(), - Some("oid4vci-access-token") - ); - assert_eq!( - notary["evidence"]["signing_keys"]["oid4vci-access-token"]["private_jwk_env"].as_str(), - Some("OID4VCI_ACCESS_TOKEN_JWK") - ); - assert_eq!( - notary["evidence"]["signing_keys"]["project-issuer"]["alg"].as_str(), - Some("EdDSA") - ); - assert_eq!( - notary["evidence"]["signing_keys"]["oid4vci-esignet-client"]["alg"].as_str(), - Some("RS256") - ); - assert_eq!( - notary["state"]["postgresql"]["sensitive_state_key_env"].as_str(), - Some("OID4VCI_SENSITIVE_STATE_KEY") - ); - assert_eq!( - notary["evidence"]["credential_profiles"]["household-eligibility.household-eligibility"] - ["holder_binding"]["proof_of_possession"] - .as_str(), - Some("required") - ); - let registry_claim = notary["evidence"]["claims"] - .as_sequence() - .expect("generated claims") - .iter() - .find(|claim| claim["id"].as_str() == Some("household-record-exists")) - .expect("selected registry-backed claim"); - assert_eq!( - registry_claim["evidence_mode"]["type"].as_str(), - Some("registry_backed") - ); - assert_eq!( - registry_claim["evidence_mode"]["consultations"]["household"]["inputs"] - ["household_reference"] - .as_str(), - Some("request.target.identifiers.household_reference") - ); - assert_eq!( - notary["subject_access"]["allowed_claims"][0].as_str(), - Some("household-record-exists") - ); - assert_eq!( - notary["subject_access"]["allowed_formats"], - serde_norway::from_str::( - "[application/vnd.registry-notary.claim-result+json]" - ) - .expect("canonical evaluation format parses") - ); - assert_eq!( - notary["subject_access"]["allowed_wallet_origins"][0].as_str(), - Some("https://wallet.example.invalid") - ); - assert_eq!( - notary["subject_access"]["citizen_clients"]["allowed_client_ids"], - serde_norway::from_str::("[example-wallet-client]") - .expect("citizen client ids parse") - ); - assert_eq!( - notary["subject_access"]["citizen_clients"]["allowed_audiences"], - serde_norway::from_str::("[example-wallet-client]") - .expect("citizen audiences parse") - ); - assert_eq!( - notary["subject_access"]["allowed_operations"]["evaluate"].as_bool(), - Some(false) - ); - assert_eq!( - notary["oid4vci"]["credential_endpoint"].as_str(), - Some("https://notary.example.invalid/oid4vci/credential") - ); - assert_eq!( - notary["oid4vci"]["pre_authorized_code"]["esignet"]["redirect_uri"].as_str(), - Some("https://notary.example.invalid/oid4vci/offer/callback") - ); - assert_eq!( - notary["oid4vci"]["pre_authorized_code"]["tx_code"]["required"].as_bool(), - Some(true) - ); - assert_eq!( - notary["oid4vci"]["credential_configurations"] - ["household-eligibility.household-eligibility"]["vct"] - .as_str(), - Some("https://notary.example.invalid/credentials/household-eligibility/v1") - ); - assert_eq!( - notary["oid4vci"]["credential_configurations"] - ["household-eligibility.household-eligibility"]["scope"] - .as_str(), - Some("evidence:household:read") - ); -} - -#[test] -fn authored_oid4vci_walt_profile_is_explicit_and_keeps_the_bearer_window_bounded() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["claims"] = - serde_norway::from_str("[household-record-exists]").expect("single registry-backed claim"); - write_yaml(&project_path, &document); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - merge_environment_yaml( - &project.join("environments/local.yaml"), - "issuance:\n algorithm: ES256\noid4vci:\n tx_code:\n required: false\n", - ); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("explicit Walt-compatible binding builds"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - assert_eq!( - notary["evidence"]["signing_keys"]["project-issuer"]["alg"].as_str(), - Some("ES256") - ); - assert_eq!( - notary["oid4vci"]["pre_authorized_code"]["tx_code"]["required"].as_bool(), - Some(false) - ); - assert_eq!( - notary["oid4vci"]["pre_authorized_code"]["pre_authorized_code_ttl_seconds"].as_u64(), - Some(300) - ); -} - -#[test] -fn authored_representative_oid4vci_builds_the_exact_status_enabled_policy() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "representative_reference", - ); - author_representative_oid4vci_binding(&project, "representative_reference"); - merge_environment_yaml( - &project.join("environments/local.yaml"), - "oid4vci:\n representative_issuance:\n max_proof_age_seconds: 180\n", - ); - - let tested = test_registry_project(&ProjectTestOptions { - project_directory: project.clone(), - environment: Some("local".to_string()), - }) - .expect("representative requester fixture passes the offline developer journey"); - assert_eq!(tested.status, "passed"); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("representative OID4VCI golden project builds through the production validator"); - let output = resolve_build_output(&project, build.output.expect("build output")); - let notary = read_yaml(&output.join("private/notary/config/notary.yaml")); - let relationship = ¬ary["subject_access"]["delegation"]["allowed_relationships"][0]; - assert!( - notary["subject_access"]["allowed_claims"] - .as_sequence() - .is_some_and(Vec::is_empty), - "a representative-only root must not become subject-bound authority" - ); - assert_eq!( - relationship["relationship_type"].as_str(), - Some("authorized-representative") - ); - assert_eq!( - relationship["proof_claim"].as_str(), - Some("household-record-exists") - ); - assert_eq!( - relationship["target_id_type"].as_str(), - Some("household_reference") - ); - assert_eq!(relationship["max_proof_age_seconds"].as_u64(), Some(180)); - assert_eq!( - notary["subject_access"]["token_policy"]["max_evaluation_age_seconds"].as_u64(), - Some(300), - "a narrower relationship-proof window must not narrow unrelated evaluation policy" - ); - assert_eq!( - notary["subject_access"]["allowed_operations"]["evaluate"].as_bool(), - Some(true) - ); - assert_eq!( - notary["oid4vci"]["credential_configurations"] - ["household-eligibility.household-eligibility"]["representative_issuance"]["ceremony"] - .as_str(), - Some("digitally_authenticated_representative") - ); - assert_eq!( - notary["oid4vci"]["credential_configurations"] - ["household-eligibility.household-eligibility"]["representative_issuance"] - ["relationship"] - .as_str(), - Some("authorized-representative") - ); - assert_eq!(notary["credential_status"]["enabled"].as_bool(), Some(true)); - assert_eq!( - notary["credential_status"]["base_url"].as_str(), - Some("https://notary.example.invalid") - ); - let root = notary["evidence"]["claims"] - .as_sequence() - .expect("generated claims") - .iter() - .find(|claim| claim["id"].as_str() == Some("source-household-approval-decision")) - .expect("representative credential root"); - assert_eq!( - root["depends_on"], - serde_norway::from_str::("[household-record-exists]") - .expect("claim dependency parses") - ); -} - -#[test] -fn authored_representative_oid4vci_rejects_non_person_requester_fixtures() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "representative_reference", - ); - author_representative_oid4vci_binding(&project, "representative_reference"); - - let mut changed_fixture = false; - for entry in std::fs::read_dir(project.join("integrations/eligibility/fixtures")) - .expect("fixture directory reads") - { - let path = entry.expect("fixture entry").path(); - let mut fixture = read_yaml(&path); - if fixture.get("request").is_none() { - continue; - } - fixture["request"]["requester"]["type"] = - serde_norway::Value::String("Organisation".to_string()); - write_yaml(&path, &fixture); - changed_fixture = true; - break; - } - assert!(changed_fixture, "representative request fixture exists"); - - let error = test_registry_project(&ProjectTestOptions { - project_directory: project, - environment: Some("local".to_string()), - }) - .expect_err("non-person representative requester must be rejected"); - assert!( - format!("{error:#}").contains("request_to_consultation_binding_invalid"), - "{error:#}" - ); -} - -#[test] -fn representative_oid4vci_rejects_registrar_clients_with_a_clear_fix() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "representative_reference", - ); - author_representative_oid4vci_binding(&project, "representative_reference"); - merge_environment_yaml( - &project.join("environments/local.yaml"), - "oid4vci:\n registrar_clients: [benefits-service]\n", - ); - - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("one Registryctl credential binding cannot use both authorities"); - assert!( - format!("{error:#}").contains( - "Representative issuance and registrar-created offers select incompatible authorities" - ), - "{error:#}" - ); - - let report = authoring_diagnostics(&project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "Representative issuance and registrar-created offers select incompatible authorities in Registryctl's single-credential binding." - }) - .unwrap_or_else(|| panic!("missing authority diagnostic: {report:#?}")); - assert_eq!( - diagnostic.remediation, - "Remove registrar_clients, or use a separate environment and Notary deployment for the registrar-created credential." - ); - for pointer in [ - "/oid4vci/registrar_clients", - "/oid4vci/representative_issuance", - ] { - assert!( - diagnostic - .addresses - .iter() - .any(|address| address.file == "environments/local.yaml" - && address.pointer == pointer) - ); - } -} - -#[test] -fn representative_oid4vci_diagnostics_name_the_invalid_authoring_field_and_fix() { - let prepare = || { - let temporary = tempfile::tempdir().expect("representative diagnostic directory"); - let project = copy_project("custom-system", temporary.path()); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - author_representative_oid4vci_binding(&project, "household_reference"); - (temporary, project) - }; - - let (unknown_root, unknown_project) = prepare(); - let environment_path = unknown_project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["oid4vci"]["representative_issuance"]["proof_claim"] = - serde_norway::Value::String("missing-relationship-proof".to_string()); - write_yaml(&environment_path, &environment); - let report = authoring_diagnostics(&unknown_project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "The representative proof claim does not exist in the selected credential service." - }) - .unwrap_or_else(|| panic!("missing proof-claim diagnostic: {report:#?}")); - assert_eq!( - diagnostic.remediation, - "Set proof_claim to a registry-backed claim in the same service as the credential profile." - ); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "environments/local.yaml" - && address.pointer == "/oid4vci/representative_issuance/proof_claim" - })); - drop(unknown_root); - - let (_mapping_root, mapping_project) = prepare(); - let project_path = mapping_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["consultations"]["household"]["input"] - .as_mapping_mut() - .expect("consultation input") - .remove(serde_norway::Value::String( - "representative_reference".to_string(), - )); - write_yaml(&project_path, &project); - let report = authoring_diagnostics(&mapping_project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "The relationship-proof consultation does not bind the authenticated representative." - }) - .unwrap_or_else(|| panic!("missing requester-binding diagnostic: {report:#?}")); - assert!(diagnostic - .remediation - .contains("request.requester.identifiers.")); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "registry-stack.yaml" - && address.pointer == "/services/household-eligibility/consultations/household/input" - })); - - let (_target_root, target_project) = prepare(); - let project_path = target_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["consultations"]["household"]["input"] - .as_mapping_mut() - .expect("consultation input") - .remove(serde_norway::Value::String( - "household_reference".to_string(), - )); - write_yaml(&project_path, &project); - let report = authoring_diagnostics(&target_project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "The relationship-proof consultation does not bind the represented subject." - }) - .unwrap_or_else(|| panic!("missing target-binding diagnostic: {report:#?}")); - assert!(diagnostic - .remediation - .contains("request.target.identifiers.")); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "environments/local.yaml" - && address.pointer == "/oid4vci/representative_issuance/target_id_type" - })); - - let (_extra_input_root, extra_input_project) = prepare(); - let project_path = extra_input_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["consultations"]["household"]["input"] - ["relationship_kind"] = - serde_norway::Value::String("request.target.attributes.relationship_kind".to_string()); - write_yaml(&project_path, &project); - let report = authoring_diagnostics(&extra_input_project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "The relationship-proof consultation requires an input that the target-selection ceremony cannot supply." - }) - .unwrap_or_else(|| panic!("unavailable ceremony-input diagnostic: {report:#?}")); - assert!(diagnostic.remediation.contains("exactly two")); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "registry-stack.yaml" - && address.pointer - == "/services/household-eligibility/consultations/household/input/relationship_kind" - })); - - let (_shared_root, shared_project) = prepare(); - let project_path = shared_project.join("registry-stack.yaml"); - let mut project = read_yaml(&project_path); - project["services"]["household-eligibility"]["credential_profiles"]["ordinary-household"] = - serde_norway::from_str( - r#"format: dc+sd-jwt -type: https://notary.example.invalid/credentials/ordinary-household/v1 -validity: 5m -claims: [source-household-approval-decision] -"#, - ) - .expect("shared credential profile"); - write_yaml(&project_path, &project); - let report = authoring_diagnostics(&shared_project); - let diagnostic = report - .diagnostics - .iter() - .find(|diagnostic| { - diagnostic.cause - == "The representative credential claim is shared by another credential profile." - }) - .unwrap_or_else(|| panic!("shared representative-root diagnostic: {report:#?}")); - assert!(diagnostic.remediation.contains("exclusive")); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "environments/local.yaml" - && address.pointer == "/oid4vci/credential/profile" - })); - assert!(diagnostic.addresses.iter().any(|address| { - address.file == "registry-stack.yaml" - && address.pointer - == "/services/household-eligibility/credential_profiles/ordinary-household/claims" - })); -} - -#[test] -fn authored_oid4vci_binding_rejects_open_or_incoherent_trust_topologies() { - for (name, mutate, expected) in [ - ( - "unknown credential profile", - "oid4vci:\n credential:\n profile: absent-profile\n", - "OID4VCI references an unknown credential profile", - ), - ( - "cross-origin token endpoint", - "oid4vci:\n authorization_server:\n token_url: https://attacker.invalid/token\n", - "OID4VCI authorization server token URL must use its bound origin", - ), - ( - "non-callback redirect", - "oid4vci:\n redirect_uri: https://notary.example.invalid/other-callback\n", - "OID4VCI redirect URI must be the public Notary offer callback", - ), - ( - "reused issuer key", - "oid4vci:\n access_token:\n signing_key: { secret: REGISTRY_NOTARY_ISSUER_JWK }\n", - "OID4VCI issuer, client, and access-token signing keys must be distinct", - ), - ( - "missing PostgreSQL state", - "notary_state: null\n", - "OID4VCI requires a Notary PostgreSQL state binding", - ), - ( - "invalid registrar client", - "oid4vci:\n registrar_clients: ['']\n", - "OID4VCI registrar client id must not be empty", - ), - ( - "duplicate registrar client", - "oid4vci:\n registrar_clients: [registrar-a, registrar-a]\n", - "OID4VCI registrar_clients must not contain duplicates", - ), - ( - "citizen client reused as registrar", - "oid4vci:\n registrar_clients: [example-wallet-client]\n", - "OID4VCI registrar_clients must not contain the citizen client id", - ), - ] { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - document["services"]["household-eligibility"]["credential_profiles"] - ["household-eligibility"]["claims"] = - serde_norway::from_str("[household-record-exists]") - .expect("single registry-backed credential claim"); - write_yaml(&project_path, &document); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - merge_environment_yaml(&project.join("environments/local.yaml"), mutate); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("incoherent OID4VCI binding must fail closed"); - let _ = (name, expected); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); - } - - let temporary = tempfile::tempdir().expect("oversized registrar-client temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - document["services"]["household-eligibility"]["credential_profiles"]["household-eligibility"] - ["claims"] = serde_norway::from_str("[household-record-exists]") - .expect("single registry-backed credential claim"); - write_yaml(&project_path, &document); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - let registrar_clients = (0..65) - .map(|index| serde_norway::Value::String(format!("registrar-{index}"))) - .collect::>(); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["oid4vci"]["registrar_clients"] = serde_norway::Value::Sequence(registrar_clients); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("oversized registrar-client trust must fail closed"); - assert_authoring_diagnostic(&error, "registryctl.authoring.environment.invalid"); - - for (name, scopes, expected) in [ - ( - "no access scope", - "[]", - "caller scopes must contain between one and 16 entries", - ), - ( - "multiple access scopes", - "[evidence:household:read, evidence:household:issue]", - "OID4VCI credential service must declare exactly one access scope", - ), - ] { - let temporary = tempfile::tempdir().expect("access-scope temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - document["services"]["household-eligibility"]["credential_profiles"] - ["household-eligibility"]["claims"] = - serde_norway::from_str("[household-record-exists]") - .expect("single registry-backed claim"); - document["services"]["household-eligibility"]["access"]["scopes"] = - serde_norway::from_str(scopes).expect("access scopes"); - write_yaml(&project_path, &document); - author_oid4vci_binding( - &project, - "household-eligibility", - "household-eligibility", - "household_reference", - ); - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment - .as_mapping_mut() - .expect("environment mapping") - .remove(serde_norway::Value::String("callers".to_string())); - write_yaml(&environment_path, &environment); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project, - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("OID4VCI service without exactly one access scope must fail closed"); - let _ = expected; - assert_authoring_diagnostic( - &error, - if name == "no access scope" { - "registryctl.authoring.project.invalid" - } else { - "registryctl.authoring.environment.invalid" - }, - ); - } + "rejected project must not leave build output" + ); +} + +#[test] +fn records_api_requires_an_explicit_public_relay_client() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = copy_project("relay-only-records", temporary.path()); + let environment_path = project.join("environments/local.yaml"); + let mut environment = read_yaml(&environment_path); + environment["relay"]["allowed_clients"] = serde_norway::Value::Sequence(Vec::new()); + write_yaml(&environment_path, &environment); + + let error = check_registry_project(&ProjectCheckOptions { + project_directory: project, + environment: "local".to_string(), + explain: false, + against: None, + anchor: None, + }) + .expect_err("records API without a public Relay client must fail authoring"); + let report = error + .downcast_ref::() + .expect("error is a typed authoring diagnostics report"); + let diagnostic = report + .diagnostics + .iter() + .find(|diagnostic| diagnostic.field == Some("relay.allowed_clients")) + .expect("empty public Relay allowlist has a focused diagnostic"); + assert_eq!( + diagnostic.cause, + "The public Relay has no admitted OpenID Connect client." + ); + assert!(diagnostic.remediation.contains("Add at least one intended")); } #[test] @@ -8132,8 +5819,8 @@ fn records_provider_change_requires_a_new_generation() { let (baseline, anchor) = create_and_sign_test_lane_baseline( temporary.path(), "records", - ProductAcceptanceLaneV1::Notary, - &output.join("signing-inputs/notary"), + ProductAcceptanceLaneV1::RelayPublic, + &output.join("signing-inputs/relay-public"), &private_key, &public_key, ); @@ -8174,168 +5861,6 @@ fn records_provider_change_requires_a_new_generation() { .any(|change| change.dimension == "operator_security")); } -#[test] -fn every_required_golden_builds_registry_backed_notary_without_transitional_sources() { - let project_names = [ - "custom-system", - "dhis2-tracker", - "dhis2-script", - "fhir-r4-coverage-active", - "opencrvs", - "opencrvs-events-api", - "opencrvs-country-variant", - "openspp-exact", - "snapshot-exact", - "snapshot-with-records", - ]; - for project_name in project_names { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project(project_name, temporary.path()); - let check = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: true, - against: None, - anchor: None, - }) - .unwrap_or_else(|error| panic!("{project_name} check failed: {error:#}")); - assert_eq!(check.status, "valid", "{project_name}"); - assert_eq!(check.baseline, "initial_without_baseline", "{project_name}"); - assert!(check.explanation.is_some(), "{project_name}"); - - let build = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .unwrap_or_else(|error| panic!("{project_name} build failed: {error:#}")); - let output = resolve_build_output(&project, build.output.expect("build output")); - assert!(output.join("reviewable/review.json").is_file()); - assert!(output - .join("private/relay-public/approval/project-state.json") - .is_file()); - assert!(output - .join("private/relay-public/config/relay.yaml") - .is_file()); - assert!(output - .join("private/relay-consultation/approval/project-state.json") - .is_file()); - assert!(output - .join("private/relay-consultation/config/relay.yaml") - .is_file()); - let notary_config_path = output.join("private/notary/config/notary.yaml"); - let notary_config = std::fs::read_to_string(¬ary_config_path) - .unwrap_or_else(|error| panic!("{}: {error}", notary_config_path.display())); - for forbidden in [ - "transitional_direct", - "source_connections", - "source_bindings", - ] { - assert!( - !notary_config.contains(forbidden), - "{project_name} generated Notary config must not contain {forbidden}" - ); - } - for product in ["relay-public", "relay-consultation", "notary"] { - assert!(output - .join(format!("private/{product}/descriptors/operations.json")) - .is_file()); - assert!(output - .join(format!( - "private/{product}/descriptors/secret-consumers.json" - )) - .is_file()); - } - let review_bytes = - std::fs::read(output.join("reviewable/review.json")).expect("human review reads"); - let review: serde_json::Value = - serde_json::from_slice(&review_bytes).expect("human review parses"); - assert_public_review_has_only_contract_hashes(&review); - for product in ["relay-public", "relay-consultation", "notary"] { - assert_eq!( - std::fs::read(output.join(format!("private/{product}/approval/review.json"))) - .expect("signed review input reads"), - review_bytes, - "{project_name} {product} approval carries the exact human review" - ); - } - assert_eq!( - std::fs::read(output.join("private/relay-public/approval/project-state.json")) - .expect("Relay approval state reads"), - std::fs::read(output.join("private/relay-consultation/approval/project-state.json")) - .expect("consultation Relay approval state reads"), - "{project_name} Relay instances carry identical approval state" - ); - assert_eq!( - std::fs::read(output.join("private/relay-public/approval/project-state.json")) - .expect("Relay approval state reads"), - std::fs::read(output.join("private/notary/approval/project-state.json")) - .expect("Notary approval state reads"), - "{project_name} products carry identical approval state" - ); - let relay_descriptor: serde_json::Value = serde_json::from_slice( - &std::fs::read(output.join("private/relay-public/descriptors/secret-consumers.json")) - .expect("Relay secret descriptor reads"), - ) - .expect("Relay secret descriptor parses"); - assert!(relay_descriptor["consumers"] - .as_array() - .is_some_and(|consumers| { - consumers - .iter() - .any(|consumer| consumer["locator"] == "REGISTRY_RELAY_AUDIT_HASH_SECRET") - && consumers.iter().all(|consumer| { - !matches!( - consumer["locator"].as_str(), - Some( - "REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1" - | "REGISTRY_RELAY_CONSULTATION_DATABASE_URL" - ) - ) - }) - })); - let consultation_descriptor: serde_json::Value = serde_json::from_slice( - &std::fs::read( - output.join("private/relay-consultation/descriptors/secret-consumers.json"), - ) - .expect("consultation Relay secret descriptor reads"), - ) - .expect("consultation Relay secret descriptor parses"); - assert!(consultation_descriptor["consumers"] - .as_array() - .is_some_and(|consumers| { - consumers - .iter() - .any(|consumer| consumer["locator"] == "REGISTRY_RELAY_AUDIT_PSEUDONYM_EPOCH_1") - && consumers.iter().any(|consumer| { - consumer["locator"] == "REGISTRY_RELAY_CONSULTATION_DATABASE_URL" - }) - })); - let notary_descriptor: serde_json::Value = serde_json::from_slice( - &std::fs::read(output.join("private/notary/descriptors/secret-consumers.json")) - .expect("Notary secret descriptor reads"), - ) - .expect("Notary secret descriptor parses"); - assert!(notary_descriptor["consumers"] - .as_array() - .is_some_and(|consumers| { - consumers.iter().any(|consumer| { - consumer["locator"] - .as_str() - .is_some_and(|locator| locator.ends_with("_TOKEN_HASH")) - }) - })); - assert!(notary_descriptor["consumers"] - .as_array() - .is_some_and(|consumers| { - consumers - .iter() - .all(|consumer| consumer["locator"] != "REGISTRY_NOTARY_POSTGRES_URL") - })); - } -} - #[test] fn generated_product_inputs_sign_and_verify_without_secret_values() { const SECRET_SENTINEL: &str = "project-authoring-secret-sentinel-8f9d7537"; @@ -8385,8 +5910,8 @@ fn generated_product_inputs_sign_and_verify_without_secret_values() { lanes.sort(); assert_eq!( lanes, - ["notary", "relay-consultation", "relay-public"], - "governed build publishes exactly the three approved lanes" + ["relay-consultation", "relay-public"], + "governed build publishes exactly the two approved lanes" ); for (label, product, lane, expected_instance) in [ @@ -8402,12 +5927,6 @@ fn generated_product_inputs_sign_and_verify_without_secret_values() { ProductAcceptanceLaneV1::RelayConsultation, "household-relay-consultation", ), - ( - "notary", - "registry-notary", - ProductAcceptanceLaneV1::Notary, - "household-notary", - ), ] { let input = signing_inputs.join(label); let marker_bytes = @@ -8453,7 +5972,7 @@ fn generated_product_inputs_sign_and_verify_without_secret_values() { assert_eq!(verified.signer_kids.len(), 1); } - let first_markers = ["relay-public", "relay-consultation", "notary"].map(|lane| { + let first_markers = ["relay-public", "relay-consultation"].map(|lane| { std::fs::read( output .join("signing-inputs") @@ -8471,7 +5990,7 @@ fn generated_product_inputs_sign_and_verify_without_secret_values() { .expect("repeated project build succeeds"); let repeated_output = resolve_build_output(&project, repeated.output.expect("repeated build output")); - let repeated_markers = ["relay-public", "relay-consultation", "notary"].map(|lane| { + let repeated_markers = ["relay-public", "relay-consultation"].map(|lane| { std::fs::read( repeated_output .join("signing-inputs") @@ -8532,431 +6051,18 @@ fn authored_request_literals_cannot_smuggle_secret_material() { serde_norway::Value::String(SECRET_SENTINEL.to_string()); write_yaml(&integration_path, &integration); let error = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: None, - anchor: None, - }) - .expect_err("credential-bearing header must fail closed"); - let diagnostic = format!("{error:#}"); - assert_authoring_diagnostic(&error, "registryctl.authoring.integration.invalid"); - assert!(!diagnostic.contains(SECRET_SENTINEL)); - assert!(!project.join(".registry-stack/build").exists()); - } -} - -#[test] -fn verified_signed_baseline_classifies_semantic_review_dimensions_independently() { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = copy_project("custom-system", temporary.path()); - let integration_file = project.join("integrations/eligibility/integration.yaml"); - let integration = std::fs::read_to_string(&integration_file) - .expect("integration reads") - .replace( - "unverified: [fixture-contract-v2]", - "unverified: [fixture-contract-v2, fixture-contract-v3]", - ); - std::fs::write(&integration_file, integration).expect("second reviewed version writes"); - let initial = build_registry_project(&ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }) - .expect("initial project build passes"); - let output = resolve_build_output(&project, initial.output.expect("initial build output")); - let private_key = temporary.path().join("baseline-private.jwk"); - let public_key = temporary.path().join("baseline-public.jwk"); - write_test_signing_key_pair(&private_key, &public_key); - let (baseline, anchor) = create_and_sign_test_lane_baseline( - temporary.path(), - "notary-baseline", - ProductAcceptanceLaneV1::Notary, - &output.join("signing-inputs/notary"), - &private_key, - &public_key, - ); - let (relay_baseline, relay_anchor) = create_and_sign_test_lane_baseline( - temporary.path(), - "relay-baseline", - ProductAcceptanceLaneV1::RelayPublic, - &output.join("signing-inputs/relay-public"), - &private_key, - &public_key, - ); - let (relay_consultation_baseline, relay_consultation_anchor) = - create_and_sign_test_lane_baseline( - temporary.path(), - "relay-consultation-baseline", - ProductAcceptanceLaneV1::RelayConsultation, - &output.join("signing-inputs/relay-consultation"), - &private_key, - &public_key, - ); - - for relative in ["approval/review.json", "approval/project-state.json"] { - let tampered = temporary - .path() - .join(format!("tampered-{}", relative.replace(['/', '.'], "-"))); - copy_tree(&baseline, &tampered); - let path = tampered.join(relative); - let mut bytes = std::fs::read(&path).expect("signed approval payload reads"); - bytes.push(b' '); - std::fs::write(&path, bytes).expect("signed approval payload tampers"); - let error = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: Some(tampered), - anchor: Some(anchor.clone()), - }) - .expect_err("post-signature approval payload tamper must fail"); - assert!(format!("{error:#}").contains("failed to verify config bundle")); - } - - let initial_review: serde_json::Value = serde_json::from_slice( - &std::fs::read(output.join("reviewable/review.json")).expect("initial review reads"), - ) - .expect("initial review parses"); - let initial_state: serde_json::Value = serde_json::from_slice( - &std::fs::read(output.join("private/notary/approval/project-state.json")) - .expect("initial approval state reads"), - ) - .expect("initial approval state parses"); - assert_eq!(initial_review["baseline"], "initial_without_baseline"); - assert!(initial_review["disclosure_profiles"].is_object()); - assert_public_review_has_only_contract_hashes(&initial_review); - assert!(initial_state["semantic_digests"].is_object()); - assert!(initial_state["generated_closure_digests"]["notary"].is_string()); - assert!(initial_state["report_digest"].is_string()); - - let reviewed_build = build_registry_project_with_baselines_and_context( - &ProjectBuildOptions { - project_directory: project.clone(), - environment: "local".to_string(), - against: None, - anchor: None, - }, - &ProjectBuildBaselineSetOptions { - relay_against: Some(relay_baseline), - relay_anchor: Some(relay_anchor), - relay_consultation_against: Some(relay_consultation_baseline), - relay_consultation_anchor: Some(relay_consultation_anchor), - notary_against: Some(baseline.clone()), - notary_anchor: Some(anchor.clone()), - }, - &project_execution_context(), - ) - .expect("verified-baseline build passes"); - let reviewed_output = resolve_build_output( - &project, - reviewed_build.output.expect("reviewed build output"), - ); - let reviewed_record: serde_json::Value = serde_json::from_slice( - &std::fs::read(reviewed_output.join("reviewable/review.json")) - .expect("reviewed record reads"), - ) - .expect("reviewed record parses"); - let reviewed_state: serde_json::Value = serde_json::from_slice( - &std::fs::read(reviewed_output.join("private/notary/approval/project-state.json")) - .expect("reviewed approval state reads"), - ) - .expect("reviewed approval state parses"); - assert_eq!(reviewed_record["baseline"], "verified_signed_bundle"); - assert_public_review_has_only_contract_hashes(&reviewed_record); - assert_eq!( - reviewed_state["baseline"]["verified_manifests"]["notary"]["schema"], - "registry.platform.config_bundle.v1" - ); - assert_eq!( - reviewed_state["baseline"]["verified_manifests"]["relay"]["schema"], - "registry.platform.config_bundle.v1" - ); - assert_eq!( - reviewed_state["baseline"]["verified_manifests"]["relay_consultation"]["schema"], - "registry.platform.config_bundle.v1" - ); - let signed_paths = reviewed_state["baseline"]["verified_manifests"]["notary"]["files"] - .as_array() - .expect("verified manifest files") - .iter() - .filter_map(|file| file["path"].as_str()) - .collect::>(); - assert!(signed_paths.contains("approval/review.json")); - assert!(signed_paths.contains("approval/project-state.json")); - - let unchanged = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: Some(baseline.clone()), - anchor: Some(anchor.clone()), - }) - .expect("unchanged project checks against signed baseline"); - assert_eq!(unchanged.baseline, "verified_signed_bundle"); - assert!(unchanged.semantic_changes.is_empty()); - - let mismatched_input = temporary.path().join("mismatched-baseline-input"); - copy_tree(&output.join("signing-inputs/notary"), &mismatched_input); - let mismatched_config = mismatched_input.join("config/notary.yaml"); - let mut mismatched_bytes = std::fs::read(&mismatched_config).expect("Notary config reads"); - mismatched_bytes.push(b'\n'); - std::fs::write(&mismatched_config, mismatched_bytes).expect("Notary config changes"); - let mismatched_bundle = sign_test_lane_bundle( - temporary.path(), - "mismatched-baseline", - ProductAcceptanceLaneV1::Notary, - &mismatched_input, - &private_key, - &anchor, - ); - let mismatch = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: Some(mismatched_bundle), - anchor: Some(anchor.clone()), - }) - .expect_err("signed product closure must match the signed review"); - assert!(format!("{mismatch:#}").contains("lane closure does not match")); - - let report_mismatch_input = temporary.path().join("report-mismatch-input"); - copy_tree( - &output.join("signing-inputs/notary"), - &report_mismatch_input, - ); - let report_mismatch_path = report_mismatch_input.join("approval/review.json"); - let mut mismatched_report: serde_json::Value = serde_json::from_slice( - &std::fs::read(&report_mismatch_path).expect("approval review reads"), - ) - .expect("approval review parses"); - mismatched_report["semantic_changes"] = serde_json::Value::Array(Vec::new()); - std::fs::write( - &report_mismatch_path, - serde_json::to_vec(&mismatched_report).expect("mismatched review serializes"), - ) - .expect("mismatched approval review writes"); - let report_mismatch_bundle = sign_test_lane_bundle( - temporary.path(), - "report-mismatch", - ProductAcceptanceLaneV1::Notary, - &report_mismatch_input, - &private_key, - &anchor, - ); - let report_mismatch = check_registry_project(&ProjectCheckOptions { - project_directory: project.clone(), - environment: "local".to_string(), - explain: false, - against: Some(report_mismatch_bundle), - anchor: Some(anchor.clone()), - }) - .expect_err("signed report/state binding mismatch must fail"); - assert!(format!("{report_mismatch:#}").contains("does not bind the signed review")); - - let scenarios = temporary.path().join("scenarios"); - std::fs::create_dir(&scenarios).expect("scenario root creates"); - let claim_project = scenarios.join("claim"); - let source_version_project = scenarios.join("source-version"); - let operator_project = scenarios.join("operator"); - let notary_cel_project = scenarios.join("notary-cel"); - let policy_project = scenarios.join("policy"); - let consultation_project = scenarios.join("consultation"); - for destination in [ - &claim_project, - &source_version_project, - &operator_project, - ¬ary_cel_project, - &policy_project, - &consultation_project, - ] { - copy_tree(&project, destination); - } - - let project_file = claim_project.join("registry-stack.yaml"); - let authored = std::fs::read_to_string(&project_file) - .expect("project reads") - .replace( - "household.matched && household.approved != null ? household.approved : null", - "household.matched && household.approved != null ? household.approved == true : null", - ); - std::fs::write(&project_file, authored).expect("claim-only edit writes"); - let changed = check_registry_project(&ProjectCheckOptions { - project_directory: claim_project.clone(), - environment: "local".to_string(), - explain: false, - against: Some(baseline.clone()), - anchor: Some(anchor.clone()), - }) - .expect("claim-only edit checks against signed baseline"); - assert_eq!( - changed - .semantic_changes - .iter() - .map(|change| change.dimension) - .collect::>(), - BTreeSet::from(["claim"]) - ); - - let compiler_input = temporary.path().join("compiler-baseline-input"); - copy_tree(&output.join("signing-inputs/notary"), &compiler_input); - let compiler_state_path = compiler_input.join("approval/project-state.json"); - let mut compiler_state: serde_json::Value = serde_json::from_slice( - &std::fs::read(&compiler_state_path).expect("compiler baseline approval state reads"), - ) - .expect("compiler baseline approval state parses"); - compiler_state["compiler_version"] = serde_json::Value::String("0.0.0".to_string()); - std::fs::write( - &compiler_state_path, - serde_json::to_vec(&compiler_state).expect("compiler baseline state serializes"), - ) - .expect("compiler baseline approval state writes"); - let compiler_baseline = sign_test_lane_bundle( - temporary.path(), - "compiler-baseline", - ProductAcceptanceLaneV1::Notary, - &compiler_input, - &private_key, - &anchor, - ); - let compiler_mismatch = check_registry_project(&ProjectCheckOptions { - project_directory: claim_project, - environment: "local".to_string(), - explain: false, - against: Some(compiler_baseline), - anchor: Some(anchor.clone()), - }) - .expect_err("signed report and approval-state mismatch must fail"); - assert!(format!("{compiler_mismatch:#}").contains("disagree on compiler version")); - - replace_in_file( - &source_version_project.join("integrations/eligibility/integration.yaml"), - "unverified: [fixture-contract-v2, fixture-contract-v3]", - "unverified: [fixture-contract-v2, fixture-contract-v3, fixture-contract-v4]", - ); - assert_change_dimensions( - source_version_project, - &baseline, - &anchor, - BTreeSet::from(["integration"]), - ); - - replace_in_file( - &operator_project.join("environments/local.yaml"), - "https://household-authority.invalid", - "https://household-authority-two.invalid", - ); - assert_change_dimensions( - operator_project, - &baseline, - &anchor, - BTreeSet::from(["operator_security"]), - ); - - let notary_cel_environment = notary_cel_project.join("environments/local.yaml"); - let mut environment = read_yaml(¬ary_cel_environment); - environment["notary_cel"] = serde_norway::from_str("worker_memory_bytes: 1073741824\n") - .expect("Notary CEL binding parses"); - write_yaml(¬ary_cel_environment, &environment); - assert_change_dimensions( - notary_cel_project, - &baseline, - &anchor, - BTreeSet::from(["operator_security"]), - ); - - replace_in_file( - &policy_project.join("registry-stack.yaml"), - "legal_basis: public-service-delivery", - "legal_basis: statutory-benefit-screening", - ); - assert_change_dimensions( - policy_project, - &baseline, - &anchor, - BTreeSet::from(["service_policy"]), - ); - - replace_in_file( - &consultation_project.join("registry-stack.yaml"), - "request.target.identifiers.household_reference", - "request.target.identifiers.household_case_number", - ); - replace_in_file( - &consultation_project.join("integrations/eligibility/fixtures/source-approved.yaml"), - "scheme: household_reference", - "scheme: household_case_number", - ); - assert_change_dimensions( - consultation_project, - &baseline, - &anchor, - BTreeSet::from(["integration"]), - ); -} - -fn assert_change_dimensions( - project: PathBuf, - baseline: &Path, - anchor: &Path, - expected: BTreeSet<&str>, -) { - let report = check_registry_project(&ProjectCheckOptions { - project_directory: project, - environment: "local".to_string(), - explain: false, - against: Some(baseline.to_path_buf()), - anchor: Some(anchor.to_path_buf()), - }) - .expect("semantic review scenario checks against signed baseline"); - assert_eq!( - report - .semantic_changes - .iter() - .map(|change| change.dimension) - .collect::>(), - expected - ); -} - -fn assert_public_review_has_only_contract_hashes(review: &serde_json::Value) { - fn visit(value: &serde_json::Value, contract_hashes: &mut usize) { - match value { - serde_json::Value::Object(object) => { - for (key, value) in object { - let lower = key.to_ascii_lowercase(); - if lower.contains("hash") || lower.contains("digest") { - assert_eq!( - key, "contract_hash", - "human review exposes lower-level field {key}" - ); - let contract_hash = - value.as_str().expect("generated contract_hash is a string"); - assert!(contract_hash.starts_with("sha256:")); - *contract_hashes += 1; - } - visit(value, contract_hashes); - } - } - serde_json::Value::Array(values) => { - for value in values { - visit(value, contract_hashes); - } - } - serde_json::Value::Null - | serde_json::Value::Bool(_) - | serde_json::Value::Number(_) - | serde_json::Value::String(_) => {} - } + project_directory: project.clone(), + environment: "local".to_string(), + explain: false, + against: None, + anchor: None, + }) + .expect_err("credential-bearing header must fail closed"); + let diagnostic = format!("{error:#}"); + assert_authoring_diagnostic(&error, "registryctl.authoring.integration.invalid"); + assert!(!diagnostic.contains(SECRET_SENTINEL)); + assert!(!project.join(".registry-stack/build").exists()); } - - let mut contract_hashes = 0; - visit(review, &mut contract_hashes); - assert!( - contract_hashes > 0, - "registry-backed human review exposes its generated contract_hash" - ); } fn replace_in_file(path: &Path, from: &str, to: &str) { @@ -9144,250 +6250,6 @@ fn extend_exact_selector(project: &Path, golden_name: &str, size: usize) { assert!(integration["id"].as_str().is_some(), "{alias}"); } -fn duplicate_project_integration(project: &Path, source_alias: &str, target_alias: &str) { - copy_tree( - &project.join("integrations").join(source_alias), - &project.join("integrations").join(target_alias), - ); - let integration_path = project - .join("integrations") - .join(target_alias) - .join("integration.yaml"); - let mut integration = read_yaml(&integration_path); - integration["id"] = serde_norway::Value::String(format!("{target_alias}-integration")); - write_yaml(&integration_path, &integration); - - let project_path = project.join("registry-stack.yaml"); - let mut project_document = read_yaml(&project_path); - project_document["integrations"] - .as_mapping_mut() - .expect("project integrations mapping") - .insert( - serde_norway::Value::String(target_alias.to_string()), - serde_norway::from_str(&format!( - "file: integrations/{target_alias}/integration.yaml\n" - )) - .expect("project integration reference"), - ); - let (service_name, consultation_name, duplicated_consultation) = project_document["services"] - .as_mapping() - .and_then(|services| { - services.iter().find_map(|(service_name, service)| { - service["consultations"] - .as_mapping() - .and_then(|consultations| { - consultations - .iter() - .find_map(|(consultation_name, consultation)| { - (consultation["integration"].as_str() == Some(source_alias)).then( - || { - ( - service_name.clone(), - consultation_name.clone(), - consultation.clone(), - ) - }, - ) - }) - }) - }) - }) - .expect("source integration consultation"); - let mut duplicated_consultation = duplicated_consultation; - duplicated_consultation["integration"] = serde_norway::Value::String(target_alias.to_string()); - let service = project_document["services"] - .as_mapping_mut() - .and_then(|services| services.get_mut(&service_name)) - .expect("project service"); - service["consultations"] - .as_mapping_mut() - .expect("project consultations mapping") - .insert( - serde_norway::Value::String(target_alias.to_string()), - duplicated_consultation, - ); - let consultation_name = consultation_name - .as_str() - .expect("consultation name is a string"); - let reference = format!("{consultation_name}."); - let duplicated_claims = service["claims"] - .as_mapping() - .map(|claims| { - claims - .iter() - .filter_map(|(name, claim)| { - let source_claim = name.as_str()?; - if !yaml_contains_string(claim, &reference) { - return None; - } - let mut duplicated_claim = claim.clone(); - replace_yaml_strings( - &mut duplicated_claim, - &reference, - &format!("{target_alias}."), - ); - Some(( - source_claim.to_string(), - format!("{target_alias}-{source_claim}"), - duplicated_claim, - )) - }) - .collect::>() - }) - .filter(|claims| !claims.is_empty()) - .expect("source consultation claims"); - for (_, target_claim, duplicated_claim) in &duplicated_claims { - service["claims"] - .as_mapping_mut() - .expect("project claims mapping") - .insert( - serde_norway::Value::String(target_claim.clone()), - duplicated_claim.clone(), - ); - } - for credential in service["credential_profiles"] - .as_mapping_mut() - .expect("project credential profiles") - .values_mut() - { - credential["claims"] - .as_sequence_mut() - .expect("credential profile claims") - .extend( - duplicated_claims - .iter() - .map(|(_, target_claim, _)| serde_norway::Value::String(target_claim.clone())), - ); - } - write_yaml(&project_path, &project_document); - let claim_translations = duplicated_claims - .iter() - .map(|(source, target, _)| (source.clone(), target.clone())) - .collect::>(); - rewrite_duplicated_fixture_claims( - &project - .join("integrations") - .join(target_alias) - .join("fixtures"), - &claim_translations, - ); - - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - let mut source_binding = environment["integrations"][source_alias].clone(); - namespace_secret_references(&mut source_binding, target_alias); - environment["integrations"] - .as_mapping_mut() - .expect("environment integrations mapping") - .insert( - serde_norway::Value::String(target_alias.to_string()), - source_binding, - ); - write_yaml(&environment_path, &environment); -} - -fn rewrite_duplicated_fixture_claims(fixtures: &Path, translations: &[(String, String)]) { - let translate = |claim: &str| { - translations - .iter() - .find_map(|(source, target)| (source == claim).then_some(target.as_str())) - }; - for entry in std::fs::read_dir(fixtures).expect("duplicated fixtures directory reads") { - let path = entry.expect("duplicated fixture entry reads").path(); - if path.extension().and_then(std::ffi::OsStr::to_str) != Some("yaml") { - continue; - } - let mut fixture = read_yaml(&path); - if let Some(claims) = fixture["expect"]["claims"].as_mapping_mut() { - let rewritten = claims - .iter() - .map(|(claim, expected)| { - let claim = claim.as_str().expect("fixture claim is a string"); - ( - serde_norway::Value::String(translate(claim).unwrap_or(claim).to_string()), - expected.clone(), - ) - }) - .collect::(); - *claims = rewritten; - } - if let Some(request_claims) = fixture - .get_mut("request") - .and_then(|request| request.get_mut("claims")) - .and_then(serde_norway::Value::as_sequence_mut) - { - for claim in request_claims { - let source_claim = claim.as_str().expect("request claim is a string"); - if let Some(target_claim) = translate(source_claim) { - *claim = serde_norway::Value::String(target_claim.to_string()); - } - } - } - write_yaml(&path, &fixture); - } -} - -fn yaml_contains_string(value: &serde_norway::Value, needle: &str) -> bool { - match value { - serde_norway::Value::String(value) => value.contains(needle), - serde_norway::Value::Mapping(mapping) => mapping.iter().any(|(key, value)| { - yaml_contains_string(key, needle) || yaml_contains_string(value, needle) - }), - serde_norway::Value::Sequence(sequence) => sequence - .iter() - .any(|value| yaml_contains_string(value, needle)), - _ => false, - } -} - -fn replace_yaml_strings(value: &mut serde_norway::Value, from: &str, to: &str) { - match value { - serde_norway::Value::String(value) => *value = value.replace(from, to), - serde_norway::Value::Mapping(mapping) => { - for value in mapping.values_mut() { - replace_yaml_strings(value, from, to); - } - } - serde_norway::Value::Sequence(sequence) => { - for value in sequence { - replace_yaml_strings(value, from, to); - } - } - _ => {} - } -} - -fn namespace_secret_references(value: &mut serde_norway::Value, namespace: &str) { - let namespace = namespace.replace('-', "_").to_ascii_uppercase(); - namespace_secret_references_with_suffix(value, &namespace); -} - -fn namespace_secret_references_with_suffix(value: &mut serde_norway::Value, namespace: &str) { - match value { - serde_norway::Value::Mapping(mapping) => { - if let Some(secret) = mapping - .get_mut(serde_norway::Value::String("secret".to_string())) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - { - mapping.insert( - serde_norway::Value::String("secret".to_string()), - serde_norway::Value::String(format!("{secret}_{namespace}")), - ); - return; - } - for nested in mapping.values_mut() { - namespace_secret_references_with_suffix(nested, namespace); - } - } - serde_norway::Value::Sequence(sequence) => { - for nested in sequence { - namespace_secret_references_with_suffix(nested, namespace); - } - } - _ => {} - } -} - fn read_yaml(path: &Path) -> serde_norway::Value { serde_norway::from_slice(&std::fs::read(path).expect("YAML reads")).expect("YAML parses") } @@ -9416,38 +6278,8 @@ fn reverse_yaml_mapping(path: &Path, keys: &[&str]) { write_yaml(path, &document); } -fn remove_custom_cel_claim(project: &Path) { - let project_path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&project_path); - let service = &mut document["services"]["household-eligibility"]; - service["claims"] - .as_mapping_mut() - .expect("custom claims") - .remove(serde_norway::Value::String( - "source-household-approval-decision".to_string(), - )); - service["credential_profiles"]["household-eligibility"]["claims"] - .as_sequence_mut() - .expect("custom credential claims") - .retain(|claim| claim.as_str() != Some("source-household-approval-decision")); - write_yaml(&project_path, &document); - for fixture in std::fs::read_dir(project.join("integrations/eligibility/fixtures")) - .expect("custom fixture directory") - { - let path = fixture.expect("fixture entry").path(); - let mut document = read_yaml(&path); - let claims = document - .get_mut("expect") - .and_then(serde_norway::Value::as_mapping_mut) - .and_then(|expect| expect.get_mut("claims")) - .and_then(serde_norway::Value::as_mapping_mut); - if let Some(claims) = claims { - claims.remove(serde_norway::Value::String( - "source-household-approval-decision".to_string(), - )); - } - write_yaml(&path, &document); - } +fn prepare_custom_selector_project(project: &Path) { + let _ = project; } fn make_opencrvs_composite_dci(project: &Path) { @@ -9496,15 +6328,6 @@ place: request.target.identifiers.place "#, ) .expect("composite DCI consultation mapping"); - let service = &mut project_document["services"]["birth-verification"]; - service["claims"] - .as_mapping_mut() - .expect("OpenCRVS claims") - .remove(serde_norway::Value::String("age-band".to_string())); - service["credential_profiles"]["birth-summary"]["claims"] - .as_sequence_mut() - .expect("OpenCRVS credential claims") - .retain(|claim| claim.as_str() != Some("age-band")); write_yaml(&project_path, &project_document); let fixture_directory = project.join("integrations/birth-record/fixtures"); @@ -9551,14 +6374,6 @@ place: request.target.identifiers.place "#, ) .expect("composite DCI request predicates"); - if let Some(claims) = fixture - .get_mut("expect") - .and_then(serde_norway::Value::as_mapping_mut) - .and_then(|expect| expect.get_mut("claims")) - .and_then(serde_norway::Value::as_mapping_mut) - { - claims.remove(serde_norway::Value::String("age-band".to_string())); - } write_yaml(&path, &fixture); } } @@ -9627,138 +6442,6 @@ fn sign_test_lane_bundle( output.join("bundle") } -fn author_oid4vci_binding(project: &Path, service: &str, profile: &str, id_type: &str) { - let project_path = project.join("registry-stack.yaml"); - let mut authored_project = read_yaml(&project_path); - authored_project["services"][service]["credential_profiles"][profile]["type"] = - serde_norway::Value::String(format!( - "https://notary.example.invalid/credentials/{profile}/v1" - )); - write_yaml(&project_path, &authored_project); - - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["notary_state"] = serde_norway::from_str( - "postgresql:\n root_certificate_path: /run/secrets/notary-postgres-ca.pem\n", - ) - .expect("Notary PostgreSQL state binding"); - environment["oid4vci"] = serde_norway::from_str(&format!( - r#"public_base_url: https://notary.example.invalid -credential: - service: {service} - profile: {profile} -authorization_server: - issuer: https://esignet.example.invalid - jwks_url: https://esignet.example.invalid/.well-known/jwks.json - userinfo_url: https://esignet.example.invalid/userinfo - authorize_url: https://esignet-ui.example.invalid/authorize - token_url: https://esignet.example.invalid/token -client: - id: example-wallet-client - signing_key: {{ secret: OID4VCI_ESIGNET_CLIENT_JWK }} - signing_kid: example-wallet-client-key-1 -access_token: - signing_key: {{ secret: OID4VCI_ACCESS_TOKEN_JWK }} - signing_kid: did:web:notary.example.invalid#access-token-key-1 -sensitive_state_key: {{ secret: OID4VCI_SENSITIVE_STATE_KEY }} -subject: - token_claim: individual_id - id_type: {id_type} -redirect_uri: https://notary.example.invalid/oid4vci/offer/callback -allowed_wallet_origins: [https://wallet.example.invalid] -"# - )) - .expect("OID4VCI binding"); - write_yaml(&environment_path, &environment); -} - -fn author_representative_oid4vci_binding(project: &Path, requester_id_type: &str) { - let project_path = project.join("registry-stack.yaml"); - let mut authored_project = read_yaml(&project_path); - let service = &mut authored_project["services"]["household-eligibility"]; - service["consultations"]["household"]["input"]["representative_reference"] = - serde_norway::Value::String(format!("request.requester.identifiers.{requester_id_type}")); - service["credential_profiles"]["household-eligibility"]["claims"] = - serde_norway::from_str("[source-household-approval-decision]") - .expect("single representative credential root"); - write_yaml(&project_path, &authored_project); - - let integration_path = project.join("integrations/eligibility/integration.yaml"); - let mut integration = read_yaml(&integration_path); - integration["input"]["representative_reference"] = serde_norway::from_str( - r#"role: selector -type: string -maxLength: 18 -pattern: "^HH-[A-Z0-9]{8}$" -"#, - ) - .expect("representative selector input"); - integration["capability"]["http"]["request"]["query"]["representative"] = - serde_norway::from_str("{ input: representative_reference }") - .expect("representative query binding"); - write_yaml(&integration_path, &integration); - - for entry in std::fs::read_dir(project.join("integrations/eligibility/fixtures")) - .expect("fixture directory reads") - { - let path = entry.expect("fixture entry").path(); - let mut fixture = read_yaml(&path); - fixture["input"]["representative_reference"] = - serde_norway::Value::String("HH-ZZ99YY88".to_string()); - fixture["interactions"][0]["expect"]["query"]["representative"] = - serde_norway::Value::String("HH-ZZ99YY88".to_string()); - if fixture.get("request").is_some() { - fixture["request"]["requester"] = serde_norway::from_str(&format!( - r#"type: Person -identifiers: - - scheme: {requester_id_type} - value: HH-ZZ99YY88 -"# - )) - .expect("fixture requester"); - fixture["request"]["claims"] = - serde_norway::from_str("[source-household-approval-decision]") - .expect("fixture representative claim"); - } - write_yaml(&path, &fixture); - } - - let environment_path = project.join("environments/local.yaml"); - let mut environment = read_yaml(&environment_path); - environment["oid4vci"]["representative_issuance"] = serde_norway::from_str( - r#"relationship: authorized-representative -proof_claim: household-record-exists -target_id_type: household_reference -"#, - ) - .expect("representative issuance binding"); - write_yaml(&environment_path, &environment); -} - -fn merge_environment_yaml(path: &Path, patch: &str) { - fn merge(target: &mut serde_norway::Value, patch: serde_norway::Value) { - match (target, patch) { - (serde_norway::Value::Mapping(target), serde_norway::Value::Mapping(patch)) => { - for (key, value) in patch { - if let Some(target) = target.get_mut(&key) { - merge(target, value); - } else { - target.insert(key, value); - } - } - } - (target, patch) => *target = patch, - } - } - - let mut environment = read_yaml(path); - merge( - &mut environment, - serde_norway::from_str(patch).expect("environment patch"), - ); - write_yaml(path, &environment); -} - fn rename_custom_input(project: &Path, name: &str) { let mut paths = vec![ project.join("registry-stack.yaml"), @@ -9782,6 +6465,72 @@ fn rename_custom_input(project: &Path, name: &str) { } } +fn duplicate_project_integration(project: &Path, source_alias: &str, target_alias: &str) { + copy_tree( + &project.join("integrations").join(source_alias), + &project.join("integrations").join(target_alias), + ); + let integration_path = project + .join("integrations") + .join(target_alias) + .join("integration.yaml"); + let mut integration = read_yaml(&integration_path); + integration["id"] = serde_norway::Value::String(format!("{target_alias}-integration")); + write_yaml(&integration_path, &integration); + + let project_path = project.join("registry-stack.yaml"); + let mut project_document = read_yaml(&project_path); + project_document["integrations"] + .as_mapping_mut() + .expect("project integrations mapping") + .insert( + serde_norway::Value::String(target_alias.to_string()), + serde_norway::from_str(&format!( + "file: integrations/{target_alias}/integration.yaml\n" + )) + .expect("project integration reference"), + ); + let (service_name, duplicated_consultation) = project_document["services"] + .as_mapping() + .and_then(|services| { + services.iter().find_map(|(service_name, service)| { + service["consultations"] + .as_mapping() + .and_then(|consultations| { + consultations.values().find_map(|consultation| { + (consultation["integration"].as_str() == Some(source_alias)) + .then(|| (service_name.clone(), consultation.clone())) + }) + }) + }) + }) + .expect("source integration consultation"); + let mut duplicated_consultation = duplicated_consultation; + duplicated_consultation["integration"] = serde_norway::Value::String(target_alias.to_string()); + project_document["services"] + .as_mapping_mut() + .and_then(|services| services.get_mut(&service_name)) + .and_then(|service| service["consultations"].as_mapping_mut()) + .expect("project consultations mapping") + .insert( + serde_norway::Value::String(target_alias.to_string()), + duplicated_consultation, + ); + write_yaml(&project_path, &project_document); + + let environment_path = project.join("environments/local.yaml"); + let mut environment = read_yaml(&environment_path); + let duplicated_binding = environment["integrations"][source_alias].clone(); + environment["integrations"] + .as_mapping_mut() + .expect("environment integrations mapping") + .insert( + serde_norway::Value::String(target_alias.to_string()), + duplicated_binding, + ); + write_yaml(&environment_path, &environment); +} + fn copy_tree(source: &Path, destination: &Path) { std::fs::create_dir(destination).expect("copy destination creates"); for entry in std::fs::read_dir(source).expect("copy source reads") { @@ -9823,6 +6572,7 @@ fn test_sha256_uri(bytes: &[u8]) -> String { format!("sha256:{}", hex::encode(Sha256::digest(bytes))) } +#[cfg(target_os = "linux")] fn closure_digest(files: &[(PathBuf, Vec)]) -> String { use std::fmt::Write as _; diff --git a/crates/registryctl/tests/project_authoring_schema_parity.rs b/crates/registryctl/tests/project_authoring_schema_parity.rs index 4b72b1a53..3690083ea 100644 --- a/crates/registryctl/tests/project_authoring_schema_parity.rs +++ b/crates/registryctl/tests/project_authoring_schema_parity.rs @@ -247,10 +247,10 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { assert_eq!( index.coverage_by_schema(), [ - (SchemaKind::Project, 220), - (SchemaKind::Environment, 213), + (SchemaKind::Project, 191), + (SchemaKind::Environment, 126), (SchemaKind::Integration, 171), - (SchemaKind::Fixture, 63), + (SchemaKind::Fixture, 39), (SchemaKind::Entity, 35), ] .into_iter() @@ -261,11 +261,11 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { index.coverage_by_path_kind(), [ (FieldPathKind::Root, 5), - (FieldPathKind::Property, 492), - (FieldPathKind::MapKey, 26), - (FieldPathKind::MapValue, 33), - (FieldPathKind::ArrayItem, 34), - (FieldPathKind::Branch, 112), + (FieldPathKind::Property, 393), + (FieldPathKind::MapKey, 22), + (FieldPathKind::MapValue, 28), + (FieldPathKind::ArrayItem, 26), + (FieldPathKind::Branch, 88), ] .into_iter() .collect(), @@ -275,11 +275,11 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { index.coverage_by_sensitivity(), [ (Sensitivity::Public, 6), - (Sensitivity::Internal, 445), - (Sensitivity::Sensitive, 69), - (Sensitivity::SecretReference, 14), - (Sensitivity::RedactedFixture, 51), - (Sensitivity::Structural, 117), + (Sensitivity::Internal, 382), + (Sensitivity::Sensitive, 43), + (Sensitivity::SecretReference, 9), + (Sensitivity::RedactedFixture, 29), + (Sensitivity::Structural, 93), ] .into_iter() .collect(), @@ -287,12 +287,12 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { ); assert_eq!( index.by_path().len(), - 702, + 562, "the field-knowledge gate covers every published schema path" ); assert_eq!( index.references().len(), - 279, + 225, "every published local reference remains resolved in the deterministic reference index" ); assert_eq!( @@ -336,12 +336,12 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { })); assert_eq!( index.coverage_by_sensitivity()[&Sensitivity::SecretReference], - 14, + 9, "secret-reference values and names remain explicitly never-reportable" ); assert_eq!( index.coverage_by_sensitivity()[&Sensitivity::RedactedFixture], - 51, + 29, "fixture request, response, input, body, and expected values remain redacted" ); walk_schema( @@ -364,12 +364,9 @@ fn published_field_knowledge_is_complete_typed_reachable_and_editor_exact() { for pointer in [ "/properties/relay/properties/origin", "/properties/relay/properties/jwks_url", - "/$defs/oid4vci/properties/authorization_server/properties/token_url", - "/$defs/oid4vci/properties/client/properties/id", "/$defs/privateCidrs/items", - "/$defs/oid4vci/properties/access_token/properties/signing_kid", "/$defs/credential/oneOf/3/properties/generation", - "/properties/notary_state/properties/postgresql/properties/root_certificate_path", + "/properties/relay_state/properties/postgresql/properties/root_certificate_path", ] { assert!( matches!( @@ -470,6 +467,51 @@ fn schemas_compile_and_all_catalog_documents_pass_schema_and_runtime() { ], "the parity gate must enumerate the exact published five-schema catalog" ); + let project_schema = compile_schema("project.schema.json").0; + assert_eq!( + project_schema.pointer("/$defs/consultationService/properties/kind/const"), + Some(&Value::String("consultation_api".to_string())), + "Relay consultations retain one explicit service kind" + ); + assert!( + project_schema.pointer("/$defs/evidenceService").is_none(), + "standalone Evidence is not a registryctl project-authoring service" + ); + let environment_schema = compile_schema("environment.schema.json").0; + assert_eq!( + environment_schema["properties"] + .as_object() + .expect("environment properties are closed") + .keys() + .map(String::as_str) + .collect::>(), + [ + "deployment", + "development", + "entities", + "integrations", + "relay", + "relay_state", + "version", + ] + .into_iter() + .collect(), + "the environment schema exposes only Relay, source, entity, and development bindings" + ); + assert_eq!( + environment_schema.pointer("/properties/deployment/required"), + Some(&serde_json::json!(["profile", "relay"])), + "the Relay-only deployment contract requires its Relay service binding" + ); + for schema in &coverage.schemas { + let document = std::fs::read_to_string(schema_root().join(&schema.file)) + .expect("published schema text reads"); + assert!( + !document.to_ascii_lowercase().contains("notary"), + "{} must not publish retired Notary vocabulary", + schema.kind + ); + } let compiled = coverage .schemas .iter() @@ -759,27 +801,27 @@ fn exact_published_structural_contract_inventory_is_release_gated() { ( "project", PublishedStructuralInventory { - nodes: 253, - local_refs: 123, - union_nodes: 9, - union_branches: 19, + nodes: 220, + local_refs: 112, + union_nodes: 7, + union_branches: 15, conditionals: 0, - objects: 51, - closed_objects: 36, - typed_maps: 15, + objects: 44, + closed_objects: 31, + typed_maps: 13, open_maps: 0, - arrays: 11, - scalar_types: 30, + arrays: 8, + scalar_types: 24, nullable_nodes: 0, - integer_lower_bounds: 9, - integer_upper_bounds: 9, - string_length_bounds: 10, - string_patterns: 12, - array_size_bounds: 9, - unique_arrays: 8, - object_size_bounds: 17, - property_name_constraints: 14, - enums: 13, + integer_lower_bounds: 8, + integer_upper_bounds: 8, + string_length_bounds: 8, + string_patterns: 11, + array_size_bounds: 6, + unique_arrays: 5, + object_size_bounds: 15, + property_name_constraints: 12, + enums: 10, consts: 8, defaults: 0, deprecations: 0, @@ -788,29 +830,29 @@ fn exact_published_structural_contract_inventory_is_release_gated() { ( "environment", PublishedStructuralInventory { - nodes: 239, - local_refs: 92, - union_nodes: 6, - union_branches: 17, - conditionals: 7, - objects: 42, - closed_objects: 38, - typed_maps: 4, + nodes: 148, + local_refs: 54, + union_nodes: 4, + union_branches: 12, + conditionals: 3, + objects: 28, + closed_objects: 25, + typed_maps: 3, open_maps: 0, - arrays: 6, - scalar_types: 46, + arrays: 3, + scalar_types: 35, nullable_nodes: 0, - integer_lower_bounds: 19, - integer_upper_bounds: 19, - string_length_bounds: 17, - string_patterns: 15, - array_size_bounds: 6, - unique_arrays: 6, - object_size_bounds: 5, - property_name_constraints: 4, - enums: 3, + integer_lower_bounds: 15, + integer_upper_bounds: 15, + string_length_bounds: 12, + string_patterns: 12, + array_size_bounds: 3, + unique_arrays: 3, + object_size_bounds: 3, + property_name_constraints: 3, + enums: 2, consts: 6, - defaults: 4, + defaults: 0, deprecations: 0, }, ), @@ -846,26 +888,26 @@ fn exact_published_structural_contract_inventory_is_release_gated() { ( "fixture", PublishedStructuralInventory { - nodes: 72, - local_refs: 11, - union_nodes: 4, - union_branches: 8, + nodes: 44, + local_refs: 6, + union_nodes: 3, + union_branches: 6, conditionals: 0, - objects: 21, - closed_objects: 11, - typed_maps: 7, - open_maps: 3, - arrays: 4, - scalar_types: 36, - nullable_nodes: 4, + objects: 14, + closed_objects: 7, + typed_maps: 5, + open_maps: 2, + arrays: 2, + scalar_types: 21, + nullable_nodes: 3, integer_lower_bounds: 1, integer_upper_bounds: 1, - string_length_bounds: 9, - string_patterns: 7, - array_size_bounds: 4, + string_length_bounds: 6, + string_patterns: 6, + array_size_bounds: 2, unique_arrays: 0, - object_size_bounds: 10, - property_name_constraints: 4, + object_size_bounds: 7, + property_name_constraints: 3, enums: 2, consts: 1, defaults: 0, diff --git a/crates/registryctl/tests/project_build_baseline_set.rs b/crates/registryctl/tests/project_build_baseline_set.rs index a4c8fb34c..8243e4e82 100644 --- a/crates/registryctl/tests/project_build_baseline_set.rs +++ b/crates/registryctl/tests/project_build_baseline_set.rs @@ -62,7 +62,6 @@ fn sign_product_baseline( let lane = match product_directory { "relay-public" => ProductAcceptanceLaneV1::RelayPublic, "relay-consultation" => ProductAcceptanceLaneV1::RelayConsultation, - "notary" => ProductAcceptanceLaneV1::Notary, _ => panic!("unexpected product signing-input directory"), }; assert_eq!( @@ -70,7 +69,7 @@ fn sign_product_baseline( match lane { ProductAcceptanceLaneV1::RelayPublic | ProductAcceptanceLaneV1::RelayConsultation => "registry-relay", - ProductAcceptanceLaneV1::Notary => "registry-notary", + _ => unreachable!("test signing inputs use Relay lanes only"), } ); let private_key = temporary.join(format!("{suffix}-private.jwk")); @@ -176,21 +175,11 @@ fn sign_common_set( "local", &format!("{suffix}-relay-consultation"), ); - let notary = sign_product_baseline( - output, - temporary, - "registry-notary", - "notary", - "local", - &format!("{suffix}-notary"), - ); ProjectBuildBaselineSetOptions { relay_against: Some(relay.bundle), relay_anchor: Some(relay.anchor), relay_consultation_against: Some(relay_consultation.bundle), relay_consultation_anchor: Some(relay_consultation.anchor), - notary_against: Some(notary.bundle), - notary_anchor: Some(notary.anchor), } } @@ -234,7 +223,7 @@ fn assert_value_free_rejection( } #[test] -fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_product_labelled() { +fn initial_and_two_lane_relay_baseline_builds_are_distinct_and_lineage_is_lane_labelled() { let temporary = tempfile::tempdir().expect("temporary directory creates"); let project = initialize_project(temporary.path()); let output = initial_build(&project); @@ -244,10 +233,7 @@ fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_produ let relay_consultation_initial = std::fs::read(output.join("private/relay-consultation/approval/project-state.json")) .expect("initial consultation Relay approval state reads"); - let notary_initial = std::fs::read(output.join("private/notary/approval/project-state.json")) - .expect("initial Notary approval state reads"); assert_eq!(relay_initial, relay_consultation_initial); - assert_eq!(relay_initial, notary_initial); let initial_state: serde_json::Value = serde_json::from_slice(&relay_initial).expect("initial approval state parses"); assert_eq!( @@ -265,28 +251,12 @@ fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_produ .relay_consultation_against .as_deref() .expect("consultation Relay baseline exists"); - let notary_baseline = baselines - .notary_against - .as_deref() - .expect("Notary baseline exists"); - assert_eq!( - std::fs::read(relay_baseline.join("approval/project-state.json")) - .expect("signed Relay approval state reads"), - std::fs::read(notary_baseline.join("approval/project-state.json")) - .expect("signed Notary approval state reads") - ); assert_eq!( std::fs::read(relay_baseline.join("approval/project-state.json")) .expect("signed Relay approval state reads"), std::fs::read(relay_consultation_baseline.join("approval/project-state.json")) .expect("signed consultation Relay approval state reads") ); - assert_eq!( - std::fs::read(relay_baseline.join("approval/review.json")) - .expect("signed Relay review reads"), - std::fs::read(notary_baseline.join("approval/review.json")) - .expect("signed Notary review reads") - ); assert_eq!( std::fs::read(relay_baseline.join("approval/review.json")) .expect("signed Relay review reads"), @@ -308,20 +278,13 @@ fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_produ let relay_consultation_next = std::fs::read(next_output.join("private/relay-consultation/approval/project-state.json")) .expect("next consultation Relay approval state reads"); - let notary_next = std::fs::read(next_output.join("private/notary/approval/project-state.json")) - .expect("next Notary approval state reads"); assert_eq!(relay_next, relay_consultation_next); - assert_eq!(relay_next, notary_next); let state: serde_json::Value = serde_json::from_slice(&relay_next).expect("next approval state parses"); assert_eq!( state["baseline"]["verified_manifests"]["relay"]["acceptance_identity"]["product"], "registry-relay" ); - assert_eq!( - state["baseline"]["verified_manifests"]["notary"]["acceptance_identity"]["product"], - "registry-notary" - ); assert_eq!( state["baseline"]["verified_manifests"]["relay_consultation"]["acceptance_identity"] ["product"], @@ -334,9 +297,6 @@ fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_produ state["baseline"]["verified_manifests"]["relay_consultation"]["bundle_id"] .as_str() .expect("consultation Relay closure digest"), - state["baseline"]["verified_manifests"]["notary"]["bundle_id"] - .as_str() - .expect("Notary closure digest"), ]; assert!(bundle_ids .iter() @@ -346,8 +306,8 @@ fn initial_and_common_approved_baseline_builds_are_distinct_and_lineage_is_produ .into_iter() .collect::>() .len(), - 3, - "each product lane binds its distinct complete signing-input closure" + 2, + "each Relay lane binds its distinct complete signing-input closure" ); } @@ -374,12 +334,10 @@ fn partial_swapped_tampered_and_wrong_environment_sets_fail_before_publication() ); let swapped = ProjectBuildBaselineSetOptions { - relay_against: baselines.notary_against.clone(), - relay_anchor: baselines.notary_anchor.clone(), - relay_consultation_against: baselines.relay_consultation_against.clone(), - relay_consultation_anchor: baselines.relay_consultation_anchor.clone(), - notary_against: baselines.relay_against.clone(), - notary_anchor: baselines.relay_anchor.clone(), + relay_against: baselines.relay_consultation_against.clone(), + relay_anchor: baselines.relay_consultation_anchor.clone(), + relay_consultation_against: baselines.relay_against.clone(), + relay_consultation_anchor: baselines.relay_anchor.clone(), }; assert_value_free_rejection(&project, &swapped, temporary.path()); @@ -457,14 +415,12 @@ fn partial_swapped_tampered_and_wrong_environment_sets_fail_before_publication() relay_anchor: Some(wrong_environment.anchor), relay_consultation_against: baselines.relay_consultation_against, relay_consultation_anchor: baselines.relay_consultation_anchor, - notary_against: baselines.notary_against, - notary_anchor: baselines.notary_anchor, }; assert_value_free_rejection(&project, &wrong_environment, temporary.path()); } #[test] -fn independently_valid_but_divergent_product_approval_states_are_rejected() { +fn independently_valid_but_divergent_relay_lane_approval_states_are_rejected() { let temporary = tempfile::tempdir().expect("temporary directory creates"); let project = initialize_project(temporary.path()); let first_output = initial_build(&project); @@ -476,15 +432,6 @@ fn independently_valid_but_divergent_product_approval_states_are_rejected() { "local", "divergent-relay", ); - let relay_consultation = sign_product_baseline( - &first_output, - temporary.path(), - "registry-relay", - "relay-consultation", - "local", - "divergent-relay-consultation", - ); - let environment_path = project.join("environments/local.yaml"); let original_environment = std::fs::read_to_string(&environment_path).expect("environment reads"); @@ -495,13 +442,13 @@ fn independently_valid_but_divergent_product_approval_states_are_rejected() { assert_ne!(changed_environment, original_environment); std::fs::write(&environment_path, changed_environment).expect("environment changes"); let second_output = initial_build(&project); - let notary = sign_product_baseline( + let relay_consultation = sign_product_baseline( &second_output, temporary.path(), - "registry-notary", - "notary", + "registry-relay", + "relay-consultation", "local", - "divergent-notary", + "divergent-relay-consultation", ); std::fs::write(&environment_path, original_environment).expect("environment restores"); @@ -510,8 +457,6 @@ fn independently_valid_but_divergent_product_approval_states_are_rejected() { relay_anchor: Some(relay.anchor), relay_consultation_against: Some(relay_consultation.bundle), relay_consultation_anchor: Some(relay_consultation.anchor), - notary_against: Some(notary.bundle), - notary_anchor: Some(notary.anchor), }; assert_value_free_rejection(&project, &divergent, temporary.path()); } @@ -531,7 +476,7 @@ fn pre_1_approval_state_baselines_are_rejected_at_ingress() { state["schema"] = serde_json::json!("registry.project.approval-state.v3"); let mut state_bytes = serde_json::to_vec_pretty(&state).expect("pre-1.0 state serializes"); state_bytes.push(b'\n'); - for product_directory in ["relay-public", "relay-consultation", "notary"] { + for product_directory in ["relay-public", "relay-consultation"] { std::fs::write( pre_1_output .join("signing-inputs") @@ -558,21 +503,11 @@ fn pre_1_approval_state_baselines_are_rejected_at_ingress() { "local", "pre-1-relay-consultation", ); - let notary = sign_product_baseline( - &pre_1_output, - temporary.path(), - "registry-notary", - "notary", - "local", - "pre-1-notary", - ); let pre_1 = ProjectBuildBaselineSetOptions { relay_against: Some(relay.bundle), relay_anchor: Some(relay.anchor), relay_consultation_against: Some(relay_consultation.bundle), relay_consultation_anchor: Some(relay_consultation.anchor), - notary_against: Some(notary.bundle), - notary_anchor: Some(notary.anchor), }; assert_value_free_rejection(&project, &pre_1, temporary.path()); diff --git a/crates/registryctl/tests/project_capability_inventory.rs b/crates/registryctl/tests/project_capability_inventory.rs index 096bff3d7..59f3427fc 100644 --- a/crates/registryctl/tests/project_capability_inventory.rs +++ b/crates/registryctl/tests/project_capability_inventory.rs @@ -70,18 +70,10 @@ fn compiled_input() -> CapabilityInventoryInput { SupportComponent::RegistryRelayProduct, SupportEvidence::LinkedCrate, ), - ( - SupportComponent::RegistryNotaryProduct, - SupportEvidence::LinkedCrate, - ), ( SupportComponent::RegistryRelayValidator, SupportEvidence::LinkedProductValidator, ), - ( - SupportComponent::RegistryNotaryValidator, - SupportEvidence::LinkedProductValidator, - ), ( SupportComponent::ProjectAuthoringSchema, SupportEvidence::EmbeddedSchema, @@ -90,10 +82,6 @@ fn compiled_input() -> CapabilityInventoryInput { SupportComponent::RegistryRelayConfigSchema, SupportEvidence::EmbeddedSchema, ), - ( - SupportComponent::RegistryNotaryConfigSchema, - SupportEvidence::EmbeddedSchema, - ), ( SupportComponent::RegistryctlDistribution, SupportEvidence::ReleaseMetadata, @@ -110,18 +98,13 @@ fn compiled_input() -> CapabilityInventoryInput { SupportEvidence::ExplicitlyMissing, ) .expect("missing worker records"); - for image in [ - SupportComponent::RegistryRelayImage, - SupportComponent::RegistryNotaryImage, - ] { - input - .record_support( - image, - SupportState::NotEvaluated, - SupportEvidence::NoEvidence, - ) - .expect("image remains not evaluated"); - } + input + .record_support( + SupportComponent::RegistryRelayImage, + SupportState::NotEvaluated, + SupportEvidence::NoEvidence, + ) + .expect("Relay image remains not evaluated"); input } @@ -132,7 +115,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityId::SourceScript, CapabilityId::SourceSnapshot, CapabilityId::RegistryRelayProduct, - CapabilityId::RegistryNotaryProduct, ] { input .record_project_declaration(capability) @@ -142,7 +124,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityId::SourceHttp, CapabilityId::SourceScript, CapabilityId::RegistryRelayProduct, - CapabilityId::RegistryNotaryProduct, ] { input .record_environment_enablement(capability) @@ -154,7 +135,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityUsageCounts { services: 1, consultations: 1, - claims: 0, }, ), ( @@ -162,7 +142,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityUsageCounts { services: 0, consultations: 1, - claims: 1, }, ), ( @@ -170,7 +149,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityUsageCounts { services: 0, consultations: 1, - claims: 1, }, ), ( @@ -178,7 +156,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityUsageCounts { services: 0, consultations: 1, - claims: 1, }, ), ( @@ -186,15 +163,6 @@ fn canonical_input() -> CapabilityInventoryInput { CapabilityUsageCounts { services: 1, consultations: 2, - claims: 0, - }, - ), - ( - CapabilityId::RegistryNotaryProduct, - CapabilityUsageCounts { - services: 0, - consultations: 0, - claims: 1, }, ), ] { @@ -251,7 +219,7 @@ fn pure_builder_is_deterministic_and_matches_the_canonical_fixture() { #[test] fn schema_and_typed_ingress_require_every_closed_inventory_row_exactly_once() { - for (collection, duplicate_index) in [("capabilities", 11), ("support", 13)] { + for (collection, duplicate_index) in [("capabilities", 8), ("support", 9)] { let mut duplicate = parse(FIXTURE); let first = duplicate[collection][0].clone(); duplicate[collection][duplicate_index] = first; @@ -328,7 +296,6 @@ fn installed_declared_enabled_used_missing_and_inactive_states_stay_distinct() { CapabilityUsageCounts { services: 0, consultations: 1, - claims: 0, }, ) .expect("script usage records"); @@ -366,7 +333,6 @@ fn builder_fails_closed_on_inconsistent_or_duplicate_evidence() { CapabilityUsageCounts { services: 0, consultations: 1, - claims: 0, }, ) .expect("usage records"); @@ -400,7 +366,53 @@ fn builder_fails_closed_on_inconsistent_or_duplicate_evidence() { } #[test] -fn image_and_runtime_activation_claims_cannot_be_inferred_from_static_input() { +fn unsupported_source_usage_is_reported_with_missing_support() { + let mut input = CapabilityInventoryInput::new(); + input + .record_installed_capability( + CapabilityId::SourceSnapshot, + InstalledCapabilityState::Unsupported, + InstalledCapabilityEvidence::ExplicitlyUnsupported, + ) + .expect("unsupported source evidence records"); + input + .record_project_declaration(CapabilityId::SourceSnapshot) + .expect("unsupported source declaration records"); + input + .record_environment_enablement(CapabilityId::SourceSnapshot) + .expect("unsupported source enablement records"); + input + .record_usage( + CapabilityId::SourceSnapshot, + CapabilityUsageCounts { + services: 1, + consultations: 0, + }, + ) + .expect("unsupported source usage records"); + + let report = build_capability_inventory(input).expect("inventory builds"); + let source = report + .capabilities + .iter() + .find(|record| record.capability == CapabilityId::SourceSnapshot) + .expect("snapshot source capability is inventoried"); + assert_eq!( + source.installed_release, + InstalledCapabilityState::Unsupported + ); + assert_eq!( + source.installed_evidence, + InstalledCapabilityEvidence::ExplicitlyUnsupported + ); + assert_eq!( + source.disposition, + CapabilityDisposition::UsedWithMissingSupport + ); +} + +#[test] +fn image_and_runtime_activation_cannot_be_inferred_from_static_input() { let mut input = CapabilityInventoryInput::new(); assert_eq!( input.record_support( @@ -424,14 +436,15 @@ fn image_and_runtime_activation_claims_cannot_be_inferred_from_static_input() { report.runtime_activation, RuntimeActivationEvaluation::NotEvaluated ); - for image in report + let images = report .support .iter() .filter(|assessment| assessment.kind == SupportKind::Image) - { - assert_eq!(image.state, SupportState::NotEvaluated); - assert_eq!(image.evidence, SupportEvidence::NoEvidence); - } + .collect::>(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].component, SupportComponent::RegistryRelayImage); + assert_eq!(images[0].state, SupportState::NotEvaluated); + assert_eq!(images[0].evidence, SupportEvidence::NoEvidence); } #[test] @@ -443,7 +456,6 @@ fn usage_bound_is_total_and_overflow_safe() { CapabilityUsageCounts { services: MAX_CAPABILITY_USAGE_COUNT, consultations: 0, - claims: 0, }, ) .expect("exact maximum records"); @@ -455,7 +467,6 @@ fn usage_bound_is_total_and_overflow_safe() { CapabilityUsageCounts { services: MAX_CAPABILITY_USAGE_COUNT, consultations: 1, - claims: 0, }, ), Err(CapabilityInventoryError::UsageCountOutOfRange) @@ -468,7 +479,6 @@ fn usage_bound_is_total_and_overflow_safe() { CapabilityUsageCounts { services: u32::MAX, consultations: u32::MAX, - claims: u32::MAX, }, ), Err(CapabilityInventoryError::UsageCountOutOfRange) @@ -486,9 +496,8 @@ fn usage_bound_is_total_and_overflow_safe() { let mut aggregate_too_large = parse(FIXTURE); aggregate_too_large["capabilities"][0]["used_by"] = json!({ - "services": 500_000, + "services": 500_001, "consultations": 500_000, - "claims": 1, "total": MAX_CAPABILITY_USAGE_COUNT }); assert_schema_valid(&aggregate_too_large); @@ -528,7 +537,6 @@ fn relay_product_usage_requires_relay_product_support() { CapabilityUsageCounts { services: 1, consultations: 0, - claims: 0, }, ) .expect("Relay product usage records"); @@ -568,7 +576,7 @@ fn schema_and_dto_reject_country_value_carriers() { ("/capabilities/1", "path", "/COUNTRY/PATH/SENTINEL"), ("/support/0", "secret_name", "COUNTRY_SECRET_NAME_SENTINEL"), ( - "/support/12", + "/support/9", "runtime_observation", "COUNTRY_RUNTIME_SENTINEL", ), @@ -596,14 +604,14 @@ fn schema_rejects_image_availability_runtime_activation_and_open_enums() { assert_schema_invalid(&runtime); let mut image = parse(FIXTURE); - image["support"][12]["state"] = json!("available"); - image["support"][12]["evidence"] = json!("release_metadata"); + image["support"][9]["state"] = json!("available"); + image["support"][9]["evidence"] = json!("release_metadata"); assert_schema_invalid(&image); let mut disguised_image = parse(FIXTURE); - disguised_image["support"][12]["kind"] = json!("worker"); - disguised_image["support"][12]["state"] = json!("available"); - disguised_image["support"][12]["evidence"] = json!("release_metadata"); + disguised_image["support"][9]["kind"] = json!("worker"); + disguised_image["support"][9]["state"] = json!("available"); + disguised_image["support"][9]["evidence"] = json!("release_metadata"); assert_schema_invalid(&disguised_image); assert!( serde_json::from_value::(disguised_image).is_err(), @@ -611,8 +619,8 @@ fn schema_rejects_image_availability_runtime_activation_and_open_enums() { ); let mut claimed_missing_image = parse(FIXTURE); - claimed_missing_image["support"][12]["state"] = json!("missing"); - claimed_missing_image["support"][12]["evidence"] = json!("explicitly_missing"); + claimed_missing_image["support"][9]["state"] = json!("missing"); + claimed_missing_image["support"][9]["evidence"] = json!("explicitly_missing"); claimed_missing_image["missing_support"] .as_array_mut() .expect("missing support is an array") diff --git a/crates/registryctl/tests/project_diagnostic_reference.rs b/crates/registryctl/tests/project_diagnostic_reference.rs index d93d9bb41..ef9e531ef 100644 --- a/crates/registryctl/tests/project_diagnostic_reference.rs +++ b/crates/registryctl/tests/project_diagnostic_reference.rs @@ -2,7 +2,6 @@ use std::collections::BTreeMap; -use registry_notary_server::NOTARY_ACTIVATION_CODE_DEFINITIONS; use registry_platform_ops::BUNDLE_VERIFICATION_CODE_DEFINITIONS; use registry_relay::consultation::consultation_service_activation_definitions; use registry_relay::process_startup::PROCESS_STARTUP_CODE_DEFINITIONS; @@ -47,8 +46,8 @@ fn published_diagnostic_references_are_closed_complete_and_unreleased() { validate_operator_error_reference(&operator).expect("operator reference is exact"); assert_eq!(authoring.entries.len(), 17); - assert_eq!(fixture.entries.len(), 16); - assert_eq!(operator.entries.len(), 60); + assert_eq!(fixture.entries.len(), 15); + assert_eq!(operator.entries.len(), 42); assert!( operator.omissions.is_empty(), "all operator catalogs now expose complete product-owned metadata" @@ -65,7 +64,6 @@ fn published_diagnostic_references_are_closed_complete_and_unreleased() { family_counts, BTreeMap::from([ ("bundle_verification", 4), - ("notary_activation", 18), ("operator_preflight", 11), ("relay_activation", 9), ("relay_process_startup", 18), @@ -157,27 +155,6 @@ fn operator_projection_is_exact_to_all_product_owned_metadata() { ) ); } - for definition in &NOTARY_ACTIVATION_CODE_DEFINITIONS { - let entry = entry_for( - &operator.entries, - ErrorReferenceFamily::NotaryActivation, - ErrorReferenceProduct::RegistryNotary, - definition.code.as_str(), - ); - assert_eq!(entry.phase, definition.phase); - assert_eq!(entry.safe_meaning, definition.meaning); - assert_eq!(entry.rule, definition.rule); - assert_eq!(entry.safe_remediation, definition.remediation); - assert_eq!(entry.evidence_scope, definition.evidence_scope); - assert_eq!(entry.evidence_limitation, definition.evidence_limitation); - assert_eq!( - entry.docs_anchor, - format!( - "/reference/diagnostics/operator/#registry_notary--{}", - definition.docs_slug - ) - ); - } } #[test] @@ -229,8 +206,8 @@ fn strict_validation_rejects_missing_duplicate_reordered_stale_and_drifted_data( let mut operator = operator_error_reference(); operator.omissions.push(OperatorErrorOmission { - family: OperatorErrorOmissionFamily::NotaryActivation, - product: ErrorReferenceProduct::RegistryNotary, + family: OperatorErrorOmissionFamily::RelayActivation, + product: ErrorReferenceProduct::RegistryRelay, reason: OperatorErrorOmissionReason::NoCompletePublicCodeCatalog, evidence: "stale omission".to_string(), required_action: "remove it".to_string(), @@ -281,7 +258,7 @@ fn operator_schema_accepts_exact_catalog_and_rejects_open_values() { .compile(&schema) .unwrap(); let canonical = serde_json::to_value(operator_error_reference()).unwrap(); - assert_eq!(canonical["entries"].as_array().unwrap().len(), 60); + assert_eq!(canonical["entries"].as_array().unwrap().len(), 42); assert!(validator.is_valid(&canonical)); let mut open_code = canonical.clone(); @@ -293,6 +270,16 @@ fn operator_schema_accepts_exact_catalog_and_rejects_open_values() { .unwrap()["code"] = Value::String("relay.startup.unregistered_open_value".to_string()); assert!(!validator.is_valid(&open_code)); + for (field, stale_value) in [ + ("family", "notary_activation"), + ("owner", "registry_notary"), + ("product", "registry_notary"), + ] { + let mut stale = canonical.clone(); + stale["entries"][0][field] = Value::String(stale_value.to_string()); + assert!(!validator.is_valid(&stale)); + } + let mut open_field = canonical; open_field["entries"][0] .as_object_mut() @@ -301,6 +288,22 @@ fn operator_schema_accepts_exact_catalog_and_rejects_open_values() { assert!(!validator.is_valid(&open_field)); } +#[test] +fn fixture_schema_rejects_retired_authorization_diagnostic() { + let schema: Value = serde_json::from_str(FIXTURE_SCHEMA).unwrap(); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .unwrap(); + let canonical = serde_json::to_value(fixture_error_reference()).unwrap(); + assert_eq!(canonical["entries"].as_array().unwrap().len(), 15); + assert!(validator.is_valid(&canonical)); + + let mut stale = canonical; + stale["entries"][0]["code"] = Value::String("authorization.denied".to_string()); + assert!(!validator.is_valid(&stale)); +} + fn entry_for<'a>( entries: &'a [ErrorReferenceEntry], family: ErrorReferenceFamily, diff --git a/crates/registryctl/tests/project_diagnostic_reference_cli.rs b/crates/registryctl/tests/project_diagnostic_reference_cli.rs index 7a94073f4..de24e863a 100644 --- a/crates/registryctl/tests/project_diagnostic_reference_cli.rs +++ b/crates/registryctl/tests/project_diagnostic_reference_cli.rs @@ -82,7 +82,6 @@ fn run(directory: &std::path::Path, catalog: &str, format: &str) -> Output { .env_clear() .env("REGISTRY_CONFIG", SENTINEL) .env("REGISTRY_RELAY_CONFIG", SENTINEL) - .env("REGISTRY_NOTARY_CONFIG", SENTINEL) .env("REGISTRYCTL_UPDATE_ENDPOINT", SENTINEL) .env("COUNTRY_SECRET", SENTINEL) .args([ diff --git a/crates/registryctl/tests/project_documentation_reference.rs b/crates/registryctl/tests/project_documentation_reference.rs index 41eef4be8..ef1fc4ea9 100644 --- a/crates/registryctl/tests/project_documentation_reference.rs +++ b/crates/registryctl/tests/project_documentation_reference.rs @@ -312,7 +312,7 @@ fn human_intent_sidecar_and_documentation_contracts_are_strict_schemas() { let intent_schema = compile_schema(&intent_schema_document); let intent = read_json(schema_root.join("project-authoring/documentation-intent.json")); assert_valid(&intent_schema, &intent, "documentation intent sidecar"); - assert_eq!(intent["structural_reviews"].as_array().unwrap().len(), 205); + assert_eq!(intent["structural_reviews"].as_array().unwrap().len(), 164); for file in [ "registry.project.configuration_reference.v1.schema.json", @@ -344,35 +344,26 @@ fn human_intent_sidecar_and_documentation_contracts_are_strict_schemas() { .join("project-documentation") .join("registry.runtime.configuration_intent.v1.schema.json"), )); - for path in [ - crate_root().join("../registry-relay/config/documentation-intent.json"), - crate_root().join("../registry-notary-core/config/documentation-intent.json"), - ] { - let runtime_intent = read_json(path); - assert_valid( - &runtime_intent_schema, - &runtime_intent, - "product-owned runtime intent", - ); - let mut cross_product = runtime_intent.clone(); - cross_product["profiles"][0]["semantic_owner"] = - if cross_product["runtime_schema"] == "relay" { - json!("notary_runtime") - } else { - json!("relay_runtime") - }; - assert!( - runtime_intent_schema.validate(&cross_product).is_err(), - "the strict runtime intent schema rejects cross-product profile ownership" - ); - let mut unknown_assignment = runtime_intent; - unknown_assignment["assignments"][0]["unexpected"] = json!(true); - assert!(runtime_intent_schema.validate(&unknown_assignment).is_err()); - assert!( - serde_json::from_value::(unknown_assignment).is_err(), - "the runtime intent DTO rejects unknown assignment fields" - ); - } + let runtime_intent = + read_json(crate_root().join("../registry-relay/config/documentation-intent.json")); + assert_valid( + &runtime_intent_schema, + &runtime_intent, + "product-owned runtime intent", + ); + let mut cross_product = runtime_intent.clone(); + cross_product["profiles"][0]["semantic_owner"] = json!("authoring_contract"); + assert!( + runtime_intent_schema.validate(&cross_product).is_err(), + "the strict runtime intent schema rejects cross-product profile ownership" + ); + let mut unknown_assignment = runtime_intent; + unknown_assignment["assignments"][0]["unexpected"] = json!(true); + assert!(runtime_intent_schema.validate(&unknown_assignment).is_err()); + assert!( + serde_json::from_value::(unknown_assignment).is_err(), + "the runtime intent DTO rejects unknown assignment fields" + ); } #[test] @@ -436,18 +427,17 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { coverage.schema_id, CONFIGURATION_REFERENCE_COVERAGE_SCHEMA_ID ); - assert_eq!(coverage.coverage.schema_count, 7); - assert_eq!(coverage.coverage.path_count, 1829); + assert_eq!(coverage.coverage.schema_count, 6); + assert_eq!(coverage.coverage.path_count, 1155); assert_eq!( coverage.coverage.by_schema, [ - (ConfigurationSchemaKind::Project, 220), - (ConfigurationSchemaKind::Environment, 213), + (ConfigurationSchemaKind::Project, 191), + (ConfigurationSchemaKind::Environment, 126), (ConfigurationSchemaKind::Integration, 171), - (ConfigurationSchemaKind::Fixture, 63), + (ConfigurationSchemaKind::Fixture, 39), (ConfigurationSchemaKind::Entity, 35), (ConfigurationSchemaKind::Relay, 593), - (ConfigurationSchemaKind::Notary, 534), ] .into_iter() .collect() @@ -455,23 +445,23 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { assert_eq!( coverage.coverage.by_path_kind, [ - (FieldPathKind::Root, 7), - (FieldPathKind::Property, 1_458), - (FieldPathKind::MapKey, 26), - (FieldPathKind::MapValue, 48), - (FieldPathKind::ArrayItem, 178), - (FieldPathKind::Branch, 112), + (FieldPathKind::Root, 6), + (FieldPathKind::Property, 903), + (FieldPathKind::MapKey, 22), + (FieldPathKind::MapValue, 34), + (FieldPathKind::ArrayItem, 102), + (FieldPathKind::Branch, 88), ] .into_iter() .collect(), "the exact reviewed structural taxonomy remains release-gated" ); - assert_eq!(coverage.reviewed_intent_assignment_required_count, 1829); + assert_eq!(coverage.reviewed_intent_assignment_required_count, 1155); assert_eq!( coverage.reviewed_intent_assignment_covered_count + coverage.missing_intent.len(), coverage.reviewed_intent_assignment_required_count ); - assert_eq!(coverage.reviewed_intent_assignment_covered_count, 1829); + assert_eq!(coverage.reviewed_intent_assignment_covered_count, 1155); assert!( coverage.distinct_reviewed_intent_count < coverage.reviewed_intent_assignment_covered_count, "assignment coverage must not imply one unique explanation per path" @@ -488,13 +478,13 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { coverage.distinct_reviewed_intents_reused_count, coverage.reviewed_intent_assignments_using_reused_intent_count, ), - (629, 86, 1_286), + (490, 62, 727), "the exact intent-text reuse baseline must change intentionally with reviewed documentation" ); assert_eq!( coverage.coverage.by_intent_profile.values().sum::(), - 1127, - "every Relay and Notary path, including both roots, records its exact reviewed profile" + 593, + "every Relay path, including its root, records its exact reviewed profile" ); assert_eq!( coverage.missing_intent.len(), @@ -636,7 +626,7 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { }) .count(), ), - (528, 315, 0, 986), + (260, 259, 0, 636), "the exact empty-string semantic coverage prevents constrained strings from regressing to allowed" ); assert_eq!( @@ -654,7 +644,7 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { }) }) .count(), - 214, + 179, "schema semantics must retain rejections that the former minLength-only heuristic missed" ); let intent_counts = @@ -762,10 +752,7 @@ fn embedded_coverage_is_complete_and_generates_the_canonical_reference() { ); assert!( reference.fields.iter().all(|field| { - let runtime = matches!( - field.address.schema, - ConfigurationSchemaKind::Relay | ConfigurationSchemaKind::Notary - ); + let runtime = matches!(field.address.schema, ConfigurationSchemaKind::Relay); runtime == field.address.key_path.is_some() && (field.address.pointer.is_empty() || field.address.pointer.starts_with('/')) }), @@ -994,13 +981,6 @@ fn runtime_intent_requires_exact_assignments_and_new_paths_cannot_inherit_profil .contains("unknown profile") ); - let mut wrong_schema = intent.clone(); - wrong_schema.assignments[0].schema = ConfigurationSchemaKind::Notary; - assert!(runtime_configuration_intent_gaps(&document, &wrong_schema) - .expect_err("wrong runtime schema identity fails closed") - .to_string() - .contains("wrong product schema")); - let mut stale_key_path = intent.clone(); stale_key_path.assignments[0].key_path = "unreviewed_runtime_field".to_owned(); assert!( @@ -1036,15 +1016,6 @@ fn runtime_intent_requires_exact_assignments_and_new_paths_cannot_inherit_profil .contains("lacks exact extension semantics") ); - let mut cross_product_diagnostic = intent.clone(); - cross_product_diagnostic.profiles[0].diagnostic = "registry.notary.config.invalid".to_owned(); - assert!( - runtime_configuration_intent_gaps(&document, &cross_product_diagnostic) - .expect_err("cross-product diagnostic code fails closed") - .to_string() - .contains("cross-product diagnostic code") - ); - let mut wrong_kind = intent; wrong_kind.assignments[0].path_kind = FieldPathKind::Branch; assert!(runtime_configuration_intent_gaps(&document, &wrong_kind) diff --git a/crates/registryctl/tests/project_fixture_coverage.rs b/crates/registryctl/tests/project_fixture_coverage.rs index 33cb67d33..8cd3543c1 100644 --- a/crates/registryctl/tests/project_fixture_coverage.rs +++ b/crates/registryctl/tests/project_fixture_coverage.rs @@ -28,10 +28,9 @@ use registryctl::{ FixtureCoverageDimensions, FixtureCoverageEvidenceKind, FixtureCoverageGapReason, FixtureCoverageNotApplicableReason, FixtureCoverageNotEvaluatedReason, FixtureCoverageRequirementState, FixtureCoverageTarget, FixtureCoverageTargetComparisonInput, - FixtureCoverageTargetSetState, FixturePassState, FixtureRequestBindingState, - FixtureRequirementCoverage, FixtureSafeCode, GeneratedRecipeApplicability, GeneratorRecipeId, - ProjectFixtureCoverageReportV1, RequiredFixtureCoverageRequirement, - Sha256Digest as RegistrySha256Digest, SourceCallExpectation, + FixtureCoverageTargetSetState, FixturePassState, FixtureRequirementCoverage, FixtureSafeCode, + GeneratedRecipeApplicability, GeneratorRecipeId, ProjectFixtureCoverageReportV1, + RequiredFixtureCoverageRequirement, Sha256Digest as RegistrySha256Digest, }; use serde_json::{json, Value}; @@ -221,8 +220,6 @@ fn empty_dimensions() -> FixtureCoverageDimensions { FixtureCoverageDimensions { input_ids: Vec::new(), output_ids: Vec::new(), - claim_ids: Vec::new(), - disclosure_modes: Vec::new(), status_mappings: Vec::new(), protocol_helpers: Vec::new(), limits: Vec::new(), @@ -252,13 +249,6 @@ fn comparison_input_for(targets: &[FixtureCoverageTarget]) -> FixtureCoverageCom .cloned() .into_iter() .collect(), - changed_claim_ids: target - .declared - .claim_ids - .first() - .cloned() - .into_iter() - .collect(), source_contract_changed: true, }) .collect(), @@ -277,8 +267,14 @@ fn canonical_representative_fixture_validates_and_roundtrips_exactly() { decoded.summary.target_set_state, FixtureCoverageTargetSetState::TargetsPresent ); - assert_eq!(decoded.summary.requirements.total, 35); - assert_eq!(decoded.targets[0].requirements.len(), 35); + assert_eq!( + decoded.summary.requirements.total as usize, + RequiredFixtureCoverageRequirement::ALL.len() + ); + assert_eq!( + decoded.targets[0].requirements.len(), + RequiredFixtureCoverageRequirement::ALL.len() + ); assert!(!decoded.targets[0].fixture_inventory.is_empty()); assert!(!decoded.targets[0].generated_cases.is_empty()); assert!(decoded.targets[0] @@ -338,7 +334,7 @@ fn explicit_no_target_fixture_validates_and_roundtrips_exactly() { } #[test] -fn generated_targets_have_exact_ordered_35_requirement_contracts() { +fn generated_targets_have_exact_ordered_requirement_contracts() { for (project, capability) in [ ("bounded-http-starter", FixtureCapability::DeclarativeHttp), ("dhis2-script", FixtureCapability::Script), @@ -355,7 +351,10 @@ fn generated_targets_have_exact_ordered_35_requirement_contracts() { ); let target = only_target(&report); assert_eq!(target.identity.capability, capability); - assert_eq!(target.requirements.len(), 35); + assert_eq!( + target.requirements.len(), + RequiredFixtureCoverageRequirement::ALL.len() + ); assert_eq!( target .requirements @@ -371,7 +370,7 @@ fn generated_targets_have_exact_ordered_35_requirement_contracts() { .map(FixtureRequirementCoverage::requirement) .collect::>() .len(), - 35 + RequiredFixtureCoverageRequirement::ALL.len() ); for requirement in target.requirements.iter().skip( RequiredFixtureCoverageRequirement::ALL.len() - FixtureCoverageChangeKind::ALL.len(), @@ -385,7 +384,10 @@ fn generated_targets_have_exact_ordered_35_requirement_contracts() { } if evidence.is_empty() )); } - assert_eq!(report.summary.requirements.total, 35); + assert_eq!( + report.summary.requirements.total as usize, + RequiredFixtureCoverageRequirement::ALL.len() + ); } } @@ -430,7 +432,7 @@ fn generated_cases_remain_executable_and_isolated_under_their_target() { } #[test] -fn synthetic_opencrvs_events_api_covers_the_bounded_consultation_contract() { +fn synthetic_opencrvs_events_api_covers_the_bounded_relay_source_contract() { let report = generated_coverage_project("opencrvs-events-api"); let target = only_target(&report); assert_eq!(target.identity.integration, "birth-event-search"); @@ -467,34 +469,6 @@ fn synthetic_opencrvs_events_api_covers_the_bounded_consultation_contract() { .find(|fixture| fixture.fixture_id == "birth-event-match") .expect("passing exact-selector fixture is present"); assert_eq!(matched.output_ids, ["event_type", "registered"]); - assert_eq!( - matched.claim_ids, - ["birth-event-found", "birth-event-registered"] - ); - assert_eq!( - matched.request_to_consultation_binding.state, - FixtureRequestBindingState::Passed - ); - assert_eq!( - matched - .request_to_consultation_binding - .actual_relay_consultations, - Some(1) - ); - assert_eq!( - matched - .request_to_consultation_binding - .consultations - .iter() - .map(|consultation| { - ( - consultation.service_id.as_str(), - consultation.consultation_id.as_str(), - ) - }) - .collect::>(), - [("birth-event-verification", "event")] - ); for (recipe, safe_code) in [ ( @@ -509,10 +483,6 @@ fn synthetic_opencrvs_events_api_covers_the_bounded_consultation_contract() { GeneratorRecipeId::Timeout, Some(FixtureSafeCode::SourceDeadlineExceeded), ), - ( - GeneratorRecipeId::AuthorizationBeforeSource, - Some(FixtureSafeCode::AuthorizationDenied), - ), (GeneratorRecipeId::OutputMinimization, None), ] { let generated = target @@ -528,15 +498,6 @@ fn synthetic_opencrvs_events_api_covers_the_bounded_consultation_contract() { )); assert_eq!(generated.actual_safe_code, safe_code); assert_eq!(generated.pass_state, FixturePassState::Passed); - if recipe == GeneratorRecipeId::AuthorizationBeforeSource { - let assertion = generated - .source_access_assertion - .as_ref() - .expect("authorization denial records source-call evidence"); - assert_eq!(assertion.expected_source_calls, SourceCallExpectation::Zero); - assert_eq!(assertion.actual_source_calls, Some(0)); - assert!(assertion.passed); - } } } @@ -571,7 +532,10 @@ fn no_targets_and_a_fixtureless_target_are_distinct_states() { report.summary.target_set_state, FixtureCoverageTargetSetState::TargetsPresent ); - assert_eq!(report.targets[0].requirements.len(), 35); + assert_eq!( + report.targets[0].requirements.len(), + RequiredFixtureCoverageRequirement::ALL.len() + ); } #[test] @@ -592,21 +556,6 @@ fn declared_and_exercised_dimensions_do_not_relabel_semantics_as_coverage() { .. }) )); - if !script.declared.claim_ids.is_empty() { - assert!(!script.declared.disclosure_modes.is_empty()); - assert!(script.exercised.disclosure_modes.is_empty()); - assert!(matches!( - script - .requirements - .iter() - .find(|coverage| coverage.requirement() - == RequiredFixtureCoverageRequirement::ExercisedDisclosureModes), - Some(FixtureRequirementCoverage::Missing { - reason: FixtureCoverageGapReason::RuntimeDimensionNotObserved, - .. - }) - )); - } assert!(script.declared.limits.len() > script.exercised.limits.len()); } @@ -685,7 +634,10 @@ fn multi_target_evidence_cannot_cross_integration_boundaries() { ProjectFixtureCoverageReportV1::from_targets("multi-target".to_owned(), None, targets) .expect("disjoint targets form one valid report"); assert_eq!(report.targets.len(), 2); - assert_eq!(report.summary.requirements.total, 70); + assert_eq!( + report.summary.requirements.total as usize, + RequiredFixtureCoverageRequirement::ALL.len() * 2 + ); let mut document = serde_json::to_value(report).unwrap(); let foreign_evidence = document["targets"][1]["fixture_inventory"][0]["evidence"].clone(); @@ -765,10 +717,6 @@ fn fixed_scope_sentinels_and_evidence_kinds_cannot_claim_live_compatibility() { ("evidence_scope", json!("live_country_source")), ("compatibility_claim", json!("source_interoperable")), ("live_compatibility", json!("compatible")), - ( - "governed_request_evidence", - json!("independent_caller_contract_compatible"), - ), ] { let mut document = serde_json::to_value(&report).unwrap(); document[field] = value; @@ -776,78 +724,12 @@ fn fixed_scope_sentinels_and_evidence_kinds_cannot_claim_live_compatibility() { assert_typed_invalid(document); } - let mut omitted_boundary = serde_json::to_value(&report).unwrap(); - omitted_boundary - .as_object_mut() - .expect("coverage report is an object") - .remove("governed_request_evidence"); - assert_schema_invalid(&omitted_boundary); - assert_typed_invalid(omitted_boundary); - let mut wrong_kind = serde_json::to_value(report).unwrap(); wrong_kind["targets"][0]["fixture_inventory"][0]["evidence"]["kind"] = json!("semantic_comparison"); assert_typed_invalid(wrong_kind); } -#[test] -fn pre_witness_v1_report_is_intentionally_rejected_during_pre_one_point_zero() { - // The public compatibility promise starts at registryctl v1.0.0: - // docs/site/src/content/docs/spec/rs-pr-registryctl.mdx. Before that - // boundary, this closed report is replaced deliberately instead of being - // accepted with a misleading mapping-derived request claim. - let mut legacy = parse(REPRESENTATIVE_FIXTURE); - legacy - .as_object_mut() - .expect("legacy report is an object") - .remove("governed_request_evidence"); - let targets = legacy["targets"] - .as_array_mut() - .expect("targets are an array"); - let mut removed_states = Vec::new(); - for target in targets { - target["contract"] - .as_object_mut() - .expect("target contract is an object") - .remove("registry_backed_consultations"); - for fixture in target["fixture_inventory"] - .as_array_mut() - .expect("fixture inventory is an array") - { - fixture - .as_object_mut() - .expect("fixture record is an object") - .remove("request_to_consultation_binding"); - } - let requirements = target["requirements"] - .as_array_mut() - .expect("requirements are an array"); - let position = requirements - .iter() - .position(|coverage| coverage["requirement"] == "request_to_consultation_binding") - .expect("new request requirement is present"); - let removed = requirements.remove(position); - removed_states.push( - removed["state"] - .as_str() - .expect("removed requirement state is a string") - .to_owned(), - ); - } - let counts = legacy["summary"]["requirements"] - .as_object_mut() - .expect("summary counts are an object"); - for state in removed_states { - let count = counts[&state].as_u64().expect("state count is numeric"); - counts.insert(state, json!(count - 1)); - let total = counts["total"].as_u64().expect("total count is numeric"); - counts.insert("total".to_owned(), json!(total - 1)); - } - - assert_schema_invalid(&legacy); - assert_typed_invalid(legacy); -} - #[test] fn report_has_no_value_path_or_secret_bearing_fields() { let temporary = tempfile::tempdir().expect("temporary directory"); @@ -859,12 +741,6 @@ fn report_has_no_value_path_or_secret_bearing_fields() { "FICTIONAL_REGISTRY_TOKEN", "SECRET_REFERENCE_SENTINEL", ); - replace_authored_text( - &project, - "environments/local.yaml", - "/run/secrets/relay-workload-token", - "/private/PATH-SENTINEL", - ); replace_authored_text( &project, "environments/local.yaml", @@ -914,7 +790,6 @@ fn report_has_no_value_path_or_secret_bearing_fields() { "query", "body", "outputs", - "claims", "values", "cel", "generated_at", @@ -930,7 +805,6 @@ fn report_has_no_value_path_or_secret_bearing_fields() { b"SECRET_REFERENCE_SENTINEL".as_slice(), b"FIXTURE-INPUT-SENTINEL".as_slice(), b"TOP-SECRET-CREDENTIAL".as_slice(), - b"/private/PATH-SENTINEL".as_slice(), b"https://ORIGIN-SENTINEL.invalid".as_slice(), ] { assert!(!bytes @@ -957,7 +831,6 @@ fn comparison_input_is_strict_and_default_reports_do_not_fake_affected_sets() { "integration": "health", "changed_input_ids": ["person_id"], "changed_output_ids": [], - "changed_claim_ids": [], "source_contract_changed": true }] }); @@ -1095,7 +968,7 @@ fn comparison_enabled_generation_validates_all_impacts_and_keeps_targets_isolate let document = serde_json::to_value(&report).unwrap(); assert_schema_valid(&document); let roundtrip: ProjectFixtureCoverageReportV1 = - serde_json::from_value(document.clone()).expect("all four impacts roundtrip"); + serde_json::from_value(document.clone()).expect("all three impacts roundtrip"); assert_eq!(roundtrip, report); for target in &report.targets { diff --git a/crates/registryctl/tests/project_preflight.rs b/crates/registryctl/tests/project_preflight.rs index 533dde43d..542576d44 100644 --- a/crates/registryctl/tests/project_preflight.rs +++ b/crates/registryctl/tests/project_preflight.rs @@ -166,17 +166,20 @@ fn secret_missing_whitespace_and_present_states_never_expose_names_or_values() { input .add_secret_reference( EMPTY_NAME, - PreflightSecretConsumer::IssuanceSigningKey, - address("environments/production.yaml", "/issuance/signing_key"), + PreflightSecretConsumer::SourceOauthClientSecret, + address( + "environments/production.yaml", + "/integrations/alpha/source/oauth/client_secret", + ), ) .expect("empty reference records"); input .add_secret_reference( PRESENT_NAME, - PreflightSecretConsumer::CallerApiKeyFingerprint, + PreflightSecretConsumer::EntityPostgresConnection, address( "environments/production.yaml", - "/callers/health/api_key_fingerprint", + "/entities/people/provider/connection/secret", ), ) .expect("present reference records"); @@ -318,8 +321,8 @@ fn runtime_files_close_missing_empty_regular_symlink_and_unsafe_modes() { ), ( unsafe_private.as_path(), - PreflightRuntimeFileKind::NotaryToRelayToken, - "/notary_relay/token_file", + PreflightRuntimeFileKind::EntityCsv, + "/entities/people/provider/path", ), ( oversized.as_path(), @@ -359,7 +362,7 @@ fn runtime_files_close_missing_empty_regular_symlink_and_unsafe_modes() { PreflightCheckState::UnsafeMode ); assert_eq!( - states[&PreflightRuntimeFileKind::NotaryToRelayToken], + states[&PreflightRuntimeFileKind::EntityCsv], PreflightCheckState::UnsafeMode ); assert_eq!( @@ -392,8 +395,11 @@ fn public_trust_and_private_material_apply_distinct_unix_modes() { input .add_runtime_file( &shared, - PreflightRuntimeFileKind::NotaryToRelayToken, - address("environments/production.yaml", "/notary_relay/token_file"), + PreflightRuntimeFileKind::EntityCsv, + address( + "environments/production.yaml", + "/entities/people/provider/path", + ), ) .expect("private material records"); @@ -408,7 +414,7 @@ fn public_trust_and_private_material_apply_distinct_unix_modes() { PreflightCheckState::Available ); assert_eq!( - states[&PreflightRuntimeFileKind::NotaryToRelayToken], + states[&PreflightRuntimeFileKind::EntityCsv], PreflightCheckState::UnsafeMode ); } @@ -499,7 +505,7 @@ fn entity_provider_files_enforce_private_posture_and_relay_default_size_bound() #[cfg(unix)] #[test] -fn undeclared_generations_are_never_inferred_for_state_roots_or_workload_token() { +fn undeclared_generations_are_never_inferred_for_relay_state_roots() { use std::os::unix::fs::PermissionsExt as _; let directory = tempfile::tempdir().expect("temporary directory"); @@ -517,14 +523,6 @@ fn undeclared_generations_are_never_inferred_for_state_roots_or_workload_token() PreflightRuntimeFileKind::RelayStateRootCertificate, "/relay_state/postgresql/root_certificate_path", ), - ( - PreflightRuntimeFileKind::NotaryStateRootCertificate, - "/notary_state/postgresql/root_certificate_path", - ), - ( - PreflightRuntimeFileKind::NotaryToRelayToken, - "/notary_relay/token_file", - ), ] { input .add_runtime_file( @@ -545,20 +543,17 @@ fn undeclared_generations_are_never_inferred_for_state_roots_or_workload_token() generations[&PreflightRuntimeFileKind::SourceCa], PreflightGenerationState::Declared ); - for kind in [ - PreflightRuntimeFileKind::RelayStateRootCertificate, - PreflightRuntimeFileKind::NotaryStateRootCertificate, - PreflightRuntimeFileKind::NotaryToRelayToken, - ] { - assert_eq!(generations[&kind], PreflightGenerationState::NotDeclared); - } + assert_eq!( + generations[&PreflightRuntimeFileKind::RelayStateRootCertificate], + PreflightGenerationState::NotDeclared + ); } #[test] fn offline_boundary_has_no_network_or_external_process_surface() { let authored_endpoints = [ "https://source.country.invalid", - "https://issuer.country.invalid/jwks", + "https://source-identity.country.invalid/jwks", "https://relay.country.invalid", ]; assert!(authored_endpoints @@ -596,81 +591,6 @@ fn offline_boundary_has_no_network_or_external_process_surface() { ); } -#[test] -fn command_adapter_keeps_invalid_endpoints_offline_and_has_no_build_side_effects() { - const SECRET_NAMES: [&str; 4] = [ - "PREFLIGHT_COMMAND_CLIENT_ID", - "PREFLIGHT_COMMAND_CLIENT_SECRET", - "PREFLIGHT_COMMAND_ISSUER_KEY", - "PREFLIGHT_COMMAND_CALLER_FINGERPRINT", - ]; - let directory = tempfile::tempdir().expect("temporary directory"); - let project = directory.path().join("project"); - copy_tree( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/project-authoring/opencrvs") - .as_path(), - &project, - ); - let environment_file = project.join("environments/local.yaml"); - let original = fs::read_to_string(&environment_file).expect("environment reads"); - let missing_token = directory.path().join("missing-workload-token"); - let authored = original - .replace("CIVIL_REGISTRY_CLIENT_ID", SECRET_NAMES[0]) - .replace("CIVIL_REGISTRY_CLIENT_SECRET", SECRET_NAMES[1]) - .replace("REGISTRY_NOTARY_ISSUER_JWK", SECRET_NAMES[2]) - .replace("BIRTH_VERIFIER_TOKEN_HASH", SECRET_NAMES[3]) - .replace( - "/run/secrets/relay-workload-token", - missing_token.to_str().expect("temporary path is UTF-8"), - ); - fs::write(&environment_file, authored).expect("environment writes"); - let fixture_path = project.join("integrations/birth-record/fixtures/match.yaml"); - let fixture_before = fs::read(&fixture_path).expect("fixture reads"); - - let report = registryctl::preflight_registry_project(®istryctl::ProjectPreflightOptions { - project_directory: project.clone(), - environment: "local".to_string(), - }) - .expect("offline preflight returns a closed report"); - let serialized = serde_json::to_string(&report).expect("report serializes"); - - assert_eq!(report.secret_checks.len(), SECRET_NAMES.len()); - assert_eq!(report.runtime_files.len(), 1); - assert_eq!( - report.runtime_files[0].state, - registryctl::PreflightCheckState::Missing - ); - assert_eq!( - report.execution.network, - registryctl::PreflightAttemptState::NotAttempted - ); - assert_eq!( - report.execution.fixture_execution, - registryctl::PreflightAttemptState::NotAttempted - ); - assert_eq!( - report.execution.build_output, - registryctl::PreflightWriteState::NotWritten - ); - assert!(!project.join(".registry-stack").exists()); - assert_eq!( - fs::read(&fixture_path).expect("fixture rereads"), - fixture_before - ); - for forbidden in SECRET_NAMES.into_iter().chain([ - "https://civil-registry.invalid", - "https://identity.civil-registry.invalid", - "https://trust.civil-registry.invalid", - missing_token.to_str().expect("temporary path is UTF-8"), - ]) { - assert!( - !serialized.contains(forbidden), - "report must not expose {forbidden}" - ); - } -} - #[cfg(unix)] #[test] fn command_adapter_checks_csv_xlsx_and_parquet_entity_provider_paths() { @@ -852,7 +772,7 @@ fn preflight_reads_only_declared_runtime_files_and_has_no_fixture_or_build_side_ } #[test] -fn project_workbook_is_validated_read_only_and_digest_bound_when_runtime_is_not_ready() { +fn project_workbook_is_validated_read_only_and_digest_bound_when_runtime_is_ready() { let directory = tempfile::tempdir().expect("temporary directory"); let project = directory.path().join("spreadsheet-project"); copy_tree( @@ -876,7 +796,7 @@ fn project_workbook_is_validated_read_only_and_digest_bound_when_runtime_is_not_ environment: "local".to_string(), }) .expect("valid workbook passes preflight"); - assert_eq!(preflight.status, registryctl::PreflightStatus::NotReady); + assert_eq!(preflight.status, registryctl::PreflightStatus::Ready); assert!(preflight.runtime_files.iter().any(|check| { check.kind == registryctl::PreflightRuntimeFileKind::EntityXlsx && check.state == registryctl::PreflightCheckState::Available @@ -1026,13 +946,8 @@ fn all_declared_secret_consumer_classes_have_a_closed_report_identity() { PreflightSecretConsumer::SourceOauthMtlsPrivateKey, PreflightSecretConsumer::SourceJwksMtlsPrivateKey, PreflightSecretConsumer::EntityPostgresConnection, - PreflightSecretConsumer::IssuanceSigningKey, - PreflightSecretConsumer::CallerApiKeyFingerprint, - PreflightSecretConsumer::Oid4vciClientSigningKey, - PreflightSecretConsumer::Oid4vciAccessTokenSigningKey, - PreflightSecretConsumer::Oid4vciSensitiveStateKey, ]; - assert_eq!(consumers.len(), 15); + assert_eq!(consumers.len(), 10); let serialized = consumers .iter() .map(|consumer| serde_json::to_string(consumer).expect("consumer serializes")) @@ -1041,6 +956,28 @@ fn all_declared_secret_consumer_classes_have_a_closed_report_identity() { assert!(!serialized.iter().any(|value| value.contains("image"))); } +#[test] +fn all_declared_runtime_file_classes_have_a_closed_report_identity() { + let kinds = [ + PreflightRuntimeFileKind::SourceCa, + PreflightRuntimeFileKind::SourceMtlsCertificate, + PreflightRuntimeFileKind::SourceOauthCa, + PreflightRuntimeFileKind::SourceOauthMtlsCertificate, + PreflightRuntimeFileKind::SourceJwksCa, + PreflightRuntimeFileKind::SourceJwksMtlsCertificate, + PreflightRuntimeFileKind::EntityCsv, + PreflightRuntimeFileKind::EntityXlsx, + PreflightRuntimeFileKind::EntityParquet, + PreflightRuntimeFileKind::RelayStateRootCertificate, + ]; + assert_eq!(kinds.len(), 10); + let serialized = kinds + .iter() + .map(|kind| serde_json::to_string(kind).expect("runtime file kind serializes")) + .collect::>(); + assert_eq!(serialized.len(), kinds.len()); +} + #[test] fn invalid_addresses_and_runtime_paths_fail_without_echoing_values() { for (file, pointer) in [ diff --git a/crates/registryctl/tests/project_report_contract.rs b/crates/registryctl/tests/project_report_contract.rs index 9f1cbc096..24af1c853 100644 --- a/crates/registryctl/tests/project_report_contract.rs +++ b/crates/registryctl/tests/project_report_contract.rs @@ -354,6 +354,42 @@ fn semantic_precision_requires_a_field_address_only_for_field_precision() { ); } +#[test] +fn semantic_impact_preserves_source_route_changes_and_both_relay_lanes() { + let mut impact = parse(PROJECT_SEMANTIC_IMPACT_FIXTURE); + impact["changes"][0]["affected_subjects"] = json!([ + { + "kind": "integration", + "id": "person-record" + }, + { + "kind": "consultation", + "id": "person-service.person-route" + }, + { + "kind": "product_input", + "id": "registry-relay.consultation.config" + } + ]); + for requirement in ["signing", "activation", "restart"] { + impact["changes"][0]["requirements"][requirement] = + json!(["relay-public", "relay-consultation"]); + } + + assert_valid(PROJECT_SEMANTIC_IMPACT_SCHEMA, &impact); + let decoded: ProjectSemanticImpactReportV1 = + serde_json::from_value(impact.clone()).expect("two-lane Relay impact decodes"); + assert_eq!( + serde_json::to_value(decoded).expect("two-lane Relay impact re-encodes"), + impact + ); + + let mut duplicate_lane = impact; + duplicate_lane["changes"][0]["requirements"]["signing"] = + json!(["relay-public", "relay-public"]); + assert_invalid(PROJECT_SEMANTIC_IMPACT_SCHEMA, &duplicate_lane); +} + #[test] fn dimension_only_projection_preserves_the_legacy_byte_shape() { let impact: ProjectSemanticImpactReportV1 = @@ -378,6 +414,22 @@ fn dimension_only_projection_preserves_the_legacy_byte_shape() { ); } +#[test] +fn artifact_integrity_digests_are_strict_lowercase_sha256() { + for invalid in [ + "sha256:abcd", + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ] { + assert!(Sha256Digest::new(invalid).is_err()); + + let mut manifest = parse(PROJECT_ARTIFACT_MANIFEST_FIXTURE); + manifest["artifacts"][0]["digest"] = json!(invalid); + assert_invalid(PROJECT_ARTIFACT_MANIFEST_SCHEMA, &manifest); + assert_typed_invalid::(manifest); + } +} + #[test] fn artifact_paths_fail_closed_before_the_report_can_be_constructed() { for path in [ diff --git a/crates/registryctl/tests/project_request_binding.rs b/crates/registryctl/tests/project_request_binding.rs deleted file mode 100644 index a0eed31c6..000000000 --- a/crates/registryctl/tests/project_request_binding.rs +++ /dev/null @@ -1,307 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -use std::fs; -use std::path::{Path, PathBuf}; - -use registryctl::{ - test_registry_project_with_context, FixtureRequirementCoverage, GovernedRequestEvidence, - ProjectExecutionContext, ProjectTestOptions, RequiredFixtureCoverageRequirement, -}; -use serde_norway::Value; - -fn fixture_root(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/project-authoring") - .join(name) -} - -fn copy_tree(source: &Path, destination: &Path) { - fs::create_dir_all(destination).expect("destination creates"); - let mut entries = fs::read_dir(source) - .expect("source reads") - .collect::>>() - .expect("entries read"); - entries.sort_by_key(|entry| entry.file_name()); - for entry in entries { - let source = entry.path(); - let destination = destination.join(entry.file_name()); - if entry.file_type().expect("type reads").is_dir() { - copy_tree(&source, &destination); - } else { - fs::copy(source, destination).expect("file copies"); - } - } -} - -fn read_yaml(path: &Path) -> Value { - serde_norway::from_slice(&fs::read(path).expect("YAML reads")).expect("YAML parses") -} - -fn write_yaml(path: &Path, value: &Value) { - fs::write( - path, - serde_norway::to_string(value).expect("YAML serializes"), - ) - .expect("YAML writes"); -} - -fn test_project(path: &Path) -> anyhow::Result { - test_registry_project_with_context( - &ProjectTestOptions { - project_directory: path.to_path_buf(), - environment: None, - }, - &ProjectExecutionContext::new(env!("CARGO_BIN_EXE_registryctl")) - .expect("Cargo provides registryctl"), - ) -} - -fn custom_project() -> (tempfile::TempDir, PathBuf) { - let temporary = tempfile::tempdir().expect("temporary directory"); - let project = temporary.path().join("project"); - copy_tree(&fixture_root("custom-system"), &project); - (temporary, project) -} - -fn set_mapping_scheme(project: &Path, scheme: &str) { - let path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&path); - document["services"]["household-eligibility"]["consultations"]["household"]["input"] - ["household_reference"] = Value::String(format!("request.target.identifiers.{scheme}")); - write_yaml(&path, &document); -} - -fn set_request_scheme(project: &Path, scheme: &str) { - let path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); - let mut fixture = read_yaml(&path); - fixture["request"]["target"]["identifiers"][0]["scheme"] = Value::String(scheme.to_owned()); - write_yaml(&path, &fixture); -} - -fn assert_zero_call_binding_failure(project: &Path) -> String { - let error = test_project(project).expect_err("binding mismatch must fail"); - let rendered = format!("{error:#}"); - assert!( - rendered.contains("request_to_consultation_binding_invalid: relay_consultations=0"), - "{rendered}" - ); - rendered -} - -#[test] -fn governed_request_binding_fails_closed_for_either_one_sided_scheme_change() { - let (_temporary, project) = custom_project(); - set_mapping_scheme(&project, "country_household_id"); - assert_zero_call_binding_failure(&project); - - let (_temporary, project) = custom_project(); - set_request_scheme(&project, "country_household_id"); - assert_zero_call_binding_failure(&project); -} - -#[test] -fn governed_request_binding_remains_country_configurable_when_both_sides_change() { - let (_temporary, project) = custom_project(); - set_mapping_scheme(&project, "country_household_id"); - set_request_scheme(&project, "country_household_id"); - - let report = test_project(&project).expect("consistent country binding passes"); - let witness = report - .fixtures - .iter() - .find(|fixture| { - fixture - .fixture - .ends_with("::derived/request_to_consultation_binding") - }) - .expect("independent request witness is reported"); - assert!(witness.passed); - assert_eq!(witness.calls, ["notary-relay-consultation"]); - assert_eq!( - witness.source_access, - Some(true), - "an entered Relay consultation must not be reported as zero source access" - ); - assert_eq!(witness.claims, ["household-record-exists"]); -} - -#[test] -fn governed_request_coverage_requires_every_reachable_consultation() { - let (_temporary, project) = custom_project(); - let path = project.join("registry-stack.yaml"); - let mut document = read_yaml(&path); - let service = &mut document["services"]["household-eligibility"]; - service["consultations"]["alternate"] = serde_norway::from_str( - "{ integration: eligibility, input: { household_reference: request.target.identifiers.household_reference } }", - ) - .expect("alternate consultation parses"); - service["claims"]["alternate-record-exists"] = - serde_norway::from_str("{ cel: alternate.matched, disclosure: predicate }") - .expect("alternate claim parses"); - service["credential_profiles"]["household-eligibility"]["claims"] - .as_sequence_mut() - .expect("credential claims are a sequence") - .push(Value::String("alternate-record-exists".to_owned())); - write_yaml(&path, &document); - let fixture_path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); - let mut fixture = read_yaml(&fixture_path); - fixture["expect"]["claims"]["alternate-record-exists"] = Value::Bool(true); - write_yaml(&fixture_path, &fixture); - let no_match_path = project.join("integrations/eligibility/fixtures/no-match.yaml"); - let mut no_match = read_yaml(&no_match_path); - no_match["expect"]["claims"]["alternate-record-exists"] = Value::Bool(false); - write_yaml(&no_match_path, &no_match); - - let report = test_project(&project).expect("project test remains executable"); - let coverage = report - .fixture_coverage - .expect("fixture coverage is reported"); - assert_eq!( - coverage.governed_request_evidence, - GovernedRequestEvidence::PerConsultationAuthoredRequestWitnessEvaluation, - "the root marker describes the proof method, not a false global pass state" - ); - let target = coverage.targets.first().expect("target is reported"); - assert_eq!(target.contract.registry_backed_consultations.len(), 2); - assert_eq!( - target - .fixture_inventory - .iter() - .flat_map(|fixture| { fixture.request_to_consultation_binding.consultations.iter() }) - .count(), - 1, - "one request witness must not claim both reachable consultations" - ); - assert!(matches!( - target.requirements.iter().find(|coverage| { - coverage.requirement() - == RequiredFixtureCoverageRequirement::RequestToConsultationBinding - }), - Some(FixtureRequirementCoverage::Missing { .. }) - )); -} - -#[test] -fn governed_request_boundary_rejects_closed_contract_mismatches_before_relay() { - type Mutation = (&'static str, Box); - let mutations: Vec = vec![ - ( - "target type", - Box::new(|fixture| { - fixture["request"]["target"]["type"] = Value::String("Household".to_owned()); - }), - ), - ( - "purpose", - Box::new(|fixture| { - fixture["request"]["purpose"] = Value::String("unknown-purpose".to_owned()); - }), - ), - ( - "claim", - Box::new(|fixture| { - fixture["request"]["claims"][0] = Value::String("unknown-claim".to_owned()); - }), - ), - ( - "disclosure", - Box::new(|fixture| { - fixture["request"]["disclosure"] = Value::String("value".to_owned()); - }), - ), - ( - "format", - Box::new(|fixture| { - fixture["request"]["format"] = Value::String("application/json".to_owned()); - }), - ), - ( - "missing identifier", - Box::new(|fixture| { - fixture["request"]["target"]["identifiers"] = Value::Sequence(Vec::new()); - }), - ), - ( - "extra identifier", - Box::new(|fixture| { - fixture["request"]["target"]["identifiers"] - .as_sequence_mut() - .expect("identifiers are a sequence") - .push( - serde_norway::from_str("{ scheme: extra, value: synthetic }") - .expect("identifier parses"), - ); - }), - ), - ]; - - for (name, mutate) in mutations { - let (_temporary, project) = custom_project(); - let path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); - let mut fixture = read_yaml(&path); - mutate(&mut fixture); - write_yaml(&path, &fixture); - let failure = assert_zero_call_binding_failure(&project); - assert!( - !failure.contains("HH-AB12CD34"), - "{name} leaked fixture data" - ); - } -} - -#[test] -fn governed_request_requires_synthetic_classification_and_rejects_secret_references() { - for replacement in ["classification: reviewed", "classification: production"] { - let (_temporary, project) = custom_project(); - let path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); - let fixture = fs::read_to_string(&path).expect("fixture reads"); - fs::write( - &path, - fixture.replace("classification: synthetic", replacement), - ) - .expect("fixture writes"); - assert!(test_project(&project).is_err()); - } - - let (_temporary, project) = custom_project(); - let integration_path = project.join("integrations/eligibility/integration.yaml"); - let mut integration = read_yaml(&integration_path); - let household_reference = integration["input"]["household_reference"] - .as_mapping_mut() - .expect("household reference contract is an object"); - household_reference.remove("pattern"); - household_reference.insert("maxLength".into(), Value::Number(128.into())); - write_yaml(&integration_path, &integration); - - let path = project.join("integrations/eligibility/fixtures/source-approved.yaml"); - let mut fixture = read_yaml(&path); - fixture["request"]["target"]["identifiers"][0]["value"] = - Value::String("${COUNTRY_IDENTIFIER}".to_owned()); - write_yaml(&path, &fixture); - let rendered = format!( - "{:#}", - test_project(&project).expect_err("secret ref must fail") - ); - assert!( - rendered.contains("fixture governed request contains a forbidden credential-like field"), - "{rendered}" - ); - assert!(!rendered.contains("COUNTRY_IDENTIFIER")); -} - -#[test] -fn governed_request_witness_executes_for_http_script_and_snapshot() { - for project in ["custom-system", "dhis2-script", "snapshot-exact"] { - let report = test_project(&fixture_root(project)) - .unwrap_or_else(|error| panic!("{project} request witness failed: {error:#}")); - assert!( - report.fixtures.iter().any(|fixture| { - fixture - .fixture - .ends_with("::derived/request_to_consultation_binding") - && fixture.passed - }), - "{project} lacks a passing request witness" - ); - } -} diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 1d2614f24..53dadecfb 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -95,7 +95,7 @@ gates for its area (see Verification), and committed. - [x] C1. Decisions on record before deletion: ROADMAP pilot line reframed to Relay + Evidence; retirement decision page drafted (published in C7); changelog entry drafted. -- [ ] C2. registryctl surgery complete: Notary compiler target, dev +- [x] C2. registryctl surgery complete: Notary compiler target, dev credentials, deployment wiring, and release-lock entries removed; registryctl builds and tests green as Relay-only tooling. - [ ] C3. `registry-notary*` crates and `products/notary` deleted; Relay's @@ -496,3 +496,20 @@ is parallel; B has no upstream dependencies and is the standing priority retirement decision without claiming the deletion is complete; and the retirement decision page is drafted with `draft: true` for publication in C7. No runtime, contract, or Relay implementation changed. Next in C: C2. +- 2026-08-03: C2 done. registryctl is Relay-only tooling: the Notary compiler, + authoring model, credentials, runtime/deployment wiring, trust material, and + release-lock entries are gone. Public and consultation Relay lanes retain + distinct client identities, and malformed retired approved-set values fail + closed. This is a pre-1.0 breaking project/release-lock migration: operators + rebuild generated deployment material before later decommissioning or + archiving old Notary state; no automatic database cleanup occurs. Relay + production source and frozen Evidence contracts are unchanged. Formatting, + check, Clippy, registryctl/language-server/workspace tests, cargo-deny, and + the affected release-lock/compose gates passed. The full workspace command + still reaches the unchanged main-branch failure in + `registry-evidencectl/tests/scaffold.rs::the_rendered_mint_configuration_passes_mint_check`: + Mint validation succeeds but emits no stdout, while the test expects a + success sentence and client count. That failure reproduces in the untouched + original checkout and is outside C; the same workspace tests pass when the + unrelated `registry-evidencectl` crate is excluded. Next in C: the mandatory + C3 deletion approval stop, with C4 reverse-dependency evidence. diff --git a/release/conformance/adopter-runtime/README.md b/release/conformance/adopter-runtime/README.md index 3a5adf245..67dde55a1 100644 --- a/release/conformance/adopter-runtime/README.md +++ b/release/conformance/adopter-runtime/README.md @@ -5,11 +5,11 @@ They are not a shipped deployment package and contain no credentials. The checker proves: -- the ordinary package has four workloads plus three least-authority, +- the ordinary package has three workloads plus two least-authority, networkless secret stagers; - all workloads use one ordinary, non-internal Compose runtime network, with no namespace-holder service or shared `network_mode`; -- only Relay public and Notary publish host ports, and both bind IPv4 loopback; +- only Relay public publishes a host port, and it binds IPv4 loopback; - product application traffic is plain HTTP within that Compose network, and the operator or platform terminates ingress TLS before the loopback boundary; - Relay public needs no staged listener TLS material, while each remaining @@ -18,7 +18,7 @@ The checker proves: - each product lane reuses one operator-owned environment file for serve, preparation, and initialization; - selecting `compose.initialize.yaml` is required to initialize PostgreSQL and - exposes the seven initialization services only in that explicit model; + exposes the eleven initialization services only in that explicit model; - `docker compose config --no-env-resolution` retains environment-file paths without resolving sentinel operator values; and - one operator-owned parent file can include the generated package using @@ -32,10 +32,9 @@ Compose normalization. The single first-country release rehearsal supplies the functional proof that these inert fixtures cannot. It retains the already tested public HTTP demo, builds and signs its `public-demo` environment, starts the generated governed -package, and sends one authenticated Notary evaluation through the private -consultation Relay to the bounded source. The retained evidence contains only -the HTTP status and minimized claim summary, not the caller token or source -response. +package, and sends one authenticated Relay consultation to the bounded source. +The retained evidence contains only the HTTP status and minimized response +summary, not the caller token or source response. Run the current and minimum supported Compose implementations: diff --git a/release/conformance/adopter-runtime/deployment-plan.probe.v1.json b/release/conformance/adopter-runtime/deployment-plan.probe.v1.json index 5c884ecdf..562d5f1eb 100644 --- a/release/conformance/adopter-runtime/deployment-plan.probe.v1.json +++ b/release/conformance/adopter-runtime/deployment-plan.probe.v1.json @@ -39,24 +39,6 @@ "restart_action": "restart", "reactivation_action": "verify_state" }, - { - "id": "notary", - "kind": "product", - "product_lane": "notary", - "action": "serve", - "image_identity": "example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "image_platform": "linux-amd64", - "immutable_inputs": ["notary-bundle", "notary-anchor"], - "mount_roles": ["bundle", "anchor", "anti-rollback-state", "secret", "audit"], - "secret_consumers": ["notary-relay-workload-credential", "notary-signing-key"], - "state_roles": ["notary-anti-rollback", "notary-audit"], - "endpoint_classes": ["public-application", "administration", "posture"], - "network_relationships": ["runtime"], - "dependencies": ["relay-consultation", "postgresql-state-plane"], - "health_semantics": "notary-health", - "restart_action": "restart", - "reactivation_action": "verify_state" - }, { "id": "postgresql-state-plane", "kind": "supporting", @@ -77,24 +59,19 @@ {"id": "bootstrap-postgresql-state-plane", "workload": "postgresql-state-plane", "action": "bootstrap_state_plane"}, {"id": "prepare-relay-public-state", "workload": "relay-public", "action": "prepare_state_store"}, {"id": "prepare-relay-consultation-state", "workload": "relay-consultation", "action": "prepare_state_store"}, - {"id": "prepare-notary-state", "workload": "notary", "action": "prepare_state_store"}, {"id": "initialize-relay-public", "workload": "relay-public", "action": "initialize_state"}, {"id": "initialize-relay-consultation", "workload": "relay-consultation", "action": "initialize_state"}, - {"id": "initialize-notary", "workload": "notary", "action": "initialize_state"}, {"id": "preview-relay-public-state", "workload": "relay-public", "action": "preview_state"}, {"id": "preview-relay-consultation-state", "workload": "relay-consultation", "action": "preview_state"}, - {"id": "preview-notary-state", "workload": "notary", "action": "preview_state"}, {"id": "accept-relay-public-state", "workload": "relay-public", "action": "accept_state"}, {"id": "accept-relay-consultation-state", "workload": "relay-consultation", "action": "accept_state"}, - {"id": "accept-notary-state", "workload": "notary", "action": "accept_state"}, {"id": "verify-relay-public-state", "workload": "relay-public", "action": "verify_state"}, - {"id": "verify-relay-consultation-state", "workload": "relay-consultation", "action": "verify_state"}, - {"id": "verify-notary-state", "workload": "notary", "action": "verify_state"} + {"id": "verify-relay-consultation-state", "workload": "relay-consultation", "action": "verify_state"} ], "recovery_consistency_groups": [ { "id": "consultation-state", - "members": ["relay-consultation", "notary", "postgresql-state-plane"] + "members": ["relay-consultation", "postgresql-state-plane"] }, { "id": "relay-public-state", @@ -104,7 +81,6 @@ "exposure_requirements": [ {"endpoint_class": "public-application", "exposure": "operator-bound"}, {"endpoint_class": "private-application", "exposure": "private-network-only"}, - {"endpoint_class": "administration", "exposure": "private-network-only"}, {"endpoint_class": "posture", "exposure": "private-network-only"} ] } diff --git a/release/conformance/adopter-runtime/package/generated/compose.initialize.yaml b/release/conformance/adopter-runtime/package/generated/compose.initialize.yaml index 32fb85b04..71175cc8f 100644 --- a/release/conformance/adopter-runtime/package/generated/compose.initialize.yaml +++ b/release/conformance/adopter-runtime/package/generated/compose.initialize.yaml @@ -50,25 +50,6 @@ services: - source: registry-postgresql-tls-certificate target: postgresql-tls-certificate - registry-notary-actions-stage-secrets: - image: example.invalid/registrystack/postgresql@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc - platform: linux/amd64 - entrypoint: ["/bin/sh", "-ceu"] - command: ["umask 077\nexit 0\n"] - user: "0:0" - read_only: true - cap_drop: [ALL] - cap_add: [CHOWN, DAC_READ_SEARCH] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - network_mode: none - restart: "no" - volumes: - - registry-operator-files-notary-prepare:/registryctl-stage/output/notary-prepare - secrets: - - source: registry-postgresql-tls-certificate - target: postgresql-tls-certificate - registry-postgres-bootstrap: image: example.invalid/registrystack/postgresql@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc platform: linux/amd64 @@ -162,42 +143,6 @@ services: - registry-relay-consultation-audit:/var/lib/registry/audit - registry-operator-files-relay-consultation-prepare:/run/secrets:ro - registry-notary-prepare-state: - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - platform: linux/amd64 - command: [product-action, prepare_state_store] - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - logging: - driver: local - options: {max-size: 10m, max-file: "3"} - restart: "no" - networks: [registry-runtime] - depends_on: - registry-postgres: - condition: service_healthy - registry-notary-actions-stage-secrets: - condition: service_completed_successfully - env_file: [../operator/secrets/notary-environment] - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-audit:/var/lib/registry/audit - - registry-operator-files-notary-prepare:/run/secrets:ro - registry-relay-public-initialize: image: example.invalid/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa platform: linux/amd64 @@ -266,37 +211,6 @@ services: - registry-relay-consultation-audit:/var/lib/registry/audit - registry-operator-files-relay-consultation-initialize:/run/secrets:ro - registry-notary-initialize: - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - platform: linux/amd64 - command: [product-action, initialize_state] - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - logging: - driver: local - options: {max-size: 10m, max-file: "3"} - restart: "no" - network_mode: none - env_file: [../operator/secrets/notary-environment] - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-state:/var/lib/registry/state - - registry-notary-audit:/var/lib/registry/audit - registry-relay-public-preview-state: image: example.invalid/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa platform: linux/amd64 @@ -355,35 +269,6 @@ services: create_host_path: false - registry-relay-consultation-state:/var/lib/registry/state:ro - registry-notary-preview-state: - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - platform: linux/amd64 - command: [product-action, preview_state] - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - logging: - driver: local - options: {max-size: 10m, max-file: "3"} - restart: "no" - network_mode: none - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-state:/var/lib/registry/state:ro - registry-relay-public-accept-state: image: example.invalid/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa platform: linux/amd64 @@ -446,37 +331,6 @@ services: - registry-relay-consultation-state:/var/lib/registry/state - registry-relay-consultation-audit:/var/lib/registry/audit - registry-notary-accept-state: - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - platform: linux/amd64 - command: [product-action, accept_state] - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - logging: - driver: local - options: {max-size: 10m, max-file: "3"} - restart: "no" - network_mode: none - env_file: [../operator/secrets/notary-environment] - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-state:/var/lib/registry/state - - registry-notary-audit:/var/lib/registry/audit - registry-relay-public-verify-state: image: example.invalid/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa platform: linux/amd64 @@ -535,37 +389,7 @@ services: create_host_path: false - registry-relay-consultation-state:/var/lib/registry/state:ro - registry-notary-verify-state: - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - platform: linux/amd64 - command: [product-action, verify_state] - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - tmpfs: [/tmp] - logging: - driver: local - options: {max-size: 10m, max-file: "3"} - restart: "no" - network_mode: none - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-state:/var/lib/registry/state:ro - volumes: registry-operator-files-postgresql-bootstrap: registry-operator-files-relay-consultation-prepare: registry-operator-files-relay-consultation-initialize: - registry-operator-files-notary-prepare: diff --git a/release/conformance/adopter-runtime/package/generated/compose.yaml b/release/conformance/adopter-runtime/package/generated/compose.yaml index 51a463fec..2d5aae694 100644 --- a/release/conformance/adopter-runtime/package/generated/compose.yaml +++ b/release/conformance/adopter-runtime/package/generated/compose.yaml @@ -53,18 +53,6 @@ services: - source: registry-postgresql-tls-certificate target: postgresql-tls-certificate - registry-notary-stage-secrets: - <<: *stager-hardening - volumes: - - registry-operator-files-notary-serve:/registryctl-stage/output/notary-serve - secrets: - - source: registry-notary-relay-workload-credential - target: notary-relay-workload-credential - - source: registry-notary-signing-key - target: notary-signing-key - - source: registry-postgresql-tls-certificate - target: postgresql-tls-certificate - registry-postgres: image: example.invalid/registrystack/postgresql@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc platform: linux/amd64 @@ -161,43 +149,6 @@ services: - registry-relay-consultation-audit:/var/lib/registry/audit - registry-operator-files-relay-consultation-serve:/run/secrets:ro - registry-notary: - <<: *product-hardening - image: example.invalid/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - restart: unless-stopped - command: [product-action, serve] - networks: [registry-runtime] - ports: - - target: 8081 - published: "4255" - host_ip: 127.0.0.1 - protocol: tcp - mode: ingress - depends_on: - registry-postgres: - condition: service_healthy - registry-relay-consultation: - condition: service_healthy - registry-notary-stage-secrets: - condition: service_completed_successfully - env_file: [../operator/secrets/notary-environment] - volumes: - - type: bind - source: ./bundles/notary - target: /run/registry/bundle - read_only: true - bind: - create_host_path: false - - type: bind - source: ./anchors/notary - target: /run/registry/anchor - read_only: true - bind: - create_host_path: false - - registry-notary-state:/var/lib/registry/state:ro - - registry-notary-audit:/var/lib/registry/audit - - registry-operator-files-notary-serve:/run/secrets:ro - networks: registry-runtime: @@ -212,19 +163,10 @@ volumes: name: registry-adopter-probe_registry-relay-consultation-state registry-relay-consultation-audit: name: registry-adopter-probe_registry-relay-consultation-audit - registry-notary-state: - name: registry-adopter-probe_registry-notary-state - registry-notary-audit: - name: registry-adopter-probe_registry-notary-audit registry-operator-files-postgresql-serve: registry-operator-files-relay-consultation-serve: - registry-operator-files-notary-serve: secrets: - registry-notary-signing-key: - file: ../operator/secrets/notary-signing-key - registry-notary-relay-workload-credential: - file: ../operator/secrets/notary-relay-workload-credential registry-postgresql-tls-certificate: file: ../operator/secrets/postgresql-tls-certificate registry-postgresql-tls-private-key: diff --git a/release/conformance/adopter-runtime/package/operator/secrets/notary-environment b/release/conformance/adopter-runtime/package/operator/secrets/notary-environment deleted file mode 100644 index ebd16e5d0..000000000 --- a/release/conformance/adopter-runtime/package/operator/secrets/notary-environment +++ /dev/null @@ -1 +0,0 @@ -REGISTRY_CONFORMANCE_SENTINEL=notary-value-must-not-enter-compose diff --git a/release/registry-release-lock-payload.v1.schema.json b/release/registry-release-lock-payload.v1.schema.json index 2a572c55f..a080c5bbd 100644 --- a/release/registry-release-lock-payload.v1.schema.json +++ b/release/registry-release-lock-payload.v1.schema.json @@ -39,16 +39,12 @@ "additionalProperties": false, "required": [ "relay", - "notary", "postgresql_state_plane" ], "properties": { "relay": { "$ref": "#/$defs/image" }, - "notary": { - "$ref": "#/$defs/image" - }, "postgresql_state_plane": { "$ref": "#/$defs/image" } @@ -60,7 +56,6 @@ "required": [ "relay_public", "relay_consultation", - "notary", "postgresql_state_plane", "operator_files" ], @@ -71,16 +66,13 @@ "relay_consultation": { "$ref": "#/$defs/product_runtime" }, - "notary": { - "$ref": "#/$defs/product_runtime" - }, "postgresql_state_plane": { "$ref": "#/$defs/postgresql_runtime" }, "operator_files": { "type": "array", - "minItems": 9, - "maxItems": 9, + "minItems": 6, + "maxItems": 6, "items": { "$ref": "#/$defs/operator_file" } @@ -502,8 +494,6 @@ "dotenv", "pem_certificate", "pem_private_key", - "json_web_key", - "compact_jwt", "opaque" ] }, @@ -542,8 +532,7 @@ "config_signature_schema", "trust_anchor_schema", "anchor_transition_schema", - "relay_config_schema", - "notary_config_schema" + "relay_config_schema" ], "properties": { "config_bundle_schema": { @@ -560,9 +549,6 @@ }, "relay_config_schema": { "const": "https://id.registrystack.org/schemas/registry-relay/registry-relay.config.schema.json" - }, - "notary_config_schema": { - "const": "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json" } } }, diff --git a/release/scripts/check_adopter_compose_contract.py b/release/scripts/check_adopter_compose_contract.py index 35d8ba29a..4d1350b9e 100644 --- a/release/scripts/check_adopter_compose_contract.py +++ b/release/scripts/check_adopter_compose_contract.py @@ -33,21 +33,18 @@ "registry-postgres", "registry-relay-public", "registry-relay-consultation", - "registry-notary", } ) STAGER_SERVICES = frozenset( { "registry-postgresql-stage-secrets", "registry-relay-consultation-stage-secrets", - "registry-notary-stage-secrets", } ) ACTION_STAGER_SERVICES = frozenset( { "registry-postgresql-actions-stage-secrets", "registry-relay-consultation-actions-stage-secrets", - "registry-notary-actions-stage-secrets", } ) ORDINARY_SERVICES = WORKLOAD_SERVICES | STAGER_SERVICES @@ -56,19 +53,14 @@ "registry-postgres-bootstrap", "registry-relay-public-prepare-state", "registry-relay-consultation-prepare-state", - "registry-notary-prepare-state", "registry-relay-public-initialize", "registry-relay-consultation-initialize", - "registry-notary-initialize", "registry-relay-public-preview-state", "registry-relay-consultation-preview-state", - "registry-notary-preview-state", "registry-relay-public-accept-state", "registry-relay-consultation-accept-state", - "registry-notary-accept-state", "registry-relay-public-verify-state", "registry-relay-consultation-verify-state", - "registry-notary-verify-state", } ) @@ -76,14 +68,11 @@ { "relay-public-environment", "relay-consultation-environment", - "notary-environment", "postgresql-bootstrap-environment", } ) OPERATOR_SECRET_FILES = frozenset( { - "notary-relay-workload-credential", - "notary-signing-key", "postgresql-admin-password", "postgresql-tls-certificate", "postgresql-tls-private-key", @@ -94,7 +83,6 @@ LANE_ENVIRONMENTS = { "registry-relay-public": "relay-public-environment", "registry-relay-consultation": "relay-consultation-environment", - "registry-notary": "notary-environment", } ORDINARY_COMMANDS = { "registry-postgres": ["postgres"], @@ -104,7 +92,6 @@ "relay-consultation", "serve", ], - "registry-notary": ["product-action", "serve"], } POSTGRESQL_ORDINARY_ENTRYPOINT = [ "/bin/bash", @@ -138,11 +125,6 @@ "registry-postgres": "service_healthy", "registry-relay-consultation-stage-secrets": ("service_completed_successfully"), }, - "registry-notary": { - "registry-postgres": "service_healthy", - "registry-relay-consultation": "service_healthy", - "registry-notary-stage-secrets": "service_completed_successfully", - }, } INITIALIZATION_COMMANDS = { "registry-postgres-bootstrap": ["postgresql-action", "bootstrap"], @@ -156,10 +138,6 @@ "relay-consultation", "prepare_state_store", ], - "registry-notary-prepare-state": [ - "product-action", - "prepare_state_store", - ], "registry-relay-public-initialize": [ "product-action", "relay-public", @@ -170,7 +148,6 @@ "relay-consultation", "initialize_state", ], - "registry-notary-initialize": ["product-action", "initialize_state"], "registry-relay-public-preview-state": [ "product-action", "relay-public", @@ -181,7 +158,6 @@ "relay-consultation", "preview_state", ], - "registry-notary-preview-state": ["product-action", "preview_state"], "registry-relay-public-accept-state": [ "product-action", "relay-public", @@ -192,7 +168,6 @@ "relay-consultation", "accept_state", ], - "registry-notary-accept-state": ["product-action", "accept_state"], "registry-relay-public-verify-state": [ "product-action", "relay-public", @@ -203,7 +178,6 @@ "relay-consultation", "verify_state", ], - "registry-notary-verify-state": ["product-action", "verify_state"], } INITIALIZATION_METADATA = { "registry-relay-public-prepare-state": ( @@ -216,11 +190,6 @@ "relay-consultation", "prepare", ), - "registry-notary-prepare-state": ( - "registry-notary", - "notary", - "prepare", - ), "registry-relay-public-initialize": ( "registry-relay-public", "relay-public", @@ -231,11 +200,6 @@ "relay-consultation", "initialize", ), - "registry-notary-initialize": ( - "registry-notary", - "notary", - "initialize", - ), "registry-relay-public-preview-state": ( "registry-relay-public", "relay-public", @@ -246,11 +210,6 @@ "relay-consultation", "preview", ), - "registry-notary-preview-state": ( - "registry-notary", - "notary", - "preview", - ), "registry-relay-public-accept-state": ( "registry-relay-public", "relay-public", @@ -261,11 +220,6 @@ "relay-consultation", "accept", ), - "registry-notary-accept-state": ( - "registry-notary", - "notary", - "accept", - ), "registry-relay-public-verify-state": ( "registry-relay-public", "relay-public", @@ -276,11 +230,6 @@ "relay-consultation", "verify", ), - "registry-notary-verify-state": ( - "registry-notary", - "notary", - "verify", - ), } INITIALIZATION_DEPENDENCIES = { "registry-relay-public-prepare-state": {}, @@ -297,20 +246,12 @@ "service_completed_successfully" ), }, - "registry-notary-prepare-state": { - "registry-postgres": "service_healthy", - "registry-notary-actions-stage-secrets": "service_completed_successfully", - }, - "registry-notary-initialize": {}, "registry-relay-public-preview-state": {}, "registry-relay-public-accept-state": {}, "registry-relay-consultation-preview-state": {}, "registry-relay-consultation-accept-state": {}, - "registry-notary-preview-state": {}, - "registry-notary-accept-state": {}, "registry-relay-public-verify-state": {}, "registry-relay-consultation-verify-state": {}, - "registry-notary-verify-state": {}, } STAGER_COMMAND = ["umask 077\nexit 0\n"] @@ -335,16 +276,6 @@ "registry-postgresql-tls-certificate", }, }, - "registry-notary-stage-secrets": { - "outputs": { - "notary-serve": "registry-operator-files-notary-serve", - }, - "secrets": { - "registry-notary-relay-workload-credential", - "registry-notary-signing-key", - "registry-postgresql-tls-certificate", - }, - }, } ACTION_STAGER_SPECS = { @@ -368,12 +299,6 @@ }, "secrets": {"registry-postgresql-tls-certificate"}, }, - "registry-notary-actions-stage-secrets": { - "outputs": { - "notary-prepare": "registry-operator-files-notary-prepare", - }, - "secrets": {"registry-postgresql-tls-certificate"}, - }, } ORDINARY_STAGER_RUNTIME_ACTIONS = { @@ -383,9 +308,6 @@ "registry-relay-consultation-stage-secrets": [ ("relay-consultation-serve", "relay_consultation", "serve"), ], - "registry-notary-stage-secrets": [ - ("notary-serve", "notary", "serve"), - ], } ACTION_STAGER_RUNTIME_ACTIONS = { @@ -404,9 +326,6 @@ "initialize_state", ), ], - "registry-notary-actions-stage-secrets": [ - ("notary-prepare", "notary", "prepare_state_store"), - ], } DURABLE_VOLUMES = frozenset( @@ -416,8 +335,6 @@ "registry-relay-public-audit", "registry-relay-consultation-state", "registry-relay-consultation-audit", - "registry-notary-state", - "registry-notary-audit", } ) ORDINARY_STAGED_SECRET_VOLUMES = frozenset( @@ -487,37 +404,6 @@ "restart_action": "restart", "reactivation_action": "verify_state", }, - "notary": { - "kind": "product", - "product_lane": "notary", - "action": "serve", - "immutable_inputs": ["notary-bundle", "notary-anchor"], - "mount_roles": [ - "bundle", - "anchor", - "anti-rollback-state", - "secret", - "audit", - ], - "secret_consumers": [ - "notary-relay-workload-credential", - "notary-signing-key", - ], - "state_roles": ["notary-anti-rollback", "notary-audit"], - "endpoint_classes": [ - "public-application", - "administration", - "posture", - ], - "network_relationships": ["runtime"], - "dependencies": [ - "relay-consultation", - "postgresql-state-plane", - ], - "health_semantics": "notary-health", - "restart_action": "restart", - "reactivation_action": "verify_state", - }, "postgresql-state-plane": { "kind": "supporting", "recipe": "postgresql_state_plane", @@ -550,11 +436,6 @@ "workload": "relay-consultation", "action": "prepare_state_store", }, - { - "id": "prepare-notary-state", - "workload": "notary", - "action": "prepare_state_store", - }, { "id": "initialize-relay-public", "workload": "relay-public", @@ -565,11 +446,6 @@ "workload": "relay-consultation", "action": "initialize_state", }, - { - "id": "initialize-notary", - "workload": "notary", - "action": "initialize_state", - }, { "id": "preview-relay-public-state", "workload": "relay-public", @@ -580,11 +456,6 @@ "workload": "relay-consultation", "action": "preview_state", }, - { - "id": "preview-notary-state", - "workload": "notary", - "action": "preview_state", - }, { "id": "accept-relay-public-state", "workload": "relay-public", @@ -595,11 +466,6 @@ "workload": "relay-consultation", "action": "accept_state", }, - { - "id": "accept-notary-state", - "workload": "notary", - "action": "accept_state", - }, { "id": "verify-relay-public-state", "workload": "relay-public", @@ -610,18 +476,12 @@ "workload": "relay-consultation", "action": "verify_state", }, - { - "id": "verify-notary-state", - "workload": "notary", - "action": "verify_state", - }, ] EXPECTED_RECOVERY_GROUPS = [ { "id": "consultation-state", "members": [ "relay-consultation", - "notary", "postgresql-state-plane", ], }, @@ -636,10 +496,6 @@ "endpoint_class": "private-application", "exposure": "private-network-only", }, - { - "endpoint_class": "administration", - "exposure": "private-network-only", - }, { "endpoint_class": "posture", "exposure": "private-network-only", @@ -685,7 +541,6 @@ def runtime_contract_from_payload(path: Path) -> dict[str, Any]: products = { "registry-relay-public": runtime["relay_public"], "registry-relay-consultation": runtime["relay_consultation"], - "registry-notary": runtime["notary"], } postgresql = runtime["postgresql_state_plane"] except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError) as error: @@ -707,9 +562,6 @@ def runtime_contract_from_payload(path: Path) -> dict[str, Any]: "prepare_state_store" ]["command"] ), - "registry-notary-prepare-state": ( - products["registry-notary"]["prepare_state_store"]["command"] - ), "registry-relay-public-initialize": ( products["registry-relay-public"]["initialize_state"]["command"] ), @@ -718,36 +570,24 @@ def runtime_contract_from_payload(path: Path) -> dict[str, Any]: "initialize_state" ]["command"] ), - "registry-notary-initialize": ( - products["registry-notary"]["initialize_state"]["command"] - ), "registry-relay-public-preview-state": ( products["registry-relay-public"]["preview_state"]["command"] ), "registry-relay-consultation-preview-state": ( products["registry-relay-consultation"]["preview_state"]["command"] ), - "registry-notary-preview-state": ( - products["registry-notary"]["preview_state"]["command"] - ), "registry-relay-public-accept-state": ( products["registry-relay-public"]["accept_state"]["command"] ), "registry-relay-consultation-accept-state": ( products["registry-relay-consultation"]["accept_state"]["command"] ), - "registry-notary-accept-state": ( - products["registry-notary"]["accept_state"]["command"] - ), "registry-relay-public-verify-state": ( products["registry-relay-public"]["verify_state"]["command"] ), "registry-relay-consultation-verify-state": ( products["registry-relay-consultation"]["verify_state"]["command"] ), - "registry-notary-verify-state": ( - products["registry-notary"]["verify_state"]["command"] - ), } health_probes = { name: recipe["health_probe"] for name, recipe in products.items() @@ -1231,13 +1071,12 @@ def validate_plan(path: Path) -> dict[str, str]: ): raise ContractError("deployment plan probe has the wrong closed schema") workloads = plan.get("workloads") - if not isinstance(workloads, list) or len(workloads) != 4: - raise ContractError("deployment plan must contain exactly four workloads") + if not isinstance(workloads, list) or len(workloads) != 3: + raise ContractError("deployment plan must contain exactly three workloads") images: dict[str, str] = {} services = { "relay-public": "registry-relay-public", "relay-consultation": "registry-relay-consultation", - "notary": "registry-notary", "postgresql-state-plane": "registry-postgres", } observed_ids = set() @@ -1293,7 +1132,7 @@ def assert_ordinary_model( services = _services(model) if set(services) != ORDINARY_SERVICES: raise ContractError( - "ordinary model must contain four workloads and three secret stagers" + "ordinary model must contain three workloads and two secret stagers" ) if INITIALIZATION_SERVICES.intersection(services): raise ContractError("ordinary model exposes initialization services") @@ -1322,7 +1161,6 @@ def assert_ordinary_model( for name in ( "registry-relay-public", "registry-relay-consultation", - "registry-notary", ): _assert_product_hardening( name, @@ -1364,7 +1202,6 @@ def assert_ordinary_model( for name, lane in ( ("registry-relay-public", "relay-public"), ("registry-relay-consultation", "relay-consultation"), - ("registry-notary", "notary"), ): _assert_product_mounts( name, @@ -1383,15 +1220,6 @@ def assert_ordinary_model( "target": 8080, } ], - "registry-notary": [ - { - "host_ip": "127.0.0.1", - "mode": "ingress", - "protocol": "tcp", - "published": "4255", - "target": 8081, - } - ], } for name, service in services.items(): if service.get("ports") != expected_ports.get(name): @@ -1497,7 +1325,7 @@ def assert_initialization_model( requires_postgresql = ( lane == "relay-consultation" and action in {"prepare", "initialize"} - ) or (lane == "notary" and action == "prepare") + ) expected_environment_files = ( [package_root / "operator/secrets" / environment] if action in {"prepare", "initialize", "accept"} diff --git a/release/scripts/registry_release_lock.py b/release/scripts/registry_release_lock.py index 33381ca4a..9a2c1b45d 100644 --- a/release/scripts/registry_release_lock.py +++ b/release/scripts/registry_release_lock.py @@ -176,12 +176,13 @@ def secret_projection( } -def product_recipe(product: str, lane: str | None = None) -> dict[str, Any]: - assert lane is not None - health_port = 8080 if product == "registry-relay" else 8081 - prefix = ["product-action"] - if product == "registry-relay": - prefix.append(lane) +def product_recipe(product: str, lane: str) -> dict[str, Any]: + if product != "registry-relay" or lane not in { + "relay-public", + "relay-consultation", + }: + raise ValueError("release lock product runtime is unsupported") + prefix = ["product-action", lane] common_mounts = [ runtime_mount("bundle", "/run/registry/bundle", True), runtime_mount("anchor", "/run/registry/anchor", True), @@ -207,17 +208,8 @@ def product_recipe(product: str, lane: str | None = None) -> dict[str, Any]: serve_secrets = [database_ca] else: preparation_secrets = [database_ca] - initialization_secrets = [] - serve_secrets = [ - database_ca, - secret_projection( - "notary-relay-workload-credential", - "/run/secrets/relay-workload-token", - ), - secret_projection( - "notary-signing-key", "/run/secrets/notary-signing-key.jwk" - ), - ] + initialization_secrets = [database_ca] + serve_secrets = [database_ca] def action( name: str, mounts: list[dict[str, Any]], @@ -227,7 +219,7 @@ def action( environment: bool = True, ) -> dict[str, Any]: command_prefix = ["development-action"] if development else prefix - if development and product == "registry-relay": + if development: command_prefix.append(lane) return { "command": [*command_prefix, name], @@ -292,7 +284,7 @@ def action( f"/usr/local/bin/{product}", "healthcheck", "--url", - f"http://127.0.0.1:{health_port}/ready", + "http://127.0.0.1:8080/ready", ], } @@ -302,10 +294,6 @@ def action( "REGISTRY_RELAY_RUNTIME_PASSWORD", "REGISTRY_RELAY_MAINTENANCE_PASSWORD", "REGISTRY_RELAY_READER_PASSWORD", - "REGISTRY_NOTARY_MIGRATOR_PASSWORD", - "REGISTRY_NOTARY_RUNTIME_PASSWORD", - "REGISTRY_NOTARY_MAINTENANCE_PASSWORD", - "REGISTRY_NOTARY_READER_PASSWORD", ] @@ -322,10 +310,6 @@ def action( \getenv relay_runtime_password REGISTRY_RELAY_RUNTIME_PASSWORD \getenv relay_maintenance_password REGISTRY_RELAY_MAINTENANCE_PASSWORD \getenv relay_reader_password REGISTRY_RELAY_READER_PASSWORD -\getenv notary_migrator_password REGISTRY_NOTARY_MIGRATOR_PASSWORD -\getenv notary_runtime_password REGISTRY_NOTARY_RUNTIME_PASSWORD -\getenv notary_maintenance_password REGISTRY_NOTARY_MAINTENANCE_PASSWORD -\getenv notary_reader_password REGISTRY_NOTARY_READER_PASSWORD SELECT 'CREATE ROLE registry_relay_owner NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS' WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_relay_owner') \gexec SELECT format('CREATE ROLE registry_relay_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', :'relay_migrator_password') @@ -347,31 +331,8 @@ def action( ALTER DATABASE registry_relay OWNER TO registry_relay_owner; REVOKE ALL ON DATABASE registry_relay FROM PUBLIC; GRANT CONNECT ON DATABASE registry_relay TO registry_relay_migrator, registry_relay_runtime, registry_relay_maintenance, registry_relay_reader; -SELECT 'CREATE ROLE registry_notary_owner NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS' -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_notary_owner') \gexec -SELECT format('CREATE ROLE registry_notary_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', :'notary_migrator_password') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_notary_migrator') \gexec -SELECT format('CREATE ROLE registry_notary_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', :'notary_runtime_password') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_notary_runtime') \gexec -SELECT format('CREATE ROLE registry_notary_maintenance LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', :'notary_maintenance_password') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_notary_maintenance') \gexec -SELECT format('CREATE ROLE registry_notary_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L', :'notary_reader_password') -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'registry_notary_reader') \gexec -ALTER ROLE registry_notary_owner NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; -ALTER ROLE registry_notary_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD :'notary_migrator_password'; -ALTER ROLE registry_notary_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD :'notary_runtime_password'; -ALTER ROLE registry_notary_maintenance LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD :'notary_maintenance_password'; -ALTER ROLE registry_notary_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD :'notary_reader_password'; -GRANT registry_notary_owner TO registry_notary_migrator WITH INHERIT FALSE, SET TRUE; -SELECT 'CREATE DATABASE registry_notary OWNER registry_notary_owner' -WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = 'registry_notary') \gexec -ALTER DATABASE registry_notary OWNER TO registry_notary_owner; -REVOKE ALL ON DATABASE registry_notary FROM PUBLIC; -GRANT CONNECT ON DATABASE registry_notary TO registry_notary_migrator, registry_notary_runtime, registry_notary_maintenance, registry_notary_reader; \connect registry_relay REVOKE ALL ON SCHEMA public FROM PUBLIC; -\connect registry_notary -REVOKE ALL ON SCHEMA public FROM PUBLIC; SQL""" @@ -468,7 +429,7 @@ def postgresql_recipe() -> dict[str, Any]: def operator_files() -> list[dict[str, Any]]: files: list[dict[str, Any]] = [] product_owners = ["root:root", "65532:65532"] - for lane in ["relay-public", "relay-consultation", "notary"]: + for lane in ["relay-public", "relay-consultation"]: files.append( { "id": f"{lane}-environment", @@ -479,8 +440,6 @@ def operator_files() -> list[dict[str, Any]]: } ) for file_id, file_format, owners in [ - ("notary-signing-key", "json_web_key", product_owners), - ("notary-relay-workload-credential", "compact_jwt", product_owners), ( "postgresql-tls-certificate", "pem_certificate", @@ -542,13 +501,11 @@ def create_payload(args: argparse.Namespace) -> int: images = image_lock.get("images") if not isinstance(images, dict) or set(images) != { "registry-relay", - "registry-notary", "postgresql", }: raise ValueError("legacy image lock image roster is not closed") relay, relay_index_digest = image(images["registry-relay"], "Relay") - notary, notary_index_digest = image(images["registry-notary"], "Notary") postgresql, postgresql_index_digest = image( images["postgresql"], "PostgreSQL" ) @@ -557,11 +514,6 @@ def create_payload(args: argparse.Namespace) -> int: "Relay", relay_index_digest, ) - notary_manifest_digest = read_platform_manifest_from_index( - args.notary_image_index, - "Notary", - notary_index_digest, - ) postgresql_manifest_digest = read_platform_manifest_from_index( args.postgresql_image_index, "PostgreSQL", @@ -608,7 +560,6 @@ def locked_image(identity: str, manifest_digest: str) -> dict[str, Any]: "registryctl_artifacts": registryctl_artifacts, "images": { "relay": locked_image(relay, relay_manifest_digest), - "notary": locked_image(notary, notary_manifest_digest), "postgresql_state_plane": locked_image( postgresql, postgresql_manifest_digest ), @@ -618,7 +569,6 @@ def locked_image(identity: str, manifest_digest: str) -> dict[str, Any]: "relay_consultation": product_recipe( "registry-relay", "relay-consultation" ), - "notary": product_recipe("registry-notary", "notary"), "postgresql_state_plane": postgresql_recipe(), "operator_files": operator_files(), }, @@ -633,10 +583,6 @@ def locked_image(identity: str, manifest_digest: str) -> dict[str, Any]: "https://id.registrystack.org/schemas/" "registry-relay/registry-relay.config.schema.json" ), - "notary_config_schema": ( - "https://id.registrystack.org/schemas/" - "registry-notary/registry-notary.config.schema.json" - ), }, "embedded_starters": [ starter_binding(starter_id, STARTERS[starter_id], args.version) @@ -702,7 +648,6 @@ def parser() -> argparse.ArgumentParser: create.add_argument("--asset-dir", required=True, type=Path) create.add_argument("--image-lock", required=True, type=Path) create.add_argument("--relay-image-index", required=True, type=Path) - create.add_argument("--notary-image-index", required=True, type=Path) create.add_argument("--postgresql-image-index", required=True, type=Path) create.add_argument("--output", required=True, type=Path) create.set_defaults(handler=create_payload) diff --git a/release/scripts/runtime_parity_payload.py b/release/scripts/runtime_parity_payload.py index 529da0e23..1d3b8859f 100755 --- a/release/scripts/runtime_parity_payload.py +++ b/release/scripts/runtime_parity_payload.py @@ -43,7 +43,6 @@ def create_payload(output: Path) -> None: image_identities = {} for name, repository, digest_character in [ ("relay", "example.invalid/registrystack/registry-relay", "d"), - ("notary", "example.invalid/registrystack/registry-notary", "e"), ("postgresql", "example.invalid/registrystack/postgresql", "f"), ]: index_path = root / f"{name}.index.json" @@ -80,7 +79,6 @@ def create_payload(output: Path) -> None: "tag_target": TAG_TARGET, "images": { "registry-relay": image_identities["relay"], - "registry-notary": image_identities["notary"], "postgresql": image_identities["postgresql"], }, }, @@ -100,7 +98,6 @@ def create_payload(output: Path) -> None: asset_dir=assets, image_lock=image_lock, relay_image_index=image_indexes["relay"], - notary_image_index=image_indexes["notary"], postgresql_image_index=image_indexes["postgresql"], output=output, ) diff --git a/release/scripts/test_check_adopter_compose_contract.py b/release/scripts/test_check_adopter_compose_contract.py index 5b9e587cc..12a1a39e5 100644 --- a/release/scripts/test_check_adopter_compose_contract.py +++ b/release/scripts/test_check_adopter_compose_contract.py @@ -24,7 +24,6 @@ def expected_images() -> dict[str, str]: return { "registry-relay-public": "example.invalid/relay@sha256:" + "a" * 64, "registry-relay-consultation": ("example.invalid/relay@sha256:" + "a" * 64), - "registry-notary": "example.invalid/notary@sha256:" + "b" * 64, "registry-postgres": "example.invalid/postgres@sha256:" + "c" * 64, } @@ -180,7 +179,6 @@ def ordinary_model() -> dict: for name, lane in ( ("registry-relay-public", "relay-public"), ("registry-relay-consultation", "relay-consultation"), - ("registry-notary", "notary"), ): services[name] = { **product_hardening(), @@ -204,15 +202,6 @@ def ordinary_model() -> dict: "target": 8080, } ] - services["registry-notary"]["ports"] = [ - { - "host_ip": "127.0.0.1", - "mode": "ingress", - "protocol": "tcp", - "published": "4255", - "target": 8081, - } - ] return { "name": CHECKER.PROJECT_NAME, "services": services, @@ -282,7 +271,7 @@ def initialization_model(ordinary: dict) -> dict: requires_postgresql = ( lane == "relay-consultation" and action in {"prepare", "initialize"} - ) or (lane == "notary" and action == "prepare") + ) if requires_postgresql: service["networks"] = {CHECKER.NETWORK_RUNTIME: {}} else: @@ -327,7 +316,7 @@ def test_ordinary_model_rejects_namespace_holder(self) -> None: model["services"]["registry-private-namespace"] = {} with self.assertRaisesRegex( CHECKER.ContractError, - "four workloads and three secret stagers", + "three workloads and two secret stagers", ): assert_ordinary(model) @@ -342,22 +331,22 @@ def test_ordinary_model_excludes_action_stagers_and_scratch_volumes(self) -> Non model["services"][action_stager] = stager(action_stager, action=True) with self.assertRaisesRegex( CHECKER.ContractError, - "four workloads and three secret stagers", + "three workloads and two secret stagers", ): assert_ordinary(model) def test_ordinary_model_requires_each_closed_runtime_field(self) -> None: mutations = { - "workload-image": lambda model: model["services"]["registry-notary"].pop( + "workload-image": lambda model: model["services"]["registry-relay-public"].pop( "image" ), - "workload-command": lambda model: model["services"]["registry-notary"].pop( + "workload-command": lambda model: model["services"]["registry-relay-public"].pop( "command" ), "hardening": lambda model: model["services"]["registry-relay-public"].pop( "read_only" ), - "publication": lambda model: model["services"]["registry-notary"].pop( + "publication": lambda model: model["services"]["registry-relay-public"].pop( "ports" ), "unauthorized-publication": lambda model: model["services"][ @@ -394,20 +383,20 @@ def test_ordinary_model_requires_each_closed_runtime_field(self) -> None: "anchor": lambda model: model["services"]["registry-relay-public"][ "volumes" ].pop(1), - "state": lambda model: model["services"]["registry-notary"]["volumes"].pop( + "state": lambda model: model["services"]["registry-relay-consultation"]["volumes"].pop( 2 ), - "audit": lambda model: model["services"]["registry-notary"]["volumes"].pop( + "audit": lambda model: model["services"]["registry-relay-consultation"]["volumes"].pop( 3 ), "staged-secret": lambda model: model["services"]["registry-postgres"][ "volumes" ].pop(), "volume-inventory": lambda model: model["volumes"].pop( - "registry-notary-state" + "registry-relay-consultation-state" ), "operator-secret": lambda model: model["secrets"].pop( - "registry-notary-signing-key" + "registry-postgresql-tls-certificate" ), } for name, mutate in mutations.items(): @@ -499,7 +488,7 @@ def test_ordinary_requires_every_protected_workload_mount(self) -> None: def test_ordinary_publications_are_exact_and_private_services_stay_private( self, ) -> None: - for service_name in ("registry-relay-public", "registry-notary"): + for service_name in ("registry-relay-public",): with self.subTest(service=service_name, mutation="missing"): model = ordinary_model() model["services"][service_name].pop("ports") @@ -512,7 +501,6 @@ def test_ordinary_publications_are_exact_and_private_services_stay_private( assert_ordinary(model) for service_name in CHECKER.ORDINARY_SERVICES - { "registry-relay-public", - "registry-notary", }: with self.subTest(service=service_name, mutation="published"): model = ordinary_model() @@ -534,7 +522,6 @@ def test_product_inputs_must_be_owned_by_the_exact_lane_and_package( lanes = { "registry-relay-public": "relay-public", "registry-relay-consultation": "relay-consultation", - "registry-notary": "notary", } for service_name, lane in lanes.items(): other_lane = next( @@ -580,8 +567,8 @@ def add_cross_lane_input(model: dict) -> None: "secrets" ].append( { - "source": "registry-notary-signing-key", - "target": "/run/secrets/notary-signing-key", + "source": "registry-postgresql-admin-password", + "target": "/run/secrets/postgresql-admin-password", } ) @@ -590,7 +577,7 @@ def add_cross_lane_output(model: dict) -> None: "volumes" ].append( volume( - "registry-operator-files-notary-serve", + "registry-operator-files-postgresql-serve", "/registryctl-stage/cross-lane", ) ) @@ -674,8 +661,8 @@ def test_each_stager_requires_its_closed_isolated_contract(self) -> None: def test_operator_files_must_stay_under_operator_directory(self) -> None: model = ordinary_model() - model["secrets"]["registry-notary-signing-key"]["file"] = ( - "/fixture/generated/notary-signing-key" + model["secrets"]["registry-postgresql-tls-certificate"]["file"] = ( + "/fixture/generated/postgresql-tls-certificate" ) with self.assertRaisesRegex( CHECKER.ContractError, @@ -685,8 +672,8 @@ def test_operator_files_must_stay_under_operator_directory(self) -> None: def test_value_free_model_rejects_resolved_sentinel(self) -> None: model = ordinary_model() - model["services"]["registry-notary"]["environment"] = { - "REGISTRY_CONFORMANCE_SENTINEL": ("notary-value-must-not-enter-compose") + model["services"]["registry-relay-public"]["environment"] = { + "REGISTRY_CONFORMANCE_SENTINEL": ("relay-value-must-not-enter-compose") } with self.assertRaisesRegex(CHECKER.ContractError, "sentinel value"): assert_ordinary(model) @@ -765,7 +752,7 @@ def test_state_checks_are_non_mutating_and_accept_has_only_lane_environment( ) -> None: ordinary = ordinary_model() initialized = initialization_model(ordinary) - for lane in ("relay-public", "relay-consultation", "notary"): + for lane in ("relay-public", "relay-consultation"): accept_name = f"registry-{lane}-accept-state" accept = initialized["services"][accept_name] self.assertEqual( @@ -796,16 +783,16 @@ def test_state_checks_are_non_mutating_and_accept_has_only_lane_environment( ) changed = initialization_model(ordinary) - changed["services"]["registry-notary-accept-state"]["env_file"] = [ - "/fixture/package/operator/secrets/relay-public-environment" + changed["services"]["registry-relay-public-accept-state"]["env_file"] = [ + "/fixture/package/operator/secrets/relay-consultation-environment" ] with self.assertRaises(CHECKER.ContractError): assert_initialization(changed, ordinary) changed = initialization_model(ordinary) - changed["services"]["registry-notary-accept-state"]["volumes"].append( + changed["services"]["registry-relay-public-accept-state"]["volumes"].append( volume( - "registry-operator-files-notary-serve", + "registry-operator-files-relay-consultation-serve", "/run/secrets", read_only=True, ) @@ -837,9 +824,6 @@ def test_preparation_and_bootstrap_use_only_action_stagers(self) -> None: "registry-relay-consultation-initialize": ( "registry-relay-consultation-actions-stage-secrets" ), - "registry-notary-prepare-state": ( - "registry-notary-actions-stage-secrets" - ), } for service_name, stager_name in expected.items(): dependencies = initialized["services"][service_name]["depends_on"] @@ -870,13 +854,13 @@ def test_initialization_requires_exact_postgresql_delta(self) -> None: def test_initialization_requires_each_closed_action_field(self) -> None: mutations = { "service": lambda model: model["services"].pop( - "registry-notary-initialize" + "registry-relay-consultation-initialize" ), - "image": lambda model: model["services"]["registry-notary-initialize"].pop( + "image": lambda model: model["services"]["registry-relay-consultation-initialize"].pop( "image" ), "command": lambda model: model["services"][ - "registry-notary-initialize" + "registry-relay-consultation-initialize" ].pop("command"), "hardening": lambda model: model["services"][ "registry-postgres-bootstrap" @@ -885,7 +869,7 @@ def test_initialization_requires_each_closed_action_field(self) -> None: "registry-relay-consultation-prepare-state" ].pop("networks"), "dependency": lambda model: model["services"][ - "registry-notary-prepare-state" + "registry-relay-consultation-prepare-state" ]["depends_on"].pop("registry-postgres"), "restart": lambda model: model["services"][ "registry-relay-public-initialize" @@ -896,17 +880,17 @@ def test_initialization_requires_each_closed_action_field(self) -> None: "bundle": lambda model: model["services"][ "registry-relay-public-prepare-state" ]["volumes"].pop(0), - "anchor": lambda model: model["services"]["registry-notary-initialize"][ + "anchor": lambda model: model["services"]["registry-relay-consultation-initialize"][ "volumes" ].pop(1), - "state": lambda model: model["services"]["registry-notary-initialize"][ + "state": lambda model: model["services"]["registry-relay-consultation-initialize"][ "volumes" ].pop(2), - "audit": lambda model: model["services"]["registry-notary-initialize"][ + "audit": lambda model: model["services"]["registry-relay-consultation-initialize"][ "volumes" ].pop(3), "staged-secret": lambda model: model["services"][ - "registry-notary-initialize" + "registry-relay-consultation-initialize" ]["volumes"].pop(), } ordinary = ordinary_model() @@ -951,7 +935,6 @@ def test_initialization_requires_each_command_restart_network_and_dependency( metadata[1] == "relay-consultation" and metadata[2] in {"prepare", "initialize"} ) - or (metadata[1] == "notary" and metadata[2] == "prepare") ) network_field = "networks" if requires_postgresql else "network_mode" for field in ("command", "restart", network_field): @@ -1030,10 +1013,10 @@ def test_initialization_services_are_unpublished_and_keep_every_mount( def test_initialization_delta_cannot_change_ordinary_service(self) -> None: ordinary = ordinary_model() initialized = initialization_model(ordinary) - initialized["services"]["registry-notary"]["command"] = ["changed"] + initialized["services"]["registry-relay-public"]["command"] = ["changed"] with self.assertRaisesRegex( CHECKER.ContractError, - "changed ordinary service registry-notary", + "changed ordinary service registry-relay-public", ): assert_initialization(initialized, ordinary) @@ -1200,8 +1183,8 @@ def test_parent_include_rejects_renamed_durable_volume(self) -> None: parent["volumes"][name] = {"name": f"{parent_name}_{name}"} for name in parent["secrets"]: parent["secrets"][name]["name"] = f"{parent_name}_{name}" - parent["volumes"]["registry-notary-state"] = { - "name": f"{parent_name}_registry-notary-state" + parent["volumes"]["registry-relay-public-state"] = { + "name": f"{parent_name}_registry-relay-public-state" } parent["services"]["parent-runtime-client"] = { "image": ( @@ -1211,7 +1194,7 @@ def test_parent_include_rejects_renamed_durable_volume(self) -> None: } with self.assertRaisesRegex( CHECKER.ContractError, - "renamed durable volume registry-notary-state", + "renamed durable volume registry-relay-public-state", ): CHECKER.assert_parent_include(parent, ordinary) diff --git a/release/scripts/test_postgresql_runtime_recipe.py b/release/scripts/test_postgresql_runtime_recipe.py index 378f44d6d..beba1549f 100644 --- a/release/scripts/test_postgresql_runtime_recipe.py +++ b/release/scripts/test_postgresql_runtime_recipe.py @@ -443,8 +443,8 @@ def test_empty_volume_tls_roles_databases_and_restart(self) -> None: "--command=\"SELECT ssl FROM pg_stat_ssl " "WHERE pid=pg_backend_pid(); " "SELECT datname || ':' || pg_get_userbyid(datdba) " - "FROM pg_database WHERE datname IN " - "('registry_relay','registry_notary') ORDER BY datname; " + "FROM pg_database WHERE datname LIKE 'registry_%' " + "ORDER BY datname; " "SELECT rolname || ':' || rolcanlogin || ':' || rolsuper " "FROM pg_roles WHERE rolname LIKE 'registry_%' " "ORDER BY rolname;\"" @@ -464,8 +464,10 @@ def test_empty_volume_tls_roles_databases_and_restart(self) -> None: ) lines = {line.strip() for line in query.splitlines() if line.strip()} self.assertIn("t", lines) - self.assertIn("registry_notary:registry_notary_owner", lines) self.assertIn("registry_relay:registry_relay_owner", lines) + self.assertFalse( + any(line.startswith("registry_notary") for line in lines) + ) expected_roles = { "registry_stack_bootstrap:true:true", "registry_relay_owner:false:false", @@ -473,11 +475,6 @@ def test_empty_volume_tls_roles_databases_and_restart(self) -> None: "registry_relay_runtime:true:false", "registry_relay_maintenance:true:false", "registry_relay_reader:true:false", - "registry_notary_owner:false:false", - "registry_notary_migrator:true:false", - "registry_notary_runtime:true:false", - "registry_notary_maintenance:true:false", - "registry_notary_reader:true:false", } self.assertFalse( expected_roles - lines, diff --git a/release/scripts/test_registry_release_lock.py b/release/scripts/test_registry_release_lock.py index 4a945f075..cb8e4709d 100644 --- a/release/scripts/test_registry_release_lock.py +++ b/release/scripts/test_registry_release_lock.py @@ -52,7 +52,6 @@ def test_create_payload_generates_complete_closed_example(self) -> None: application_digests = {} for name, repository, value in [ ("relay", "ghcr.io/registrystack/registry-relay", "d"), - ("notary", "ghcr.io/registrystack/registry-notary", "e"), ("postgresql", "docker.io/library/postgres", "f"), ]: application_digest = f"sha256:{value * 64}" @@ -91,7 +90,6 @@ def test_create_payload_generates_complete_closed_example(self) -> None: "tag_target": tag_target, "images": { "registry-relay": image_identities["relay"], - "registry-notary": image_identities["notary"], "postgresql": image_identities["postgresql"], }, } @@ -111,7 +109,6 @@ def test_create_payload_generates_complete_closed_example(self) -> None: asset_dir=assets, image_lock=image_lock, relay_image_index=image_indexes["relay"], - notary_image_index=image_indexes["notary"], postgresql_image_index=image_indexes["postgresql"], output=output, ) @@ -141,7 +138,7 @@ def test_create_payload_generates_complete_closed_example(self) -> None: ) self.assertEqual( set(payload["images"]), - {"relay", "notary", "postgresql_state_plane"}, + {"relay", "postgresql_state_plane"}, ) self.assertEqual( set(payload["images"]), @@ -159,16 +156,22 @@ def test_create_payload_generates_complete_closed_example(self) -> None: ], }, ) - self.assertEqual( - payload["images"]["notary"]["platforms"][0]["manifest_digest"], - application_digests["notary"], - ) self.assertEqual( payload["images"]["postgresql_state_plane"]["platforms"][0][ "manifest_digest" ], application_digests["postgresql"], ) + self.assertEqual( + set(payload["supported_contracts"]), + { + "config_bundle_schema", + "config_signature_schema", + "trust_anchor_schema", + "anchor_transition_schema", + "relay_config_schema", + }, + ) self.assertEqual( set(schema["properties"]["runtime"]["required"]), set(schema["properties"]["runtime"]["properties"]), @@ -180,6 +183,15 @@ def test_create_payload_generates_complete_closed_example(self) -> None: payload["runtime"] ) ) + self.assertEqual( + set(payload["runtime"]), + { + "relay_public", + "relay_consultation", + "postgresql_state_plane", + "operator_files", + }, + ) self.assertEqual( set(payload["runtime"]), set(schema["properties"]["runtime"]["required"]), @@ -262,19 +274,10 @@ def test_create_payload_generates_complete_closed_example(self) -> None: "preparation": ["postgresql-tls-certificate"], "serve": ["postgresql-tls-certificate"], }, - "notary": { - "preparation": ["postgresql-tls-certificate"], - "serve": [ - "postgresql-tls-certificate", - "notary-relay-workload-credential", - "notary-signing-key", - ], - }, } for lane, product in [ ("relay-public", "registry-relay"), ("relay-consultation", "registry-relay"), - ("notary", "registry-notary"), ]: recipe = payload["runtime"][lane.replace("-", "_")] prefix = ["product-action"] @@ -421,7 +424,7 @@ def test_create_payload_generates_complete_closed_example(self) -> None: hashlib.sha256( postgresql["bootstrap"]["command"][2].encode() ).hexdigest(), - "cbad443afb9700702df52be6513cf8afd95b97747d75a0a417df4fd079a2e79c", + "02515ab47034a241554bc13f616de00c14b42a36139d6d07a1a53e52c6c28f0e", ) bootstrap_file = next( file @@ -435,15 +438,12 @@ def test_create_payload_generates_complete_closed_example(self) -> None: product_environment_ids = { "relay-public-environment", "relay-consultation-environment", - "notary-environment", } operator_files = payload["runtime"]["operator_files"] self.assertEqual( {file["id"] for file in operator_files}, product_environment_ids | { - "notary-signing-key", - "notary-relay-workload-credential", "postgresql-tls-certificate", "postgresql-tls-private-key", "postgresql-admin-password", From e6fad1fc05cf69193b27b192f2bac659b814c89a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 19:22:37 +0700 Subject: [PATCH 087/136] fix(ci): expect Evidence contracts in Rust result Signed-off-by: Jeremi Joslin --- release/scripts/test_registry_release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 401583084..eec596550 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -447,6 +447,7 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "rust-policy", "rust-quality", "rust-tests", + "evidence-contracts", "notary-contracts", "relay-contracts", }, From cb8ae930a115883fa2677a4ce29b15c54a73638e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 19:29:39 +0700 Subject: [PATCH 088/136] docs(evidence): rewrite the Solmara Lab first run against the composed demo The tutorial ended at `just smoke`, which proved the lab was alive and nothing else. A reader finished it without ever seeing the thing the lab now exists to show: a registry answering a question about a person without handing over the record. It now runs the Evidence overlay and closes on one hand-made call. Reading two fields of one civil-register row through Relay, Evidence returns a signed `true`, and the page reads the decoded payload back to name what is not in it. The last Verify command fails unless the answer is `true` and carries neither the subject reference nor the date of birth, so the claim is checked rather than asserted. The gate had drifted well past the Evidence work: it still expected `redis` and three shared Notary instances the lab dropped some time ago. It now names the 17 services the page names and asserts the running total of 41 separately, so the count printed on the page is the count the gate checks. Two prerequisites stay documented rather than hidden. No release ships Evidence or Mint, so the reader names a clean Registry Stack checkout; and `just generate` rotates the Postgres password, so a second run against kept volumes dies in bootstrap with a message naming no cause. That failure now has a troubleshooting row, and the gate refuses to start against a checkout still holding volumes rather than deleting someone else's data. Signed-off-by: Jeremi Joslin --- .../public/images/solmara-lab-topology.svg | 195 ++++++----- docs/site/scripts/check-tutorial.sh | 95 ++++-- .../tutorials/first-run-with-solmara-lab.mdx | 302 +++++++++++------- ...tary-retirement-and-evidence-onboarding.md | 20 +- 4 files changed, 376 insertions(+), 236 deletions(-) diff --git a/docs/site/public/images/solmara-lab-topology.svg b/docs/site/public/images/solmara-lab-topology.svg index f147eea56..12e210e14 100644 --- a/docs/site/public/images/solmara-lab-topology.svg +++ b/docs/site/public/images/solmara-lab-topology.svg @@ -1,132 +1,125 @@ - - Solmara Lab compose topology - The 16 Solmara Lab services in four groups. Six authority Relay instances (cra-civil-relay on host port 4311, nia-population-relay 4312, sro-social-relay 4313, programme-mis-relay 4314, sipf-pensions-relay 4315, nagdi-agriculture-relay 4316) provide evidence reads to four Notary instances (child-benefit-notary 4321, which this tutorial calls directly; pension-notary 4322; nagdi-notary 4323; citizen-notary 4324). The citizen portal on 4300 is wired to the citizen Notary; the Visitor's Center runs on 4301. A support strip holds static-metadata on 4331, scenario-runner, Postgres on 54329, and Redis on 63799. All 16 services start from one just up; host ports shown are the compose defaults. + Solmara Lab compose topology, the Evidence path + The five hops this tutorial makes. One, your shell signs a request with the demo caller's own key and sends it to Registry Mint on host port 4341. Two, Mint returns a short-lived access token. Three, your shell presents that token to Registry Evidence on host port 4343 and asks one question: is this subject an adult. Four, Evidence reads two fields of one row, uin and birth_date, from the Civil Registration Authority's records Relay on host port 4342, under the recorded purpose adult-status-verification. Five, Evidence returns a signed answer carrying true or false and no record. Below, a strip lists the rest of the lab that just up-evidence also starts: six authority Relay instances on host ports 4311 to 4316, the citizen portal on 4300, the Visitor's Center on 4301, static metadata on 4331, Postgres on 54329, and the consultation Relay instances, workload identity agents, and journey services behind them. Forty-one long-running services in all. - SOLMARA LAB — COMPOSE TOPOLOGY · 16 SERVICES + SOLMARA LAB — THE EVIDENCE PATH - - AUTHORITY RELAYS (6) - NOTARIES (4) - EXPERIENCE - - - - - + + + + curl + + Your shell, holding + the caller's own key - - - - - - - - - - - cra-civil-relay - nia-population-relay - sro-social-relay - programme-mis-relay - sipf-pensions-relay - nagdi-agriculture-relay + + + mint + :4341 + Registry Mint + + + + + evidence + :4343 + + Registry Evidence: + is this subject an adult? - - :4311 - :4312 - :4313 - :4314 - :4315 - :4316 + + + + cra-records-relay + :4342 + + Registry Relay over + the civil register: + two fields of one row - - evidence - reads + - - + + + 1 - - - - - - - - - child-benefit-notary - you call this one directly - - pension-notary - nagdi-notary - citizen-notary - - - :4321 - :4322 - :4323 - :4324 + + + + + 2 - - - - - portal - home - - - :4300 - :4301 + + + + - - Citizen portal - Visitor's Center + 3 + + + + + + 4 - + - - + + + + 5 + + + + 1 The caller signs a request with its own key. 2 Mint returns a short-lived access token. + 3 The caller presents that token and asks one question. 4 Evidence reads two fields under one + recorded purpose. 5 Evidence returns a signed answer: true or false, and no record. - - SUPPORT - - + + THE REST OF THE LAB + + - static-metadata - scenario-runner - postgres - redis + 6 authority relays + portal + home + static-metadata + postgres - :4331 - :54329 - :63799 + :4311-:4316 + :4300 + :4301 + :4331 + :54329 - metadata bundle - backs the portal's guided demos + one per ministry authority + citizen portal + Visitor's Center + metadata bundle - All 16 services start from one - just up - ; host ports shown are the compose defaults. + All 41 long-running services start from one + just up-evidence + ; host ports shown are the compose defaults. diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh index 5cfd5acea..51c13a1b6 100755 --- a/docs/site/scripts/check-tutorial.sh +++ b/docs/site/scripts/check-tutorial.sh @@ -30,6 +30,11 @@ # SOLMARA_LAB_REF commit to clone when SOLMARA_LAB_PATH is unset. # This pins the check's own reproducibility; the # tutorial itself tells readers to clone `main`. +# REGISTRY_STACK_SOURCE_DIR +# clean Registry Stack checkout the lab builds the +# Evidence and Mint images from, a documented tutorial +# prerequisite until a release publishes them. +# Default: this repository. # # Exit codes: # 0 success @@ -41,8 +46,9 @@ # were extracted from the matching sections; bump these constants when you # intentionally add or remove a documented command # - after compose comes up, the script asserts every entry in -# EXPECTED_SERVICES is in `running` state; bump the array when you -# intentionally add or remove a long-running service +# EXPECTED_SERVICES is in `running` state and that EXPECTED_RUNNING_TOTAL +# services are running in all; bump both when you intentionally add or +# remove a long-running service # - the script runs whatever commands appear in the tutorial verbatim, so a # command change in the docs causes the runner to exercise the new command # @@ -50,9 +56,12 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TUTORIAL="$REPO_ROOT/src/content/docs/tutorials/first-run-with-solmara-lab.mdx" -EXPECTED_STEP_COUNT=3 +EXPECTED_STEP_COUNT=4 EXPECTED_VERIFY_COUNT=4 EXPECTED_DEMO_ARTIFACTS=3 +# Every service the tutorial names or gives a host port. The topology holds far +# more; EXPECTED_RUNNING_TOTAL below covers the rest as a count, because the +# page states one. EXPECTED_SERVICES=( cra-civil-relay nia-population-relay @@ -60,24 +69,26 @@ EXPECTED_SERVICES=( programme-mis-relay sipf-pensions-relay nagdi-agriculture-relay - child-benefit-notary - pension-notary - nagdi-notary - citizen-notary + child-benefit-federator static-metadata portal home scenario-runner postgres - redis + evidence-gateway + mint + cra-records-relay + cra-records-workload-agent + evidence ) +EXPECTED_RUNNING_TOTAL=41 DRY_RUN=0 for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=1 ;; -h | --help) - sed -n '3,37p' "$0" + sed -n '3,53p' "$0" exit 0 ;; *) @@ -267,6 +278,43 @@ else (cd "$CLONE_DIR" && just setup) fi +# The tutorial's Steps run the Evidence overlay. A lab checkout that predates it +# would fail several commands in without saying why. +if [[ ! -f "$LAB_DIR/compose.evidence.yaml" ]]; then + printf 'solmara-lab checkout has no compose.evidence.yaml: %s\n' "$LAB_DIR" >&2 + printf 'the tutorial runs the Evidence overlay; advance SOLMARA_LAB_REF in %s\n' \ + "${BASH_SOURCE[0]}" >&2 + exit 1 +fi + +# No Registry Stack release ships the Evidence or Mint binaries, so the lab +# builds both images from a checkout the operator names, and refuses a dirty +# one. This is a documented tutorial prerequisite until a release publishes +# them; default it to the repository this script lives in. +REGISTRY_STACK_SOURCE_DIR="${REGISTRY_STACK_SOURCE_DIR:-$(cd "$REPO_ROOT/../.." && pwd)}" +export REGISTRY_STACK_SOURCE_DIR +if [[ -n "$(git -C "$REGISTRY_STACK_SOURCE_DIR" status --porcelain 2>&1)" ]]; then + printf 'REGISTRY_STACK_SOURCE_DIR is not a clean git checkout: %s\n' \ + "$REGISTRY_STACK_SOURCE_DIR" >&2 + printf 'point it at a clean checkout or git worktree carrying crates/registry-evidence\n' >&2 + exit 1 +fi + +# Step 1 rotates every local credential, including the Postgres password, so a +# data volume left by an earlier run no longer matches it and the stack cannot +# bootstrap. Clone mode always starts empty. A caller-supplied checkout has to be +# reset by its owner, because cleanup below never deletes their volumes. +if [[ -z "$CLONE_DIR" ]]; then + lab_project="$(cd "$LAB_DIR" && python3 scripts/compose_project_name.py)" + if docker volume ls --format '{{.Name}}' | grep -q "^${lab_project}_"; then + printf 'solmara-lab checkout still holds volumes from an earlier run: %s\n' \ + "$lab_project" >&2 + printf 'step 1 rotates the Postgres password; run just reset-evidence in %s first\n' \ + "$LAB_DIR" >&2 + exit 1 + fi +fi + for tool in just docker uv pnpm python3 openssl git; do if ! command -v "$tool" >/dev/null 2>&1; then printf 'required tool not on PATH: %s\n' "$tool" >&2 @@ -285,11 +333,11 @@ cleanup() { # Clone mode owns its stack outright, so remove the volumes too; for a # caller-supplied checkout, stop containers but never touch its volumes. if [[ -n "$CLONE_DIR" ]]; then - printf '\n--- cleanup: just reset ---\n' | tee -a "$LOG_FILE" - (cd "$LAB_DIR" && just reset) >>"$LOG_FILE" 2>&1 || true + printf '\n--- cleanup: just reset-evidence ---\n' | tee -a "$LOG_FILE" + (cd "$LAB_DIR" && just reset-evidence) >>"$LOG_FILE" 2>&1 || true else - printf '\n--- cleanup: just down ---\n' | tee -a "$LOG_FILE" - (cd "$LAB_DIR" && just down) >>"$LOG_FILE" 2>&1 || true + printf '\n--- cleanup: just down-evidence ---\n' | tee -a "$LOG_FILE" + (cd "$LAB_DIR" && just down-evidence) >>"$LOG_FILE" 2>&1 || true fi if [[ -n "$CLONE_DIR" ]]; then rm -rf "$CLONE_DIR" @@ -320,11 +368,11 @@ for i in "${!STEPS[@]}"; do run_command "step $((i + 1))" "${STEPS[$i]}" done -# After all Steps, the topology should be up. Assert every long-running -# service (everything except the one-shot volume-permissions init job) is in -# `running` state. +# After all Steps, the topology should be up. Assert every service the tutorial +# names is in `running` state, and that the total matches the count the page +# states (everything except the one-shot bootstrap and secret-root jobs). printf '\n--- assert services running ---\n' | tee -a "$LOG_FILE" -running_services="$(docker compose --env-file versions.env --env-file .env -f compose.yaml ps --services --filter status=running)" +running_services="$(docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.evidence.yaml ps --services --filter status=running)" printf 'running services:\n%s\n' "$running_services" >>"$LOG_FILE" missing=() for svc in "${EXPECTED_SERVICES[@]}"; do @@ -338,10 +386,19 @@ if ((${#missing[@]} > 0)); then printf ' %s\n' "$svc" >&2 done printf 'docker compose ps:\n' >&2 - docker compose --env-file versions.env --env-file .env -f compose.yaml ps >&2 || true + docker compose --env-file versions.env --env-file .env -f compose.yaml -f compose.evidence.yaml ps >&2 || true + exit 1 +fi +running_total="$(grep -c . <<<"$running_services")" +if ((running_total != EXPECTED_RUNNING_TOTAL)); then + printf 'tutorial drift: %d services running, the tutorial states %d\n' \ + "$running_total" "$EXPECTED_RUNNING_TOTAL" >&2 + printf 'if this change was intentional, update EXPECTED_RUNNING_TOTAL in %s and the count on the page\n' \ + "${BASH_SOURCE[0]}" >&2 exit 1 fi -printf 'all %d expected services running\n' "${#EXPECTED_SERVICES[@]}" +printf 'all %d named services running, %d in total\n' \ + "${#EXPECTED_SERVICES[@]}" "$running_total" for i in "${!VERIFY[@]}"; do run_command "verify $((i + 1))" "${VERIFY[$i]}" diff --git a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx index b2caf19c2..25157662e 100644 --- a/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx +++ b/docs/site/src/content/docs/tutorials/first-run-with-solmara-lab.mdx @@ -1,51 +1,55 @@ --- title: First run with Solmara Lab -description: Bring up the Solmara Lab compose demo on a laptop, verify it with the smoke suite, and call a Notary evaluation route directly. +description: Bring up the Solmara Lab compose demo on a laptop, verify it with the smoke suites, and ask Registry Evidence one question over a Relay-protected API. status: current draft: true owner: registry-docs source_repos: - solmara-lab - registry-relay - - registry-notary -last_reviewed: "2026-07-07" + - registry-evidence + - registry-mint +last_reviewed: "2026-08-03" doc_type: tutorial persona: - operator + - consumer or verifier locale: en standards_referenced: - openapi - - sd-jwt-vc + - cccev --- import QuickstartMeta from '../../../components/QuickstartMeta.astro'; import { Steps } from '@astrojs/starlight/components'; -Solmara Lab is a standalone Registry Stack adopter demo for the fictional Republic of Solmara: six -[Registry Relay](../../products/registry-relay/) instances and four -[Registry Notary](../../products/registry-notary/) instances wired over generated synthetic data -into one topology, alongside a citizen portal and a public Visitor's Center. This tutorial brings -the topology up with the lab's `just` recipes, verifies it with the smoke suite, and calls a Notary -evaluation route directly so you touch the protected API surface, not only the portal. +Solmara Lab is a standalone Registry Stack adopter demo for the fictional Republic of Solmara: a +whole country's worth of registry services over generated synthetic data, with a citizen portal and +a public Visitor's Center in front of them. This tutorial brings that topology up with the lab's +`just` recipes, verifies it with the smoke suites, and then asks +[Registry Evidence](../../products/registry-evidence/) one question by hand: is this person an +adult? The answer comes back signed, and the date of birth behind it never leaves the Civil +Registration Authority. This tutorial uses synthetic data and local demo credentials. Do not use the generated local keys in production. For a standalone local project, use -[Build and run an HTTP registry project](../author-registry-project/). +[Build and run an HTTP registry project](../author-registry-project/). For Evidence on its own, +without a country around it, start at [Get a first Evidence assertion](../first-evidence-assertion/). Clone Solmara Lab when you want the full multi-service country demo. ## Prerequisites -- Docker with Compose v2. `just up` and `just down` run `docker compose ... up -d --build` and - `docker compose ... down`. +- Docker with Compose v2. `just up-evidence` and `just down-evidence` run + `docker compose ... up -d --build` and `docker compose ... down`. - The [`just`](https://github.com/casey/just) command runner. Every step in this tutorial is a `just` recipe. - `uv`. `just generate` runs the fixture generator with `uv run python -m solmara_lab.generate`. @@ -54,7 +58,12 @@ Clone Solmara Lab when you want the full multi-service country demo. - `pnpm` `11.3.0`, matching the `packageManager` field the portal and Visitor's Center pin. Run `corepack enable` first if `pnpm` is not already on your PATH. - `git`. -- `curl`, for the direct Notary evaluation call and the verify commands. +- `curl`, for the direct Evidence call and the verify commands. +- A clean checkout of the Registry Stack repository, named by the `REGISTRY_STACK_SOURCE_DIR` + environment variable. No Registry Stack release ships the Evidence or Mint binaries yet, so the + lab builds those two images from source; every other image it runs is a pinned digest. The + checkout must have no uncommitted changes, because the build labels each image with the commit + that produced it. This prerequisite goes away once a release publishes both images. ## Get the repository @@ -72,11 +81,11 @@ just setup ## Steps -Run these from your Solmara Lab checkout, in order. +Run these from your Solmara Lab checkout, in order, with `REGISTRY_STACK_SOURCE_DIR` exported. -1. Generate the deterministic fixtures, local demo secrets, and Postgres TLS material: +1. Generate the deterministic fixtures, local demo secrets, and TLS material: ```sh just generate @@ -85,132 +94,183 @@ Run these from your Solmara Lab checkout, in order. 2. Build the local images and start the topology in the background: ```sh - just up + just up-evidence ``` -3. Run the smoke suite, the primary verification: +3. Run the country smoke suite: ```sh just smoke ``` +4. Run the Evidence smoke, which drives Mint, Relay, and Evidence end to end: + + ```sh + just smoke-evidence + ``` + -`just generate` also writes `.env`, a Compose-required file of local demo credentials. Skipping it -is the most common first failure; see the troubleshooting table. `just up` builds the `portal`, -`home`, and `scenario-runner` images locally and pulls the digest-pinned Relay and Notary images; -expect the first run to take most of this tutorial's time. `just smoke` runs story-preview checks -plus, by default, live checks against the running Relay, Notary, and portal services. +`just generate` also writes `.env`, a Compose-required file of local demo credentials, and the +lab's own certificate authority under `config/evidence/tls/`. Skipping it is the most common first +failure; see the troubleshooting table. `just up-evidence` builds the Evidence and Mint images from +your Registry Stack checkout, builds the `portal`, `home`, and `scenario-runner` images, and pulls +the digest-pinned Relay and Notary images; expect the first run to take most of this tutorial's +time. `just smoke` runs story-preview checks plus live checks against the running Relay, Notary, +and portal services. `just smoke-evidence` runs four cases against the Evidence path: an adult, a +minor, a reference no record resolves, and an unusable access token. ## What is running -`just up` starts 16 long-running services: six purpose-scoped Relay instances, one per Solmara -ministry authority, four purpose-scoped Notary instances, the citizen portal, the public Visitor's -Center, a static metadata publisher, a scenario-runner service that backs the portal's guided -demos, Postgres, and Redis. +`just up-evidence` starts 41 long-running services. Most of them exist so that the country's own +journeys work; this tutorial calls six of them. {/* SVG diagram. Every service name and host port in the diagram is restated in the - table and paragraphs that follow. */} + tables and paragraphs that follow. */}
Solmara Lab compose topology, 16 services. Six authority Relay instances
-            (cra-civil-relay :4311, nia-population-relay :4312, sro-social-relay :4313,
-            programme-mis-relay :4314, sipf-pensions-relay :4315, nagdi-agriculture-relay :4316)
-            provide evidence reads to four Notary instances (child-benefit-notary :4321, which
-            you call directly in this tutorial; pension-notary :4322; nagdi-notary :4323;
-            citizen-notary :4324). The citizen portal :4300 is wired to the citizen Notary;
-            the Visitor's Center runs on :4301. A support strip holds static-metadata :4331,
-            scenario-runner, Postgres :54329, and Redis :63799. + alt="The five hops this tutorial makes. Your shell signs a request with the demo + caller's own key and sends it to Registry Mint on host port 4341; Mint returns a + short-lived access token; your shell presents that token to Registry Evidence on + host port 4343 and asks whether the subject is an adult; Evidence reads two fields + of one row from the Civil Registration Authority's records Relay on host port 4342 + under the recorded purpose adult-status-verification; Evidence returns a signed + answer carrying true or false and no record. A strip below lists the rest of the + lab: six authority Relay instances on host ports 4311 to 4316, the citizen portal + on 4300, the Visitor's Center on 4301, static metadata on 4331, Postgres on 54329, + and the consultation Relay instances, workload identity agents, and journey services + behind them." />
-The services this tutorial calls directly: +The endpoints this tutorial uses: -| Service | Host port | Role | +| Endpoint | Reaches | Role | | --- | --- | --- | -| `portal` | `4300` | Citizen portal: a SvelteKit application wired to the citizen Notary. | -| `home` | `4301` | Visitor's Center: the lab's public tour, including an Engineer door. | -| `child-benefit-notary` | `4321` | Notary for the birth-to-child-benefit journey; you call its evaluation route directly. | -| `static-metadata` | `4331` | Unauthenticated multi-authority metadata bundle. | +| `https://127.0.0.1:4341` | `mint` | Registry Mint: issues the short-lived access token Evidence verifies. | +| `https://127.0.0.1:4342` | `cra-records-relay` | Registry Relay over the civil register: the source Evidence reads. | +| `https://127.0.0.1:4343` | `evidence` | Registry Evidence: answers adult status and signs the answer. | +| `http://127.0.0.1:4300` | `portal` | Citizen portal: a SvelteKit application over the authority services. | +| `http://127.0.0.1:4301` | `home` | Visitor's Center: the lab's public tour, including an Engineer door. | +| `http://127.0.0.1:4331` | `static-metadata` | Unauthenticated multi-authority metadata bundle. | + +The three HTTPS endpoints terminate at `evidence-gateway`, a TLS proxy in front of Mint, the +records Relay, and Evidence. Those three containers publish no host port of their own. The gateway +presents certificates from the lab's own certificate authority, which `just generate` writes to +`config/evidence/tls/lab-ca.crt`; every command below passes it with `--cacert`. -The other six Relay instances, three Notary instances, Postgres, and Redis back the pension and -farmer-voucher journeys and the portal's own evidence calls; this tutorial does not call them -directly. The topology figure shows the full port map. +The rest of the topology, which this tutorial does not call: -Each instance publishes its rendered API reference at `/docs`, a route that is always public, and -its raw `/openapi.json`, public here because every Relay and Notary configuration in this lab sets +| Group | Count | What it is | +| --- | --- | --- | +| Public authority Relay instances | 6 | One per ministry authority, on host ports `4311` through `4316`. | +| Consultation Relay instances | 6 | Private per-authority reads, on `4322`, `4323`, and `4325` through `4328`. | +| Workload identity agents | 12 | One per Relay, issuing that Relay's short-lived caller credential. | +| Evidence gateway service | 1 | The TLS proxy described above. | +| Records workload agent | 1 | The caller credential for `cra-records-relay`. | +| Notary instances and the child benefit federator | 7 | The country's own evidence gateway services, on `4321` for the federator. | +| Portal, Visitor's Center, static metadata | 3 | The experience layer. | +| Postgres and the scenario runner | 2 | Shared state on `54329`, and the service backing the portal's guided demos. | + +Each Relay instance publishes its rendered API reference at `/docs`, a route that is always public, +and its raw `/openapi.json`, public here because every Relay configuration in this lab sets `openapi_requires_auth: false`. ## See it: the citizen portal and the Visitor's Center Open the citizen portal at [http://127.0.0.1:4300](http://127.0.0.1:4300). It is a SvelteKit -application backed by the citizen Notary and the civil, social, and agriculture Relay instances. -Sign in with one of the portal's fixed demo personas to see a resident's own view of the evidence -flows this tutorial calls directly. +application backed by the country's own registry services. Sign in with one of the portal's fixed +demo personas to see a resident's own view of the journeys the lab tells. Open the Visitor's Center at [http://127.0.0.1:4301](http://127.0.0.1:4301). It is Solmara Lab's public tour, organized as three doors: visit as a citizen, as a relying agency running a guided -story, or as an engineer. Scroll to the Engineer door section. It republishes the same four -synthetic Notary tokens this tutorial's `.env` generates: `child-benefit-notary`, `pension-notary`, -`nagdi-notary`, and `citizen-notary`. They appear as ready-made copy-as-curl examples, including -the evaluation call you make by hand next. +story, or as an engineer. Scroll to the Engineer door section for ready-made copy-as-curl examples +against the running stack. -## Call a Notary evaluation route directly +## Ask Evidence one question -The birth-to-child-benefit journey is one of the lab's guided stories. Its positive fixture is Mateo -Santos, Solmara UIN `2300010248`: a child whose birth is registered, who is under five, whose -household falls below the poverty threshold, and who is not already enrolled. +A benefits desk needs to know one thing about an applicant: whether they are an adult. It does not +need a date of birth, and the Civil Registration Authority would rather not hand one over. That is +the question the Evidence overlay answers. -Load the generated Notary token for this journey from `.env`, then evaluate the same four claims -the birth-to-child-benefit story checks: +Getting an access token is the first hop. Registry Mint does not accept a password or a shared key: +the caller proves who it is by signing a short-lived assertion with its own private key, and Mint +answers with an access token good for a few minutes. The lab's demo caller key lives in the +generated `.env`, and one recipe does the whole exchange: ```sh -export CHILD_BENEFIT_NOTARY_TOKEN="$(grep -m1 '^CHILD_BENEFIT_NOTARY_TOKEN=' .env | cut -d= -f2-)" +export EVIDENCE_TOKEN="$(just evidence-token)" ``` +Now ask the question. Elena's fixture record, Solmara UIN `2300018263`, carries a date of birth in +1992: + ```sh -curl -sS -X POST \ - -H "x-api-key: $CHILD_BENEFIT_NOTARY_TOKEN" \ - -H "Data-Purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review" \ +curl -sS --cacert config/evidence/tls/lab-ca.crt \ + -X POST \ + -H "Authorization: Bearer $EVIDENCE_TOKEN" \ -H "Content-Type: application/json" \ - -H "Accept: application/vnd.registry-notary.claim-result+json" \ - -d '{ - "target": {"type": "Person", "identifiers": [{"scheme": "solmara_uin", "value": "2300010248"}]}, - "claims": ["birth-is-registered", "child-age-under-5", "household-below-poverty-threshold", "not-already-enrolled"], - "disclosure": "predicate", - "format": "application/vnd.registry-notary.claim-result+json" - }' \ - http://127.0.0.1:4321/v1/evaluations + -H "Accept: application/jose+json" \ + -d "{ + \"requestNonce\": \"$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')\", + \"requirement\": \"https://id.registrystack.org/solmara/requirement/adult-status/v1\", + \"purpose\": \"adult-status-verification\", + \"subjects\": [{\"role\": \"subject\", \"selector\": {\"profile\": \"civil-record-v1\", \"values\": {\"record_reference\": \"2300018263\"}}}] + }" \ + https://127.0.0.1:4343/v1/evidence ``` -The Notary returns `200 OK` with all four claims `satisfied`. The important fields look like this; -other fields are omitted: +The response is `200 OK` and a flattened JWS: a JSON object of `protected`, `payload`, and +`signature`. Decode the payload and it reads like this, with the volatile identifiers and +timestamps left as they came back: ```json { - "results": [ - { "claim_id": "birth-is-registered", "satisfied": true, "value": true }, - { "claim_id": "child-age-under-5", "satisfied": true, "value": true }, - { "claim_id": "household-below-poverty-threshold", "satisfied": true, "value": true }, - { "claim_id": "not-already-enrolled", "satisfied": true, "value": true } + "schema": "registry.assertion-evidence/v1", + "requestNonce": "7mfZrjyeJnBy3oAIPTwXgwnKFFY_KM-vg0-E9WhGMow", + "id": "urn:ulid:01KZ3RHVZ4N3ETVVPQZ08A11KY", + "type": "Evidence", + "supportsRequirement": "https://id.registrystack.org/solmara/requirement/adult-status/v1", + "isConformantTo": "https://id.registrystack.org/solmara/evidence-type/adult-status/v1", + "issuedBy": "https://id.registrystack.org/solmara/issuer/civil-registration-authority", + "providedBy": "https://id.registrystack.org/solmara/evidence/civil-registration-authority", + "issuedAt": "2026-08-03T12:11:08Z", + "observedAt": "2026-08-03T12:11:08Z", + "validUntil": "2026-08-04T12:11:08Z", + "purpose": "adult-status-verification", + "audience": "https://id.registrystack.org/solmara/party/benefits-desk", + "configurationRevision": "sha256:c067a9c3e0167a5f2ef601ef1defc4fd873ccb7dd61fbfd4c1f15f88073fd744", + "subjects": [ + { + "role": "subject", + "binding": "urn:evidence:subject:v1_1tZ8Tpn1A5A36GCa5Q8D1ZC76ms-INzA7yvAnj9UWMw" + } + ], + "supportedValues": [ + { + "providesValueFor": "https://id.registrystack.org/solmara/concept/adult-status", + "value": true + } ] } ``` -The `x-api-key` header carries the caller's credential. `Data-Purpose` declares why the caller is -asking, and the Notary's configuration allows this token to ask only under the -`child-benefit-review` purpose. The `disclosure: predicate` request means the response discloses a -true or false satisfaction per claim, never the underlying civil, population, social, or programme -registry rows Relay consulted to answer it. The Notary can also issue a signed -[SD-JWT verifiable credential (SD-JWT VC)](../../reference/glossary/) for this same eligibility -claim set; that holder-bound issuance flow is out of scope for this tutorial. See -[Registry Notary](../../products/registry-notary/) for the credential issuance contract. +Read what is not in it. The UIN you sent is gone, replaced by an opaque `binding` that identifies +the subject within this assertion and nowhere else. The date of birth Relay disclosed to Evidence +is gone. What travels is one boolean against one named concept, plus enough provenance to check it +later: who issued it, under which purpose, for which audience, against which configuration +revision, and how long it stays valid. That is the whole product in one response. + +Repeat the call with `2300010248`, a fixture child born in 2022, and `value` comes back `false` +with the same shape. Repeat it with `9900000001`, which no record resolves, and Evidence answers +`422` with the problem code `evidence_not_available`: one refusal for every reason a subject cannot +be resolved, so a caller cannot use the refusal to probe who exists. ## Verify -`just smoke` (step 3) is the authoritative check. These commands confirm its artifacts are on -disk, and reproduce the call from -[Call a Notary evaluation route directly](#call-a-notary-evaluation-route-directly) in a single -command so it can run without a shell variable. +`just smoke` and `just smoke-evidence` (steps 3 and 4) are the authoritative checks. These commands +confirm the country smoke's artifacts are on disk, and reproduce the call from +[Ask Evidence one question](#ask-evidence-one-question) in a single command that also asserts what +the assertion does not carry. ```sh ls output/smoke/story-previews.json @@ -225,49 +285,61 @@ ls output/smoke/portal-compose.json ``` ```sh -curl -sS -X POST -H "x-api-key: $(grep -m1 '^CHILD_BENEFIT_NOTARY_TOKEN=' .env | cut -d= -f2-)" -H "Data-Purpose: https://id.registrystack.org/solmara/purpose/child-benefit-review" -H "Content-Type: application/json" -H "Accept: application/vnd.registry-notary.claim-result+json" -d '{"target":{"type":"Person","identifiers":[{"scheme":"solmara_uin","value":"2300010248"}]},"claims":["birth-is-registered","child-age-under-5","household-below-poverty-threshold","not-already-enrolled"],"disclosure":"predicate","format":"application/vnd.registry-notary.claim-result+json"}' http://127.0.0.1:4321/v1/evaluations | python3 -c "import json, sys; data = json.load(sys.stdin); got = {r['claim_id']: r['satisfied'] for r in data['results']}; expected = ['birth-is-registered', 'child-age-under-5', 'household-below-poverty-threshold', 'not-already-enrolled']; raise SystemExit(0 if len(data['results']) == 4 and all(got.get(c) is True for c in expected) else 1)" +curl -sS --cacert config/evidence/tls/lab-ca.crt -X POST -H "Authorization: Bearer $(just evidence-token)" -H "Content-Type: application/json" -H "Accept: application/jose+json" -d "{\"requestNonce\":\"$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')\",\"requirement\":\"https://id.registrystack.org/solmara/requirement/adult-status/v1\",\"purpose\":\"adult-status-verification\",\"subjects\":[{\"role\":\"subject\",\"selector\":{\"profile\":\"civil-record-v1\",\"values\":{\"record_reference\":\"2300018263\"}}}]}" https://127.0.0.1:4343/v1/evidence | python3 -c "import base64, json, sys; envelope = json.load(sys.stdin); payload = json.loads(base64.urlsafe_b64decode(envelope['payload'] + '=' * (-len(envelope['payload']) % 4))); text = json.dumps(payload); raise SystemExit(0 if payload['supportedValues'][0]['value'] is True and '2300018263' not in text and '1992-11-02' not in text else 1)" ``` +The last command exits `0` only if the answer is `true` and neither the reference you sent nor the +date of birth behind it appears anywhere in the signed payload. + ## What you built -You brought up 16 Relay, Notary, portal, and supporting services on your laptop from a single -`just up`, and the smoke suite verified the topology. You then called the child benefit Notary -directly and watched it return four satisfied predicates for Mateo Santos without exposing the -civil, population, social, or programme registry rows Relay consulted to answer. The same -synthetic token and evaluation call are also visible through the Visitor's Center's Engineer door, -so you can confirm what you ran by hand against what the lab publishes. +You brought up 41 services on your laptop from a single `just up-evidence`, and two smoke suites +verified them. You then held a short-lived access token from Registry Mint, asked Registry Evidence +one question about one person, and got a signed boolean back. Behind that boolean, Evidence read +two fields of one row from a Registry Relay deployment under a recorded purpose, and disclosed +neither to you. The Civil Registration Authority answered the question without handing over the +record, which is the point of the whole arrangement. ## Next +- [Get a first Evidence assertion](../first-evidence-assertion/): build your own Evidence project + from scratch, offline, without a country around it. +- [Registry Evidence](../../products/registry-evidence/): the assertion contract, the acceptance + definition model, and the operator contract. - [Build and run an HTTP registry project](../author-registry-project/): create the maintained - local project and verify Relay and Notary outcomes. -- [When to use Registry Stack](../../start/when-to-use/): decide whether Relay, Notary, or both fit - your integration. + local project and verify its outcomes. - [Registry Relay](../../products/registry-relay/): route reference, auth scopes, and configuration guide. -- [Registry Notary](../../products/registry-notary/): claim configuration, disclosure modes, and - credential issuance. ## Troubleshooting | Symptom | Cause | Resolution | | --- | --- | --- | -| `just up` fails immediately with `required variable ... is missing a value: .env not loaded; run just gen-secrets once, then just up` | `just generate` (or `just gen-secrets`) never ran, so `.env` does not exist. | Run `just generate`, then `just up` again. | -| `docker compose` reports a port already allocated | Another process on your machine already holds one of the lab's ports: `4300`, `4301`, `4311` through `4316`, `4321` through `4324`, `4331`, `54329`, or `63799`. | Stop the conflicting process, or set the matching `SOLMARA_*_PORT` environment variable (for example `SOLMARA_PORTAL_PORT=4400`) before `just up`. | +| `just up-evidence` fails immediately with `required variable ... is missing a value: .env not loaded; run just gen-secrets once, then just up` | `just generate` (or `just gen-secrets`) never ran, so `.env` does not exist. | Run `just generate`, then `just up-evidence` again. | +| `just up-evidence` fails with `No Registry Stack release ships Registry Evidence or Registry Mint yet` | `REGISTRY_STACK_SOURCE_DIR` is unset. | Export it with the path to your Registry Stack checkout, then run `just up-evidence` again. | +| `just up-evidence` fails with `Registry Stack source checkout must be clean` | The named checkout has uncommitted changes, so the built image could not be labelled with a real commit. | Commit or stash the changes, or point `REGISTRY_STACK_SOURCE_DIR` at a separate clean checkout or `git worktree`. | +| `just up-evidence` fails with `service "registry-postgresql-bootstrap" didn't complete successfully: exit 2` | You ran `just generate` a second time. It rotates every local credential, including the Postgres password, and the data volume from your earlier run still holds the old one. | Run `just reset-evidence` to drop this checkout's volumes, then `just generate` and `just up-evidence` again. | +| `docker compose` reports a port already allocated | Another process holds one of the lab's ports: `4300`, `4301`, `4311` through `4316`, `4321` through `4323`, `4325` through `4328`, `4331`, `4341` through `4343`, or `54329`. | Stop the conflicting process, or set the matching `SOLMARA_*_PORT` environment variable (for example `SOLMARA_PORTAL_PORT=4400`) before `just up-evidence`. | +| `curl` reports a certificate problem against `4341`, `4342`, or `4343` | The lab's certificate authority was not passed, or `just generate` never wrote it. | Pass `--cacert config/evidence/tls/lab-ca.crt`, and run `just generate` if that file is missing. | +| `just evidence-token` reports `missing SOLMARA_EVIDENCE_CALLER_JWK` | `.env` predates the Evidence overlay, so it has no caller key. | Run `just gen-secrets`, then `just up-evidence` again so Mint picks up the new registration. | | `command not found: uv` or `command not found: pnpm` | A required tool is not on your PATH. | Install [`uv`](https://docs.astral.sh/uv/), or run `corepack enable` for `pnpm`, then run `just setup` again. | | `docker compose ps -a` lists lab containers you did not start | The lab derives a per-checkout Compose project name (`solmara-lab-` plus a hash of the checkout path), so two checkouts never share a project. Leftover containers come from a lab version that used the shared project name `solmara-lab`. | Inspect the old project with `docker compose -p solmara-lab ps`, then remove it with `docker compose -p solmara-lab down` (add `-v` only if you also want its volumes deleted). | -| The Relay and Notary containers start slowly or show high CPU on Apple Silicon | The pinned Relay and Notary images publish `linux/amd64` only, so Compose runs them under emulation. | Expected. Allow extra time on the first `just up`; later runs are faster once the images are cached. | -| The first `just up` appears to hang | The first run pulls the pinned Relay and Notary images and builds the `portal`, `home`, and `scenario-runner` images locally. | Let it finish; do not interrupt it. | +| The Relay and Notary containers start slowly or show high CPU on Apple Silicon | The pinned Relay and Notary images publish `linux/amd64` only, so Compose runs them under emulation. | Expected. Allow extra time on the first `just up-evidence`; later runs are faster once the images are cached. | +| The first `just up-evidence` appears to hang | The first run compiles Evidence and Mint from source, pulls the pinned images, and builds the local application images. | Let it finish; do not interrupt it. | ## Cleanup Stop the containers: ```sh -just down +just down-evidence ``` -`just down` runs `docker compose ... down`, keeping the Postgres data volume and the Relay and -Notary state volumes, so a later `just up` resumes with the same data. To also remove the -volumes, run `just reset` instead; that is the lab's documented clean slate: rerun -`just generate` and `just up` for a fresh topology. +`just down-evidence` runs `docker compose ... down`, keeping the Postgres data volume, the Relay +and Notary state volumes, and the Evidence and Mint secret volumes, so a later `just up-evidence` +resumes with the same data. Do not rerun `just generate` before that: it rotates the Postgres +password, and the kept volume still holds the old one. + +To also remove the volumes, run `just reset-evidence` instead; that is the lab's documented clean +slate, and the one starting point from which rerunning the whole tutorial works: `just generate`, +then `just up-evidence`, for a fresh topology. diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 7c3e347ae..b6e80f9f7 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -123,7 +123,7 @@ gates for its area (see Verification), and committed. - [x] D1. Compose runs Relay + Mint + Evidence: spreadsheet source through Relay, Evidence assertion over Relay's API, smoke suite green. -- [ ] D2. `tutorials/first-run-with-solmara-lab` rewritten against the +- [x] D2. `tutorials/first-run-with-solmara-lab` rewritten against the rebuilt demo and passing its gate. ### E. Shared docs rewrites @@ -507,3 +507,21 @@ is parallel; B has no upstream dependencies and is the standing priority workload agent wrote a trailing newline into the token file, which Notary trims and Evidence does not, since Evidence treats a file secret as opaque bytes; it now publishes the token alone. +- 2026-08-03: D2 done. `first-run-with-solmara-lab` is rewritten against the + composed demo: four Steps ending in `just smoke-evidence`, a new section + that asks Evidence one question by hand and reads the decoded answer, and a + Verify command that fails unless the answer is `true` and carries neither + the subject reference nor the date of birth. The gate was rebuilt with it, + because it had drifted well past the Evidence work: it still expected + `redis` and three shared Notary instances that no longer exist. It now names + the 17 services the page names and asserts the running total of 41 + separately, so the count on the page is checked rather than asserted twice. + Two prerequisites are real and documented rather than worked around. The + reader needs a clean Registry Stack checkout in `REGISTRY_STACK_SOURCE_DIR` + until F3 publishes Evidence and Mint images, and the gate's clone mode stays + pinned to a lab ref that predates the Evidence overlay until the lab commits + are pushed, so this run used `SOLMARA_LAB_PATH`. Found on the way: `just + generate` rotates every credential including the Postgres password, so a + second run against kept volumes dies in `registry-postgresql-bootstrap` + with a message naming neither cause; the page now has that row and the gate + refuses to start against a checkout still holding volumes. From 554463604ffa266a1a7ce23fbf624fa9382a4353 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 20:05:06 +0700 Subject: [PATCH 089/136] refactor(release): retire Notary release surface Signed-off-by: Jeremi Joslin --- .github/dependabot.yml | 31 - .github/workflows/ci.yml | 3 - .github/workflows/release-canary.yml | 22 - .github/workflows/release-candidate.yml | 26 +- .github/workflows/release-repeatability.yml | 46 +- .github/workflows/release.yml | 3 +- ...tary-retirement-and-evidence-onboarding.md | 22 +- release/READINESS.md | 42 +- release/REPEATABLE-BUILDS.md | 8 +- release/VERIFY.md | 9 +- release/conformance/integrations/README.md | 193 -- .../integrations/pilot-report.template.md | 86 - .../dhis2-tracker-2.41.9.profile.json | 85 - .../profiles/opencrvs-dci-v1.9.profile.json | 104 - .../schema/run-result.schema.json | 259 --- release/conformance/openid/README.md | 286 --- .../docker-compose-builder.override.yaml | 3 - .../openid/docker-compose.override.yaml | 11 - .../openid/evidence-summary.schema.json | 375 --- release/conformance/openid/initial-report.md | 18 +- release/conformance/openid/nginx.Dockerfile | 11 - release/conformance/openid/plan-map.json | 105 - .../conformance/openid/python-requirements.in | 2 - .../openid/python-requirements.txt | 38 - ...gistry-notary-oid4vci-issuer.template.json | 17 - .../conformance/openid/server-dev.Dockerfile | 6 - release/contracts/selected-metrics.json | 90 - release/docker/Dockerfile.registry-notary | 39 - release/exercises/README.md | 11 +- release/manifests/registry-stack-beta-27.yaml | 29 + release/scripts/build-release-binaries.sh | 25 - release/scripts/build-release-image.sh | 2 +- release/scripts/check-debian13-images.py | 78 +- release/scripts/check-gates-inventory.py | 6 +- release/scripts/check-release-source-model.sh | 4 +- .../check-stable-surface-compatibility.py | 30 +- release/scripts/conformance_candidate.py | 22 +- release/scripts/integration-e2-runner.py | 1271 ----------- release/scripts/openid-conformance-runner.py | 1685 -------------- release/scripts/registry-release | 65 +- release/scripts/registryctl_image_lock.py | 13 +- release/scripts/release_candidate.py | 71 +- .../scripts/smoke-release-image-oci-labels.sh | 8 +- release/scripts/test_check_gates_inventory.py | 14 +- .../test_check_release_image_oci_labels.py | 14 +- .../test_check_release_source_model.py | 11 + ...test_check_stable_surface_compatibility.py | 83 +- release/scripts/test_conformance_candidate.py | 96 +- release/scripts/test_integration_e2_runner.py | 965 +------- .../scripts/test_openid_conformance_runner.py | 2031 +---------------- release/scripts/test_registry_release.py | 235 +- .../scripts/test_registry_release_plans.py | 2 - release/scripts/test_release_candidate.py | 157 +- .../test_release_workflow_structure.py | 34 + .../scripts/test_validate_upgrade_exercise.py | 22 + release/scripts/validate-upgrade-exercise.py | 20 +- 56 files changed, 921 insertions(+), 8023 deletions(-) delete mode 100644 release/conformance/integrations/README.md delete mode 100644 release/conformance/integrations/pilot-report.template.md delete mode 100644 release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json delete mode 100644 release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json delete mode 100644 release/conformance/integrations/schema/run-result.schema.json delete mode 100644 release/conformance/openid/README.md delete mode 100644 release/conformance/openid/docker-compose-builder.override.yaml delete mode 100644 release/conformance/openid/docker-compose.override.yaml delete mode 100644 release/conformance/openid/evidence-summary.schema.json delete mode 100644 release/conformance/openid/nginx.Dockerfile delete mode 100644 release/conformance/openid/plan-map.json delete mode 100644 release/conformance/openid/python-requirements.in delete mode 100644 release/conformance/openid/python-requirements.txt delete mode 100644 release/conformance/openid/registry-notary-oid4vci-issuer.template.json delete mode 100644 release/conformance/openid/server-dev.Dockerfile delete mode 100644 release/docker/Dockerfile.registry-notary create mode 100644 release/manifests/registry-stack-beta-27.yaml delete mode 100755 release/scripts/integration-e2-runner.py delete mode 100755 release/scripts/openid-conformance-runner.py mode change 100644 => 100755 release/scripts/test_integration_e2_runner.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a38b94bf0..5cd523afa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,21 +30,6 @@ updates: - minor - patch - # The conformance helper lock is a pip-compile requirements file. Its direct - # input remains byte-bound to the reviewed upstream suite requirements. - - package-ecosystem: pip - directory: "/release/conformance/openid" - schedule: - interval: weekly - day: wednesday - time: "08:30" - timezone: Etc/UTC - open-pull-requests-limit: 1 - groups: - conformance-python: - patterns: - - "*" - # Pinned GitHub Actions used by the workflows. - package-ecosystem: github-actions directory: "/" @@ -64,7 +49,6 @@ updates: - package-ecosystem: docker directories: - "/release/docker" - - "/release/conformance/openid" - "/crates/registry-relay" - "/products/notary" schedule: @@ -77,18 +61,3 @@ updates: docker-images: patterns: - "*" - - # Docker Compose image pins use a distinct Dependabot ecosystem from - # Dockerfiles. Both override filenames match Dependabot's Compose matcher. - - package-ecosystem: docker-compose - directory: "/release/conformance/openid" - schedule: - interval: weekly - day: wednesday - time: "11:30" - timezone: Etc/UTC - open-pull-requests-limit: 1 - groups: - docker-compose-images: - patterns: - - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9673a22f9..8215dda6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -746,9 +746,6 @@ jobs: - name: Test conformance candidate binding run: python3 -m unittest release/scripts/test_conformance_candidate.py - - name: Validate external integration evidence packet - run: python3 release/scripts/integration-e2-runner.py validate - - name: Test Relay OIDC smoke run: python3 -m unittest release/scripts/test_relay_oidc_smoke.py diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index b3cc70b9e..ef41ddb81 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -56,7 +56,6 @@ jobs: printf 'canary docs\n' > canary/bundle-root/registry-docs-${tag}.tar.gz printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - notary_ref="ghcr.io/registrystack/registry-notary-candidate@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" relay_ref="ghcr.io/registrystack/registry-relay-candidate@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" postgresql_ref="$(tr -d '\n' < release/registryctl-postgresql-image.ref)" printf '%s\n' "${postgresql_ref}" \ @@ -84,7 +83,6 @@ jobs: } }' > "${evidence_root}/grype/${name}.grype.json" } - write_image_reports registry-notary "${notary_ref}" write_image_reports registry-relay "${relay_ref}" write_image_reports postgresql "${postgresql_ref}" postgresql_digest="${postgresql_ref##*@}" @@ -106,15 +104,12 @@ jobs: schema_version:"registry-stack.advisory-verdict.v2", verdict:"passed", subjects:[ - "registry-notary-image", "registry-relay-image", "postgresql-runtime" ] }' > "${evidence_root}/advisory-verdict.json" cp "${evidence_root}/grype/registry-relay.grype.json" \ canary/bundle-root/security/registry-relay.grype.json - cp "${evidence_root}/grype/registry-notary.grype.json" \ - canary/bundle-root/security/registry-notary.grype.json cp "${evidence_root}/advisory-verdict.json" \ canary/bundle-root/security/advisory-verdict.json evidence_name="registry-stack-${tag}-security-evidence.tar.gz" @@ -127,7 +122,6 @@ jobs: sbom_sha="$(sha256sum canary/bundle-root/registry-stack-${tag}.sbom.spdx.json | awk '{print $1}')" evidence_sha="$(sha256sum "canary/bundle-root/${evidence_name}" | awk '{print $1}')" relay_scan_sha="$(sha256sum canary/bundle-root/security/registry-relay.grype.json | awk '{print $1}')" - notary_scan_sha="$(sha256sum canary/bundle-root/security/registry-notary.grype.json | awk '{print $1}')" advisory_sha="$(sha256sum canary/bundle-root/security/advisory-verdict.json | awk '{print $1}')" payload_size="$(stat -c %s canary/bundle-root/registryctl-${tag}-linux-amd64)" docs_size="$(stat -c %s canary/bundle-root/registry-docs-${tag}.tar.gz)" @@ -159,7 +153,6 @@ jobs: --arg evidence_sha "${evidence_sha}" \ --argjson evidence_size "${evidence_size}" \ --arg relay_scan_sha "${relay_scan_sha}" \ - --arg notary_scan_sha "${notary_scan_sha}" \ --arg advisory_sha "${advisory_sha}" \ --arg bundle_sha "${bundle_sha}" \ --argjson bundle_size "${bundle_size}" \ @@ -209,14 +202,6 @@ jobs: } ], images: [ - { - name: "registry-notary", - candidate_ref: - "ghcr.io/registrystack/registry-notary-candidate@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - digest: - "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - final_ref: ("ghcr.io/registrystack/registry-notary:" + $tag) - }, { name: "registry-relay", candidate_ref: @@ -235,12 +220,6 @@ jobs: sha256: $sbom_sha }, scans: [ - { - image: "registry-notary", - name: "security/registry-notary.grype.json", - sha256: $notary_scan_sha, - status: "passed" - }, { image: "registry-relay", name: "security/registry-relay.grype.json", @@ -305,7 +284,6 @@ jobs: python3 release/scripts/release_candidate.py verify-canary \ --metadata canary/trusted-canary-run.json \ --workflow-revision "${workflow_revision}" - python3 products/notary/tests/advisory_baseline_check_test.py python3 crates/registry-relay/tests/advisory_baseline_check_test.py jq -n \ --arg tag "${tag}" \ diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ac9eb59af..56d60c59b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -131,7 +131,7 @@ jobs: echo "GitHub Release destination is not absent" >&2 exit 1 fi - for package in registry-notary registry-relay; do + for package in registry-relay; do versions="${RUNNER_TEMP}/${package}-versions.json" gh api --paginate --slurp \ "/orgs/${IMAGE_NAMESPACE}/packages/container/${package}/versions?per_page=100" \ @@ -187,8 +187,6 @@ jobs: REGISTRY_RELEASE_SOURCE_MODE=monorepo \ release/scripts/check-release-source-model.sh release/scripts/check-debian13-images.py - python3 -m unittest \ - release/scripts/test_check_advisory_checker_copies.py build-canonical: name: Build Linux payload, private images, and docs once @@ -240,7 +238,7 @@ jobs: run: | set -euo pipefail mkdir -p dist/images - for name in registry-notary registry-relay; do + for name in registry-relay; do package="${name}-candidate" candidate="${REGISTRY}/${IMAGE_NAMESPACE}/${package}:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" metadata="dist/images/${name}.metadata.json" @@ -477,7 +475,7 @@ jobs: run: | set -euo pipefail canonical="inputs/candidate-canonical-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - for name in registry-notary registry-relay; do + for name in registry-relay; do expected_label_args=() if [[ "${name}" == registry-relay ]]; then expected_label_args+=( @@ -503,7 +501,7 @@ jobs: printf '%s' "${GH_TOKEN}" \ | oras login "${REGISTRY}" \ --username "${GITHUB_ACTOR}" --password-stdin - for name in registry-notary registry-relay; do + for name in registry-relay; do package="${name}-candidate" test "$( gh api "/orgs/${IMAGE_NAMESPACE}/packages/container/${package}" \ @@ -596,7 +594,7 @@ jobs: exit 1 fi } - for name in registry-notary registry-relay; do + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" test "$(crane digest "${candidate_ref}")" = "${digest}" @@ -646,10 +644,6 @@ jobs: echo "Exported PostgreSQL rootfs contains a forbidden special file" >&2 exit 1 fi - python3 products/notary/scripts/check_advisory_baselines.py \ - grype candidate/security/grype/registry-notary.grype.json \ - --syft-report candidate/security/syft/registry-notary.syft.json \ - --subject registry-notary-image python3 crates/registry-relay/scripts/check_advisory_baselines.py \ grype candidate/security/grype/registry-relay.grype.json \ --syft-report candidate/security/syft/registry-relay.syft.json \ @@ -664,7 +658,6 @@ jobs: schema_version: "registry-stack.advisory-verdict.v2", verdict: "passed", subjects: [ - "registry-notary-image", "registry-relay-image", "postgresql-runtime" ] @@ -690,7 +683,7 @@ jobs: evidencectl_installer="evidencectl-${{ needs.validate.outputs.tag }}-install.sh" cp crates/registry-evidencectl/install.sh "candidate/bundle-root/${evidencectl_installer}" chmod 0755 "candidate/bundle-root/${evidencectl_installer}" - for name in registry-notary registry-relay; do + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" printf '%s\n' "${REGISTRY}/${IMAGE_NAMESPACE}/${name}@${digest}" \ @@ -699,7 +692,6 @@ jobs: release/scripts/registry-release render-registryctl-image-lock \ "${{ needs.validate.outputs.manifest }}" \ --relay-digest "${RUNNER_TEMP}/registry-relay.release.digest" \ - --notary-digest "${RUNNER_TEMP}/registry-notary.release.digest" \ --postgresql-ref-file release/registryctl-postgresql-image.ref \ --tag-target "${{ needs.validate.outputs.source_sha }}" \ --source-sha "${{ needs.validate.outputs.source_sha }}" \ @@ -712,7 +704,7 @@ jobs: "candidate/bundle-root/registry-stack-${{ needs.validate.outputs.tag }}-security-evidence.tar.gz" \ -C candidate/security \ images image-sbom syft grype advisory-verdict.json - for name in registry-notary registry-relay; do + for name in registry-relay; do cp "candidate/security/grype/${name}.grype.json" \ candidate/bundle-root/ done @@ -781,7 +773,7 @@ jobs: done | jq -s . )" images="$( - for name in registry-notary registry-relay; do + for name in registry-relay; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" jq -n \ @@ -793,7 +785,7 @@ jobs: done | jq -s . )" scans="$( - for name in registry-notary registry-relay; do + for name in registry-relay; do file="candidate/bundle-root/${name}.grype.json" jq -n \ --arg image "${name}" \ diff --git a/.github/workflows/release-repeatability.yml b/.github/workflows/release-repeatability.yml index 3d0d90c3d..cab59437f 100644 --- a/.github/workflows/release-repeatability.yml +++ b/.github/workflows/release-repeatability.yml @@ -128,13 +128,39 @@ jobs: run: | set -euo pipefail mkdir -p proof - expected=( - "registry-manifest-${TAG}-linux-amd64" - "registry-notary-${TAG}-linux-amd64" - "registry-notary-cel-worker-${TAG}-linux-amd64" - "registry-relay-${TAG}-linux-amd64" - "registry-relay-rhai-worker-${TAG}-linux-amd64" - "registryctl-${TAG}-linux-amd64" + mapfile -t expected < <( + python3 - "source" "${TAG}" <<'PY' + import glob + import sys + + import yaml + + root, tag = sys.argv[1:] + version = tag.removeprefix("v") + matches = [] + for path in glob.glob(f"{root}/release/manifests/registry-stack-*.yaml"): + with open(path, encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if str(data.get("stack", {}).get("version", "")) == version: + matches.append(data) + if len(matches) != 1: + raise SystemExit(f"expected one release manifest for {version}") + artifacts = matches[0].get("artifacts", {}) + binary_names = { + "evidence": "evidence", + "evidencectl": "evidencectl", + "mint": "mint", + "registry-manifest-cli": "registry-manifest", + "registry-notary": "registry-notary", + "registry-notary-cel-worker": "registry-notary-cel-worker", + "registry-relay": "registry-relay", + "registry-relay-rhai-worker": "registry-relay-rhai-worker", + "registryctl": "registryctl", + } + for artifact, asset in sorted(binary_names.items()): + if artifact in artifacts: + print(f"{asset}-{tag}-linux-amd64") + PY ) for asset in "${expected[@]}"; do test -s "published/${asset}" @@ -177,7 +203,11 @@ jobs: set -euo pipefail image_lock="published/registryctl-${TAG}-image-lock.json" mkdir -p proof/image-layouts - for name in registry-notary registry-relay; do + mapfile -t product_images < <( + jq -er '.images | keys[] | select(startswith("registry-"))' \ + "${image_lock}" + ) + for name in "${product_images[@]}"; do published_ref="$(jq -er --arg name "${name}" '.images[$name]' "${image_lock}")" published_layout="proof/image-layouts/${name}-published" rebuilt_layout="${GITHUB_WORKSPACE}/proof/image-layouts/${name}-rebuilt" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f6944b45..66cfa473a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -795,7 +795,7 @@ jobs: PY )" mkdir -p "${RUNNER_TEMP}/release-image-indexes" - for name in registry-notary registry-relay; do + for name in registry-relay; do index_digest="$(jq -er --arg name "${name}" \ '.images[] | select(.name == $name) | .digest | select(test("^sha256:[0-9a-f]{64}$"))' "${candidate}")" @@ -817,7 +817,6 @@ jobs: release/scripts/registry-release render-registryctl-image-lock \ "${release_manifest}" \ --relay-digest "${RUNNER_TEMP}/registry-relay.release.digest" \ - --notary-digest "${RUNNER_TEMP}/registry-notary.release.digest" \ --postgresql-ref-file release/registryctl-postgresql-image.ref \ --tag-target "${{ needs.verify.outputs.source_sha }}" \ --source-sha "${{ needs.verify.outputs.workflow_revision }}" \ diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 53dadecfb..0c9e8bc4b 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -106,7 +106,7 @@ gates for its area (see Verification), and committed. `registry-platform-testing` usage. - [ ] C5. CI updated: Notary OpenAPI drift gate removed, Relay's kept, Evidence gates confirmed present. -- [ ] C6. Release tooling: a new manifest without Notary components +- [x] C6. Release tooling: a new manifest without Notary components validates; the source-model proof passes; the OpenID conformance runner's fate decided and recorded (retire if it was OID4VCI-only). Old manifests untouched. @@ -513,3 +513,23 @@ is parallel; B has no upstream dependencies and is the standing priority original checkout and is outside C; the same workspace tests pass when the unrelated `registry-evidencectl` crate is excluded. Next in C: the mandatory C3 deletion approval stop, with C4 reverse-dependency evidence. +- 2026-08-03: C6 done. `registry-stack-beta-27.yaml` defines the first + post-Notary release at v0.17.0, with Relay, the reviewed PostgreSQL runtime, + Manifest, registryctl, Evidence, evidencectl, Mint, docs, and installers but + no Notary artifact. Current release workflows, image locks, security + evidence, repeatability checks, stable-surface metrics, and source-model + proof follow that inventory; pre-0.17 candidate and image-lock validation + still requires the historical Notary closure, and every older manifest is + byte-untouched. The OpenID runner was retired because its plans and protocol + path were exclusively Notary OID4VCI; its initial report remains historical, + while the independent Relay OIDC smoke remains supported. The likewise + Notary-bound external-integration runner was retired rather than relabelled + as evidence for a topology it never exercised. Capsule backfill follows the + same v0.17 boundary, and upgrade-exercise v1 is explicitly frozen as a + historical pre-v0.17 contract instead of reading deleted paths for a current + target. The five required C6 commands passed, as did the consolidated + 585-test release suite (2 skipped), release + workflow lint/structure checks, candidate compatibility tests, and the + current plus historical-base stable-surface checks. Evidence artifacts stay + optional until F3; C6 does not claim that separate workstream complete. Next + in C: C3 approval, then C5 can complete against the deleted workspace graph. diff --git a/release/READINESS.md b/release/READINESS.md index 6a1dbe791..89e527db0 100644 --- a/release/READINESS.md +++ b/release/READINESS.md @@ -1,6 +1,6 @@ # 1.0 Release Readiness -This document tracks the evidence that Registry Relay and Registry Notary are +This document tracks the evidence that Registry Relay and Evidence are ready for a stable release. "Stable" means both semver API stability commitments and a production security posture suitable for government deployments that self-host the stack. @@ -34,7 +34,7 @@ client, plus where those guarantees could leak (logs, error messages, audit records, caches, timing). - [ ] Relay threat model written and reviewed. -- [ ] Notary threat model written and reviewed. +- [ ] Evidence threat model written and reviewed. - [ ] Attack checklist derived from the threat models (drives section 4). ## 3. Standards conformance @@ -46,25 +46,16 @@ write. - [x] Inventory of standards and specification claims across docs and specs. Evidence: [`standards-claims-inventory.md`](notes/standards-claims-inventory.md). - [ ] Per-claim evidence recorded (conformance run, test vectors, or interop). -- [ ] OpenID conformance suite running repeatably against a supported - deployment topology (#205). Must not depend on the retired monorepo lab, - which was replaced by the standalone - [Solmara Lab](https://github.com/registrystack/solmara-lab) (#224). - The release-owned [OIDF harness and plan mapping](conformance/openid/README.md) - and [Relay OIDC smoke](conformance/relay-oidc/README.md) no longer depend - on either lab. The Relay smoke is source-ready and directly runnable - against a digest-pinned published candidate, but no reviewed 1.0 - candidate result exists yet. The OIDF harness's - [initial report](conformance/openid/initial-report.md) preserves the - historical hosted-lab failures but does not yet prove a pinned release - topology. -- [ ] Credentialing, OID4VCI, and status-list interop proof (#57). -- [ ] OpenCRVS and DHIS2 project-authored integration proof (#72). The - [candidate-neutral source packet](conformance/integrations/README.md) - pins both unofficial profiles, closes the public result shape, and - validates published candidate assets. It is not live evidence; the - external compatibility, instance, source-side audit, and teardown - prerequisites remain required for each candidate run. +- [ ] OpenID conformance evidence for supported surfaces. The release-owned + [Relay OIDC smoke](conformance/relay-oidc/README.md) is source-ready and + directly runnable against a digest-pinned published candidate, but no + reviewed 1.0 candidate result exists yet. The retired Notary OID4VCI + wrapper was product-specific and is no longer an active gate; its + [initial report](conformance/openid/initial-report.md) remains historical + evidence only. +- [ ] OpenCRVS and DHIS2 project-authored integration proof (#72). The prior + integration packet was Notary-specific and was retired on 2026-08-03. + No current Relay or Evidence interoperability claim replaces it. ## 4. Adversarial verification @@ -74,13 +65,12 @@ section is about challenging it. - [ ] Maintainer adversarial review of the load-bearing crates: `registry-platform-pdp`, `registry-platform-sdjwt`, `registry-platform-crypto`, `registry-platform-authcommon`, Relay scope - enforcement, Notary disclosure policy evaluation. `registry-platform-sts` - is parked outside the workspace until a consumer is promoted (#298). + enforcement, and Evidence assertion evaluation and signing. - [ ] Negative-path test coverage mapped against the attack checklist; gaps closed with tests that assert denial and correct audit records. Mapping evidence: [`negative-path-coverage-map.md`](notes/negative-path-coverage-map.md). - [ ] cargo-fuzz targets for manifest and artifact parsers (#26). -- [ ] cargo-fuzz targets for token, credential, Relay consultation, and script +- [ ] cargo-fuzz targets for token, assertion, Relay consultation, and script adapter parse boundaries. - [ ] Data-minimization leak review across logs, error paths, audit records, and caches (maintainer work; #176 is the known open case). @@ -126,7 +116,7 @@ What 1.0 promises the institutions that run this. source-built release-tag images; release-artifact verification, credential issuance, metrics, the historical Redis replay/nonce path used by that release, and anti-rollback monotonic rejection remain outside this - run). Current Notary replay and nonce correctness state is PostgreSQL-backed. + run). Backup and restore guidance for generated and single-node deployments is documented in [`backup-and-restore.mdx`](../docs/site/src/content/docs/operate/backup-and-restore.mdx) (#226). The @@ -159,7 +149,7 @@ What 1.0 promises the institutions that run this. ## 8. Data protection posture -Notary's pitch is minimization; it will be held to it. +Evidence's pitch is minimum disclosure; it will be held to it. - [ ] Behavioral guarantee claims extracted from the docs site and verified against implementation. Extraction evidence: diff --git a/release/REPEATABLE-BUILDS.md b/release/REPEATABLE-BUILDS.md index b744a4c10..1826c0102 100644 --- a/release/REPEATABLE-BUILDS.md +++ b/release/REPEATABLE-BUILDS.md @@ -23,10 +23,10 @@ The workflow: release image lock. 3. Rebuilds the canonical Linux payload with fresh Cargo and target directories. -4. Requires byte equality for the six Linux amd64 payloads. -5. Rebuilds Registry Notary and Registry Relay images without cache. -6. Compares image configuration and ordered root filesystem layers with the - published digest-bound images. +4. Requires byte equality for the seven declared Linux amd64 binaries. +5. Rebuilds the Registry Relay image without cache. +6. Compares its image configuration and ordered root filesystem layers with + the published digest-bound image. 7. Records a compact result and retains it for 30 days. The proof excludes native macOS and Linux arm64 Registryctl binaries, diff --git a/release/VERIFY.md b/release/VERIFY.md index 6af50ee79..3483d9d12 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -90,16 +90,17 @@ identity and immutable digest references: image_lock="registryctl-${tag}-image-lock.json" jq -e --arg tag "${tag}" ' - .schema_version == "registryctl.release_image_lock.v1" and + .schema_version == "registryctl.release_image_lock.v3" and .release_tag == $tag and .platform == "linux/amd64" and + ((.images | keys) == ["postgresql", "registry-relay"]) and (.images["registry-relay"] | test("^ghcr\\.io/registrystack/registry-relay@sha256:[0-9a-f]{64}$")) and - (.images["registry-notary"] | - test("^ghcr\\.io/registrystack/registry-notary@sha256:[0-9a-f]{64}$")) + (.images["postgresql"] | + test("^docker\\.io/library/postgres@sha256:[0-9a-f]{64}$")) ' "${image_lock}" -for name in registry-notary registry-relay; do +for name in registry-relay postgresql; do ref="$(jq -er --arg name "${name}" '.images[$name]' "${image_lock}")" expected="${ref##*@}" test "$(crane digest "${ref}")" = "${expected}" diff --git a/release/conformance/integrations/README.md b/release/conformance/integrations/README.md deleted file mode 100644 index 3dc7fce81..000000000 --- a/release/conformance/integrations/README.md +++ /dev/null @@ -1,193 +0,0 @@ -# External integration evidence - -This directory defines the release-owned evidence boundary for Registry Stack's -OpenCRVS and DHIS2 integration profiles. It lets an approved operator prepare a -repeatable run and publish a bounded result without publishing source records, -credentials, infrastructure details, or raw logs. - -Both profiles are **Registry Stack-supported unofficial integration profiles**. -They prove one reviewed read operation against one exact upstream baseline. -They are not product certification or general conformance claims. -An integration pilot alone does not close the separate Country ready or -First-country success gates. - -## Evidence boundary - -The checked-in profiles, schema, and runner are a source packet, not live -evidence. A passing public result also requires all of these external inputs: - -- A published Registry Stack candidate and its complete signed release assets. -- An owner-approved non-production source instance with stable test records. -- Owner-attested metadata, source routes, identifiers, and credentials. -- A source-side audit or request-counter probe that can distinguish zero from - one data-operation call for every case. -- Approved restricted evidence storage, retention, redaction, and teardown. - -For OpenCRVS, the `/registry/sync/search` compatibility probe must pass against -the exact pinned DCI adapter, core, and Farajaland tuple before the live run. -The current starter's synthetic route is not evidence that the real operation -is compatible. - -For DHIS2, the instance owner must attest every metadata UID used by the -authored adapter, including the child programme and BCG, OPV, and measles -programme-stage UIDs. The `DEMO_*` values in the starter are examples and -cannot appear in a live evidence project. - -The DHIS2 starter's offline fixtures are the deterministic acceptance path for -claim semantics and failure behavior. Record live public-demo or operator-owned -compatibility only through this release evidence flow. Public demo uptime, -credentials, and mutable records are not offline acceptance dependencies. - -Do not simulate either prerequisite. Do not convert fixture output, a dry run, -or application-only logs into candidate evidence. - -## Inspect the plan - -Validate the checked-in packet: - -```sh -python3 release/scripts/integration-e2-runner.py validate -``` - -Inspect either profile as a readable plan: - -```sh -python3 release/scripts/integration-e2-runner.py plan \ - --profile opencrvs-dci-v1.9 -``` - -Use `dry-run` for the same bounded contract as JSON: - -```sh -python3 release/scripts/integration-e2-runner.py dry-run \ - --profile dhis2-tracker-2.41.9 > integration-plan.json -``` - -The JSON has `candidate_evidence: false` and -`status: planned_not_executed`. It includes input names, not values. - -## Run the operator-owned journey - -The approved operator wrapper must execute these stages in order and within -the profile limits: - -1. Download the candidate assets listed in `release/VERIFY.md` into a fresh, - dedicated directory. -2. Use the public runner to validate checksums, signatures, provenance, - capsule lineage, image locks, and digest files. The runner copies the exact - closed asset set through no-follow file descriptors into a fresh owner-only - temporary snapshot. It makes every snapshot file non-writable, verifies - and authenticates only snapshot files, then executes only the snapshot - binary for its self-reported version. It removes the snapshot after success - or failure. The source-directory binary is never executed. -3. Have the approved operator wrapper create its own authenticated, - non-writable snapshot and initialize the profile's starter only from that - snapshot. A completed candidate-only validation does not make the mutable - source directory safe for later execution. -4. Apply only the reviewed authored changes listed in the selected profile. - Do not edit generated YAML. -5. Run the offline project `test`, `check`, and `build` commands. Inspect the - generated project, then record hashes of authored inputs, the build review, - and both generated closures. -6. Deploy one candidate-digest Registry Relay, Registry Notary, and PostgreSQL - set per authority. -7. Query the approved source-side probe before and after every closed test - case. The five trust denials must prove no data-operation contact. -8. Retain raw evidence only in the approved restricted location. Publish only - safe result codes, timings, contact classifications, correlation hashes, - and evidence hashes. -9. Seed restricted-value canaries. Scan restricted evidence before producing - the public artifact, then re-hash generated files and compare them with the - reviewed build hashes. The public runner separately scans the supplied - public result for the same canaries. -10. Attempt scoped teardown from a `finally` path, even after a failed case. - Record its start and completion times, bounded duration, outcome, and - sanitized evidence hash. - -`source_data_access` counts only the profile's reviewed data operation. For -OpenCRVS, OAuth or JSON Web Key Set (JWKS) traffic does not count as a -`/registry/sync/search` call. The source-side evidence still has to account for -that supporting traffic. - -The operator wrapper owns product credentials, network access, deployment -details, project inspection, source probe invocation, restricted evidence -inspection and storage, generated-file comparisons, and cleanup. A maintainer -must compare the public hashes and flags with that restricted evidence. Those -instance-specific operations are intentionally not embedded in this public -runner. - -Run the candidate-only validation before creating the project: - -```sh -python3 release/scripts/integration-e2-runner.py validate \ - --candidate-dir /restricted/candidate-assets \ - --tag v1.0.0 -``` - -## Validate candidate evidence - -Create an owner-only canary file with one unique 8 to 128 character ASCII value -per line. Canary values can contain letters, digits, `.`, `_`, `:`, `@`, and -`-`. Seed the same values into the restricted test inputs so the scan can -detect accidental disclosure. Do not pass canary values on the command line. - -```sh -chmod 0600 /restricted/run-72.canaries - -python3 release/scripts/integration-e2-runner.py validate \ - --profile opencrvs-dci-v1.9 \ - --candidate-dir /restricted/candidate-assets \ - --tag v1.0.0 \ - --result /restricted/sanitized-run-result.json \ - --canary-file /restricted/run-72.canaries -``` - -This validation requires `cosign` and `slsa-verifier`. The runner independently -rejects missing or extra candidate assets, symlinks, wrong checksums, invalid -signatures or provenance, capsule and image-lock disagreements, an unbounded -result, an unknown public field, a public-result canary match, inconsistent -timestamps or durations, a passed case without the required recorded -source-contact classification, over-time recorded teardown, and a `passed` -status paired with failed recorded teardown. - -Candidate validation always uses a disposable private snapshot. Replacing a -file in the supplied candidate directory during or after validation cannot -change the executable path used for the version check. The runner removes the -snapshot in its cleanup path and never returns the snapshot path as an -operator artifact. - -The public runner receives neither the generated project nor restricted raw -evidence. It validates the closed flags, hashes, timings, and classifications -in the sanitized result, but it cannot independently re-hash the project, -inspect source-side audit records, or confirm the restricted redaction report. -The operator wrapper performs those checks, and a maintainer reviews their -restricted evidence before treating the result as proof. - -The result schema is -[`schema/run-result.schema.json`](schema/run-result.schema.json). It is closed: -raw responses, request bodies, headers, tokens, source identifiers, hostnames, -and credentials have no public fields. A failed run can remain as honest -non-closing evidence. The validator accepts `status: passed` only when every -applicable case and teardown is recorded as passed; maintainer review still -establishes whether the recorded hashes correspond to the restricted evidence. - -After an independent operator completes the frozen journey, use -[`pilot-report.template.md`](pilot-report.template.md) to publish the bounded -outcome, findings, and public triage links without copying restricted evidence -into the repository. A plan or dry run is not evidence, and one pilot is not -proof of broad production readiness. - -## Review the source packet - -- [`profiles/opencrvs-dci-v1.9.profile.json`](profiles/opencrvs-dci-v1.9.profile.json) - pins the OpenCRVS tuple and signed exact-UIN search. -- [`profiles/dhis2-tracker-2.41.9.profile.json`](profiles/dhis2-tracker-2.41.9.profile.json) - pins DHIS2 2.41.9 and the singleton tracked-entity read. -- [`schema/run-result.schema.json`](schema/run-result.schema.json) defines the - only public result shape. - -Review raw evidence and the owner attestation outside the public repository. -Commit only the sanitized result after a maintainer compares its hashes and -flags with the generated project, source audit records, redaction report, and -teardown evidence. Candidate identity is the part the public runner verifies -directly. diff --git a/release/conformance/integrations/pilot-report.template.md b/release/conformance/integrations/pilot-report.template.md deleted file mode 100644 index 497cdfc35..000000000 --- a/release/conformance/integrations/pilot-report.template.md +++ /dev/null @@ -1,86 +0,0 @@ -# External integration pilot report - -Use this template only after an independent operator has completed the frozen -pilot journey. Publish a concise account of the outcome and link the validated, -sanitized result. Do not include credentials, network origins, operator or -source identifiers, record identifiers, raw audits, private evidence, or links -to restricted evidence. - -Plans, dry runs, fixture runs, and source-built branch runs are not pilot -evidence. One completed pilot is not proof of broad production readiness. -An integration pilot alone does not close Country ready or First-country -success. - -## Closing evidence - -- Sanitized run result: [link to the public result accepted by - `python3 release/scripts/integration-e2-runner.py validate`] -- Frozen Registry Stack candidate: [link to the published release] -- Independent operator: [confirmed or not confirmed; do not identify the - operator] -- Owner-approved non-production source: [confirmed or not confirmed; do not - identify the owner or source] -- Integration profile and reviewed operation: [safe public summary; the exact - values remain in the sanitized result] -- Supported topology and journey: [safe public summary] -- Generated Registry YAML edited by hand: [no, or explain why the pilot does - not close] -- Maintainer comparison of public hashes and flags with restricted evidence: - [confirmed or not confirmed; do not identify the maintainer or expose - restricted evidence] -- Overall outcome: [passed, failed, or incomplete] - -Issue closure still requires a frozen published candidate, an independent -operator, an owner-approved source, and a confirmed maintainer comparison of -the public hashes and flags with the generated project, source audit records, -redaction report, and teardown evidence. A plan or maintainer-run substitute -cannot satisfy any of these requirements. - -## What the pilot did and did not prove - -The pilot showed: [summarize only the bounded journey completed from published -artifacts, including offline checks and the selected Relay and, when -applicable, Notary flow]. - -The pilot did not show: [summarize excluded versions, operations, topologies, -scale, availability, recovery, or other support claims]. It is not upstream -product certification, general country-system conformance, a security audit, -or evidence that Registry Stack is broadly production-ready. - -## Findings and triage - -Use safe summaries and public issue or pull-request links. Record `not -exercised` rather than inferring success. - -| Area | Exercised | Sanitized outcome | Public triage links | -|---|---|---|---| -| Operator handoff and independence | [yes/no] | [summary] | [links or none] | -| Install or deployment | [yes/no] | [summary] | [links or none] | -| Configuration and environment binding | [yes/no] | [summary] | [links or none] | -| Diagnostics and ordinary source failures | [yes/no] | [summary] | [links or none] | -| Upgrade or rollback | [yes/no] | [summary] | [links or none] | -| Restart, teardown, and other operations | [yes/no] | [summary] | [links or none] | -| Security boundaries and redaction | [yes/no] | [summary] | [links or none] | -| Documentation and operator journey | [yes/no] | [summary] | [links or none] | - -### Blocking findings - -List each blocker with its public triage issue, fix, and independent -re-verification link. Unresolved blockers keep the pilot open. - -- [none, or safe finding summary and links] - -### Accepted limitations and narrowed support - -List each owner-approved limitation or narrowed support claim with its public -decision, operator-guidance update, support-wording update, and review trigger. -Do not use this section to waive a blocker silently. - -- [none, or safe limitation summary and links] - -## Conclusion - -[State whether the bounded pilot closes the external-pilot gate, remains -blocked, or requires a narrower support claim. It cannot close unless the -maintainer comparison above is confirmed. Reiterate any unexercised area that -affects the conclusion.] diff --git a/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json b/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json deleted file mode 100644 index b74e88f7f..000000000 --- a/release/conformance/integrations/profiles/dhis2-tracker-2.41.9.profile.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "schema_version": "registry.release.integration_e2_profile.v1", - "profile_id": "dhis2-tracker-2.41.9", - "support_status": "Registry Stack-supported unofficial integration profile", - "starter": "dhis2-tracker", - "source": { - "product": "dhis2-tracker", - "baseline": [ - { - "component": "dhis2", - "version": "2.41.9", - "commit": "ce6404687f6a5806e2661cffe4bc7a1d9b2ad2ed" - } - ], - "operations": [ - { - "id": "tracked-entity-read", - "role": "data", - "method": "GET", - "path": "/api/tracker/trackedEntities/{uid}", - "selector": "exact tracked entity UID", - "field_projection": "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]", - "max_calls": 1 - } - ] - }, - "authored_contract": { - "integration_alias": "health-record", - "generated_yaml_edits_allowed": false, - "reviewed_changes": [ - "Replace every DEMO_* metadata UID in the authored Rhai adapter with an owner-attested instance value.", - "Keep the exact singleton trackedEntities resource path, reviewed field projection, and echoed UID comparison.", - "Bind the owner-attested source origin and Basic authentication through the reviewed environment input.", - "Keep source origins, metadata UIDs, selectors, and credentials out of the checked-in profile and public result." - ] - }, - "dynamic_inputs": [ - { "env": "DHIS2_SOURCE_ORIGIN", "classification": "restricted", "purpose": "source origin" }, - { "env": "DHIS2_USERNAME", "classification": "secret", "purpose": "Basic authentication username" }, - { "env": "DHIS2_PASSWORD", "classification": "secret", "purpose": "Basic authentication password" }, - { "env": "DHIS2_CHILD_PROGRAM_UID", "classification": "restricted", "purpose": "child programme metadata" }, - { "env": "DHIS2_MATERNAL_PROGRAM_UID", "classification": "restricted", "purpose": "maternal programme metadata" }, - { "env": "DHIS2_TB_PROGRAM_UID", "classification": "restricted", "purpose": "tuberculosis programme metadata" }, - { "env": "DHIS2_CHILD_VISIT_STAGE_UID", "classification": "restricted", "purpose": "child visit stage metadata" }, - { "env": "DHIS2_BCG_BIRTH_STAGE_UID", "classification": "restricted", "purpose": "BCG birth-dose stage metadata" }, - { "env": "DHIS2_OPV_BIRTH_STAGE_UID", "classification": "restricted", "purpose": "OPV birth-dose stage metadata" }, - { "env": "DHIS2_MEASLES_STAGE_UID", "classification": "restricted", "purpose": "measles-dose stage metadata" }, - { "env": "DHIS2_FIRST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "first-name attribute metadata" }, - { "env": "DHIS2_LAST_NAME_ATTRIBUTE_UID", "classification": "restricted", "purpose": "last-name attribute metadata" }, - { "env": "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", "classification": "restricted", "purpose": "birth-date attribute metadata" }, - { "env": "DHIS2_RECONCILIATION_ATTRIBUTE_UID", "classification": "restricted", "purpose": "reconciliation attribute metadata" }, - { "env": "DHIS2_MATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "match selector" }, - { "env": "DHIS2_NO_MATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "no-match selector" }, - { "env": "DHIS2_MISMATCH_TRACKED_ENTITY", "classification": "subject", "purpose": "selector-mismatch selector" }, - { "env": "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", "classification": "restricted", "purpose": "source-side audit probe path" }, - { "env": "REGISTRY_INTEGRATION_E2_CANARY_FILE", "classification": "restricted", "purpose": "redaction canary file path" } - ], - "cases": [ - { "id": "authorized-match", "expected_result_code": "match", "expected_source_data_access": "contacted_once" }, - { "id": "authorized-no-match", "expected_result_code": "no-match", "expected_source_data_access": "contacted_once" }, - { "id": "ambiguity", "expected_result_code": "not-applicable", "expected_source_data_access": "not_applicable" }, - { "id": "invalid-selector", "expected_result_code": "invalid-selector", "expected_source_data_access": "not_contacted" }, - { "id": "wrong-caller", "expected_result_code": "denied-wrong-caller", "expected_source_data_access": "not_contacted" }, - { "id": "missing-scope", "expected_result_code": "denied-missing-scope", "expected_source_data_access": "not_contacted" }, - { "id": "wrong-purpose", "expected_result_code": "denied-wrong-purpose", "expected_source_data_access": "not_contacted" }, - { "id": "stale-contract", "expected_result_code": "denied-stale-contract", "expected_source_data_access": "not_contacted" }, - { "id": "subject-mismatch", "expected_result_code": "subject-mismatch", "expected_source_data_access": "contacted_once" }, - { "id": "source-authorization-failure", "expected_result_code": "source-authorization-failure", "expected_source_data_access": "contacted_once" }, - { "id": "deadline-enforced", "expected_result_code": "deadline-exceeded", "expected_source_data_access": "contacted_once" } - ], - "prerequisites": [ - "A frozen published Registry Stack 1.0 candidate with independently verified assets.", - "An owner-approved non-production DHIS2 2.41.9 instance provides stable match, no-match, mismatch, authorization-failure, and delayed-response cases.", - "Every DEMO_* value in the starter is replaced by an owner-attested metadata UID before project test, check, and build.", - "Source-side audit or request-counter access can prove whether the tracked entity operation was contacted for every case.", - "Restricted raw-evidence storage, retention, redaction review, and scoped teardown are approved." - ], - "limits": { - "case_timeout_seconds": 30, - "run_timeout_seconds": 1200, - "raw_evidence_bytes": 8388608, - "public_result_bytes": 1048576, - "teardown_timeout_seconds": 300 - } -} diff --git a/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json b/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json deleted file mode 100644 index bc2b54959..000000000 --- a/release/conformance/integrations/profiles/opencrvs-dci-v1.9.profile.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "schema_version": "registry.release.integration_e2_profile.v1", - "profile_id": "opencrvs-dci-v1.9", - "support_status": "Registry Stack-supported unofficial integration profile", - "starter": "opencrvs-dci", - "source": { - "product": "opencrvs", - "baseline": [ - { - "component": "dci-adapter", - "version": "v1.9.0-rc.1", - "commit": "5e31d1e381d4bd8c7c74112d714fd49d263c6df7" - }, - { - "component": "core", - "version": "v1.9.5", - "commit": "7243ccb79eb84254420878ff5146c6e50f084554" - }, - { - "component": "farajaland", - "version": "v1.9.5", - "commit": "3c1a5612d8d7bebddd43463682860b38ef14bcb4" - } - ], - "operations": [ - { - "id": "oauth-token", - "role": "credential", - "method": "POST", - "path": "owner-attested", - "max_calls": 1 - }, - { - "id": "jwks", - "role": "verification", - "method": "GET", - "path": "owner-attested", - "max_calls": 1 - }, - { - "id": "signed-uin-search", - "role": "data", - "method": "POST", - "path": "/registry/sync/search", - "selector": "exact UIN", - "max_calls": 1 - } - ] - }, - "authored_contract": { - "integration_alias": "birth-record", - "generated_yaml_edits_allowed": false, - "reviewed_changes": [ - "Replace the synthetic /dci/v1/birth/search path with /registry/sync/search only after the compatibility probe passes.", - "Bind the owner-attested OAuth, JWKS, sender, receiver, registry, and record identifiers through the reviewed project inputs.", - "Keep the signed DCI helper and exact UIN selector verification in the authored integration and Rhai adapter.", - "Keep source origins and all live values out of the checked-in profile and public result." - ] - }, - "dynamic_inputs": [ - { "env": "OPENCRVS_SOURCE_ORIGIN", "classification": "restricted", "purpose": "source origin" }, - { "env": "OPENCRVS_OAUTH_ORIGIN", "classification": "restricted", "purpose": "OAuth origin" }, - { "env": "OPENCRVS_OAUTH_PATH", "classification": "restricted", "purpose": "OAuth token path" }, - { "env": "OPENCRVS_JWKS_ORIGIN", "classification": "restricted", "purpose": "JWKS origin" }, - { "env": "OPENCRVS_JWKS_PATH", "classification": "restricted", "purpose": "JWKS path" }, - { "env": "OPENCRVS_SENDER_ID", "classification": "restricted", "purpose": "DCI sender" }, - { "env": "OPENCRVS_RECEIVER_ID", "classification": "restricted", "purpose": "DCI receiver" }, - { "env": "OPENCRVS_CLIENT_ID", "classification": "secret", "purpose": "OAuth client id" }, - { "env": "OPENCRVS_CLIENT_SECRET", "classification": "secret", "purpose": "OAuth client secret" }, - { "env": "OPENCRVS_MATCH_UIN", "classification": "subject", "purpose": "match selector" }, - { "env": "OPENCRVS_NO_MATCH_UIN", "classification": "subject", "purpose": "no-match selector" }, - { "env": "OPENCRVS_AMBIGUOUS_UIN", "classification": "subject", "purpose": "ambiguity selector" }, - { "env": "OPENCRVS_MISMATCH_UIN", "classification": "subject", "purpose": "selector-mismatch selector" }, - { "env": "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", "classification": "restricted", "purpose": "source-side audit probe path" }, - { "env": "REGISTRY_INTEGRATION_E2_CANARY_FILE", "classification": "restricted", "purpose": "redaction canary file path" } - ], - "cases": [ - { "id": "authorized-match", "expected_result_code": "match", "expected_source_data_access": "contacted_once" }, - { "id": "authorized-no-match", "expected_result_code": "no-match", "expected_source_data_access": "contacted_once" }, - { "id": "ambiguity", "expected_result_code": "ambiguous", "expected_source_data_access": "contacted_once" }, - { "id": "invalid-selector", "expected_result_code": "invalid-selector", "expected_source_data_access": "not_contacted" }, - { "id": "wrong-caller", "expected_result_code": "denied-wrong-caller", "expected_source_data_access": "not_contacted" }, - { "id": "missing-scope", "expected_result_code": "denied-missing-scope", "expected_source_data_access": "not_contacted" }, - { "id": "wrong-purpose", "expected_result_code": "denied-wrong-purpose", "expected_source_data_access": "not_contacted" }, - { "id": "stale-contract", "expected_result_code": "denied-stale-contract", "expected_source_data_access": "not_contacted" }, - { "id": "subject-mismatch", "expected_result_code": "subject-mismatch", "expected_source_data_access": "contacted_once" }, - { "id": "source-authorization-failure", "expected_result_code": "source-authorization-failure", "expected_source_data_access": "not_contacted" }, - { "id": "deadline-enforced", "expected_result_code": "deadline-exceeded", "expected_source_data_access": "contacted_once" } - ], - "prerequisites": [ - "A frozen published Registry Stack 1.0 candidate with independently verified assets.", - "The /registry/sync/search compatibility probe passes against the exact owner-attested tuple.", - "An owner-approved non-production instance provides stable match, no-match, ambiguity, mismatch, authorization-failure, and delayed-response cases.", - "Source-side audit or request-counter access can prove whether /registry/sync/search was contacted for every case.", - "Restricted raw-evidence storage, retention, redaction review, and scoped teardown are approved." - ], - "limits": { - "case_timeout_seconds": 60, - "run_timeout_seconds": 1800, - "raw_evidence_bytes": 8388608, - "public_result_bytes": 1048576, - "teardown_timeout_seconds": 300 - } -} diff --git a/release/conformance/integrations/schema/run-result.schema.json b/release/conformance/integrations/schema/run-result.schema.json deleted file mode 100644 index b47653c85..000000000 --- a/release/conformance/integrations/schema/run-result.schema.json +++ /dev/null @@ -1,259 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.org/schemas/release/integration-e2-run-result-v1.json", - "title": "Registry Stack integration E2 public run result", - "description": "Closed, redaction-safe public evidence for one published-candidate OpenCRVS or DHIS2 integration run. Raw source evidence is intentionally excluded.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "record_kind", - "run_id", - "profile_id", - "support_status", - "status", - "started_at", - "completed_at", - "release", - "source", - "project", - "cases", - "redaction", - "teardown", - "limitations" - ], - "properties": { - "schema_version": { "const": "registry.release.integration_e2_run_result.v1" }, - "record_kind": { "const": "candidate_evidence" }, - "run_id": { "$ref": "#/$defs/slug" }, - "profile_id": { - "enum": ["opencrvs-dci-v1.9", "dhis2-tracker-2.41.9"] - }, - "support_status": { - "const": "Registry Stack-supported unofficial integration profile" - }, - "status": { "enum": ["passed", "failed"] }, - "started_at": { "$ref": "#/$defs/timestamp" }, - "completed_at": { "$ref": "#/$defs/timestamp" }, - "release": { "$ref": "#/$defs/release" }, - "source": { "$ref": "#/$defs/source" }, - "project": { "$ref": "#/$defs/project" }, - "cases": { - "type": "array", - "minItems": 11, - "maxItems": 11, - "items": { "$ref": "#/$defs/case" } - }, - "redaction": { "$ref": "#/$defs/redaction" }, - "teardown": { "$ref": "#/$defs/teardown" }, - "limitations": { - "type": "array", - "minItems": 4, - "maxItems": 6, - "uniqueItems": true, - "items": { - "enum": [ - "unofficial-integration-profile", - "single-pinned-product-version", - "single-reviewed-read-operation", - "non-production-instance", - "not-product-certification", - "not-general-country-system-conformance" - ] - } - } - }, - "$defs": { - "slug": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$" - }, - "sha256": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - }, - "commit": { - "type": "string", - "pattern": "^[0-9a-f]{40}$" - }, - "timestamp": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{3})?Z$" - }, - "image": { - "type": "string", - "pattern": "^ghcr\\.io/registrystack/registry-(?:relay|notary)@sha256:[0-9a-f]{64}$" - }, - "baseline": { - "type": "object", - "additionalProperties": false, - "required": ["component", "version", "commit"], - "properties": { - "component": { "$ref": "#/$defs/slug" }, - "version": { "type": "string", "pattern": "^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$" }, - "commit": { "$ref": "#/$defs/commit" } - } - }, - "release": { - "type": "object", - "additionalProperties": false, - "required": [ - "tag", - "version", - "source_commit", - "registryctl_asset_sha256", - "image_lock_sha256", - "release_capsule_sha256", - "relay_image", - "notary_image", - "candidate_assets_verified", - "authenticity_verified" - ], - "properties": { - "tag": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, - "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, - "source_commit": { "$ref": "#/$defs/commit" }, - "registryctl_asset_sha256": { "$ref": "#/$defs/sha256" }, - "image_lock_sha256": { "$ref": "#/$defs/sha256" }, - "release_capsule_sha256": { "$ref": "#/$defs/sha256" }, - "relay_image": { "$ref": "#/$defs/image" }, - "notary_image": { "$ref": "#/$defs/image" }, - "candidate_assets_verified": { "const": true }, - "authenticity_verified": { "const": true } - } - }, - "source": { - "type": "object", - "additionalProperties": false, - "required": ["product", "baseline", "operation_id", "method", "path", "owner_attestation_sha256"], - "properties": { - "product": { "enum": ["opencrvs", "dhis2-tracker"] }, - "baseline": { - "type": "array", - "minItems": 1, - "maxItems": 3, - "items": { "$ref": "#/$defs/baseline" } - }, - "operation_id": { "$ref": "#/$defs/slug" }, - "method": { "enum": ["GET", "POST"] }, - "path": { - "enum": ["/registry/sync/search", "/api/tracker/trackedEntities/{uid}"] - }, - "owner_attestation_sha256": { "$ref": "#/$defs/sha256" } - } - }, - "project": { - "type": "object", - "description": "Operator-attested project hashes and generated-file comparison. The public validator validates this shape but does not receive the project files.", - "additionalProperties": false, - "required": [ - "starter", - "starter_content_digest", - "authored_inputs_sha256", - "build_review_sha256", - "relay_closure_sha256", - "notary_closure_sha256", - "generated_files_unchanged" - ], - "properties": { - "starter": { "enum": ["opencrvs-dci", "dhis2-tracker"] }, - "starter_content_digest": { "$ref": "#/$defs/sha256" }, - "authored_inputs_sha256": { "$ref": "#/$defs/sha256" }, - "build_review_sha256": { "$ref": "#/$defs/sha256" }, - "relay_closure_sha256": { "$ref": "#/$defs/sha256" }, - "notary_closure_sha256": { "$ref": "#/$defs/sha256" }, - "generated_files_unchanged": { - "description": "Operator and maintainer attestation backed by restricted comparison evidence.", - "const": true - } - } - }, - "case": { - "type": "object", - "additionalProperties": false, - "required": [ - "case_id", - "outcome", - "started_at", - "completed_at", - "duration_ms", - "result_code", - "source_data_access", - "source_data_access_evidence_sha256", - "audit_correlation_sha256", - "evidence_sha256" - ], - "properties": { - "case_id": { - "enum": [ - "authorized-match", - "authorized-no-match", - "ambiguity", - "invalid-selector", - "wrong-caller", - "missing-scope", - "wrong-purpose", - "stale-contract", - "subject-mismatch", - "source-authorization-failure", - "deadline-enforced" - ] - }, - "outcome": { "enum": ["passed", "failed", "not_applicable"] }, - "started_at": { "$ref": "#/$defs/timestamp" }, - "completed_at": { "$ref": "#/$defs/timestamp" }, - "duration_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, - "result_code": { "$ref": "#/$defs/slug" }, - "source_data_access": { - "enum": ["not_contacted", "contacted_once", "not_applicable", "unknown"] - }, - "source_data_access_evidence_sha256": { "$ref": "#/$defs/sha256" }, - "audit_correlation_sha256": { "$ref": "#/$defs/sha256" }, - "evidence_sha256": { "$ref": "#/$defs/sha256" } - } - }, - "redaction": { - "type": "object", - "description": "Operator-attested restricted-evidence scan summary. The public runner independently scans only the supplied public result for canaries.", - "additionalProperties": false, - "required": [ - "passed", - "scanned_artifacts", - "scanned_bytes", - "seeded_canaries", - "forbidden_values_found", - "restricted_raw_evidence_bytes", - "raw_evidence_retained_restricted", - "report_sha256" - ], - "properties": { - "passed": { "const": true }, - "scanned_artifacts": { "type": "integer", "minimum": 1, "maximum": 4096 }, - "scanned_bytes": { "type": "integer", "minimum": 1, "maximum": 67108864 }, - "seeded_canaries": { "type": "integer", "minimum": 1, "maximum": 128 }, - "forbidden_values_found": { "const": 0 }, - "restricted_raw_evidence_bytes": { - "type": "integer", - "minimum": 1, - "maximum": 8388608 - }, - "raw_evidence_retained_restricted": { "const": true }, - "report_sha256": { "$ref": "#/$defs/sha256" } - } - }, - "teardown": { - "type": "object", - "description": "Operator-attested scoped teardown result with validator-cross-checked public timing fields.", - "additionalProperties": false, - "required": ["attempted", "status", "started_at", "duration_ms", "completed_at", "evidence_sha256"], - "properties": { - "attempted": { "const": true }, - "status": { "enum": ["completed", "failed"] }, - "started_at": { "$ref": "#/$defs/timestamp" }, - "duration_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, - "completed_at": { "$ref": "#/$defs/timestamp" }, - "evidence_sha256": { "$ref": "#/$defs/sha256" } - } - } - } -} diff --git a/release/conformance/openid/README.md b/release/conformance/openid/README.md deleted file mode 100644 index 239c170a1..000000000 --- a/release/conformance/openid/README.md +++ /dev/null @@ -1,286 +0,0 @@ -# OpenID conformance suite - -This directory owns Registry Stack's wrapper for the OpenID Foundation -conformance suite. It stays with the release surface so conformance work does -not depend on a mutable hosted environment or on the separately maintained -[Solmara Lab](https://github.com/registrystack/solmara-lab). - -The wrapper pins the upstream suite checkout to `release-v5.2.0` -(`dee9a25160e789f0f80517674693ef7989ab9fa1`) and overlays the upstream Compose -files with digest-pinned MongoDB, Maven, Nginx, and Java images. The suite JAR -cache is bound to the checked-out commit, and the suite's Python helpers install -from the checked-in fully hashed lock only when its upstream requirements still -match the reviewed input. A different suite ref can be supplied for -investigation, but results from an override are not evidence for the checked-in -mapping until the image, Python, and JAR pins are reviewed with it. - -## Evidence boundary - -The checked-in runner, plan map, and non-secret configuration template make -the suite invocation repeatable. They are not external conformance evidence by -themselves: - -- The supported Registry Notary topology must use a frozen release-candidate - image pinned by digest and checked-in non-secret configuration. -- The owner-only `submit-offer` adapter can send the real issuer-initiated - pre-authorized offer to the suite's `/credential_offer` callback without - exposing it in process arguments or command output. -- The upstream full-plan shape currently selects DPoP. Registry Notary 1.0 does - not support or claim DPoP, wallet attestation, PAR, EUDI, HAIP, an - authorization-code wallet grant, or ES256 holder proof. -- Registry Relay uses the separate - [candidate-neutral Relay and Zitadel smoke](../relay-oidc/README.md) with - `auth.mode: oidc`. The OIDF suite has no generic resource-server plan for - that surface. - -Development and historical demo runs are not release evidence. A reviewed -result becomes evidence only when it records the candidate image digest, suite -commit, exact plan variants, configuration digest, start and completion times, -and unmodified result status without retaining secrets. - -`promote-evidence` provides that source-side boundary for the metadata-only -slice. It authenticates the referenced published candidate through the same -signed image lock, release capsule, provenance, checksums, local tag target, and -manifest binding used by the other conformance tooling. Candidate fields are -derived, not supplied individually. The OIDF export cannot prove that the -tested endpoint ran that candidate image, so the summary marks that association -as operator-attested and pending review. The command creates a new, closed JSON -summary for separate maintainer review. It does not make the summary -certification evidence by itself. - -## Plan mapping - -[`plan-map.json`](plan-map.json) is the machine-readable mapping. - -- `notary-oid4vci-issuer-metadata` is a candidate-only slice for Registry - Notary's registry-backed OID4VCI issuer. It runs - `oid4vci-1_0-issuer-test-plan` with only - `oid4vci-1_0-issuer-metadata-test`. -- `notary-oid4vci-issuer-full` is mapped but blocked until the suite path - matches the supported Registry Notary profile. The offer adapter closes only - the callback transport gap; it does not add DPoP, attestation, batch, - notification, or other unsupported product behavior. - -The suite's `sender_constrain=dpop` selector is required by the upstream plan -shape. The metadata-only module does not exercise DPoP, and the selector must -not be reported as product support. - -The map also records why Relay OIDC bearer validation and third-party OpenID -Providers are outside the available OIDF plan set. That exclusion is not a -substitute for exercising Relay's OIDC path. The release-owned Relay smoke is -directly runnable against a published image digest, but its output remains -unreviewed until a maintainer binds it to the release candidate. - -## Prerequisites - -- Python 3.11 or later -- Git -- Docker with Docker Compose -- A Registry Notary issuer whose image is pinned by digest and whose issuer URL - is reachable from the conformance-suite container - -## Run the candidate metadata slice - -List the mapped scenarios, prepare the pinned suite, and start it: - -```bash -release/scripts/openid-conformance-runner.py list -release/scripts/openid-conformance-runner.py prepare -release/scripts/openid-conformance-runner.py up -``` - -Start the frozen Registry Notary candidate topology separately. Its configured credential -issuer URL must exactly match its metadata and be reachable from the suite -container. Then run: - -```bash -REGISTRY_OPENID_CONFORMANCE_ISSUER_URL="https://issuer.example.test" \ - release/scripts/openid-conformance-runner.py run \ - notary-oid4vci-issuer-metadata -``` - -Candidate-only scenarios are directly runnable. `--allow-blocked` is reserved -for deliberate investigation of scenarios whose status is explicitly blocked; -it does not turn their output into release evidence. - -For an issuer-initiated suite module, store the exact -`openid-credential-offer` URI rendered after Notary completes its authenticated -`/oid4vci/offer/callback` in an owner-only file. After `up`, export the exact -self-signed certificate generated for the suite's Nginx service from -`/etc/ssl/certs/nginx-selfsigned.crt`, then submit the offer: - -```bash -release/scripts/openid-conformance-runner.py export-suite-ca \ - --output target/openid-conformance/conformance-suite-ca.pem - -chmod 600 /private/path/notary-offer.txt -release/scripts/openid-conformance-runner.py submit-offer \ - --offer-file /private/path/notary-offer.txt \ - --issuer-url https://issuer.example.test \ - --suite-offer-endpoint 'https://localhost.emobix.co.uk:8443//credential_offer' \ - --suite-ca-certificate target/openid-conformance/conformance-suite-ca.pem -``` - -The adapter accepts only an inline Notary offer with the pre-authorized-code -grant, sends it once to the pinned suite origin without proxies or redirects, -and prints no offer content. TLS uses normal hostname and certificate -validation. The checked-in certificate recipe covers -`localhost.emobix.co.uk`, `localhost`, `127.0.0.1`, and `::1`. The optional CA -file is read once without following symlinks and adds only that explicitly -captured local trust anchor. The export command refuses to overwrite an -existing output. A fabricated offer is not candidate evidence. - -Set `REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER` when the authorization -server differs from the issuer. Set -`REGISTRY_OPENID_CONFORMANCE_CREDENTIAL_CONFIGURATION_ID` when the topology -does not use the default `person_is_alive_sd_jwt` identifier. - -Use `--dry-run` to render configuration and inspect the exact suite command -without starting a test plan: - -```bash -REGISTRY_OPENID_CONFORMANCE_ISSUER_URL="https://issuer.example.test" \ - release/scripts/openid-conformance-runner.py run \ - notary-oid4vci-issuer-metadata --dry-run -``` - -Stop the suite when finished: - -```bash -release/scripts/openid-conformance-runner.py down -``` - -The checkout, Python environment, Maven cache, rendered configuration, and -exported suite artifacts live under `target/openid-conformance/`, which Git -ignores. - -## Promote a result for review - -Keep the raw plan export outside the repository and make it owner-only. Download -the candidate's `registryctl--image-lock.json`, its `.sig` and `.pem`, the -release capsule and its `.sig` and `.pem`, release provenance, and -`SHA256SUMS` into one private directory. Fetch the immutable release tag and -install `cosign` and `slsa-verifier`. - -While the same suite instance used for the run is still running, capture its -generated CA and `/jwks` signing-key response through the runner's authenticated -HTTPS path: - -```bash -release/scripts/openid-conformance-runner.py export-suite-ca \ - --output /private/oidf/suite-ca.pem - -release/scripts/openid-conformance-runner.py export-suite-jwks \ - --conformance-server https://localhost.emobix.co.uk:8443 \ - --suite-ca-certificate /private/oidf/suite-ca.pem \ - --output /private/oidf/suite-jwks.json -``` - -The JWKS command disables proxies and redirects, validates a closed RSA signing -key shape, and refuses to overwrite its owner-only output. Do not restart or -replace the suite after this capture. Run the metadata slice with an explicit -private output directory: - -```bash -mkdir -m 700 /private/oidf/metadata-run - -REGISTRY_OPENID_CONFORMANCE_ISSUER_URL="https://issuer.example.test" \ - release/scripts/openid-conformance-runner.py run \ - notary-oid4vci-issuer-metadata \ - --output-dir /private/oidf/metadata-run -``` - -The wrapper passes that directory to the pinned upstream runner as -`--export-dir`. The completed directory contains the rendered configuration -and exactly one plan-export ZIP for this one-module slice. There is no separate -UI download step. Keep the same suite instance running until the command -finishes, then identify the ZIP and make it owner-only: - -```bash -find /private/oidf/metadata-run -maxdepth 1 -type f -name '*.zip' -print -chmod 600 /private/oidf/metadata-run/.zip - -release/scripts/openid-conformance-runner.py promote-evidence \ - --suite-export /private/oidf/metadata-run/.zip \ - --suite-jwks /private/oidf/suite-jwks.json \ - --release-manifest release/manifests/registry-stack-.yaml \ - --image-lock /private/release/registryctl--image-lock.json \ - --output /private/review/openid-metadata-evidence.json -``` - -The command refuses to overwrite the output. It accepts only the single -`oid4vci-1_0-issuer-metadata-test` JSON export and its matching signature, -rejects unsafe or unexpected ZIP entries, and verifies the Base64URL -SHA256withRSA signature over the exact JSON bytes against exactly one captured -suite key. The filename run identifier must match `testInfo._id`, -`testInfo.testId`, and every considered log entry. - -The authoritative module status must be `FINISHED`. Its `PASSED`, `FAILED`, -`WARNING`, `REVIEW`, `SKIPPED`, or `UNKNOWN` result is copied unchanged. The -archive does not contain a plan verdict or runner exit status. Completion time -comes from the matching terminal log event. Condition identifiers and messages -are not copied. The summary retains only aggregate informational, successful, -review, warning, and failure counts. - -The summary records: - -- the authenticated release tag target, manifest source ref, signed capsule, - image-lock hashes, and exact Registry Notary image digest; -- the exact issuer URL from the signed runtime configuration and its explicit - operator-attested association with the candidate deployment; -- the pinned 40-character suite commit, release tag, signed export's matching - reported version and origin, exact scenario, expected plan, module, variants, - and the explicit operator-attested plan and commit associations; -- the canonical SHA-256 digest of the captured suite JWKS and successful - exact-byte export signature verification; -- SHA-256 digests of the checked-in plan map and configuration template; -- a digest of the effective runtime configuration after replacing only - `vci.static_tx_code` with the fixed `` marker; and -- the suite start time, terminal-log completion time, terminal status, - unmodified result, and condition outcome counts. - -The transaction code is not copied or hashed because a digest of a -low-entropy code would be brute-forceable. The public summary includes no raw -export hash, plan or module instance id, free-form message, request, response, -token, proof, credential, civil identifier, or suite log. Its committed shape -is [`evidence-summary.schema.json`](evidence-summary.schema.json), which the -command validates before writing output. Review the candidate deployment -evidence, suite checkout and runtime, and summary separately before committing -it as release evidence. - -When advancing the suite ref, compare its `scripts/requirements.txt` with -`python-requirements.in`. After review, regenerate the hashed lock with the -command recorded at the top of `python-requirements.txt`. Dependabot scans that -pip-compile lock weekly, while the runner keeps its direct input byte-bound to -the pinned suite. Review the four image tags and refresh their immutable digests -through the matching Dependabot Dockerfile and Docker Compose updates. -`prepare` reuses the suite JAR only while its recorded source ref, builder -override digest, and artifact digest still match. - -## Sensitive result handling - -Do not commit a raw result export. Full-flow output may include bearer tokens, -proof JWTs, issued credentials, transaction codes, or seeded civil identifiers. -The promotion command reads the private export only to validate the selected -module, terminal record, safe runtime-configuration shape, and condition -counts. It constructs the public summary -from an explicit allowlist and checks that sensitive raw values did not cross -that boundary. A failed, warned, skipped, review, or unknown result remains -visible and is never upgraded to a pass. - -A promoted summary is still unreviewed candidate output until a maintainer -checks the authenticated candidate assets, the recorded issuer URL's -operator-attested candidate association, the expected plan association, and -the result. The full issuer scenario remains explicitly -unsupported because the upstream profile requires behavior outside Registry -Notary 1.0. The offer adapter is no longer a blocker; it closes only the -callback transport gap. - -The first metadata-only run and its known failures are recorded in -[`initial-report.md`](initial-report.md). It is historical context only. It is -not evidence for the current candidate, any wallet, any verifier, or the full -issuer profile. - -The Rust SD-JWT verifier is a caller-invoked library, not an OID4VP endpoint. -Its library and fixture tests therefore do not support an OID4VP verifier -conformance claim. diff --git a/release/conformance/openid/docker-compose-builder.override.yaml b/release/conformance/openid/docker-compose-builder.override.yaml deleted file mode 100644 index 856ee40c7..000000000 --- a/release/conformance/openid/docker-compose-builder.override.yaml +++ /dev/null @@ -1,3 +0,0 @@ -services: - builder: - image: maven:3-eclipse-temurin-21@sha256:2b4496088e7b80ae10a8c9f74e574ea21380325a006ec684532ad6bad5bc7273 diff --git a/release/conformance/openid/docker-compose.override.yaml b/release/conformance/openid/docker-compose.override.yaml deleted file mode 100644 index 1dc1f9eb2..000000000 --- a/release/conformance/openid/docker-compose.override.yaml +++ /dev/null @@ -1,11 +0,0 @@ -services: - mongodb: - image: mongo:6.0.13@sha256:b415b12f638e2685d06c58ab7fb5943577c50fadec6d9340ef67d21aeac72070 - nginx: - build: - context: ./nginx - dockerfile: ${REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR:?runner must set config directory}/nginx.Dockerfile - server: - build: - context: ./server-dev - dockerfile: ${REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR:?runner must set config directory}/server-dev.Dockerfile diff --git a/release/conformance/openid/evidence-summary.schema.json b/release/conformance/openid/evidence-summary.schema.json deleted file mode 100644 index b119b818d..000000000 --- a/release/conformance/openid/evidence-summary.schema.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://registrystack.org/schemas/release/openid-conformance-evidence-v1.json", - "title": "Registry Stack OpenID conformance candidate evidence summary", - "description": "Closed, redaction-safe review summary for the Registry Notary OID4VCI metadata-only suite slice. Candidate assets are authenticated while the tested-endpoint association remains operator-attested and pending review. Raw suite logs are intentionally excluded.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "classification", - "review_required", - "contains_sensitive_material", - "raw_suite_export_included", - "candidate", - "deployment", - "suite", - "scenario", - "configuration", - "run", - "unsupported_scenarios" - ], - "properties": { - "schema_version": { - "const": "registry.release.openid_conformance_evidence.v1" - }, - "classification": { - "const": "unreviewed-candidate-evidence-summary" - }, - "review_required": { - "const": true - }, - "contains_sensitive_material": { - "const": false - }, - "raw_suite_export_included": { - "const": false - }, - "candidate": { - "$ref": "#/$defs/candidate" - }, - "deployment": { - "$ref": "#/$defs/deployment" - }, - "suite": { - "$ref": "#/$defs/suite" - }, - "scenario": { - "$ref": "#/$defs/scenario" - }, - "configuration": { - "$ref": "#/$defs/configuration" - }, - "run": { - "$ref": "#/$defs/run" - }, - "unsupported_scenarios": { - "const": [ - { - "scenario_id": "notary-oid4vci-issuer-full", - "status": "blocked-by-suite-profile" - } - ] - } - }, - "$defs": { - "commit": { - "type": "string", - "pattern": "^[0-9a-f]{40}$" - }, - "sha256": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$" - }, - "timestamp": { - "type": "string", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]{1,9})?Z$" - }, - "candidate": { - "type": "object", - "additionalProperties": false, - "required": [ - "release_id", - "version", - "source_repo", - "source_ref", - "source_tag", - "tag_target", - "manifest_sha256", - "image_lock_sha256", - "release_capsule_sha256", - "notary_image", - "release_assets_authenticity_verified" - ], - "properties": { - "release_id": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" - }, - "version": { - "type": "string", - "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" - }, - "source_repo": { - "const": "registrystack/registry-stack" - }, - "source_ref": { - "$ref": "#/$defs/commit" - }, - "source_tag": { - "type": "string", - "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?$" - }, - "tag_target": { - "$ref": "#/$defs/commit" - }, - "manifest_sha256": { - "$ref": "#/$defs/sha256" - }, - "image_lock_sha256": { - "$ref": "#/$defs/sha256" - }, - "release_capsule_sha256": { - "$ref": "#/$defs/sha256" - }, - "notary_image": { - "type": "string", - "pattern": "^ghcr\\.io/registrystack/registry-notary@sha256:[0-9a-f]{64}$" - }, - "release_assets_authenticity_verified": { - "const": true - } - } - }, - "deployment": { - "type": "object", - "additionalProperties": false, - "required": [ - "issuer_url", - "candidate_association" - ], - "properties": { - "issuer_url": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "pattern": "^https://(?:\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9][A-Za-z0-9.-]*)(?::(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(?:/[^\\s\\x00-\\x1f\\x7f\\\\?#]*)?$" - }, - "candidate_association": { - "const": "operator-attested-pending-review" - } - } - }, - "suite": { - "type": "object", - "additionalProperties": false, - "required": [ - "repository", - "commit", - "release_tag", - "reported_version", - "exported_from", - "commit_association", - "jwks_sha256", - "export_signature_verified" - ], - "properties": { - "repository": { - "const": "https://gitlab.com/openid/conformance-suite.git" - }, - "commit": { - "$ref": "#/$defs/commit" - }, - "release_tag": { - "const": "release-v5.2.0" - }, - "reported_version": { - "const": "5.2.0" - }, - "exported_from": { - "const": "https://localhost.emobix.co.uk:8443" - }, - "commit_association": { - "const": "operator-attested-pending-review" - }, - "jwks_sha256": { - "$ref": "#/$defs/sha256" - }, - "export_signature_verified": { - "const": true - } - } - }, - "scenario": { - "type": "object", - "additionalProperties": false, - "required": [ - "scenario_id", - "expected_plan", - "plan_association", - "modules", - "variants" - ], - "properties": { - "scenario_id": { - "const": "notary-oid4vci-issuer-metadata" - }, - "expected_plan": { - "const": "oid4vci-1_0-issuer-test-plan" - }, - "plan_association": { - "const": "operator-attested-pending-review" - }, - "modules": { - "const": [ - "oid4vci-1_0-issuer-metadata-test" - ] - }, - "variants": { - "type": "object", - "additionalProperties": false, - "required": [ - "client_auth_type", - "sender_constrain", - "fapi_profile", - "fapi_request_method", - "authorization_request_type", - "credential_format", - "vci_credential_encryption", - "vci_grant_type", - "vci_authorization_code_flow_variant" - ], - "properties": { - "client_auth_type": { - "const": "private_key_jwt" - }, - "sender_constrain": { - "const": "dpop" - }, - "fapi_profile": { - "const": "vci" - }, - "fapi_request_method": { - "const": "unsigned" - }, - "authorization_request_type": { - "const": "simple" - }, - "credential_format": { - "const": "sd_jwt_vc" - }, - "vci_credential_encryption": { - "const": "plain" - }, - "vci_grant_type": { - "const": "pre_authorization_code" - }, - "vci_authorization_code_flow_variant": { - "const": "issuer_initiated" - } - } - } - } - }, - "configuration": { - "type": "object", - "additionalProperties": false, - "required": [ - "plan_map_sha256", - "template_sha256", - "redacted_runtime_configuration_sha256", - "redacted_fields" - ], - "properties": { - "plan_map_sha256": { - "$ref": "#/$defs/sha256" - }, - "template_sha256": { - "$ref": "#/$defs/sha256" - }, - "redacted_runtime_configuration_sha256": { - "$ref": "#/$defs/sha256" - }, - "redacted_fields": { - "const": [ - "vci.static_tx_code" - ] - } - } - }, - "run": { - "type": "object", - "additionalProperties": false, - "required": [ - "started_at", - "completed_at", - "terminal_status", - "result", - "conditions" - ], - "properties": { - "started_at": { - "$ref": "#/$defs/timestamp" - }, - "completed_at": { - "$ref": "#/$defs/timestamp", - "description": "Timestamp carried by the matching terminal module log event; this is not a plan verdict timestamp." - }, - "terminal_status": { - "const": "FINISHED" - }, - "result": { - "description": "Authoritative testInfo module result copied unchanged; the archive contains no plan verdict.", - "enum": [ - "PASSED", - "FAILED", - "WARNING", - "REVIEW", - "SKIPPED", - "UNKNOWN" - ] - }, - "conditions": { - "$ref": "#/$defs/conditions" - } - } - }, - "conditions": { - "type": "object", - "additionalProperties": false, - "required": [ - "counts" - ], - "properties": { - "counts": { - "type": "object", - "additionalProperties": false, - "required": [ - "info", - "success", - "review", - "warning", - "failure" - ], - "properties": { - "info": { - "type": "integer", - "minimum": 0, - "maximum": 100000 - }, - "success": { - "type": "integer", - "minimum": 0, - "maximum": 100000 - }, - "review": { - "type": "integer", - "minimum": 0, - "maximum": 100000 - }, - "warning": { - "type": "integer", - "minimum": 0, - "maximum": 100000 - }, - "failure": { - "type": "integer", - "minimum": 0, - "maximum": 100000 - } - } - } - } - } - } -} diff --git a/release/conformance/openid/initial-report.md b/release/conformance/openid/initial-report.md index 9ef11b856..4c8225fda 100644 --- a/release/conformance/openid/initial-report.md +++ b/release/conformance/openid/initial-report.md @@ -64,14 +64,14 @@ The raw suite export is not committed because future full-flow runs may contain bearer tokens, proof JWTs, issued credentials, transaction codes, or seeded civil identifiers. -## Current reproduction path +## Retirement note -The release-owned runner, mapping, configuration template, and pinned Compose -override are documented in [`README.md`](README.md). A current metadata run -uses `REGISTRY_OPENID_CONFORMANCE_ISSUER_URL` and writes ignored artifacts under -`target/openid-conformance/`. +Registry Notary and its OID4VCI-only conformance runner were retired on +2026-08-03. The operational runner, mapping, configuration template, and +pinned Compose assets no longer form part of the current release surface. +This report remains as historical evidence of the 2026-07-09 run; it is not a +reproduction procedure for Registry Relay or Evidence. -The next reviewed run must use a pinned release topology. The full OIDF issuer -plan also remains blocked by a flow mismatch: the OIDF issuer tests wait for an -issuer-initiated credential offer callback into the suite, while the available -Registry Notary smoke flow pulls the offer endpoint directly. +Registry Relay's supported OpenID check is the separate release-owned Relay +OIDC smoke. Evidence authentication is covered by its frozen contracts and +ordinary product tests, not by the retired OID4VCI issuer plan. diff --git a/release/conformance/openid/nginx.Dockerfile b/release/conformance/openid/nginx.Dockerfile deleted file mode 100644 index fe87e108a..000000000 --- a/release/conformance/openid/nginx.Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM nginx:1.27.3@sha256:bc2f6a7c8ddbccf55bdb19659ce3b0a92ca6559e86d42677a5a02ef6bda2fcef - -RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ - -keyout /etc/ssl/private/nginx-selfsigned.key \ - -out /etc/ssl/certs/nginx-selfsigned.crt \ - -subj "/CN=localhost.emobix.co.uk" \ - -addext "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost,IP:127.0.0.1,IP:::1" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -addext "keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign" \ - -addext "extendedKeyUsage=serverAuth" -COPY nginx.conf /etc/nginx/nginx.conf diff --git a/release/conformance/openid/plan-map.json b/release/conformance/openid/plan-map.json deleted file mode 100644 index 681bb972b..000000000 --- a/release/conformance/openid/plan-map.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "schema_version": "registry.release.openid_conformance_plan_map.v1", - "suite": { - "repo": "https://gitlab.com/openid/conformance-suite.git", - "ref": "dee9a25160e789f0f80517674693ef7989ab9fa1", - "release_tag": "release-v5.2.0", - "base_url": "https://localhost.emobix.co.uk:8443", - "local_base_url": "https://localhost.emobix.co.uk:8443", - "mtls_base_url": "https://localhost.emobix.co.uk:8444" - }, - "scenarios": [ - { - "id": "notary-oid4vci-issuer-metadata", - "surface": "Registry Notary registry-backed OID4VCI issuer metadata", - "status": "candidate-only", - "suite_plan": "oid4vci-1_0-issuer-test-plan", - "suite_modules": [ - "oid4vci-1_0-issuer-metadata-test" - ], - "variants": { - "client_auth_type": "private_key_jwt", - "sender_constrain": "dpop", - "fapi_profile": "vci", - "fapi_request_method": "unsigned", - "authorization_request_type": "simple", - "credential_format": "sd_jwt_vc", - "vci_credential_encryption": "plain", - "vci_grant_type": "pre_authorization_code", - "vci_authorization_code_flow_variant": "issuer_initiated" - }, - "config_template": "registry-notary-oid4vci-issuer.template.json", - "default_parameters": { - "issuer_url_env": "REGISTRY_OPENID_CONFORMANCE_ISSUER_URL", - "authorization_server_env": "REGISTRY_OPENID_CONFORMANCE_AUTHORIZATION_SERVER", - "credential_configuration_id_env": "REGISTRY_OPENID_CONFORMANCE_CREDENTIAL_CONFIGURATION_ID", - "default_credential_configuration_id": "person_is_alive_sd_jwt" - }, - "requires": [ - "A frozen Registry Notary release-candidate image pinned by digest and configured with checked-in, non-secret topology configuration.", - "An issuer URL reachable from the OIDF conformance-suite server container that exactly matches the credential issuer metadata.", - "A reviewed result export bound to the candidate image digest, suite commit, plan variants, and configuration digest.", - "No bearer tokens, proof JWTs, issued credentials, transaction codes, or civil identifiers committed from generated results." - ], - "notes": [ - "This repeatable suite slice exercises issuer metadata only and is not a wallet, verifier, or full issuer conformance claim.", - "The suite requires the sender_constrain=dpop plan selector, but the metadata-only module does not exercise DPoP and Registry Notary does not support or claim DPoP.", - "No external result is release evidence until it is captured from the frozen candidate artifact with the required immutable identifiers.", - "The submit-offer adapter closes the callback transport gap. The full OID4VCI issuer plan remains blocked only by suite profile requirements outside Registry Notary 1.0." - ] - }, - { - "id": "notary-oid4vci-issuer-full", - "surface": "Registry Notary registry-backed OID4VCI issuer", - "status": "blocked-by-suite-profile", - "suite_plan": "oid4vci-1_0-issuer-test-plan", - "suite_modules": [], - "variants": { - "client_auth_type": "private_key_jwt", - "sender_constrain": "dpop", - "fapi_profile": "vci", - "fapi_request_method": "unsigned", - "authorization_request_type": "simple", - "credential_format": "sd_jwt_vc", - "vci_credential_encryption": "plain", - "vci_grant_type": "pre_authorization_code", - "vci_authorization_code_flow_variant": "issuer_initiated" - }, - "config_template": "registry-notary-oid4vci-issuer.template.json", - "offer_adapter": { - "command": "release/scripts/openid-conformance-runner.py submit-offer", - "input": "The owner-only openid-credential-offer URI rendered only after Registry Notary completes its authenticated /oid4vci/offer/callback and registry-backed evaluation.", - "output": "One no-redirect GET to the pinned suite module's exposed /credential_offer endpoint with the unchanged inline credential_offer value." - }, - "requires": [ - "A frozen Registry Notary release-candidate topology with a suite-reachable issuer URL.", - "The candidate-neutral submit-offer adapter must receive the real owner-only Notary offer URI produced after the authenticated callback; a fabricated offer does not qualify.", - "A reviewed suite path that does not require DPoP, wallet attestation, PAR, an authorization-code wallet grant, ES256 holder proof, EUDI, or HAIP behavior outside Registry Notary 1.0." - ], - "notes": [ - "Registry Notary supports issuer-initiated pre-authorized code only. The identity provider's authorization code is internal to Notary and is not a wallet grant.", - "The pinned suite accepts issuer-initiated pre-authorized offers at its exposed /credential_offer endpoint. The adapter now closes that transport gap without adding a Registry Notary product endpoint.", - "The checked-in sender_constrain=dpop variant is required by this upstream suite plan shape and does not represent Registry Notary DPoP support.", - "The full plan remains blocked because its current module and variant roster exercises unsupported DPoP, attestation, batch, notification, and other behavior outside the Registry Notary 1.0 profile.", - "Do not treat this blocked mapping, a development topology, or historical wallet smoke output as certification evidence." - ] - } - ], - "non_oidf_surfaces": [ - { - "surface": "Registry Relay OIDC bearer validation", - "evidence": "The release-owned candidate-neutral Relay and Zitadel smoke is directly runnable against a published Relay image digest. Its live output requires candidate binding and maintainer review before it becomes release evidence.", - "reason": "The OIDF suite does not publish a generic resource-server conformance plan for a Relay-style protected API. The separate release topology verifies discovery, signature, audience, token type, and native Zitadel role-object scope mapping." - }, - { - "surface": "Registry Notary Rust SD-JWT verifier", - "evidence": "Rust library and fixture interoperability tests only; no OIDF OID4VP verifier-plan result applies to this surface.", - "reason": "The Rust SD-JWT verifier is a caller-invoked verification library, not an OID4VP endpoint. Registry Stack 1.0 therefore makes no OID4VP verifier conformance claim for it." - }, - { - "surface": "Third-party OpenID Providers used by demonstration topologies", - "evidence": "Out of Registry Stack conformance scope.", - "reason": "Registry Stack consumes these providers and does not claim OpenID Provider certification for them." - } - ] -} diff --git a/release/conformance/openid/python-requirements.in b/release/conformance/openid/python-requirements.in deleted file mode 100644 index c491c56b2..000000000 --- a/release/conformance/openid/python-requirements.in +++ /dev/null @@ -1,2 +0,0 @@ -httpx -pyparsing diff --git a/release/conformance/openid/python-requirements.txt b/release/conformance/openid/python-requirements.txt deleted file mode 100644 index de62d8f6a..000000000 --- a/release/conformance/openid/python-requirements.txt +++ /dev/null @@ -1,38 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile release/conformance/openid/python-requirements.in --python-version 3.11 --generate-hashes --universal --no-emit-index-url --output-file release/conformance/openid/python-requirements.txt -anyio==4.14.2 \ - --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ - --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f - # via httpx -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db - # via - # httpcore - # httpx -h11==0.16.0 \ - --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ - --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 - # via httpcore -httpcore==1.0.9 \ - --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ - --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 - # via httpx -httpx==0.28.1 \ - --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ - --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - # via -r release/conformance/openid/python-requirements.in -idna==3.18 \ - --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ - --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 - # via - # anyio - # httpx -pyparsing==3.3.2 \ - --hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \ - --hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc - # via -r release/conformance/openid/python-requirements.in -typing-extensions==4.16.0 ; python_full_version < '3.13' \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 - # via anyio diff --git a/release/conformance/openid/registry-notary-oid4vci-issuer.template.json b/release/conformance/openid/registry-notary-oid4vci-issuer.template.json deleted file mode 100644 index 832085dd2..000000000 --- a/release/conformance/openid/registry-notary-oid4vci-issuer.template.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "alias": "registry-stack-notary-oid4vci-issuer", - "description": "Registry Stack Notary OID4VCI issuer conformance slice", - "vci": { - "credential_issuer_url": "${issuer_url}", - "authorization_server": "${authorization_server}", - "credential_configuration_id": "${credential_configuration_id}", - "credential_proof_type_hint": "jwt", - "static_tx_code": "${static_tx_code}" - }, - "client": { - "client_id": "${client_id}" - }, - "client2": { - "client_id": "${client2_id}" - } -} diff --git a/release/conformance/openid/server-dev.Dockerfile b/release/conformance/openid/server-dev.Dockerfile deleted file mode 100644 index 6f2de45ee..000000000 --- a/release/conformance/openid/server-dev.Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -# Mirrors the pinned suite's development server image while fixing its base. -FROM eclipse-temurin:21@sha256:da9d3a4f7650db39b918fc5a2c3da76556fb8cc8e5f3767cdea0bb409286951a - -RUN apt-get update \ - && apt-get install -y --no-install-recommends redir=3.3-1build1 \ - && rm -rf /var/lib/apt/lists/* diff --git a/release/contracts/selected-metrics.json b/release/contracts/selected-metrics.json index 43851d683..115cc0773 100644 --- a/release/contracts/selected-metrics.json +++ b/release/contracts/selected-metrics.json @@ -91,96 +91,6 @@ "resource_id": "Configured resource identifier; cardinality is bounded by configured resources." }, "source": "crates/registry-relay/src/observability.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_http_requests_total", - "type": "counter", - "meaning": "Completed HTTP requests grouped by bounded request attributes.", - "labels": { - "endpoint_kind": "Bounded route family, never a raw request path.", - "error_code": "Released machine error code, or none for a successful response.", - "method": "Normalized HTTP method.", - "status_class": "HTTP status class.", - "status_code": "HTTP response status code." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_http_request_duration_seconds", - "type": "histogram", - "meaning": "HTTP request duration in seconds grouped by bounded request attributes.", - "labels": { - "endpoint_kind": "Bounded route family, never a raw request path.", - "error_code": "Released machine error code, or none for a successful response.", - "method": "Normalized HTTP method.", - "status_class": "HTTP status class.", - "status_code": "HTTP response status code." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_audit_events_total", - "type": "counter", - "meaning": "Audit write attempts grouped by bounded outcome.", - "labels": { - "outcome": "Bounded audit write outcome." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_replay_events_total", - "type": "counter", - "meaning": "Replay-store decisions grouped by protocol flow and bounded outcome.", - "labels": { - "flow": "Bounded replay-protected protocol flow.", - "outcome": "Bounded replay decision outcome." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_credential_issuance_total", - "type": "counter", - "meaning": "Credential issuance attempts grouped by protocol and bounded outcome.", - "labels": { - "outcome": "Bounded credential issuance outcome.", - "protocol": "Credential issuance protocol." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_cel_evaluations_total", - "type": "counter", - "meaning": "CEL worker evaluations grouped by bounded outcome.", - "labels": { - "outcome": "Bounded CEL evaluation outcome." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_cel_evaluation_duration_ms_total", - "type": "counter", - "meaning": "Accumulated CEL evaluation duration in milliseconds grouped by bounded outcome.", - "labels": { - "outcome": "Bounded CEL evaluation outcome." - }, - "source": "crates/registry-notary-server/src/metrics.rs" - }, - { - "product": "registry-notary", - "name": "registry_notary_cel_worker_pool", - "type": "gauge", - "meaning": "Current CEL worker-pool state value.", - "labels": { - "state": "Bounded worker-pool state name." - }, - "source": "crates/registry-notary-server/src/metrics.rs" } ] } diff --git a/release/docker/Dockerfile.registry-notary b/release/docker/Dockerfile.registry-notary deleted file mode 100644 index 4fb4db39e..000000000 --- a/release/docker/Dockerfile.registry-notary +++ /dev/null @@ -1,39 +0,0 @@ -# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e - -# SPDX-License-Identifier: Apache-2.0 - -ARG SOURCE_DATE_EPOCH=0 - -FROM debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd AS runtime-root -ARG SOURCE_DATE_EPOCH - -RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ - mkdir -p \ - /workspace/runtime-root/etc/registry-notary \ - /workspace/runtime-root/usr/local/bin \ - /workspace/runtime-root/var/lib/registry/audit \ - /workspace/runtime-root/var/lib/registry/state \ - /workspace/runtime-root/var/lib/registry-notary \ - /workspace/runtime-root/var/log/registry-notary \ - && install -m 0755 /workspace/image-bin/registry-notary /workspace/runtime-root/usr/local/bin/registry-notary \ - && install -m 0755 /workspace/image-bin/registry-notary-cel-worker /workspace/runtime-root/usr/local/bin/registry-notary-cel-worker \ - && chown -R 65532:65532 \ - /workspace/runtime-root/etc/registry-notary \ - /workspace/runtime-root/var/lib/registry \ - /workspace/runtime-root/var/lib/registry-notary \ - /workspace/runtime-root/var/log/registry-notary \ - && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + - -FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime - -COPY --from=runtime-root /workspace/runtime-root/ / - -WORKDIR /var/lib/registry-notary - -ENV REGISTRY_NOTARY_BIND=0.0.0.0:8080 -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/registry-notary", "healthcheck"] - -ENTRYPOINT ["/usr/local/bin/registry-notary"] -CMD ["--config", "/etc/registry-notary/config.yaml"] diff --git a/release/exercises/README.md b/release/exercises/README.md index 4a2c1d55f..fb570b71c 100644 --- a/release/exercises/README.md +++ b/release/exercises/README.md @@ -102,12 +102,19 @@ If the candidate coordinate, manifest, product inputs, trust generation, activation generation, or retained evidence changes, discard the result and repeat the lifecycle against the new exact digests. -## Upgrade lifecycle +## Historical Notary-era upgrade lifecycle + +The `registry-stack.upgrade-exercise/v1` contract is retained to validate +committed Notary-era upgrade records whose target version is earlier than +v0.17.0. The validator rejects v0.17.0 and later targets before reading any +Notary-era schema, image, or release-input path. Post-Notary upgrades require +a successor contract; do not repurpose v1 by removing its Notary fields. ## Candidate-neutral preparation `upgrade-exercise-v1.template.json` defines the machine-validated evidence -record for a Registry Stack stable upgrade. The template is preparation only. +record for a historical Registry Stack stable upgrade. The template is +preparation only. Its `record_kind` is `template`, every result is `not_run`, and both candidate attestations are `false`. Every result's observation and evidence fields are null. A validated template contains zero candidate evidence and does not diff --git a/release/manifests/registry-stack-beta-27.yaml b/release/manifests/registry-stack-beta-27.yaml new file mode 100644 index 000000000..e0272b995 --- /dev/null +++ b/release/manifests/registry-stack-beta-27.yaml @@ -0,0 +1,29 @@ +stack: + release: beta-27 + version: 0.17.0 + source_repo: registrystack/registry-stack + source_tag: v0.17.0 + +artifacts: + registry-relay: 0.17.0 + registry-relay-rhai-worker: 0.17.0 + registry-manifest-cli: 0.17.0 + registryctl: 0.17.0 + registryctl-image-lock: 0.17.0 + registryctl-installer: 0.17.0 + registry-docs: 0.17.0 + evidence: 0.17.0 + evidencectl: 0.17.0 + mint: 0.17.0 + evidencectl-installer: 0.17.0 + +external: + crosswalk: + repo: PublicSchema/crosswalk + ref: 1d44ec735fdc8a7c719264b339574371e8330337 + status: tested external input + +warnings: + - code: notary-retired + classification: breaking-product-retirement + detail: Registry Notary is absent from this release. Historical Notary manifests and evidence remain immutable; operators must preserve any records they still require and rebuild current deployment projects with Registryctl rather than carrying retired Notary services forward. diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 34f028767..6b0b83de9 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -79,23 +79,6 @@ docker run --rm \ cp target/release/registry-relay dist/image-bin/registry-relay cp target/release/registry-relay-rhai-worker dist/image-bin/registry-relay-rhai-worker - cargo build --release --locked \ - -p registry-notary \ - --features registry-notary/registry-notary-cel - cp target/release/registry-notary "dist/bin/registry-notary-${RELEASE_TAG}-linux-amd64" - - cargo build --release --locked \ - -p registry-notary \ - --features registry-notary/registry-notary-cel,registry-notary/pkcs11 - cp target/release/registry-notary dist/image-bin/registry-notary - - cargo build --release --locked \ - -p registry-notary-server \ - --bin registry-notary-cel-worker \ - --features registry-notary-server/registry-notary-cel - cp target/release/registry-notary-cel-worker "dist/bin/registry-notary-cel-worker-${RELEASE_TAG}-linux-amd64" - cp target/release/registry-notary-cel-worker dist/image-bin/registry-notary-cel-worker - cargo build --release --locked \ -p registry-evidence \ -p registry-evidencectl \ @@ -111,13 +94,9 @@ chmod 0755 \ "${repo_root}/dist/bin/registry-manifest-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-relay-${tag}-linux-amd64" \ "${repo_root}/dist/bin/registry-relay-rhai-worker-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/registry-notary-${tag}-linux-amd64" \ - "${repo_root}/dist/bin/registry-notary-cel-worker-${tag}-linux-amd64" \ "${repo_root}/dist/bin/evidence-${tag}-linux-amd64" \ "${repo_root}/dist/bin/evidencectl-${tag}-linux-amd64" \ "${repo_root}/dist/bin/mint-${tag}-linux-amd64" \ - "${repo_root}/dist/image-bin/registry-notary" \ - "${repo_root}/dist/image-bin/registry-notary-cel-worker" \ "${repo_root}/dist/image-bin/registry-relay" \ "${repo_root}/dist/image-bin/registry-relay-rhai-worker" @@ -128,8 +107,6 @@ chmod 0755 \ "evidencectl-${tag}-linux-amd64" \ "mint-${tag}-linux-amd64" \ "registry-manifest-${tag}-linux-amd64" \ - "registry-notary-${tag}-linux-amd64" \ - "registry-notary-cel-worker-${tag}-linux-amd64" \ "registry-relay-${tag}-linux-amd64" \ "registry-relay-rhai-worker-${tag}-linux-amd64" \ "registryctl-${tag}-linux-amd64" \ @@ -139,8 +116,6 @@ chmod 0755 \ cd -- "${repo_root}/dist/image-bin" sha256sum -- \ RELEASE_BUILDER_IMAGE \ - registry-notary \ - registry-notary-cel-worker \ registry-relay \ registry-relay-rhai-worker \ > SHA256SUMS diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index 96ef60727..b6891522c 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -24,7 +24,7 @@ release_image_context="${RELEASE_IMAGE_CONTEXT:-${repo_root}}" created_builder=false case "${name}" in - registry-notary|registry-relay) + registry-relay) dockerfile="${repo_root}/release/docker/Dockerfile.${name}" ;; *) diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index aa8fd1c8d..3d5d7aa25 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -35,8 +35,6 @@ DOCKERFILES = ( Path("crates/registry-relay/Dockerfile"), Path("crates/registry-relay/Dockerfile.demo"), - Path("products/notary/Dockerfile"), - Path("release/docker/Dockerfile.registry-notary"), Path("release/docker/Dockerfile.registry-relay"), ) @@ -58,21 +56,15 @@ Path("crates/registry-relay/docs/ops.md"), Path("crates/registry-relay/docs/security-assurance.md"), Path("crates/registry-relay/scripts/check_docker_build_contract.py"), - Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), - Path("products/notary/docs/security-assurance.md"), ) -RUST_BUILDER_DOCKERFILES = DOCKERFILES[:3] -PREPARATION_DOCKERFILES = DOCKERFILES[3:] +RUST_BUILDER_DOCKERFILES = DOCKERFILES[:2] +PREPARATION_DOCKERFILES = DOCKERFILES[2:] RELAY_DOCKERFILES = ( Path("crates/registry-relay/Dockerfile"), Path("crates/registry-relay/Dockerfile.demo"), Path("release/docker/Dockerfile.registry-relay"), ) -NOTARY_DOCKERFILES = ( - Path("products/notary/Dockerfile"), - Path("release/docker/Dockerfile.registry-notary"), -) FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) STAGE_NAME_RE = re.compile(r"^FROM\s+\S+\s+AS\s+(\S+)", re.MULTILINE | re.IGNORECASE) @@ -289,53 +281,6 @@ def check_repository(root: Path = ROOT) -> list[str]: failures, ) - product_notary = texts[Path("products/notary/Dockerfile")] - require( - product_notary, - 'ARG REGISTRY_NOTARY_FEATURES="registry-notary-cel,pkcs11"', - Path("products/notary/Dockerfile"), - "PKCS#11-enabled product build", - failures, - ) - for relative in NOTARY_DOCKERFILES: - text = texts[relative] - require( - text, - "registry-notary-cel-worker", - relative, - "Notary CEL worker binary", - failures, - ) - require( - runtime_stage(text), - 'ENTRYPOINT ["/usr/local/bin/registry-notary"]', - relative, - "absolute Notary entrypoint", - failures, - ) - require( - text, - "chown -R 65532:65532", - relative, - "numeric nonroot-owned Notary runtime directories", - failures, - ) - require( - runtime_stage(text), - "WORKDIR /var/lib/registry-notary", - relative, - "Notary working directory", - failures, - ) - if re.search( - r"^\s*(?:COPY|ADD)\b[^\n]*(?:\.so\b|pkcs11[^/\s]*module)", - text, - re.IGNORECASE | re.MULTILINE, - ): - failures.append( - f"{relative}: vendor PKCS#11 modules must remain external read-only mounts" - ) - candidate_workflow = texts[Path(".github/workflows/release-candidate.yml")] release_workflow = texts[Path(".github/workflows/release.yml")] binary_recipe = texts[Path("release/scripts/build-release-binaries.sh")] @@ -358,25 +303,6 @@ def check_repository(root: Path = ROOT) -> list[str]: ".github/workflows/release.yml: promotion workflow must not " f"rebuild candidate artifacts: {forbidden!r}" ) - require( - binary_recipe, - "--features registry-notary/registry-notary-cel,registry-notary/pkcs11", - Path("release/scripts/build-release-binaries.sh"), - "PKCS#11-enabled release build", - failures, - ) - - live_journey = texts[ - Path("crates/registry-relay/scripts/run-live-consultation-journey.sh") - ] - require( - live_journey, - RUST_BUILDER, - Path("crates/registry-relay/scripts/run-live-consultation-journey.sh"), - "pinned Debian 13 live-journey builder", - failures, - ) - return failures diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 1a33f4de1..a88afe716 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -238,13 +238,9 @@ "run: python3 -m unittest release/scripts/test_openid_conformance_runner.py", ), ( - "External integration evidence runner tests", + "External integration retirement guard", "run: python3 -m unittest release/scripts/test_integration_e2_runner.py", ), - ( - "External integration evidence packet", - "run: python3 release/scripts/integration-e2-runner.py validate", - ), ( "Relay OIDC smoke tests", "run: python3 -m unittest release/scripts/test_relay_oidc_smoke.py", diff --git a/release/scripts/check-release-source-model.sh b/release/scripts/check-release-source-model.sh index d95735929..40d05686f 100755 --- a/release/scripts/check-release-source-model.sh +++ b/release/scripts/check-release-source-model.sh @@ -59,8 +59,10 @@ stack_dirty="$(dirty_count "${stack_root}")" require_cargo_repo "registry-stack" "${stack_root}" require_path "registry-platform crates" "${stack_root}/crates/registry-platform-authcommon" require_path "registry-manifest crates" "${stack_root}/crates/registry-manifest-core" -require_path "registry-notary crates" "${stack_root}/crates/registry-notary-server" require_path "registry-relay crate" "${stack_root}/crates/registry-relay" +require_path "registry-evidence crate" "${stack_root}/crates/registry-evidence" +require_path "registry-evidencectl crate" "${stack_root}/crates/registry-evidencectl" +require_path "registry-mint crate" "${stack_root}/crates/registry-mint" require_path "registryctl crate" "${stack_root}/crates/registryctl" if [[ "${stack_git_root}" != "${stack_root}" ]]; then echo "release source model failed: registry-stack source dir must be the monorepo root, got ${stack_root} inside ${stack_git_root}" >&2 diff --git a/release/scripts/check-stable-surface-compatibility.py b/release/scripts/check-stable-surface-compatibility.py index d0ef6e2fd..2ad3fdf27 100644 --- a/release/scripts/check-stable-surface-compatibility.py +++ b/release/scripts/check-stable-surface-compatibility.py @@ -19,8 +19,11 @@ ERROR_REFERENCE = Path("docs/site/src/content/docs/reference/errors.mdx") OPENAPI_SPECS = { "registry-relay": Path("crates/registry-relay/openapi/registry-relay.openapi.json"), - "registry-notary": Path("products/notary/openapi/registry-notary.openapi.json"), } +MAINTAINED_RELEASE_PRODUCTS = frozenset({"registry-relay"}) +HISTORICAL_RELEASE_PRODUCTS = frozenset({"registry-notary"}) +KNOWN_RELEASE_PRODUCTS = MAINTAINED_RELEASE_PRODUCTS | HISTORICAL_RELEASE_PRODUCTS +HISTORICAL_DIAGNOSTIC_PRODUCTS = frozenset({"registry_notary"}) DIAGNOSTIC_CATALOGS = { "authoring": ( Path("docs/site/public/generated/diagnostics/authoring.v1.json"), @@ -223,6 +226,8 @@ def compare_diagnostic_contracts( errors: list[str] = [] for key, old in sorted(base.items()): family, product, code = key + if product in HISTORICAL_DIAGNOSTIC_PRODUCTS: + continue new = current.get(key) label = f"{family} {product} {code}" if new is None: @@ -268,10 +273,17 @@ def parse_error_registry(text: str) -> dict[str, ErrorContract]: if not entries: raise ContractError("error reference contains no Registry Relay or Registry Notary codes") - return { - code: ErrorContract(meaning, frozenset(products)) - for code, (meaning, products) in entries.items() - } + current_entries: dict[str, ErrorContract] = {} + for code, (meaning, products) in entries.items(): + maintained_products = products & MAINTAINED_RELEASE_PRODUCTS + if maintained_products: + current_entries[code] = ErrorContract( + meaning, + frozenset(maintained_products), + ) + if not current_entries: + raise ContractError("error reference contains no maintained Registry Relay codes") + return current_entries def compare_error_contracts( @@ -325,8 +337,8 @@ def validate_metrics_contract(data: Any, root: Path = ROOT) -> dict[tuple[str, s meaning = metric["meaning"] labels = metric["labels"] source = metric["source"] - if product not in {"registry-relay", "registry-notary"}: - raise ContractError(f"{label}.product is not a released product") + if product not in MAINTAINED_RELEASE_PRODUCTS: + raise ContractError(f"{label}.product is not a maintained product") if not isinstance(name, str) or re.fullmatch(r"[a-z_:][a-z0-9_:]*", name) is None: raise ContractError(f"{label}.name is not a Prometheus metric name") if metric_type not in {"counter", "gauge", "histogram", "summary", "untyped"}: @@ -373,6 +385,8 @@ def compare_metrics_contracts( for key, old in sorted(base.items()): new = current.get(key) product, name = key + if product in HISTORICAL_RELEASE_PRODUCTS: + continue if new is None: errors.append(f"selected metric removed: {product} {name}") continue @@ -574,7 +588,7 @@ def _validate_metrics_shape_only(data: Any) -> dict[tuple[str, str], dict[str, A metric[field] except (KeyError, TypeError) as error: raise ContractError(f"{label} is missing a protected field") from error - if product not in {"registry-relay", "registry-notary"}: + if product not in KNOWN_RELEASE_PRODUCTS: raise ContractError(f"{label}.product is not a released product") if not isinstance(name, str) or re.fullmatch(r"[a-z_:][a-z0-9_:]*", name) is None: raise ContractError(f"{label}.name is not a Prometheus metric name") diff --git a/release/scripts/conformance_candidate.py b/release/scripts/conformance_candidate.py index 26a342096..16ba17c93 100644 --- a/release/scripts/conformance_candidate.py +++ b/release/scripts/conformance_candidate.py @@ -20,7 +20,7 @@ from registryctl_image_lock import ( PLATFORM as IMAGE_LOCK_PLATFORM, - PRODUCT_IMAGE_REPOSITORIES, + repositories_for_schema, schema_for_release_version, validate_images, ) @@ -33,7 +33,6 @@ IMAGE_LOCK_FILE = re.compile( r"^registryctl-(v[A-Za-z0-9][A-Za-z0-9._+-]*)-image-lock\.json$" ) -IMAGE_REPOSITORIES = PRODUCT_IMAGE_REPOSITORIES CAPSULE_REPOSITORY = "registrystack/registry-stack" SLSA_SOURCE_URI = "github.com/registrystack/registry-stack" RELEASE_WORKFLOW = ( @@ -594,18 +593,19 @@ def _load_candidate_snapshot( raise CandidateError( "release manifest does not identify one immutable candidate" ) + try: + expected_image_lock_schema = schema_for_release_version(version) + image_repositories = repositories_for_schema(expected_image_lock_schema) + except ValueError as exc: + raise CandidateError(str(exc)) from None + product_components = set(image_repositories) - {"postgresql"} artifacts = manifest.get("artifacts") if not isinstance(artifacts, dict) or any( - artifacts.get(component) != version for component in IMAGE_REPOSITORIES + artifacts.get(component) != version for component in product_components ): raise CandidateError( "release manifest product artifacts do not match its version" ) - - try: - expected_image_lock_schema = schema_for_release_version(version) - except ValueError as exc: - raise CandidateError(str(exc)) from None if ( not isinstance(lock, dict) or image_lock_path.name != f"registryctl-v{version}-image-lock.json" @@ -645,7 +645,7 @@ def _load_candidate_snapshot( raise CandidateError("Solmara topology requires one exact source commit") elif topology != "release-owned" or solmara_source_ref is not None: raise CandidateError("Solmara must be explicitly selected and commit-pinned") - return { + candidate = { "release_id": release_id, "version": version, "source_repo": stack["source_repo"], @@ -656,11 +656,13 @@ def _load_candidate_snapshot( "manifest_sha256": f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}", "image_lock_sha256": f"sha256:{image_lock_sha256}", "release_capsule_sha256": f"sha256:{capsule_sha256}", - "notary_image": images["registry-notary"], "relay_image": images["registry-relay"], "topology": topology, "solmara_source_ref": solmara_source_ref, } + if "registry-notary" in images: + candidate["notary_image"] = images["registry-notary"] + return candidate def load_candidate( diff --git a/release/scripts/integration-e2-runner.py b/release/scripts/integration-e2-runner.py deleted file mode 100755 index aaa4e1a92..000000000 --- a/release/scripts/integration-e2-runner.py +++ /dev/null @@ -1,1271 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -"""Prepare and validate candidate-neutral external integration evidence. - -This tool deliberately does not drive an unreviewed live product instance. It -closes the portable plan, candidate trust checks, and public evidence boundary -while leaving instance-specific orchestration to an approved operator wrapper. -""" - -from __future__ import annotations - -import argparse -import datetime as dt -import hashlib -import json -import os -import re -import shutil -import stat -import subprocess -import sys -import tempfile -from contextlib import contextmanager -from pathlib import Path -from typing import Any, Callable, Iterator - -from closed_json_schema import ( - SchemaValidationError, - validate_against_schema as validate_closed_schema, -) -from registryctl_image_lock import ( - PLATFORM as IMAGE_LOCK_PLATFORM, - schema_for_release_version, - validate_images, -) - - -ROOT = Path(__file__).resolve().parents[2] -CONFIG_DIR = ROOT / "release" / "conformance" / "integrations" -PROFILE_DIR = CONFIG_DIR / "profiles" -SCHEMA_PATH = CONFIG_DIR / "schema" / "run-result.schema.json" -PROFILE_SCHEMA = "registry.release.integration_e2_profile.v1" -RESULT_SCHEMA = "registry.release.integration_e2_run_result.v1" -SUPPORT_STATUS = "Registry Stack-supported unofficial integration profile" -CAPSULE_REPOSITORY = "registrystack/registry-stack" -SLSA_SOURCE_URI = "github.com/registrystack/registry-stack" -RELEASE_WORKFLOW = ( - "https://github.com/registrystack/registry-stack/.github/workflows/" - "release.yml@refs/tags/{tag}" -) -PROFILE_FILES = { - "opencrvs-dci-v1.9": "opencrvs-dci-v1.9.profile.json", - "dhis2-tracker-2.41.9": "dhis2-tracker-2.41.9.profile.json", -} -CASE_IDS = ( - "authorized-match", - "authorized-no-match", - "ambiguity", - "invalid-selector", - "wrong-caller", - "missing-scope", - "wrong-purpose", - "stale-contract", - "subject-mismatch", - "source-authorization-failure", - "deadline-enforced", -) -TAG = re.compile(r"^v([0-9]+\.[0-9]+\.[0-9]+)$") -COMMIT = re.compile(r"^[0-9a-f]{40}$") -MAX_ASSET_BYTES = 128 * 1024 * 1024 -SAFE_CANARY = re.compile(rb"^[A-Za-z0-9._:@-]{8,128}$") -REQUIRED_LIMITATIONS = { - "unofficial-integration-profile", - "single-pinned-product-version", - "single-reviewed-read-operation", - "non-production-instance", - "not-product-certification", - "not-general-country-system-conformance", -} -EXPECTED_PROFILE_BINDINGS = { - "opencrvs-dci-v1.9": { - "starter": "opencrvs-dci", - "product": "opencrvs", - "baseline": [ - ("dci-adapter", "v1.9.0-rc.1", "5e31d1e381d4bd8c7c74112d714fd49d263c6df7"), - ("core", "v1.9.5", "7243ccb79eb84254420878ff5146c6e50f084554"), - ("farajaland", "v1.9.5", "3c1a5612d8d7bebddd43463682860b38ef14bcb4"), - ], - "operations": [ - ("oauth-token", "credential", "POST", "owner-attested", 1), - ("jwks", "verification", "GET", "owner-attested", 1), - ("signed-uin-search", "data", "POST", "/registry/sync/search", 1), - ], - "inputs": ( - "OPENCRVS_SOURCE_ORIGIN", - "OPENCRVS_OAUTH_ORIGIN", - "OPENCRVS_OAUTH_PATH", - "OPENCRVS_JWKS_ORIGIN", - "OPENCRVS_JWKS_PATH", - "OPENCRVS_SENDER_ID", - "OPENCRVS_RECEIVER_ID", - "OPENCRVS_CLIENT_ID", - "OPENCRVS_CLIENT_SECRET", - "OPENCRVS_MATCH_UIN", - "OPENCRVS_NO_MATCH_UIN", - "OPENCRVS_AMBIGUOUS_UIN", - "OPENCRVS_MISMATCH_UIN", - "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", - "REGISTRY_INTEGRATION_E2_CANARY_FILE", - ), - "case_access": ( - "contacted_once", - "contacted_once", - "contacted_once", - "not_contacted", - "not_contacted", - "not_contacted", - "not_contacted", - "not_contacted", - "contacted_once", - "not_contacted", - "contacted_once", - ), - "result_codes": ( - "match", - "no-match", - "ambiguous", - "invalid-selector", - "denied-wrong-caller", - "denied-missing-scope", - "denied-wrong-purpose", - "denied-stale-contract", - "subject-mismatch", - "source-authorization-failure", - "deadline-exceeded", - ), - "limits": (60, 1800, 8388608, 1048576, 300), - }, - "dhis2-tracker-2.41.9": { - "starter": "dhis2-tracker", - "product": "dhis2-tracker", - "baseline": [ - ("dhis2", "2.41.9", "ce6404687f6a5806e2661cffe4bc7a1d9b2ad2ed"), - ], - "operations": [ - ( - "tracked-entity-read", - "data", - "GET", - "/api/tracker/trackedEntities/{uid}", - 1, - ), - ], - "inputs": ( - "DHIS2_SOURCE_ORIGIN", - "DHIS2_USERNAME", - "DHIS2_PASSWORD", - "DHIS2_CHILD_PROGRAM_UID", - "DHIS2_MATERNAL_PROGRAM_UID", - "DHIS2_TB_PROGRAM_UID", - "DHIS2_CHILD_VISIT_STAGE_UID", - "DHIS2_BCG_BIRTH_STAGE_UID", - "DHIS2_OPV_BIRTH_STAGE_UID", - "DHIS2_MEASLES_STAGE_UID", - "DHIS2_FIRST_NAME_ATTRIBUTE_UID", - "DHIS2_LAST_NAME_ATTRIBUTE_UID", - "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", - "DHIS2_RECONCILIATION_ATTRIBUTE_UID", - "DHIS2_MATCH_TRACKED_ENTITY", - "DHIS2_NO_MATCH_TRACKED_ENTITY", - "DHIS2_MISMATCH_TRACKED_ENTITY", - "REGISTRY_INTEGRATION_E2_SOURCE_PROBE", - "REGISTRY_INTEGRATION_E2_CANARY_FILE", - ), - "case_access": ( - "contacted_once", - "contacted_once", - "not_applicable", - "not_contacted", - "not_contacted", - "not_contacted", - "not_contacted", - "not_contacted", - "contacted_once", - "contacted_once", - "contacted_once", - ), - "result_codes": ( - "match", - "no-match", - "not-applicable", - "invalid-selector", - "denied-wrong-caller", - "denied-missing-scope", - "denied-wrong-purpose", - "denied-stale-contract", - "subject-mismatch", - "source-authorization-failure", - "deadline-exceeded", - ), - "limits": (30, 1200, 8388608, 1048576, 300), - }, -} - - -class RunnerError(RuntimeError): - """A user-actionable integration evidence error.""" - - -def load_json(path: Path, *, max_bytes: int = 1024 * 1024) -> Any: - require_regular_file(path, max_bytes=max_bytes) - try: - return json.loads(path.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: - raise RunnerError(f"could not read valid JSON from {path}: {exc}") from exc - - -def require_regular_file(path: Path, *, max_bytes: int) -> None: - try: - info = path.lstat() - except OSError as exc: - raise RunnerError(f"required file is unavailable: {path}: {exc}") from exc - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): - raise RunnerError(f"required path must be a regular, non-symlink file: {path}") - if info.st_size <= 0 or info.st_size > max_bytes: - raise RunnerError( - f"file size for {path} must be between 1 and {max_bytes} bytes" - ) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def require_object(value: Any, label: str, keys: set[str]) -> dict[str, Any]: - if not isinstance(value, dict): - raise RunnerError(f"{label} must be an object") - missing = keys - set(value) - unknown = set(value) - keys - if missing or unknown: - details = [] - if missing: - details.append("missing " + ", ".join(sorted(missing))) - if unknown: - details.append("unknown " + ", ".join(sorted(unknown))) - raise RunnerError(f"{label} has invalid fields: {'; '.join(details)}") - return value - - -def load_profile(profile_id: str) -> dict[str, Any]: - try: - path = PROFILE_DIR / PROFILE_FILES[profile_id] - except KeyError as exc: - raise RunnerError(f"unknown integration profile: {profile_id}") from exc - profile = load_json(path) - validate_profile(profile, path.name) - if profile["profile_id"] != profile_id: - raise RunnerError(f"{path.name} declares the wrong profile_id") - return profile - - -def validate_profile(value: Any, label: str) -> None: - profile = require_object( - value, - label, - { - "schema_version", - "profile_id", - "support_status", - "starter", - "source", - "authored_contract", - "dynamic_inputs", - "cases", - "prerequisites", - "limits", - }, - ) - if profile["schema_version"] != PROFILE_SCHEMA: - raise RunnerError(f"{label} has an unsupported schema_version") - if profile["profile_id"] not in PROFILE_FILES: - raise RunnerError(f"{label} has an unknown profile_id") - expected = EXPECTED_PROFILE_BINDINGS[profile["profile_id"]] - if profile["support_status"] != SUPPORT_STATUS: - raise RunnerError(f"{label} must use the reviewed support wording") - if profile["starter"] != expected["starter"]: - raise RunnerError(f"{label} has the wrong pinned starter") - authored = require_object( - profile["authored_contract"], - f"{label}.authored_contract", - {"integration_alias", "generated_yaml_edits_allowed", "reviewed_changes"}, - ) - if authored["generated_yaml_edits_allowed"] is not False: - raise RunnerError(f"{label} must prohibit generated YAML edits") - if ( - not isinstance(authored["integration_alias"], str) - or not isinstance(authored["reviewed_changes"], list) - or not authored["reviewed_changes"] - ): - raise RunnerError( - f"{label}.authored_contract must contain reviewed authored changes" - ) - - source = require_object( - profile["source"], f"{label}.source", {"product", "baseline", "operations"} - ) - if source["product"] != expected["product"]: - raise RunnerError(f"{label} has the wrong pinned source product") - baseline = source["baseline"] - if not isinstance(baseline, list): - raise RunnerError(f"{label}.source.baseline must be an array") - baseline_tuples = [] - for index, item in enumerate(baseline): - entry = require_object( - item, - f"{label}.source.baseline[{index}]", - {"component", "version", "commit"}, - ) - baseline_tuples.append((entry["component"], entry["version"], entry["commit"])) - if baseline_tuples != expected["baseline"]: - raise RunnerError(f"{label} does not use the exact reviewed upstream baseline") - operations = source["operations"] - if not isinstance(operations, list): - raise RunnerError(f"{label}.source.operations must be an array") - operation_tuples = [] - for index, item in enumerate(operations): - if not isinstance(item, dict): - raise RunnerError(f"{label}.source.operations[{index}] must be an object") - allowed = { - "id", - "role", - "method", - "path", - "max_calls", - "selector", - "field_projection", - } - required = {"id", "role", "method", "path", "max_calls"} - if set(item) - allowed or required - set(item): - raise RunnerError(f"{label}.source.operations[{index}] has invalid fields") - operation_tuples.append( - (item["id"], item["role"], item["method"], item["path"], item["max_calls"]) - ) - expected_keys = required | ( - {"selector", "field_projection"} - if item["id"] == "tracked-entity-read" - else {"selector"} - if item["id"] == "signed-uin-search" - else set() - ) - if set(item) != expected_keys: - raise RunnerError( - f"{label}.source.operations[{index}] has unexpected optional fields" - ) - if operation_tuples != expected["operations"]: - raise RunnerError(f"{label} does not use the exact reviewed source operations") - data_operation = next(item for item in operations if item["role"] == "data") - if data_operation.get("selector") not in {"exact UIN", "exact tracked entity UID"}: - raise RunnerError( - f"{label} data operation must use the reviewed exact selector" - ) - if ( - profile["profile_id"].startswith("dhis2") - and data_operation.get("field_projection") - != "trackedEntity,attributes[attribute,value],enrollments[program,status,events[programStage,status]]" - ): - raise RunnerError(f"{label} does not use the reviewed DHIS2 field projection") - - dynamic = profile["dynamic_inputs"] - if not isinstance(dynamic, list) or not dynamic: - raise RunnerError(f"{label}.dynamic_inputs must be a non-empty list") - env_names = [] - for index, item in enumerate(dynamic): - entry = require_object( - item, - f"{label}.dynamic_inputs[{index}]", - {"env", "classification", "purpose"}, - ) - if ( - entry["classification"] not in {"restricted", "secret", "subject"} - or not isinstance(entry["purpose"], str) - or not entry["purpose"] - ): - raise RunnerError( - f"{label}.dynamic_inputs[{index}] has an invalid classification or purpose" - ) - env_names.append(entry["env"]) - if tuple(env_names) != expected["inputs"]: - raise RunnerError( - f"{label}.dynamic_inputs does not match the reviewed input names" - ) - cases = profile["cases"] - if not isinstance(cases, list): - raise RunnerError(f"{label}.cases must be an array") - for index, case in enumerate(cases): - require_object( - case, - f"{label}.cases[{index}]", - {"id", "expected_result_code", "expected_source_data_access"}, - ) - if [case["id"] for case in cases] != list(CASE_IDS): - raise RunnerError(f"{label}.cases must contain the closed ordered case set") - allowed_access = {"not_contacted", "contacted_once", "not_applicable"} - if any( - case.get("expected_source_data_access") not in allowed_access for case in cases - ): - raise RunnerError( - f"{label}.cases contains an invalid source access expectation" - ) - if ( - tuple(case["expected_source_data_access"] for case in cases) - != expected["case_access"] - ): - raise RunnerError( - f"{label}.cases does not match the reviewed source access contract" - ) - if ( - tuple(case["expected_result_code"] for case in cases) - != expected["result_codes"] - ): - raise RunnerError( - f"{label}.cases does not match the reviewed safe result codes" - ) - prerequisites = profile["prerequisites"] - if ( - not isinstance(prerequisites, list) - or len(prerequisites) < 5 - or any(not isinstance(item, str) or not item for item in prerequisites) - ): - raise RunnerError( - f"{label}.prerequisites must make external ownership explicit" - ) - limits = require_object( - profile["limits"], - f"{label}.limits", - { - "case_timeout_seconds", - "run_timeout_seconds", - "raw_evidence_bytes", - "public_result_bytes", - "teardown_timeout_seconds", - }, - ) - if any(not isinstance(item, int) or item <= 0 for item in limits.values()): - raise RunnerError(f"{label}.limits must contain positive integers") - actual_limits = tuple( - limits[name] - for name in ( - "case_timeout_seconds", - "run_timeout_seconds", - "raw_evidence_bytes", - "public_result_bytes", - "teardown_timeout_seconds", - ) - ) - if actual_limits != expected["limits"]: - raise RunnerError( - f"{label}.limits does not match the reviewed bounded contract" - ) - - -def validate_packet() -> None: - schema = load_json(SCHEMA_PATH) - if schema.get("$schema") != "https://json-schema.org/draft/2020-12/schema": - raise RunnerError("run result schema must use JSON Schema draft 2020-12") - if schema.get("additionalProperties") is not False: - raise RunnerError("run result schema must be closed") - assert_closed_schema(schema) - for profile_id in PROFILE_FILES: - load_profile(profile_id) - - -def assert_closed_schema(value: Any, label: str = "schema") -> None: - if isinstance(value, dict): - if ( - value.get("type") == "object" - and value.get("additionalProperties") is not False - ): - raise RunnerError(f"{label} contains an open object schema") - for name, item in value.items(): - assert_closed_schema(item, f"{label}.{name}") - elif isinstance(value, list): - for index, item in enumerate(value): - assert_closed_schema(item, f"{label}[{index}]") - - -def parse_checksums(path: Path) -> dict[str, str]: - require_regular_file(path, max_bytes=1024 * 1024) - checksums: dict[str, str] = {} - for line_number, line in enumerate( - path.read_text(encoding="utf-8").splitlines(), 1 - ): - match = re.fullmatch(r"([0-9a-f]{64}) \*?([^/\x00]+)", line) - if match is None: - raise RunnerError(f"SHA256SUMS line {line_number} has an unsafe format") - digest, name = match.groups() - if name in checksums: - raise RunnerError(f"SHA256SUMS contains duplicate entry {name}") - checksums[name] = digest - return checksums - - -def signed_subject_names(tag: str) -> tuple[str, ...]: - binary = f"registryctl-{tag}-linux-amd64" - image_lock = f"registryctl-{tag}-image-lock.json" - return ( - binary, - image_lock, - f"{image_lock}.spdx.json", - f"registry-stack-{tag}-release-capsule.json", - "registry-relay.digest", - "registry-notary.digest", - ) - - -def required_asset_names(tag: str) -> set[str]: - subjects = signed_subject_names(tag) - return { - *subjects, - *(f"{name}.sig" for name in subjects), - *(f"{name}.pem" for name in subjects), - "SHA256SUMS", - f"registry-stack-{tag}-release-provenance.intoto.jsonl", - } - - -def run_authenticity_command(command: list[str]) -> None: - result = subprocess.run(command, text=True, capture_output=True, check=False) - if result.returncode != 0: - detail = (result.stderr or result.stdout).strip().splitlines() - suffix = f": {detail[-1]}" if detail else "" - raise RunnerError(f"authenticity command failed ({command[0]}){suffix}") - - -def verify_authenticity( - asset_dir: Path, - tag: str, - *, - command_runner: Callable[[list[str]], None] = run_authenticity_command, -) -> None: - cosign = shutil.which("cosign") - slsa = shutil.which("slsa-verifier") - if not cosign or not slsa: - missing = [ - name - for name, path in (("cosign", cosign), ("slsa-verifier", slsa)) - if not path - ] - raise RunnerError( - "candidate authenticity verification requires installed " - + " and ".join(missing) - ) - provenance = asset_dir / f"registry-stack-{tag}-release-provenance.intoto.jsonl" - identity = RELEASE_WORKFLOW.format(tag=tag) - for name in signed_subject_names(tag): - subject = asset_dir / name - command_runner( - [ - cosign, - "verify-blob", - str(subject), - "--signature", - str(asset_dir / f"{name}.sig"), - "--certificate", - str(asset_dir / f"{name}.pem"), - "--certificate-oidc-issuer", - "https://token.actions.githubusercontent.com", - "--certificate-identity", - identity, - ] - ) - command_runner( - [ - slsa, - "verify-artifact", - str(subject), - "--provenance-path", - str(provenance), - "--source-uri", - SLSA_SOURCE_URI, - "--source-tag", - tag, - ] - ) - - -def exact_digest_file(path: Path, repository: str) -> str: - require_regular_file(path, max_bytes=1024) - value = path.read_text(encoding="utf-8").strip() - expected = re.compile( - rf"^ghcr\.io/registrystack/{re.escape(repository)}@sha256:[0-9a-f]{{64}}$" - ) - if expected.fullmatch(value) is None: - raise RunnerError( - f"{path.name} must contain one digest-bound {repository} reference" - ) - return value - - -def find_named(items: Any, name: str, label: str) -> dict[str, Any]: - if not isinstance(items, list): - raise RunnerError(f"release capsule {label} must be an array") - matches = [ - item for item in items if isinstance(item, dict) and item.get("name") == name - ] - if len(matches) != 1: - raise RunnerError( - f"release capsule must contain exactly one {label} entry for {name}" - ) - return matches[0] - - -def verify_file_sbom(path: Path, subject_name: str, subject_sha256: str) -> None: - document = load_json(path, max_bytes=16 * 1024 * 1024) - if not isinstance(document, dict): - raise RunnerError(f"{path.name} must be an SPDX JSON object") - described = document.get("documentDescribes") - packages = document.get("packages") - if not isinstance(described, list) or not isinstance(packages, list): - raise RunnerError(f"{path.name} must contain SPDX subjects and packages") - described_ids = {item for item in described if isinstance(item, str)} - for package in packages: - if not isinstance(package, dict) or package.get("SPDXID") not in described_ids: - continue - if ( - package.get("name") != subject_name - and package.get("packageFileName") != subject_name - ): - continue - checksums = package.get("checksums") - if isinstance(checksums, list) and any( - isinstance(item, dict) - and item.get("algorithm") == "SHA256" - and item.get("checksumValue") == subject_sha256 - for item in checksums - ): - return - raise RunnerError( - f"{path.name} does not describe {subject_name} at its actual SHA-256" - ) - - -def require_candidate_directory(path: Path) -> None: - try: - info = path.lstat() - except OSError as exc: - raise RunnerError( - f"candidate asset directory is unavailable: {path}: {exc}" - ) from exc - if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): - raise RunnerError( - "candidate asset directory must be a real, non-symlink directory" - ) - - -@contextmanager -def candidate_asset_snapshot(asset_dir: Path, tag: str) -> Iterator[Path]: - """Copy the closed candidate set through no-follow descriptors and remove it.""" - require_candidate_directory(asset_dir) - no_follow = getattr(os, "O_NOFOLLOW", None) - directory_flag = getattr(os, "O_DIRECTORY", None) - if no_follow is None or directory_flag is None: - raise RunnerError("candidate snapshotting requires O_NOFOLLOW and O_DIRECTORY") - directory_fd = None - try: - directory_fd = os.open( - asset_dir, - os.O_RDONLY | os.O_CLOEXEC | no_follow | directory_flag, - ) - required = required_asset_names(tag) - actual = set(os.listdir(directory_fd)) - missing = required - actual - unknown = actual - required - if missing or unknown: - details = [] - if missing: - details.append("missing " + ", ".join(sorted(missing))) - if unknown: - details.append("unexpected " + ", ".join(sorted(unknown))) - raise RunnerError( - "candidate asset set is not closed: " + "; ".join(details) - ) - - with tempfile.TemporaryDirectory( - prefix="registry-integration-e2-candidate-" - ) as temporary: - snapshot = Path(temporary) - snapshot.chmod(0o700) - binary_name = f"registryctl-{tag}-linux-amd64" - for name in sorted(required): - source_fd = None - destination_fd = None - try: - source_fd = os.open( - name, - os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | no_follow, - dir_fd=directory_fd, - ) - source_info = os.fstat(source_fd) - if not stat.S_ISREG(source_info.st_mode): - raise RunnerError( - f"candidate asset must be a regular, non-symlink file: {name}" - ) - if ( - source_info.st_size <= 0 - or source_info.st_size > MAX_ASSET_BYTES - ): - raise RunnerError( - f"candidate asset size for {name} must be between 1 and {MAX_ASSET_BYTES} bytes" - ) - destination = snapshot / name - destination_fd = os.open( - destination, - os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | no_follow, - 0o600, - ) - with os.fdopen(source_fd, "rb") as source_handle: - source_fd = None - with os.fdopen(destination_fd, "wb") as destination_handle: - destination_fd = None - shutil.copyfileobj(source_handle, destination_handle) - if destination.stat().st_size != source_info.st_size: - raise RunnerError( - f"candidate asset changed while snapshotting: {name}" - ) - destination.chmod(0o500 if name == binary_name else 0o400) - finally: - if source_fd is not None: - os.close(source_fd) - if destination_fd is not None: - os.close(destination_fd) - snapshot.chmod(0o500) - try: - yield snapshot - finally: - snapshot.chmod(0o700) - except OSError as exc: - raise RunnerError( - f"could not create private candidate snapshot: {exc}" - ) from exc - finally: - if directory_fd is not None: - os.close(directory_fd) - - -def verify_candidate_assets( - asset_dir: Path, - tag: str, - *, - authenticate: Callable[[Path, str], None] = verify_authenticity, - binary_runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, -) -> dict[str, str]: - tag_match = TAG.fullmatch(tag) - if tag_match is None: - raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") - with candidate_asset_snapshot(asset_dir, tag) as snapshot: - return verify_candidate_snapshot( - snapshot, - tag, - authenticate=authenticate, - binary_runner=binary_runner, - ) - - -def verify_candidate_snapshot( - asset_dir: Path, - tag: str, - *, - authenticate: Callable[[Path, str], None], - binary_runner: Callable[..., subprocess.CompletedProcess[str]], -) -> dict[str, str]: - tag_match = TAG.fullmatch(tag) - if tag_match is None: - raise RunnerError("candidate tag must be a stable vMAJOR.MINOR.PATCH tag") - require_candidate_directory(asset_dir) - required = required_asset_names(tag) - actual = {path.name for path in asset_dir.iterdir()} - missing = required - actual - unknown = actual - required - if missing or unknown: - details = [] - if missing: - details.append("missing " + ", ".join(sorted(missing))) - if unknown: - details.append("unexpected " + ", ".join(sorted(unknown))) - raise RunnerError("candidate asset set is not closed: " + "; ".join(details)) - for name in sorted(required): - require_regular_file(asset_dir / name, max_bytes=MAX_ASSET_BYTES) - - binary_name = f"registryctl-{tag}-linux-amd64" - image_lock_name = f"registryctl-{tag}-image-lock.json" - capsule_name = f"registry-stack-{tag}-release-capsule.json" - checksums = parse_checksums(asset_dir / "SHA256SUMS") - for name in (binary_name, image_lock_name): - if checksums.get(name) != sha256(asset_dir / name): - raise RunnerError(f"{name} does not match its SHA256SUMS entry") - - binary = asset_dir / binary_name - lock = require_object( - load_json(asset_dir / image_lock_name), - image_lock_name, - { - "schema_version", - "release_tag", - "manifest_source_ref", - "tag_target", - "platform", - "images", - }, - ) - try: - expected_image_lock_schema = schema_for_release_version(tag_match.group(1)) - except ValueError as exc: - raise RunnerError(str(exc)) from None - if ( - lock["schema_version"] != expected_image_lock_schema - or lock["release_tag"] != tag - ): - raise RunnerError("candidate image lock has the wrong schema or release tag") - if lock["platform"] != IMAGE_LOCK_PLATFORM: - raise RunnerError("candidate image lock must target linux/amd64") - if not COMMIT.fullmatch(str(lock["manifest_source_ref"])) or not COMMIT.fullmatch( - str(lock["tag_target"]) - ): - raise RunnerError("candidate image lock source refs must be full commits") - try: - images = validate_images(expected_image_lock_schema, lock["images"]) - except ValueError as exc: - raise RunnerError(str(exc)) from None - relay = exact_digest_file(asset_dir / "registry-relay.digest", "registry-relay") - notary = exact_digest_file(asset_dir / "registry-notary.digest", "registry-notary") - if images["registry-relay"] != relay or images["registry-notary"] != notary: - raise RunnerError("candidate digest files do not match the image lock") - - capsule = load_json(asset_dir / capsule_name, max_bytes=8 * 1024 * 1024) - if not isinstance(capsule, dict): - raise RunnerError("release capsule must be an object") - if capsule.get("release_tag") != tag or capsule.get("version") != tag_match.group( - 1 - ): - raise RunnerError("release capsule identity does not match the candidate tag") - if capsule.get("repository") != CAPSULE_REPOSITORY: - raise RunnerError(f"release capsule repository must be {CAPSULE_REPOSITORY}") - source = capsule.get("source") - if ( - not isinstance(source, dict) - or source.get("source_tag") != tag - or source.get("source_ref") != lock["manifest_source_ref"] - or source.get("source_commit") != lock["tag_target"] - ): - raise RunnerError( - "release capsule source lineage does not match the image lock" - ) - lineage = source.get("lineage") - expected_lineage_keys = { - "tag_matches_source_tag", - "head_matches_tag_target", - "source_ref_ancestor_or_equal", - "default_branch_reachable", - } - if ( - not isinstance(lineage, dict) - or set(lineage) != expected_lineage_keys - or any(value is not True for value in lineage.values()) - ): - raise RunnerError("release capsule does not prove every source lineage check") - binary_entry = find_named(capsule.get("binaries"), binary_name, "binaries") - lock_entry = find_named( - capsule.get("release_files"), image_lock_name, "release_files" - ) - if binary_entry.get("sha256") != sha256(binary): - raise RunnerError( - "release capsule binary hash does not match the candidate asset" - ) - if lock_entry.get("kind") != "registryctl-release-image-lock" or lock_entry.get( - "sha256" - ) != sha256(asset_dir / image_lock_name): - raise RunnerError( - "release capsule image-lock classification or hash is invalid" - ) - lock_sbom_name = f"{image_lock_name}.spdx.json" - lock_sbom = asset_dir / lock_sbom_name - verify_file_sbom(lock_sbom, image_lock_name, sha256(asset_dir / image_lock_name)) - expected_sbom = { - "asset_name": lock_sbom_name, - "subject": image_lock_name, - "format": "spdx-json", - "sha256": sha256(lock_sbom), - } - if lock_entry.get("sbom") != expected_sbom: - raise RunnerError("release capsule image-lock SBOM binding is invalid") - capsule_image_items = capsule.get("images") - if not isinstance(capsule_image_items, list): - raise RunnerError("release capsule images must be an array") - capsule_image_names = [ - item.get("name") for item in capsule_image_items if isinstance(item, dict) - ] - if ( - len(capsule_image_names) != len(images) - or set(capsule_image_names) != set(images) - ): - raise RunnerError( - "release capsule images must contain exactly the image-lock images" - ) - for name, digest_ref in images.items(): - image = find_named(capsule_image_items, name, "images") - if image.get("digest_ref") != digest_ref: - raise RunnerError( - "release capsule images do not match the candidate image lock" - ) - expected_role = ( - "supporting-runtime-image" - if name == "postgresql" - else "released-product-image" - ) - if image.get("role") != expected_role: - raise RunnerError( - f"release capsule {name} image must be a {expected_role}" - ) - relay_entry = find_named(capsule_image_items, "registry-relay", "images") - notary_entry = find_named(capsule_image_items, "registry-notary", "images") - if ( - relay_entry.get("digest_ref") != relay - or notary_entry.get("digest_ref") != notary - ): - raise RunnerError( - "release capsule images do not match the candidate digest files" - ) - - signed_subject_hashes = { - name: sha256(asset_dir / name) for name in signed_subject_names(tag) - } - # Candidate-controlled code must remain passive until every local binding - # and both external authenticity mechanisms have accepted the assets. - authenticate(asset_dir, tag) - if any( - sha256(asset_dir / name) != digest - for name, digest in signed_subject_hashes.items() - ): - raise RunnerError("a signed candidate subject changed during verification") - binary.chmod(binary.stat().st_mode | stat.S_IXUSR) - version_result = binary_runner( - [str(binary), "--version"], - text=True, - capture_output=True, - check=False, - timeout=10, - ) - if ( - version_result.returncode != 0 - or version_result.stdout.strip() != f"registryctl {tag_match.group(1)}" - ): - raise RunnerError( - f"{binary_name} does not self-report registryctl {tag_match.group(1)}" - ) - return { - "tag": tag, - "version": tag_match.group(1), - "source_commit": lock["tag_target"], - "registryctl_asset_sha256": f"sha256:{signed_subject_hashes[binary_name]}", - "image_lock_sha256": f"sha256:{signed_subject_hashes[image_lock_name]}", - "release_capsule_sha256": f"sha256:{signed_subject_hashes[capsule_name]}", - "relay_image": relay, - "notary_image": notary, - } - - -def validate_against_schema( - value: Any, rule: dict[str, Any], schema: dict[str, Any], label: str = "result" -) -> None: - try: - validate_closed_schema(value, rule, schema, label) - except SchemaValidationError as exc: - raise RunnerError(str(exc)) from None - - -def read_canaries(path: Path) -> list[bytes]: - require_regular_file(path, max_bytes=64 * 1024) - mode = path.stat().st_mode - if mode & (stat.S_IRWXG | stat.S_IRWXO): - raise RunnerError("canary file must not grant group or other permissions") - canaries = [] - for line_number, line in enumerate(path.read_bytes().splitlines(), 1): - if SAFE_CANARY.fullmatch(line) is None: - raise RunnerError( - f"canary file line {line_number} must contain 8 to 128 safe ASCII bytes" - ) - canaries.append(line) - if not canaries or len(canaries) > 128 or len(set(canaries)) != len(canaries): - raise RunnerError("canary file must contain 1 to 128 unique values") - return canaries - - -def parse_timestamp(value: str) -> dt.datetime: - return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") - - -def elapsed_milliseconds(started: dt.datetime, completed: dt.datetime) -> int: - elapsed = completed - started - return ( - elapsed.days * 86_400_000 - + elapsed.seconds * 1000 - + elapsed.microseconds // 1000 - ) - - -def validate_result( - path: Path, profile: dict[str, Any], canary_file: Path -) -> dict[str, Any]: - limit = profile["limits"]["public_result_bytes"] - require_regular_file(path, max_bytes=limit) - public_bytes = path.read_bytes() - canaries = read_canaries(canary_file) - if any(canary in public_bytes for canary in canaries): - raise RunnerError("public result contains a seeded restricted-value canary") - result = load_json(path, max_bytes=limit) - schema = load_json(SCHEMA_PATH) - validate_against_schema(result, schema, schema) - if result["profile_id"] != profile["profile_id"]: - raise RunnerError( - "public result profile_id does not match the selected profile" - ) - - data_operation = next( - item for item in profile["source"]["operations"] if item["role"] == "data" - ) - expected_source = { - "product": profile["source"]["product"], - "baseline": profile["source"]["baseline"], - "operation_id": data_operation["id"], - "method": data_operation["method"], - "path": data_operation["path"], - } - for key, expected in expected_source.items(): - if result["source"][key] != expected: - raise RunnerError( - f"public result source.{key} does not match the pinned profile" - ) - if result["project"]["starter"] != profile["starter"]: - raise RunnerError("public result starter does not match the pinned profile") - if set(result["limitations"]) != REQUIRED_LIMITATIONS: - raise RunnerError("public result must retain every profile limitation") - - cases = result["cases"] - if [case["case_id"] for case in cases] != list(CASE_IDS): - raise RunnerError("public result cases must use the closed ordered case set") - expectations = {item["id"]: item for item in profile["cases"]} - all_passed = True - run_started = parse_timestamp(result["started_at"]) - run_completed = parse_timestamp(result["completed_at"]) - if run_completed < run_started: - raise RunnerError("public result completes before it starts") - latest_case_completion = run_started - for case in cases: - expectation = expectations[case["case_id"]] - expected_access = expectation["expected_source_data_access"] - if ( - case["outcome"] == "passed" - and case["source_data_access"] != expected_access - ): - raise RunnerError( - f"{case['case_id']} passed without expected source-side access evidence {expected_access}" - ) - if ( - case["outcome"] in {"passed", "not_applicable"} - and case["result_code"] != expectation["expected_result_code"] - ): - raise RunnerError( - f"{case['case_id']} does not use the reviewed safe result code" - ) - if expected_access == "not_applicable": - if ( - case["outcome"] != "not_applicable" - or case["source_data_access"] != "not_applicable" - ): - raise RunnerError( - f"{case['case_id']} must record the profile's not-applicable proof" - ) - elif case["outcome"] != "passed": - all_passed = False - case_started = parse_timestamp(case["started_at"]) - case_completed = parse_timestamp(case["completed_at"]) - if case_completed < case_started: - raise RunnerError(f"{case['case_id']} completes before it starts") - case_elapsed_ms = elapsed_milliseconds(case_started, case_completed) - if case["duration_ms"] != case_elapsed_ms: - raise RunnerError( - f"{case['case_id']} duration_ms does not match its timestamps" - ) - if case_elapsed_ms > profile["limits"]["case_timeout_seconds"] * 1000: - raise RunnerError(f"{case['case_id']} exceeds the profile case timeout") - if case_started < run_started or case_completed > run_completed: - raise RunnerError(f"{case['case_id']} falls outside the recorded run") - latest_case_completion = max(latest_case_completion, case_completed) - run_ms = elapsed_milliseconds(run_started, run_completed) - if run_ms > profile["limits"]["run_timeout_seconds"] * 1000: - raise RunnerError("public result exceeds the profile run timeout") - if result["redaction"]["seeded_canaries"] != len(canaries): - raise RunnerError( - "public result seeded_canaries does not match the protected canary file" - ) - if result["redaction"]["scanned_bytes"] < len(public_bytes): - raise RunnerError( - "redaction scan byte count does not include the public result" - ) - if ( - result["redaction"]["restricted_raw_evidence_bytes"] - > profile["limits"]["raw_evidence_bytes"] - ): - raise RunnerError("restricted raw evidence exceeds the profile byte limit") - teardown_started = parse_timestamp(result["teardown"]["started_at"]) - teardown_completed = parse_timestamp(result["teardown"]["completed_at"]) - if teardown_completed < teardown_started: - raise RunnerError("teardown completes before it starts") - teardown_elapsed_ms = elapsed_milliseconds(teardown_started, teardown_completed) - if result["teardown"]["duration_ms"] != teardown_elapsed_ms: - raise RunnerError("teardown duration_ms does not match its timestamps") - if teardown_elapsed_ms > profile["limits"]["teardown_timeout_seconds"] * 1000: - raise RunnerError("teardown exceeds the profile timeout") - if teardown_started < latest_case_completion: - raise RunnerError("teardown starts before the recorded test cases complete") - if teardown_started < run_started: - raise RunnerError("teardown starts before the recorded run") - if teardown_completed > run_completed: - raise RunnerError("public result completion must include the teardown attempt") - complete = all_passed and result["teardown"]["status"] == "completed" - if (result["status"] == "passed") != complete: - raise RunnerError( - "public result status is inconsistent with cases and teardown" - ) - return result - - -def plan_document(profile: dict[str, Any]) -> dict[str, Any]: - return { - "schema_version": "registry.release.integration_e2_plan.v1", - "profile_id": profile["profile_id"], - "support_status": profile["support_status"], - "candidate_evidence": False, - "status": "planned_not_executed", - "executor": "approved_operator_wrapper", - "starter": profile["starter"], - "pinned_source": profile["source"]["baseline"], - "source_operations": profile["source"]["operations"], - "authored_contract": profile["authored_contract"], - "required_input_names": [item["env"] for item in profile["dynamic_inputs"]], - "cases": profile["cases"], - "prerequisites": profile["prerequisites"], - "limits": profile["limits"], - "stages": [ - "Copy the closed candidate assets without following symlinks, then verify and version-check only the private non-writable snapshot.", - "Have the operator wrapper create its own authenticated non-writable snapshot and initialize the pinned starter only from that snapshot.", - "Apply only the profile's reviewed authored inputs; never edit generated YAML.", - "Run the offline project test, check, build, and generated-file hash review.", - "Deploy one digest-pinned Relay, Notary, and PostgreSQL set per authority within the approved run timeout.", - "Probe source-side audit or request counters before and after every closed test case.", - "Capture bounded restricted evidence and emit only hashes, timings, safe codes, and source-contact classifications.", - "Scan the public result for seeded canaries and forbidden values.", - "Re-hash generated project outputs and reject hand edits.", - "Attempt scoped teardown in a finally path and record its sanitized evidence hash.", - ], - } - - -def print_plan(profile: dict[str, Any], *, as_json: bool) -> None: - plan = plan_document(profile) - if as_json: - print(json.dumps(plan, indent=2, sort_keys=True)) - return - print(f"{plan['profile_id']}: {plan['support_status']}") - print("Status: planned, not executed; this is not candidate evidence.") - print( - "Executor: approved operator wrapper; the public runner does not run live stages." - ) - print("Prerequisites:") - for item in plan["prerequisites"]: - print(f" - {item}") - print("Stages:") - for index, item in enumerate(plan["stages"], 1): - print(f" {index}. {item}") - - -def parser() -> argparse.ArgumentParser: - common = argparse.ArgumentParser(add_help=False) - common.add_argument("--profile", choices=sorted(PROFILE_FILES), required=True) - root = argparse.ArgumentParser(description=__doc__) - commands = root.add_subparsers(dest="command", required=True) - plan = commands.add_parser( - "plan", parents=[common], help="show prerequisites and bounded stages" - ) - plan.add_argument("--json", action="store_true") - commands.add_parser( - "dry-run", - parents=[common], - help="emit the non-evidence orchestration plan as JSON", - ) - validate = commands.add_parser( - "validate", help="validate the source packet and optional real evidence" - ) - validate.add_argument("--profile", choices=sorted(PROFILE_FILES)) - validate.add_argument("--candidate-dir", type=Path) - validate.add_argument("--tag") - validate.add_argument("--result", type=Path) - validate.add_argument("--canary-file", type=Path) - return root - - -def main(argv: list[str] | None = None) -> int: - args = parser().parse_args(argv) - try: - validate_packet() - if args.command == "plan": - print_plan(load_profile(args.profile), as_json=args.json) - elif args.command == "dry-run": - print_plan(load_profile(args.profile), as_json=True) - else: - candidate_requested = args.candidate_dir is not None or args.tag is not None - if candidate_requested and (args.candidate_dir is None or args.tag is None): - raise RunnerError( - "candidate validation requires --candidate-dir and --tag together" - ) - result_requested = args.result is not None or args.canary_file is not None - if result_requested and not candidate_requested: - raise RunnerError( - "public result validation also requires --candidate-dir and --tag" - ) - if result_requested and ( - args.profile is None or args.result is None or args.canary_file is None - ): - raise RunnerError( - "result validation requires --profile, --result, and --canary-file together" - ) - candidate = None - result = None - if candidate_requested: - candidate = verify_candidate_assets(args.candidate_dir, args.tag) - if result_requested: - profile = load_profile(args.profile) - result = validate_result(args.result, profile, args.canary_file) - elif args.profile is not None: - load_profile(args.profile) - if candidate is not None and result is not None: - expected_release = { - **candidate, - "candidate_assets_verified": True, - "authenticity_verified": True, - } - if result["release"] != expected_release: - raise RunnerError( - "public result release identity does not match verified candidate assets" - ) - if candidate is not None and result is not None: - print("integration E2 candidate result validation passed") - elif candidate is not None: - print("integration E2 candidate asset validation passed") - elif args.profile is not None: - print(f"integration E2 profile validation passed: {args.profile}") - else: - print("integration E2 source packet validation passed") - except (RunnerError, OSError, subprocess.SubprocessError, ValueError) as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/release/scripts/openid-conformance-runner.py b/release/scripts/openid-conformance-runner.py deleted file mode 100755 index 24c6ce20f..000000000 --- a/release/scripts/openid-conformance-runner.py +++ /dev/null @@ -1,1685 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -"""Run mapped OpenID Foundation conformance-suite slices for Registry Stack.""" - -from __future__ import annotations - -import argparse -import base64 -import binascii -import datetime as dt -import hashlib -import hmac -import io -import json -import os -import re -import shutil -import ssl -import stat -import subprocess -import sys -import tempfile -import time -import urllib.error -import urllib.parse -import urllib.request -import zipfile -import zlib -from collections import Counter -from pathlib import Path -from string import Template -from typing import Any - -from closed_json_schema import SchemaValidationError, validate_against_schema - - -REPO_ROOT = Path(__file__).resolve().parents[2] -CONFIG_DIR = REPO_ROOT / "release" / "conformance" / "openid" -PLAN_MAP_PATH = CONFIG_DIR / "plan-map.json" -EVIDENCE_SCHEMA_PATH = CONFIG_DIR / "evidence-summary.schema.json" -COMPOSE_OVERRIDE_PATH = CONFIG_DIR / "docker-compose.override.yaml" -BUILDER_COMPOSE_OVERRIDE_PATH = CONFIG_DIR / "docker-compose-builder.override.yaml" -SUITE_REQUIREMENTS_INPUT_PATH = CONFIG_DIR / "python-requirements.in" -SUITE_REQUIREMENTS_LOCK_PATH = CONFIG_DIR / "python-requirements.txt" -DEFAULT_WORK_ROOT = REPO_ROOT / "target" / "openid-conformance" -DEFAULT_CACHE_DIR = DEFAULT_WORK_ROOT / "cache" -DEFAULT_OUTPUT_ROOT = DEFAULT_WORK_ROOT / "results" -DEFAULT_SUITE_JWKS_PATH = DEFAULT_WORK_ROOT / "conformance-suite-jwks.json" -SCHEMA_VERSION = "registry.release.openid_conformance_plan_map.v1" -EVIDENCE_SCHEMA_VERSION = "registry.release.openid_conformance_evidence.v1" -EVIDENCE_SCENARIO_ID = "notary-oid4vci-issuer-metadata" -EVIDENCE_CLASSIFICATION = "unreviewed-candidate-evidence-summary" -EVIDENCE_ASSOCIATION = "operator-attested-pending-review" -EVIDENCE_UNSUPPORTED_SCENARIOS = ( - ("notary-oid4vci-issuer-full", "blocked-by-suite-profile"), -) -SUITE_JAR = "target/fapi-test-suite.jar" -SUITE_JAR_STAMP = "target/fapi-test-suite.jar.registry-stack-source-ref" -COMPOSE_CONFIG_DIR_ENV = "REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR" -SUITE_CA_CONTAINER_PATH = "/etc/ssl/certs/nginx-selfsigned.crt" -DEFAULT_SUITE_CA_PATH = DEFAULT_WORK_ROOT / "conformance-suite-ca.pem" -MAX_SUITE_EXPORT_BYTES = 64 * 1024 * 1024 -MAX_SUITE_EXPORT_ENTRIES = 2 -MAX_SUITE_EXPORT_COMPRESSION_RATIO = 100 -MAX_SUITE_JWKS_BYTES = 1024 * 1024 -MAX_SUITE_SIGNATURE_BYTES = 16 * 1024 -SUITE_RESULTS = {"PASSED", "FAILED", "WARNING", "REVIEW", "SKIPPED", "UNKNOWN"} -CONDITION_RESULTS = {"INFO", "SUCCESS", "REVIEW", "WARNING", "FAILURE"} -KEY_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") -TEST_ID = re.compile(r"^[A-Za-z0-9]{15}$") -COMMIT = re.compile(r"^[0-9a-f]{40}$") -SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") -SHA256_DIGEST_INFO_PREFIX = bytes.fromhex( - "3031300d060960864801650304020105000420" -) -FORBIDDEN_PUBLIC_KEYS = { - "access_token", - "authorization", - "credential", - "credentials", - "civil_id", - "date_of_birth", - "family_name", - "given_name", - "id_token", - "logs", - "message", - "messages", - "msg", - "national_id", - "pre-authorized_code", - "pre_authorized_code", - "proof", - "raw", - "refresh_token", - "request", - "response", - "results", - "subject_id", - "static_tx_code", - "token", - "transaction_code", - "tx_code", -} -SENSITIVE_RAW_KEYS = FORBIDDEN_PUBLIC_KEYS - { - "logs", - "messages", - "raw", - "request", - "response", - "results", -} - - -class RunnerError(RuntimeError): - """A user-actionable conformance runner failure.""" - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, file_pointer, code, message, headers, url): - return None - - -def load_plan_map(path: Path = PLAN_MAP_PATH) -> dict[str, Any]: - with path.open(encoding="utf-8") as handle: - plan_map = json.load(handle) - if plan_map.get("schema_version") != SCHEMA_VERSION: - raise RunnerError(f"unsupported plan map schema: {plan_map.get('schema_version')}") - scenarios = plan_map.get("scenarios") - if not isinstance(scenarios, list) or not scenarios: - raise RunnerError("plan map must include at least one scenario") - ids = [scenario.get("id") for scenario in scenarios] - if any(not scenario_id for scenario_id in ids): - raise RunnerError("every plan map scenario must have an id") - if len(ids) != len(set(ids)): - raise RunnerError("plan map scenario ids must be unique") - return plan_map - - -def find_scenario(plan_map: dict[str, Any], scenario_id: str) -> dict[str, Any]: - for scenario in plan_map["scenarios"]: - if scenario.get("id") == scenario_id: - return scenario - raise RunnerError(f"unknown OpenID conformance scenario: {scenario_id}") - - -def scenario_plan_arg(scenario: dict[str, Any]) -> str: - plan = scenario["suite_plan"] - variants = scenario.get("variants") or {} - variant_args = "".join(f"[{key}={value}]" for key, value in variants.items()) - modules = scenario.get("suite_modules") or [] - module_arg = ":" + ",".join(modules) if modules else "" - return f"{plan}{variant_args}{module_arg}" - - -def default_params(scenario: dict[str, Any], args: argparse.Namespace) -> dict[str, str]: - defaults = scenario.get("default_parameters") or {} - issuer_env = defaults.get( - "issuer_url_env", "REGISTRY_OPENID_CONFORMANCE_ISSUER_URL" - ) - issuer_url = args.issuer_url or os.environ.get(issuer_env) - if not issuer_url: - raise RunnerError( - f"issuer URL is required; pass --issuer-url or set {issuer_env}" - ) - authorization_server = ( - args.authorization_server - or os.environ.get(defaults.get("authorization_server_env", "")) - or issuer_url - ) - credential_configuration_id = ( - args.credential_configuration_id - or os.environ.get(defaults.get("credential_configuration_id_env", "")) - or defaults.get("default_credential_configuration_id") - ) - if not credential_configuration_id: - raise RunnerError("credential configuration id is required") - return { - "issuer_url": issuer_url, - "authorization_server": authorization_server, - "credential_configuration_id": credential_configuration_id, - "static_tx_code": args.static_tx_code, - "client_id": args.client_id, - "client2_id": args.client2_id, - } - - -def render_config(scenario: dict[str, Any], params: dict[str, str]) -> str: - template_path = CONFIG_DIR / scenario["config_template"] - rendered = Template(template_path.read_text(encoding="utf-8")).substitute(params) - json.loads(rendered) - return rendered - - -def write_rendered_config( - scenario: dict[str, Any], output_dir: Path, params: dict[str, str] -) -> Path: - path = output_dir / f"{scenario['id']}.config.json" - return write_new_file( - path, - (render_config(scenario, params) + "\n").encode("utf-8"), - ) - - -def suite_settings(plan_map: dict[str, Any], args: argparse.Namespace) -> dict[str, str]: - suite = plan_map["suite"] - return { - "repo": args.suite_repo or suite["repo"], - "ref": args.suite_ref or suite["ref"], - "base_url": args.conformance_server or suite["base_url"], - "local_base_url": args.conformance_server_local or suite["local_base_url"], - "mtls_base_url": args.conformance_server_mtls or suite["mtls_base_url"], - } - - -def suite_dir(args: argparse.Namespace) -> Path: - if args.suite_dir: - return Path(args.suite_dir).expanduser().resolve() - return Path(args.cache_dir).expanduser().resolve() / "conformance-suite" - - -def run_checked( - command: list[str], cwd: Path | None = None, env: dict[str, str] | None = None -) -> None: - result = subprocess.run(command, cwd=cwd, env=env, text=True, check=False) - if result.returncode != 0: - raise RunnerError(f"command failed ({result.returncode}): {' '.join(command)}") - - -def ensure_suite_checkout(plan_map: dict[str, Any], args: argparse.Namespace) -> Path: - settings = suite_settings(plan_map, args) - checkout = suite_dir(args) - checkout.parent.mkdir(parents=True, exist_ok=True) - git = shutil.which("git") - if not git: - raise RunnerError("git is required to prepare the OpenID conformance suite") - if checkout.exists(): - status = subprocess.run( - [git, "status", "--porcelain"], - cwd=checkout, - text=True, - capture_output=True, - check=False, - ) - if status.returncode != 0: - raise RunnerError(status.stderr.strip() or "could not inspect suite checkout") - if status.stdout.strip(): - raise RunnerError(f"suite checkout has local changes: {checkout}") - run_checked([git, "fetch", "--tags", "origin"], cwd=checkout) - else: - run_checked([git, "clone", settings["repo"], str(checkout)]) - run_checked([git, "fetch", "--tags", "origin"], cwd=checkout) - run_checked([git, "checkout", "--detach", settings["ref"]], cwd=checkout) - actual = subprocess.check_output( - [git, "rev-parse", "HEAD"], cwd=checkout, text=True - ).strip() - expected = settings["ref"] - if len(expected) == 40 and actual != expected: - raise RunnerError(f"suite checkout is at {actual}, expected {expected}") - return checkout - - -def compose_command( - checkout: Path, args: argparse.Namespace, *compose_args: str -) -> list[str]: - command = ["docker", "compose", "-f", str(checkout / "docker-compose.yml")] - if COMPOSE_OVERRIDE_PATH.exists(): - command += ["-f", str(COMPOSE_OVERRIDE_PATH)] - command += list(compose_args) - return command - - -def builder_command(checkout: Path, *compose_args: str) -> list[str]: - return [ - "docker", - "compose", - "-f", - str(checkout / "builder-compose.yml"), - "-f", - str(BUILDER_COMPOSE_OVERRIDE_PATH), - *compose_args, - ] - - -def suite_checkout_ref(checkout: Path) -> str: - git = shutil.which("git") - if not git: - raise RunnerError("git is required to inspect the OpenID conformance suite") - result = subprocess.run( - [git, "rev-parse", "HEAD"], - cwd=checkout, - text=True, - capture_output=True, - check=False, - ) - actual = result.stdout.strip() - if result.returncode != 0 or len(actual) != 40: - raise RunnerError(result.stderr.strip() or "could not resolve suite checkout ref") - return actual - - -def file_sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def canonical_sha256(value: Any) -> str: - encoded = json.dumps( - value, ensure_ascii=False, separators=(",", ":"), sort_keys=True - ).encode("utf-8") - return f"sha256:{hashlib.sha256(encoded).hexdigest()}" - - -def read_owner_only_file(path: Path, *, max_bytes: int, label: str) -> bytes: - path = path.expanduser() - nofollow = getattr(os, "O_NOFOLLOW", None) - cloexec = getattr(os, "O_CLOEXEC", None) - if nofollow is None or cloexec is None or not hasattr(os, "geteuid"): - raise RunnerError("secure private result handling is unavailable") - descriptor: int | None = None - try: - descriptor = os.open(path, os.O_RDONLY | cloexec | nofollow) - before = os.fstat(descriptor) - if ( - not stat.S_ISREG(before.st_mode) - or before.st_uid != os.geteuid() - or before.st_mode & 0o077 - or not 0 < before.st_size <= max_bytes - ): - raise RunnerError(f"{label} must be an owner-only, bounded regular file") - with os.fdopen(descriptor, "rb", closefd=True) as handle: - descriptor = None - content = handle.read(max_bytes + 1) - after = os.fstat(handle.fileno()) - except OSError: - raise RunnerError(f"{label} could not be opened securely") from None - finally: - if descriptor is not None: - os.close(descriptor) - if ( - len(content) != before.st_size - or len(content) > max_bytes - or ( - before.st_dev, - before.st_ino, - before.st_size, - before.st_mtime_ns, - before.st_ctime_ns, - ) - != ( - after.st_dev, - after.st_ino, - after.st_size, - after.st_mtime_ns, - after.st_ctime_ns, - ) - ): - raise RunnerError(f"{label} changed while it was read") - return content - - -def base64url_decode(value: Any, label: str, *, allow_padding: bool) -> bytes: - if not isinstance(value, str) or not value or len(value) > 16_384: - raise RunnerError(f"{label} is invalid") - if allow_padding: - if re.fullmatch(r"[A-Za-z0-9_-]+={0,2}", value) is None: - raise RunnerError(f"{label} is invalid") - unpadded = value.rstrip("=") - if len(value) - len(unpadded) != (-len(unpadded)) % 4: - raise RunnerError(f"{label} is invalid") - else: - if re.fullmatch(r"[A-Za-z0-9_-]+", value) is None: - raise RunnerError(f"{label} is invalid") - unpadded = value - try: - return base64.urlsafe_b64decode(unpadded + "=" * (-len(unpadded) % 4)) - except (ValueError, binascii.Error): - raise RunnerError(f"{label} is invalid") from None - - -def validate_suite_jwks(value: Any) -> dict[str, Any]: - if not isinstance(value, dict) or set(value) != {"keys"}: - raise RunnerError("suite JWKS has an unsupported shape") - keys = value.get("keys") - if not isinstance(keys, list) or not 0 < len(keys) <= 8: - raise RunnerError("suite JWKS must contain a bounded key list") - for key in keys: - if ( - not isinstance(key, dict) - or set(key) != {"alg", "e", "kid", "kty", "n", "use"} - or key.get("alg") != "RS256" - or key.get("kty") != "RSA" - or key.get("use") != "sig" - or not isinstance(key.get("kid"), str) - or KEY_ID.fullmatch(key["kid"]) is None - ): - raise RunnerError("suite JWKS contains an unsupported signing key") - modulus_bytes = base64url_decode( - key.get("n"), "suite JWKS RSA modulus", allow_padding=False - ) - exponent_bytes = base64url_decode( - key.get("e"), "suite JWKS RSA exponent", allow_padding=False - ) - if ( - not modulus_bytes - or modulus_bytes[0] == 0 - or not 2048 <= int.from_bytes(modulus_bytes).bit_length() <= 8192 - or int.from_bytes(modulus_bytes) % 2 == 0 - or not exponent_bytes - or exponent_bytes[0] == 0 - ): - raise RunnerError("suite JWKS contains an invalid RSA signing key") - exponent = int.from_bytes(exponent_bytes) - if not 3 <= exponent <= 2_147_483_647 or exponent % 2 == 0: - raise RunnerError("suite JWKS contains an invalid RSA signing key") - return value - - -def parse_suite_jwks(content: bytes) -> dict[str, Any]: - try: - parsed = json.loads(content) - except (UnicodeDecodeError, json.JSONDecodeError, RecursionError): - raise RunnerError("suite JWKS is not valid JSON") from None - return validate_suite_jwks(parsed) - - -def load_suite_jwks(path: Path) -> tuple[dict[str, Any], str]: - content = read_owner_only_file( - path, - max_bytes=MAX_SUITE_JWKS_BYTES, - label="suite JWKS", - ) - jwks = parse_suite_jwks(content) - return jwks, canonical_sha256(jwks) - - -def rsa_key_verifies(content: bytes, signature: bytes, key: dict[str, Any]) -> bool: - modulus = int.from_bytes( - base64url_decode(key["n"], "suite JWKS RSA modulus", allow_padding=False) - ) - exponent = int.from_bytes( - base64url_decode(key["e"], "suite JWKS RSA exponent", allow_padding=False) - ) - encoded_size = (modulus.bit_length() + 7) // 8 - if len(signature) != encoded_size: - return False - signature_number = int.from_bytes(signature) - if signature_number <= 0 or signature_number >= modulus: - return False - digest_info = SHA256_DIGEST_INFO_PREFIX + hashlib.sha256(content).digest() - padding_size = encoded_size - len(digest_info) - 3 - if padding_size < 8: - return False - expected = b"\x00\x01" + b"\xff" * padding_size + b"\x00" + digest_info - recovered = pow(signature_number, exponent, modulus).to_bytes(encoded_size) - return hmac.compare_digest(recovered, expected) - - -def verify_suite_export_signature( - content: bytes, encoded_signature: bytes, jwks: dict[str, Any] -) -> None: - try: - signature_text = encoded_signature.decode("ascii") - except UnicodeDecodeError: - raise RunnerError("suite export signature is invalid") from None - signature = base64url_decode( - signature_text, "suite export signature", allow_padding=True - ) - matching_keys = [ - key for key in jwks["keys"] if rsa_key_verifies(content, signature, key) - ] - if len(matching_keys) != 1: - raise RunnerError( - "suite export signature must verify with exactly one trusted suite key" - ) - - -def load_suite_export( - path: Path, module_id: str, suite_jwks: dict[str, Any] -) -> dict[str, Any]: - raw = read_owner_only_file( - path, - max_bytes=MAX_SUITE_EXPORT_BYTES, - label="suite export", - ) - try: - archive = zipfile.ZipFile(io.BytesIO(raw)) - except zipfile.BadZipFile: - raise RunnerError("suite export is not a valid ZIP archive") from None - try: - with archive: - entries = archive.infolist() - names = [entry.filename for entry in entries] - if len(entries) != MAX_SUITE_EXPORT_ENTRIES or len(names) != len( - set(names) - ): - raise RunnerError( - "suite export must contain one module JSON and one signature" - ) - json_pattern = re.compile( - rf"^test-log-{re.escape(module_id)}-([A-Za-z0-9]{{15}})\.json$" - ) - json_matches = [ - (name, match) - for name in names - if (match := json_pattern.fullmatch(name)) is not None - ] - if len(json_matches) != 1: - raise RunnerError("suite export does not contain the expected module") - json_name, filename_match = json_matches[0] - filename_test_id = filename_match.group(1) - signature_name = f"{json_name.removesuffix('.json')}.sig" - expected_names = { - json_name, - signature_name, - } - if set(names) != expected_names: - raise RunnerError("suite export contains unexpected files") - - total_size = 0 - for entry in entries: - mode = entry.external_attr >> 16 - if ( - entry.is_dir() - or "/" in entry.filename - or "\\" in entry.filename - or stat.S_ISLNK(mode) - or entry.flag_bits & 0x1 - or entry.compress_type - not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} - or entry.file_size <= 0 - or entry.file_size > MAX_SUITE_EXPORT_BYTES - or ( - entry.filename == signature_name - and entry.file_size > MAX_SUITE_SIGNATURE_BYTES - ) - ): - raise RunnerError("suite export contains an unsafe ZIP entry") - total_size += entry.file_size - if total_size > MAX_SUITE_EXPORT_BYTES: - raise RunnerError("suite export uncompressed size is too large") - if entry.file_size > 1024 * 1024 and ( - entry.compress_size <= 0 - or entry.file_size - > entry.compress_size * MAX_SUITE_EXPORT_COMPRESSION_RATIO - ): - raise RunnerError( - "suite export contains a suspicious compression ratio" - ) - - content: dict[str, bytes] = {} - for name in expected_names: - entry = archive.getinfo(name) - with archive.open(entry) as handle: - content[name] = handle.read(entry.file_size + 1) - if len(content[name]) != entry.file_size: - raise RunnerError("suite export ZIP entry has an invalid size") - except (EOFError, zipfile.BadZipFile, zlib.error): - raise RunnerError("suite export contains invalid compressed data") from None - encoded = content[json_name] - verify_suite_export_signature(encoded, content[signature_name], suite_jwks) - try: - exported = json.loads(encoded) - except (UnicodeDecodeError, json.JSONDecodeError, RecursionError): - raise RunnerError("suite export module is not valid JSON") from None - if not isinstance(exported, dict): - raise RunnerError("suite export module must be a JSON object") - expected_keys = { - "exportedAt", - "exportedBy", - "exportedFrom", - "exportedVersion", - "results", - "testInfo", - } - if set(exported) != expected_keys: - raise RunnerError("suite export module has an unsupported shape") - results = exported.get("results") - if ( - not isinstance(results, list) - or not results - or len(results) > 20_000 - or any(not isinstance(entry, dict) for entry in results) - ): - raise RunnerError("suite export results have an unsupported shape") - test_info = exported.get("testInfo") - if ( - not isinstance(test_info, dict) - or test_info.get("_id") != filename_test_id - or test_info.get("testId") != filename_test_id - or not isinstance(test_info.get("planId"), str) - or not 0 < len(test_info["planId"]) <= 128 - ): - raise RunnerError("suite export run identifiers do not match") - return exported - - -def validate_suite_timestamp(value: Any) -> tuple[str, dt.datetime]: - if ( - not isinstance(value, str) - or re.fullmatch( - r"[0-9]{4}-[0-9]{2}-[0-9]{2}T" - r"[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z", - value, - ) - is None - ): - raise RunnerError("suite run start timestamp is invalid") - try: - parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") - except ValueError: - raise RunnerError("suite run start timestamp is invalid") from None - return value, parsed - - -def completion_timestamp( - results: list[dict[str, Any]], - module_id: str, - suite_result: str, - started: dt.datetime, -) -> str: - terminal = [ - entry - for entry in results - if entry.get("src") == module_id - and entry.get("result") == "FINISHED" - and entry.get("testmodule_result") == suite_result - ] - if len(terminal) != 1: - raise RunnerError( - "suite export must contain one matching terminal module record" - ) - epoch_ms = terminal[0].get("time") - if ( - isinstance(epoch_ms, bool) - or not isinstance(epoch_ms, int) - or not 0 < epoch_ms < 32_503_680_000_000 - ): - raise RunnerError("suite run completion timestamp is invalid") - completed = dt.datetime.fromtimestamp(epoch_ms / 1000, tz=dt.UTC) - if completed < started: - raise RunnerError("suite run completes before it starts") - return completed.isoformat(timespec="milliseconds").replace("+00:00", "Z") - - -def validate_considered_log_bindings( - results: list[dict[str, Any]], module_id: str, test_id: str -) -> None: - for entry in results: - considered = entry.get("result") in CONDITION_RESULTS or ( - entry.get("src") == module_id and entry.get("result") == "FINISHED" - ) - if considered and entry.get("testId") != test_id: - raise RunnerError("suite export log entry does not match the selected run") - - -def validate_https_url(value: Any, label: str) -> str: - if ( - not isinstance(value, str) - or len(value) > 2048 - or "\\" in value - or any( - character.isspace() - or ord(character) < 0x20 - or ord(character) == 0x7F - for character in value - ) - ): - raise RunnerError(f"suite runtime configuration {label} is invalid") - try: - parsed = urllib.parse.urlsplit(value) - port = parsed.port - except ValueError: - raise RunnerError( - f"suite runtime configuration {label} is invalid" - ) from None - hostname = parsed.hostname - if ( - parsed.scheme != "https" - or not hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or ( - re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]*", hostname) is None - and re.fullmatch(r"[0-9A-Fa-f:.]+", hostname) is None - ) - ): - raise RunnerError(f"suite runtime configuration {label} is invalid") - normalized_host = ( - f"[{hostname.lower()}]" if ":" in hostname else hostname.lower() - ) - if port is not None: - normalized_host = f"{normalized_host}:{port}" - return urllib.parse.urlunsplit( - ("https", normalized_host, parsed.path, "", "") - ) - - -def redacted_runtime_configuration_sha256( - config: Any, template: dict[str, Any], module_id: str -) -> str: - expected_top_level = {"alias", "description", "vci", "client", "client2"} - expected_vci = { - "authorization_server", - "credential_configuration_id", - "credential_issuer_url", - "credential_proof_type_hint", - "static_tx_code", - } - if ( - not isinstance(config, dict) - or set(config) != expected_top_level - or not isinstance(config.get("vci"), dict) - or set(config["vci"]) != expected_vci - or not isinstance(config.get("client"), dict) - or set(config["client"]) != {"client_id"} - or not isinstance(config.get("client2"), dict) - or set(config["client2"]) != {"client_id"} - or config.get("alias") != template.get("alias") - or config.get("description") - not in { - template.get("description"), - f"{template.get('description')} [{module_id}]", - } - ): - raise RunnerError("suite runtime configuration has an unsupported shape") - validate_https_url(config["vci"].get("credential_issuer_url"), "issuer URL") - validate_https_url( - config["vci"].get("authorization_server"), "authorization server" - ) - string_fields = ( - config["vci"].get("credential_configuration_id"), - config["vci"].get("credential_proof_type_hint"), - config["vci"].get("static_tx_code"), - config["client"].get("client_id"), - config["client2"].get("client_id"), - ) - if any( - not isinstance(value, str) or not value or len(value) > 256 - for value in string_fields - ): - raise RunnerError("suite runtime configuration contains an invalid value") - redacted = json.loads(json.dumps(config)) - redacted["vci"]["static_tx_code"] = "" - return canonical_sha256(redacted) - - -def condition_summary(results: list[dict[str, Any]]) -> dict[str, Any]: - counts = Counter( - entry.get("result") - for entry in results - if entry.get("result") in CONDITION_RESULTS - ) - return { - "counts": { - "info": counts["INFO"], - "success": counts["SUCCESS"], - "review": counts["REVIEW"], - "warning": counts["WARNING"], - "failure": counts["FAILURE"], - } - } - - -def collect_sensitive_raw_values(value: Any) -> set[str]: - sensitive: set[str] = set() - - def collect_scalars(item: Any) -> None: - if isinstance(item, str) and len(item) >= 8: - sensitive.add(item) - elif isinstance(item, dict): - for nested in item.values(): - collect_scalars(nested) - elif isinstance(item, list): - for nested in item: - collect_scalars(nested) - - def visit(item: Any) -> None: - if isinstance(item, dict): - for key, nested in item.items(): - if key.lower() in SENSITIVE_RAW_KEYS: - collect_scalars(nested) - else: - visit(nested) - elif isinstance(item, list): - for nested in item: - visit(nested) - - visit(value) - return sensitive - - -def assert_public_summary_safe( - summary: dict[str, Any], sensitive_values: set[str] -) -> bytes: - expected_top_level = { - "candidate", - "classification", - "configuration", - "contains_sensitive_material", - "deployment", - "raw_suite_export_included", - "review_required", - "run", - "scenario", - "schema_version", - "suite", - "unsupported_scenarios", - } - if set(summary) != expected_top_level: - raise RunnerError("evidence summary contains non-allowlisted fields") - - def check_keys(value: Any) -> None: - if isinstance(value, dict): - for key, nested in value.items(): - if key.lower() in FORBIDDEN_PUBLIC_KEYS: - raise RunnerError( - f"evidence summary contains forbidden field {key}" - ) - check_keys(nested) - elif isinstance(value, list): - for nested in value: - check_keys(nested) - - check_keys(summary) - encoded = ( - json.dumps(summary, ensure_ascii=False, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") - text = encoded.decode("utf-8") - if any(secret in text for secret in sensitive_values): - raise RunnerError("evidence summary contains sensitive suite material") - if "openid-credential-offer://" in text or re.search( - r"(? dict[str, Any]: - keys = ( - "release_id", - "version", - "source_repo", - "source_ref", - "source_tag", - "tag_target", - "manifest_sha256", - "image_lock_sha256", - "release_capsule_sha256", - "notary_image", - ) - try: - selected = {key: candidate[key] for key in keys} - except KeyError: - raise RunnerError("authenticated candidate result is incomplete") from None - selected["release_assets_authenticity_verified"] = True - return selected - - -def build_evidence_summary( - plan_map: dict[str, Any], - scenario: dict[str, Any], - exported: dict[str, Any], - candidate: dict[str, Any], - suite_jwks_sha256: str, -) -> tuple[dict[str, Any], set[str]]: - modules = scenario.get("suite_modules") - if modules != ["oid4vci-1_0-issuer-metadata-test"]: - raise RunnerError("evidence scenario must select only the metadata module") - suite_ref = plan_map.get("suite", {}).get("ref") - if not isinstance(suite_ref, str) or COMMIT.fullmatch(suite_ref) is None: - raise RunnerError("evidence suite ref must be one pinned commit") - suite_release_tag = plan_map.get("suite", {}).get("release_tag") - suite_base_url = plan_map.get("suite", {}).get("base_url") - release_match = ( - re.fullmatch(r"release-v([0-9]+\.[0-9]+\.[0-9]+)", suite_release_tag) - if isinstance(suite_release_tag, str) - else None - ) - if release_match is None or not isinstance(suite_base_url, str): - raise RunnerError("evidence suite release identity is invalid") - suite_version = release_match.group(1) - if ( - not isinstance(suite_jwks_sha256, str) - or SHA256.fullmatch(suite_jwks_sha256) is None - ): - raise RunnerError("suite JWKS digest is invalid") - test_info = exported.get("testInfo") - results = exported["results"] - if ( - not isinstance(test_info, dict) - or test_info.get("testName") != modules[0] - or test_info.get("variant") != scenario.get("variants") - or test_info.get("status") != "FINISHED" - or test_info.get("result") not in SUITE_RESULTS - or test_info.get("version") != suite_version - or exported.get("exportedVersion") != suite_version - or exported.get("exportedFrom") != suite_base_url - ): - raise RunnerError( - "suite export identity, version, selection, or terminal status does not match" - ) - started_at, started = validate_suite_timestamp(test_info.get("started")) - test_id = test_info["testId"] - if not isinstance(test_id, str) or TEST_ID.fullmatch(test_id) is None: - raise RunnerError("suite export run identifier is invalid") - validate_considered_log_bindings(results, modules[0], test_id) - completed_at = completion_timestamp( - results, modules[0], test_info["result"], started - ) - template_path = CONFIG_DIR / scenario["config_template"] - try: - template = json.loads(template_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - raise RunnerError( - "checked-in suite configuration template is invalid" - ) from None - runtime_config_sha256 = redacted_runtime_configuration_sha256( - test_info.get("config"), template, modules[0] - ) - issuer_url = validate_https_url( - test_info["config"]["vci"]["credential_issuer_url"], "issuer URL" - ) - unsupported = [ - { - "scenario_id": item["id"], - "status": item["status"], - } - for item in plan_map["scenarios"] - if item.get("status") not in {"applicable", "candidate-only"} - ] - expected_unsupported = [ - {"scenario_id": scenario_id, "status": status} - for scenario_id, status in EVIDENCE_UNSUPPORTED_SCENARIOS - ] - if unsupported != expected_unsupported: - raise RunnerError("plan map unsupported scenario contract changed") - summary = { - "schema_version": EVIDENCE_SCHEMA_VERSION, - "classification": EVIDENCE_CLASSIFICATION, - "review_required": True, - "contains_sensitive_material": False, - "raw_suite_export_included": False, - "candidate": candidate_evidence_summary(candidate), - "deployment": { - "issuer_url": issuer_url, - "candidate_association": EVIDENCE_ASSOCIATION, - }, - "suite": { - "repository": plan_map["suite"]["repo"], - "commit": suite_ref, - "release_tag": suite_release_tag, - "reported_version": suite_version, - "exported_from": suite_base_url, - "commit_association": EVIDENCE_ASSOCIATION, - "jwks_sha256": suite_jwks_sha256, - "export_signature_verified": True, - }, - "scenario": { - "scenario_id": scenario["id"], - "expected_plan": scenario["suite_plan"], - "plan_association": EVIDENCE_ASSOCIATION, - "modules": modules, - "variants": scenario["variants"], - }, - "configuration": { - "plan_map_sha256": f"sha256:{file_sha256(PLAN_MAP_PATH)}", - "template_sha256": f"sha256:{file_sha256(template_path)}", - "redacted_runtime_configuration_sha256": runtime_config_sha256, - "redacted_fields": ["vci.static_tx_code"], - }, - "run": { - "started_at": started_at, - "completed_at": completed_at, - "terminal_status": test_info["status"], - "result": test_info["result"], - "conditions": condition_summary(results), - }, - "unsupported_scenarios": unsupported, - } - return summary, collect_sensitive_raw_values(exported) - - -def expected_suite_artifact_stamp(checkout: Path, jar: Path) -> dict[str, str]: - return { - "source_ref": suite_checkout_ref(checkout), - "builder_override_sha256": file_sha256(BUILDER_COMPOSE_OVERRIDE_PATH), - "jar_sha256": file_sha256(jar), - } - - -def ensure_suite_artifact(checkout: Path, args: argparse.Namespace) -> Path: - jar = checkout / SUITE_JAR - stamp = checkout / SUITE_JAR_STAMP - if jar.exists() and stamp.exists() and not args.rebuild_suite: - try: - stamped = json.loads(stamp.read_text(encoding="utf-8")) - except json.JSONDecodeError: - stamped = None - if stamped == expected_suite_artifact_stamp(checkout, jar): - return jar - if not shutil.which("docker"): - raise RunnerError("docker is required to build the OpenID conformance suite") - maven_cache = Path(args.maven_cache_dir).expanduser().resolve() - maven_cache.mkdir(parents=True, exist_ok=True) - env = os.environ.copy() - env["MAVEN_CACHE"] = str(maven_cache) - run_checked( - builder_command(checkout, "run", "--rm", "builder"), - cwd=checkout, - env=env, - ) - if not jar.exists(): - raise RunnerError(f"OpenID conformance suite build did not create {jar}") - stamp.write_text( - json.dumps(expected_suite_artifact_stamp(checkout, jar), sort_keys=True) - + "\n", - encoding="utf-8", - ) - return jar - - -def requirements_digest(*requirements_paths: Path) -> str: - digest = hashlib.sha256() - for path in requirements_paths: - digest.update(path.name.encode("utf-8")) - digest.update(b"\0") - digest.update(path.read_bytes()) - digest.update(b"\0") - return digest.hexdigest() - - -def suite_python(args: argparse.Namespace) -> Path: - digest = requirements_digest( - SUITE_REQUIREMENTS_INPUT_PATH, SUITE_REQUIREMENTS_LOCK_PATH - ) - cache_key = f"py{sys.version_info.major}.{sys.version_info.minor}-{digest[:16]}" - venv_dir = Path(args.python_venv_dir).expanduser().resolve() / cache_key - if os.name == "nt": - return venv_dir / "Scripts" / "python.exe" - return venv_dir / "bin" / "python" - - -def ensure_suite_python(checkout: Path, args: argparse.Namespace) -> Path: - requirements_path = checkout / "scripts" / "requirements.txt" - if not requirements_path.exists(): - raise RunnerError(f"missing suite Python requirements: {requirements_path}") - if requirements_path.read_bytes() != SUITE_REQUIREMENTS_INPUT_PATH.read_bytes(): - raise RunnerError( - "suite Python requirements differ from the checked-in locked input; " - "review and regenerate release/conformance/openid/python-requirements.txt" - ) - python = suite_python(args) - venv_dir = python.parents[1] - digest = requirements_digest( - SUITE_REQUIREMENTS_INPUT_PATH, SUITE_REQUIREMENTS_LOCK_PATH - ) - stamp = venv_dir / ".requirements.sha256" - cache_matches = ( - python.exists() - and stamp.exists() - and stamp.read_text(encoding="utf-8").strip() == digest - ) - if venv_dir.exists() and not cache_matches: - shutil.rmtree(venv_dir) - if not python.exists(): - run_checked([sys.executable, "-m", "venv", str(venv_dir)]) - run_checked( - [ - str(python), - "-m", - "pip", - "install", - "--disable-pip-version-check", - "--require-hashes", - "--only-binary=:all:", - "-r", - str(SUITE_REQUIREMENTS_LOCK_PATH), - ] - ) - stamp.write_text(digest + "\n", encoding="utf-8") - return python - - -def wait_for_suite(base_url: str, timeout_seconds: int) -> None: - url = base_url.rstrip("/") + "/api/runner/available" - # The pinned suite's local development endpoint uses a self-signed certificate. - context = ssl._create_unverified_context() - deadline = time.time() + timeout_seconds - last_error = "" - while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=5, context=context) as response: - if response.status == 200: - return - except (urllib.error.URLError, TimeoutError) as exc: - last_error = str(exc) - time.sleep(2) - raise RunnerError(f"conformance suite did not become ready at {url}: {last_error}") - - -def read_offer(path: Path, issuer_url: str) -> str: - nofollow = getattr(os, "O_NOFOLLOW", None) - cloexec = getattr(os, "O_CLOEXEC", None) - if nofollow is None or cloexec is None or not hasattr(os, "geteuid"): - raise RunnerError("secure offer input handling is unavailable") - descriptor: int | None = None - try: - descriptor = os.open(path, os.O_RDONLY | cloexec | nofollow) - info = os.fstat(descriptor) - if ( - not stat.S_ISREG(info.st_mode) - or info.st_uid != os.geteuid() - or info.st_mode & 0o077 - ): - raise RunnerError("offer input must be an owner-only regular file") - if not 0 < info.st_size <= 65_536: - raise RunnerError("offer input has an invalid size") - with os.fdopen(descriptor, "rb", closefd=True) as handle: - descriptor = None - raw = handle.read(65_537) - except OSError: - raise RunnerError("offer input could not be opened securely") from None - finally: - if descriptor is not None: - os.close(descriptor) - if len(raw) != info.st_size: - raise RunnerError("offer input changed while it was read") - try: - offer_uri = raw.decode("utf-8").strip() - except UnicodeDecodeError: - raise RunnerError("offer input is not valid UTF-8") from None - parsed = urllib.parse.urlsplit(offer_uri) - try: - query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) - except ValueError: - raise RunnerError("offer input has a malformed query") from None - if ( - parsed.scheme != "openid-credential-offer" - or parsed.netloc - or parsed.path - or parsed.fragment - or set(query) != {"credential_offer"} - or len(query["credential_offer"]) != 1 - ): - raise RunnerError("offer input is not one inline credential offer URI") - inline = query["credential_offer"][0] - offer = json.loads(inline) - grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" - if ( - not isinstance(offer, dict) - or offer.get("credential_issuer") != issuer_url.rstrip("/") - or not isinstance(offer.get("grants"), dict) - or set(offer["grants"]) != {grant} - or not isinstance(offer["grants"][grant], dict) - or not isinstance(offer["grants"][grant].get("pre-authorized_code"), str) - ): - raise RunnerError("offer is not the expected Notary pre-authorized offer") - return inline - - -def read_suite_ca_certificate(path: Path) -> bytes: - path = path.expanduser() - nofollow = getattr(os, "O_NOFOLLOW", 0) - cloexec = getattr(os, "O_CLOEXEC", 0) - descriptor: int | None = None - before: os.stat_result | None = None - try: - if not nofollow: - before = path.lstat() - if stat.S_ISLNK(before.st_mode): - raise RunnerError( - "suite CA certificate could not be opened securely" - ) - descriptor = os.open(path, os.O_RDONLY | nofollow | cloexec) - info = os.fstat(descriptor) - if before is not None and ( - before.st_dev != info.st_dev or before.st_ino != info.st_ino - ): - raise RunnerError("suite CA certificate changed while it was opened") - if ( - not stat.S_ISREG(info.st_mode) - or not 0 < info.st_size <= 1024 * 1024 - ): - raise RunnerError( - "suite CA certificate must be a bounded regular file" - ) - with os.fdopen(descriptor, "rb", closefd=True) as handle: - descriptor = None - certificate = handle.read(1024 * 1024 + 1) - except OSError: - raise RunnerError( - "suite CA certificate could not be opened securely" - ) from None - finally: - if descriptor is not None: - os.close(descriptor) - if len(certificate) != info.st_size: - raise RunnerError("suite CA certificate changed while it was read") - return certificate - - -def add_suite_ca(context: ssl.SSLContext, certificate: bytes) -> None: - try: - text = certificate.decode("ascii") - except UnicodeDecodeError: - cadata: str | bytes = certificate - else: - cadata = text if "-----BEGIN CERTIFICATE-----" in text else certificate - try: - context.load_verify_locations(cadata=cadata) - except (OSError, ValueError): - raise RunnerError("suite CA certificate could not be loaded") from None - - -def suite_tls_context(ca_certificate: Path | None) -> ssl.SSLContext: - if ca_certificate is None: - return ssl.create_default_context() - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - add_suite_ca(context, read_suite_ca_certificate(ca_certificate)) - return context - - -def suite_https_opener(context: ssl.SSLContext): - return urllib.request.build_opener( - urllib.request.ProxyHandler({}), - urllib.request.HTTPSHandler(context=context), - NoRedirect(), - ) - - -def suite_jwks_url(base_url: str) -> str: - parsed = urllib.parse.urlsplit(base_url) - if ( - parsed.scheme != "https" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.path not in {"", "/"} - or parsed.query - or parsed.fragment - ): - raise RunnerError("conformance server must be one HTTPS origin") - return urllib.parse.urlunsplit(parsed._replace(path="/jwks")) - - -def cmd_export_suite_jwks(args: argparse.Namespace) -> int: - url = suite_jwks_url(args.conformance_server) - context = suite_tls_context(args.suite_ca_certificate) - opener = suite_https_opener(context) - try: - with opener.open(url, timeout=args.timeout_seconds) as response: - if not 200 <= response.status < 300: - raise RunnerError( - f"suite JWKS endpoint returned HTTP {response.status}" - ) - content = response.read(MAX_SUITE_JWKS_BYTES + 1) - except urllib.error.HTTPError as exc: - raise RunnerError(f"suite JWKS endpoint returned HTTP {exc.code}") from None - except (OSError, urllib.error.URLError): - raise RunnerError("suite JWKS fetch failed") from None - if not 0 < len(content) <= MAX_SUITE_JWKS_BYTES: - raise RunnerError("suite JWKS response has an invalid size") - jwks = parse_suite_jwks(content) - encoded = ( - json.dumps(jwks, ensure_ascii=False, indent=2, sort_keys=True) + "\n" - ).encode("utf-8") - output = write_new_file(args.output, encoded) - print(output) - return 0 - - -def cmd_submit_offer(args: argparse.Namespace) -> int: - inline = read_offer(args.offer_file, args.issuer_url) - base = urllib.parse.urlsplit(args.conformance_server) - endpoint = urllib.parse.urlsplit(args.suite_offer_endpoint) - if ( - (endpoint.scheme, endpoint.netloc) != (base.scheme, base.netloc) - or endpoint.scheme != "https" - or not endpoint.path.endswith("/credential_offer") - or endpoint.query - or endpoint.fragment - ): - raise RunnerError( - "suite offer endpoint must use HTTPS on the pinned suite origin" - ) - url = urllib.parse.urlunsplit( - endpoint._replace(query=urllib.parse.urlencode({"credential_offer": inline})) - ) - context = suite_tls_context(args.suite_ca_certificate) - opener = suite_https_opener(context) - try: - with opener.open(url, timeout=args.timeout_seconds) as response: - if not 200 <= response.status < 300: - raise RunnerError(f"suite offer endpoint returned HTTP {response.status}") - except urllib.error.HTTPError as exc: - raise RunnerError(f"suite offer endpoint returned HTTP {exc.code}") from None - except (OSError, urllib.error.URLError): - raise RunnerError("suite offer submission failed") from None - print("credential offer submitted") - return 0 - - -def write_new_file(path: Path, content: bytes) -> Path: - path = path.expanduser() - path.parent.mkdir(parents=True, exist_ok=True) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) - descriptor: int | None = None - try: - descriptor = os.open(path, flags, 0o600) - with os.fdopen(descriptor, "wb", closefd=True) as handle: - descriptor = None - handle.write(content) - except OSError: - raise RunnerError("output could not be created") from None - finally: - if descriptor is not None: - os.close(descriptor) - return path - - -def cmd_export_suite_ca(args: argparse.Namespace) -> int: - checkout = suite_dir(args) - if not checkout.is_dir(): - raise RunnerError("suite checkout is unavailable; run prepare first") - output = Path(args.output).expanduser() - output.parent.mkdir(parents=True, exist_ok=True) - env = os.environ.copy() - env[COMPOSE_CONFIG_DIR_ENV] = str(CONFIG_DIR) - with tempfile.TemporaryDirectory( - prefix=".openid-suite-ca-", dir=output.parent - ) as tmp: - copied = Path(tmp) / "nginx-selfsigned.crt" - run_checked( - compose_command( - checkout, - args, - "cp", - f"nginx:{SUITE_CA_CONTAINER_PATH}", - str(copied), - ), - env=env, - ) - certificate = read_suite_ca_certificate(copied) - validation_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - add_suite_ca(validation_context, certificate) - write_new_file(output, certificate) - print(output) - return 0 - - -def output_dir_for(args: argparse.Namespace, scenario_id: str) -> Path: - if args.output_dir: - return Path(args.output_dir).expanduser().resolve() - stamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") - return DEFAULT_OUTPUT_ROOT / f"{scenario_id}-{stamp}" - - -def build_run( - plan_map: dict[str, Any], - scenario: dict[str, Any], - args: argparse.Namespace, - python_executable: str | None = None, -) -> tuple[Path, dict[str, str], list[str]]: - settings = suite_settings(plan_map, args) - checkout = suite_dir(args) - output_dir = output_dir_for(args, scenario["id"]) - params = default_params(scenario, args) - config_path = write_rendered_config(scenario, output_dir, params) - env = os.environ.copy() - env["CONFORMANCE_SERVER"] = settings["base_url"] - env["CONFORMANCE_SERVER_LOCAL"] = settings["local_base_url"] - env["CONFORMANCE_SERVER_MTLS"] = settings["mtls_base_url"] - if not env.get("CONFORMANCE_TOKEN"): - env["CONFORMANCE_DEV_MODE"] = "1" - command = [ - python_executable or sys.executable, - str(checkout / "scripts" / "run-test-plan.py"), - "--export-dir", - str(output_dir), - scenario_plan_arg(scenario), - str(config_path), - ] - return output_dir, env, command - - -def cmd_list(args: argparse.Namespace) -> int: - plan_map = load_plan_map(args.plan_map) - for scenario in plan_map["scenarios"]: - print(f"{scenario['id']}\t{scenario['status']}\t{scenario_plan_arg(scenario)}") - return 0 - - -def cmd_prepare(args: argparse.Namespace) -> int: - plan_map = load_plan_map(args.plan_map) - checkout = ensure_suite_checkout(plan_map, args) - ensure_suite_artifact(checkout, args) - ensure_suite_python(checkout, args) - print(checkout) - return 0 - - -def cmd_up(args: argparse.Namespace) -> int: - plan_map = load_plan_map(args.plan_map) - checkout = ensure_suite_checkout(plan_map, args) - ensure_suite_artifact(checkout, args) - env = os.environ.copy() - env[COMPOSE_CONFIG_DIR_ENV] = str(CONFIG_DIR) - run_checked(compose_command(checkout, args, "up", "-d", "--build"), env=env) - settings = suite_settings(plan_map, args) - wait_for_suite(settings["base_url"], args.wait_seconds) - print(settings["base_url"]) - return 0 - - -def cmd_down(args: argparse.Namespace) -> int: - checkout = suite_dir(args) - env = os.environ.copy() - env[COMPOSE_CONFIG_DIR_ENV] = str(CONFIG_DIR) - run_checked(compose_command(checkout, args, "down"), env=env) - return 0 - - -def cmd_render_config(args: argparse.Namespace) -> int: - plan_map = load_plan_map(args.plan_map) - scenario = find_scenario(plan_map, args.scenario) - output_dir = output_dir_for(args, scenario["id"]) - config_path = write_rendered_config( - scenario, output_dir, default_params(scenario, args) - ) - print(config_path) - return 0 - - -def cmd_run(args: argparse.Namespace) -> int: - plan_map = load_plan_map(args.plan_map) - scenario = find_scenario(plan_map, args.scenario) - if scenario.get("status") not in {"applicable", "candidate-only"} and not args.allow_blocked: - raise RunnerError( - f"scenario {scenario['id']} is {scenario.get('status')}; " - "pass --allow-blocked to run it anyway" - ) - if not args.no_prepare: - ensure_suite_checkout(plan_map, args) - checkout = suite_dir(args) - python = suite_python(args) if args.dry_run else ensure_suite_python(checkout, args) - output_dir, env, command = build_run(plan_map, scenario, args, str(python)) - if args.dry_run: - print(json.dumps({"output_dir": str(output_dir), "command": command}, indent=2)) - return 0 - wait_for_suite(env["CONFORMANCE_SERVER"], args.wait_seconds) - result = subprocess.run(command, cwd=checkout, env=env, text=True, check=False) - if result.returncode != 0: - raise RunnerError( - f"OpenID conformance run failed with status {result.returncode}; " - f"output: {output_dir}" - ) - print(output_dir) - return 0 - - -def cmd_promote_evidence(args: argparse.Namespace) -> int: - plan_map = load_plan_map() - scenario = find_scenario(plan_map, EVIDENCE_SCENARIO_ID) - modules = scenario.get("suite_modules") - if not isinstance(modules, list) or len(modules) != 1: - raise RunnerError("evidence scenario must select exactly one suite module") - suite_jwks, suite_jwks_sha256 = load_suite_jwks(args.suite_jwks) - exported = load_suite_export(args.suite_export, modules[0], suite_jwks) - candidate = load_authenticated_candidate(args.release_manifest, args.image_lock) - try: - summary, sensitive_values = build_evidence_summary( - plan_map, - scenario, - exported, - candidate, - suite_jwks_sha256, - ) - encoded = assert_public_summary_safe(summary, sensitive_values) - except RecursionError: - raise RunnerError("suite export is too deeply nested") from None - try: - schema = json.loads(EVIDENCE_SCHEMA_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - raise RunnerError("checked-in evidence schema is invalid") from None - if ( - schema.get("properties", {}).get("schema_version", {}).get("const") - != EVIDENCE_SCHEMA_VERSION - ): - raise RunnerError("checked-in evidence schema version is invalid") - try: - validate_against_schema(summary, schema, schema, "evidence summary") - except SchemaValidationError as exc: - raise RunnerError(f"evidence summary does not match its schema: {exc}") from None - output = write_new_file(args.output, encoded) - print(output) - return 0 - - -def load_authenticated_candidate( - release_manifest: Path, image_lock: Path -) -> dict[str, Any]: - try: - from conformance_candidate import CandidateError, load_candidate - except ModuleNotFoundError as exc: - dependency = exc.name or "the candidate validation dependency" - raise RunnerError( - f"promote-evidence requires {dependency}; install the release tooling dependencies" - ) from None - try: - return load_candidate(release_manifest, image_lock) - except CandidateError as exc: - raise RunnerError(str(exc)) from None - - -def add_common(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--plan-map", type=Path, default=PLAN_MAP_PATH) - parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE_DIR)) - parser.add_argument("--suite-dir") - parser.add_argument("--suite-repo") - parser.add_argument("--suite-ref") - parser.add_argument("--conformance-server") - parser.add_argument("--conformance-server-local") - parser.add_argument("--conformance-server-mtls") - parser.add_argument("--maven-cache-dir", default=str(DEFAULT_CACHE_DIR / "maven")) - parser.add_argument("--python-venv-dir", default=str(DEFAULT_CACHE_DIR / "python")) - parser.add_argument("--rebuild-suite", action="store_true") - - -def add_config_args(parser: argparse.ArgumentParser) -> None: - parser.add_argument("scenario") - parser.add_argument("--issuer-url") - parser.add_argument("--authorization-server") - parser.add_argument("--credential-configuration-id") - parser.add_argument("--static-tx-code", default="0000") - parser.add_argument("--client-id", default="registry-stack-openid-conformance-client") - parser.add_argument( - "--client2-id", default="registry-stack-openid-conformance-client-2" - ) - parser.add_argument("--output-dir") - - -def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - list_parser = subparsers.add_parser("list") - add_common(list_parser) - list_parser.set_defaults(func=cmd_list) - - prepare_parser = subparsers.add_parser("prepare") - add_common(prepare_parser) - prepare_parser.set_defaults(func=cmd_prepare) - - up_parser = subparsers.add_parser("up") - add_common(up_parser) - up_parser.add_argument("--wait-seconds", type=int, default=180) - up_parser.set_defaults(func=cmd_up) - - down_parser = subparsers.add_parser("down") - add_common(down_parser) - down_parser.set_defaults(func=cmd_down) - - export_ca_parser = subparsers.add_parser("export-suite-ca") - export_ca_parser.add_argument("--cache-dir", default=str(DEFAULT_CACHE_DIR)) - export_ca_parser.add_argument("--suite-dir") - export_ca_parser.add_argument( - "--output", - type=Path, - default=DEFAULT_SUITE_CA_PATH, - help="new file that receives the running suite's generated certificate", - ) - export_ca_parser.set_defaults(func=cmd_export_suite_ca) - - export_jwks_parser = subparsers.add_parser( - "export-suite-jwks", - help="capture the suite export-signing keys over authenticated HTTPS", - ) - export_jwks_parser.add_argument( - "--conformance-server", - default=load_plan_map()["suite"]["base_url"], - ) - export_jwks_parser.add_argument( - "--suite-ca-certificate", - type=Path, - help="PEM or DER trust anchor captured from the local suite", - ) - export_jwks_parser.add_argument( - "--output", - type=Path, - default=DEFAULT_SUITE_JWKS_PATH, - help="new owner-only file that receives the validated suite JWKS", - ) - export_jwks_parser.add_argument("--timeout-seconds", type=int, default=10) - export_jwks_parser.set_defaults(func=cmd_export_suite_jwks) - - render_parser = subparsers.add_parser("render-config") - add_common(render_parser) - add_config_args(render_parser) - render_parser.set_defaults(func=cmd_render_config) - - run_parser = subparsers.add_parser("run") - add_common(run_parser) - add_config_args(run_parser) - run_parser.add_argument("--allow-blocked", action="store_true") - run_parser.add_argument("--dry-run", action="store_true") - run_parser.add_argument("--no-prepare", action="store_true") - run_parser.add_argument("--wait-seconds", type=int, default=180) - run_parser.set_defaults(func=cmd_run) - - offer_parser = subparsers.add_parser("submit-offer") - offer_parser.add_argument("--offer-file", type=Path, required=True) - offer_parser.add_argument("--issuer-url", required=True) - offer_parser.add_argument("--suite-offer-endpoint", required=True) - offer_parser.add_argument( - "--conformance-server", - default=load_plan_map()["suite"]["base_url"], - ) - offer_parser.add_argument( - "--suite-ca-certificate", - type=Path, - help="PEM or DER trust anchor captured from the local suite", - ) - offer_parser.add_argument("--timeout-seconds", type=int, default=10) - offer_parser.set_defaults(func=cmd_submit_offer) - - promote_parser = subparsers.add_parser( - "promote-evidence", - help="create a closed candidate-referenced summary from one private suite export", - ) - promote_parser.add_argument( - "--suite-export", - type=Path, - required=True, - help="owner-only OIDF plan export ZIP kept outside the repository", - ) - promote_parser.add_argument( - "--suite-jwks", - type=Path, - required=True, - help="owner-only JWKS captured from the authenticated suite origin", - ) - promote_parser.add_argument( - "--release-manifest", - type=Path, - required=True, - help="checked-in release manifest for the published candidate", - ) - promote_parser.add_argument( - "--image-lock", - type=Path, - required=True, - help="downloaded signed registryctl release image lock", - ) - promote_parser.add_argument( - "--output", - type=Path, - required=True, - help="new JSON file for the allowlisted review summary", - ) - promote_parser.set_defaults(func=cmd_promote_evidence) - - return parser.parse_args(argv) - - -def main(argv: list[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - try: - return int(args.func(args)) - except ( - OSError, - json.JSONDecodeError, - KeyError, - RunnerError, - ) as exc: - print(f"openid-conformance-runner: {exc}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/release/scripts/registry-release b/release/scripts/registry-release index d7e04b235..b0699664c 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -22,6 +22,7 @@ from registryctl_image_lock import ( PLATFORM as RELEASE_IMAGE_LOCK_PLATFORM, PRODUCT_IMAGE_REPOSITORIES, SCHEMA_V2 as RELEASE_IMAGE_LOCK_SCHEMA_V2, + SCHEMA_V3 as RELEASE_IMAGE_LOCK_SCHEMA_V3, read_reviewed_postgresql_image_ref, schema_for_release_version, validate_images, @@ -38,6 +39,7 @@ SEMVER_TAG = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+$") IMAGE_DIGEST_REF = re.compile(r"^([^@]+)@(sha256:[0-9a-f]{64})$") RELEASE_IMAGE_LOCK_MINIMUM_VERSION = (0, 9, 0) EXACT_ARTIFACT_INVENTORY_MINIMUM_VERSION = (0, 10, 0) +NOTARY_RETIREMENT_MINIMUM_VERSION = (0, 17, 0) REGISTRYCTL_INSTALLER_MINIMUM_VERSION = (0, 14, 0) EXACT_ARTIFACT_INVENTORY = { "registry-notary", @@ -49,6 +51,10 @@ EXACT_ARTIFACT_INVENTORY = { "registryctl-image-lock", "registry-docs", } +POST_NOTARY_ARTIFACT_INVENTORY = EXACT_ARTIFACT_INVENTORY - { + "registry-notary", + "registry-notary-cel-worker", +} OPTIONAL_EXACT_ARTIFACTS = { "registryctl-installer", # The Evidence toolset joins the release channel as optional artifacts @@ -135,9 +141,14 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s and parsed_version >= EXACT_ARTIFACT_INVENTORY_MINIMUM_VERSION ): artifact_names = set(artifacts) - missing = sorted(EXACT_ARTIFACT_INVENTORY - artifact_names) + expected_inventory = ( + POST_NOTARY_ARTIFACT_INVENTORY + if parsed_version >= NOTARY_RETIREMENT_MINIMUM_VERSION + else EXACT_ARTIFACT_INVENTORY + ) + missing = sorted(expected_inventory - artifact_names) unexpected = sorted( - artifact_names - EXACT_ARTIFACT_INVENTORY - OPTIONAL_EXACT_ARTIFACTS + artifact_names - expected_inventory - OPTIONAL_EXACT_ARTIFACTS ) if missing or unexpected: details = [] @@ -220,7 +231,7 @@ def read_release_image_digest(path: Path, image_name: str) -> str: def render_registryctl_image_lock( manifest_path: Path, relay_digest_path: Path, - notary_digest_path: Path, + notary_digest_path: Path | None, postgresql_ref_path: Path | None, tag_target: str, source_sha: str | None, @@ -273,16 +284,29 @@ def render_registryctl_image_lock( raise ValueError(f"image lock output must be a regular file path: {output}") relay_digest = read_release_image_digest(relay_digest_path, "registry-relay") - notary_digest = read_release_image_digest(notary_digest_path, "registry-notary") schema_version = schema_for_release_version(version) - images = { - "registry-relay": relay_digest, - "registry-notary": notary_digest, - } - if schema_version == RELEASE_IMAGE_LOCK_SCHEMA_V2: + images = {"registry-relay": relay_digest} + if schema_version != RELEASE_IMAGE_LOCK_SCHEMA_V3: + if notary_digest_path is None: + raise ValueError( + "historical registryctl release image locks require --notary-digest" + ) + images["registry-notary"] = read_release_image_digest( + notary_digest_path, + "registry-notary", + ) + elif notary_digest_path is not None: + raise ValueError( + "registryctl release image lock v3 does not accept --notary-digest" + ) + if schema_version in { + RELEASE_IMAGE_LOCK_SCHEMA_V2, + RELEASE_IMAGE_LOCK_SCHEMA_V3, + }: if postgresql_ref_path is None: raise ValueError( - "registryctl release image lock v2 requires --postgresql-ref-file" + "registryctl release image lock v2 or later requires " + "--postgresql-ref-file" ) images["postgresql"] = read_reviewed_postgresql_image_ref( postgresql_ref_path @@ -418,9 +442,10 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i f"registryctl-{tag}-linux-amd64", f"registry-manifest-{tag}-linux-amd64", f"registry-relay-{tag}-linux-amd64", - f"registry-notary-{tag}-linux-amd64", "SHA256SUMS", ] + if parsed_version < NOTARY_RETIREMENT_MINIMUM_VERSION: + binary_names.append(f"registry-notary-{tag}-linux-amd64") optional_binary_names = [ f"registryctl-{tag}-macos-arm64", f"registryctl-{tag}-linux-arm64", @@ -428,10 +453,11 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i for evidence_binary in ("evidence", "evidencectl", "mint"): for platform_label in ("linux-amd64", "linux-arm64", "macos-arm64"): optional_binary_names.append(f"{evidence_binary}-{tag}-{platform_label}") - worker_binary_names = [ - f"registry-relay-rhai-worker-{tag}-linux-amd64", - f"registry-notary-cel-worker-{tag}-linux-amd64", - ] + worker_binary_names = [f"registry-relay-rhai-worker-{tag}-linux-amd64"] + if parsed_version < NOTARY_RETIREMENT_MINIMUM_VERSION: + worker_binary_names.append( + f"registry-notary-cel-worker-{tag}-linux-amd64" + ) if parsed_version >= EXACT_ARTIFACT_INVENTORY_MINIMUM_VERSION: binary_names.extend(worker_binary_names) else: @@ -446,10 +472,9 @@ def stage_capsule_backfill_assets(asset_dir: Path, tag: str, binary_dir: Path, i required_release_file_names.append(image_lock_name) else: optional_release_file_names.append(image_lock_name) - image_names = [ - "registry-notary", - "registry-relay", - ] + image_names = ["registry-relay"] + if parsed_version < NOTARY_RETIREMENT_MINIMUM_VERSION: + image_names.append("registry-notary") staged_optional: list[str] = [] staged_release_files: list[str] = [] @@ -3360,7 +3385,7 @@ def main() -> int: image_lock_parser = subparsers.add_parser("render-registryctl-image-lock") image_lock_parser.add_argument("manifest", type=Path) image_lock_parser.add_argument("--relay-digest", type=Path, required=True) - image_lock_parser.add_argument("--notary-digest", type=Path, required=True) + image_lock_parser.add_argument("--notary-digest", type=Path) image_lock_parser.add_argument("--postgresql-ref-file", type=Path) image_lock_parser.add_argument("--tag-target", required=True) image_lock_parser.add_argument("--source-sha") diff --git a/release/scripts/registryctl_image_lock.py b/release/scripts/registryctl_image_lock.py index 5a72a5b38..5189a536c 100644 --- a/release/scripts/registryctl_image_lock.py +++ b/release/scripts/registryctl_image_lock.py @@ -13,8 +13,10 @@ POSTGRESQL_IMAGE_REF_PATH = ROOT / "release" / "registryctl-postgresql-image.ref" SCHEMA_V1 = "registryctl.release_image_lock.v1" SCHEMA_V2 = "registryctl.release_image_lock.v2" +SCHEMA_V3 = "registryctl.release_image_lock.v3" PLATFORM = "linux/amd64" V2_MINIMUM_VERSION = (0, 14, 0) +V3_MINIMUM_VERSION = (0, 17, 0) SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") PRODUCT_IMAGE_REPOSITORIES = { "registry-relay": "ghcr.io/registrystack/registry-relay", @@ -30,6 +32,8 @@ def schema_for_release_version(version: str) -> str: f"got {version!r}" ) parsed = tuple(int(part) for part in version.split(".")) + if parsed >= V3_MINIMUM_VERSION: + return SCHEMA_V3 return SCHEMA_V2 if parsed >= V2_MINIMUM_VERSION else SCHEMA_V1 @@ -41,9 +45,14 @@ def repositories_for_schema(schema_version: str) -> dict[str, str]: **PRODUCT_IMAGE_REPOSITORIES, "postgresql": POSTGRESQL_IMAGE_REPOSITORY, } + if schema_version == SCHEMA_V3: + return { + "registry-relay": PRODUCT_IMAGE_REPOSITORIES["registry-relay"], + "postgresql": POSTGRESQL_IMAGE_REPOSITORY, + } raise ValueError( "registryctl release image lock schema_version must be " - f"{SCHEMA_V1!r} or {SCHEMA_V2!r}" + f"{SCHEMA_V1!r}, {SCHEMA_V2!r}, or {SCHEMA_V3!r}" ) @@ -123,7 +132,7 @@ def validate_images(schema_version: str, images: Any) -> dict[str, str]: f"{repository}@sha256:<64 lowercase hex>" ) if ( - schema_version == SCHEMA_V2 + schema_version in {SCHEMA_V2, SCHEMA_V3} and images["postgresql"] != reviewed_postgresql_image_ref() ): raise ValueError( diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 0155d2934..55dd39ebb 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -46,7 +46,9 @@ DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") VERSION = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") -IMAGE_NAMES = {"registry-notary", "registry-relay"} +LEGACY_IMAGE_NAMES = {"registry-notary", "registry-relay"} +CURRENT_IMAGE_NAMES = {"registry-relay"} +NOTARY_RETIREMENT_MINIMUM_VERSION = (0, 17, 0) ATTEMPT_ARTIFACT_PREFIXES = { "registry-stack-candidate-build-a", "registry-stack-candidate-macos-arm64", @@ -108,20 +110,22 @@ "syft", "grype", } -SECURITY_EVIDENCE_REQUIRED_FILES = { +SECURITY_EVIDENCE_COMMON_REQUIRED_FILES = { "images/postgresql.digest", - "image-sbom/registry-notary.spdx.json", - "image-sbom/registry-relay.spdx.json", "image-sbom/postgresql.spdx.json", - "syft/registry-notary.syft.json", - "syft/registry-relay.syft.json", "syft/postgresql.syft.json", - "grype/registry-notary.grype.json", - "grype/registry-relay.grype.json", "grype/postgresql.grype.json", "grype/grype-db-status.json", "advisory-verdict.json", } +SECURITY_EVIDENCE_REQUIRED_FILES = SECURITY_EVIDENCE_COMMON_REQUIRED_FILES | { + f"{directory}/registry-relay.{suffix}.json" + for directory, suffix in ( + ("image-sbom", "spdx"), + ("syft", "syft"), + ("grype", "grype"), + ) +} POSTGRESQL_DIGEST_REF = re.compile( r"^docker\.io/library/postgres@sha256:[0-9a-f]{64}$" ) @@ -131,6 +135,28 @@ class CandidateError(ValueError): """A candidate cannot be trusted for promotion.""" +def _candidate_image_names(version: str) -> set[str]: + parsed = tuple(int(part) for part in version.split(".")) + if parsed >= NOTARY_RETIREMENT_MINIMUM_VERSION: + return CURRENT_IMAGE_NAMES + return LEGACY_IMAGE_NAMES + + +def _security_evidence_required_files( + product_image_names: Iterable[str], +) -> set[str]: + required = set(SECURITY_EVIDENCE_COMMON_REQUIRED_FILES) + for image in product_image_names: + required.update( + { + f"image-sbom/{image}.spdx.json", + f"syft/{image}.syft.json", + f"grype/{image}.grype.json", + } + ) + return required + + def read_json(path: Path) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) @@ -729,6 +755,7 @@ def validate_security_evidence_archive( ): raise CandidateError("security evidence archive size exceeds its bound") + required_files = _security_evidence_required_files(product_image_refs) seen: set[str] = set() top_level: set[str] = set() contents: dict[str, bytes] = {} @@ -773,7 +800,7 @@ def validate_security_evidence_archive( raise CandidateError( f"security evidence archive has non-regular entry {name!r}" ) - if name not in SECURITY_EVIDENCE_REQUIRED_FILES: + if name not in required_files: raise CandidateError( f"security evidence archive has unexpected member {name!r}" ) @@ -806,7 +833,7 @@ def validate_security_evidence_archive( f"cannot inspect security evidence archive: {exc}" ) from exc - missing = SECURITY_EVIDENCE_REQUIRED_FILES - set(contents) + missing = required_files - set(contents) if missing: raise CandidateError( f"security evidence archive is incomplete: missing {sorted(missing)!r}" @@ -906,11 +933,8 @@ def validate_security_evidence_archive( raise CandidateError("security evidence Grype database status is empty") advisory = _security_evidence_json(contents, "advisory-verdict.json") - expected_subjects = { - "registry-notary-image", - "registry-relay-image", - "postgresql-runtime", - } + expected_subjects = {"postgresql-runtime"} + expected_subjects.update(f"{name}-image" for name in product_image_refs) subjects = advisory.get("subjects") if ( advisory.get("schema_version") != "registry-stack.advisory-verdict.v2" @@ -1140,9 +1164,10 @@ def validate_candidate_manifest( candidate_refs.add(candidate_ref) final_refs.add(final_ref) product_image_refs[name] = candidate_ref - if image_names != IMAGE_NAMES: + expected_image_names = _candidate_image_names(version) + if image_names != expected_image_names: raise CandidateError( - f"image inventory must be exactly {sorted(IMAGE_NAMES)!r}" + f"image inventory must be exactly {sorted(expected_image_names)!r}" ) for kind in ("docs", "sbom"): @@ -1538,7 +1563,7 @@ def _validate_images( }, ) name = require_nonempty_string(image["name"], f"{label}.name") - if name not in IMAGE_NAMES or name in seen: + if name not in LEGACY_IMAGE_NAMES or name in seen: raise CandidateError(f"{label}.name is unexpected or duplicated: {name!r}") seen.add(name) expected_repository = f"ghcr.io/registrystack/{name}-candidate" @@ -1691,8 +1716,10 @@ def _validate_images( raise CandidateError( f"{label}.comparison does not match the candidate build mode" ) - if seen != IMAGE_NAMES: - raise CandidateError(f"image inventory must be exactly {sorted(IMAGE_NAMES)!r}") + if seen != LEGACY_IMAGE_NAMES: + raise CandidateError( + f"image inventory must be exactly {sorted(LEGACY_IMAGE_NAMES)!r}" + ) def validate_receipt( @@ -2047,7 +2074,7 @@ def validate_promotion_state( public_images = require_object( state["public_images"], "promotion state.public_images", - IMAGE_NAMES, + LEGACY_IMAGE_NAMES, ) expected_digests = { image["name"]: image["index_digest"] for image in receipt["images"] @@ -2100,7 +2127,7 @@ def validate_promotion_state( if phase == "prewrite": if release_state["exists"] or any( - public_images[name] is not None for name in IMAGE_NAMES + public_images[name] is not None for name in LEGACY_IMAGE_NAMES ): raise CandidateError( "prewrite promotion state is not empty; partial publication or replay " diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 9e03c8151..c53dc9fb2 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -6,7 +6,7 @@ repo_root="$(cd -- "${script_dir}/../.." && pwd)" checker="${script_dir}/check-release-image-oci-labels.py" image_builder="${script_dir}/build-release-image.sh" layout_comparator="${script_dir}/compare-release-image-layouts.py" -images=(registry-notary registry-relay) +images=(registry-relay) relay_dockerfile="${repo_root}/release/docker/Dockerfile.registry-relay" source_label="https://github.com/registrystack/registry-stack" @@ -32,8 +32,6 @@ if [[ ! -x "${true_binary}" ]]; then fi cp "${true_binary}" "${context_dir}/dist/image-bin/registry-relay" cp "${true_binary}" "${context_dir}/dist/image-bin/registry-relay-rhai-worker" -cp "${true_binary}" "${context_dir}/dist/image-bin/registry-notary" -cp "${true_binary}" "${context_dir}/dist/image-bin/registry-notary-cel-worker" cp "${repo_root}/LICENSE" "${context_dir}/LICENSE" docker buildx create \ @@ -102,8 +100,6 @@ for image in "${images[@]}"; do touch -t 200001010101 \ "${context_dir}/dist/image-bin/registry-relay" \ "${context_dir}/dist/image-bin/registry-relay-rhai-worker" \ - "${context_dir}/dist/image-bin/registry-notary" \ - "${context_dir}/dist/image-bin/registry-notary-cel-worker" \ "${context_dir}/LICENSE" build_layout "${image}" "${first_layout}" "${revision_label}" "${version_label}" expected_label_args=() @@ -121,8 +117,6 @@ for image in "${images[@]}"; do touch -t 203001010101 \ "${context_dir}/dist/image-bin/registry-relay" \ "${context_dir}/dist/image-bin/registry-relay-rhai-worker" \ - "${context_dir}/dist/image-bin/registry-notary" \ - "${context_dir}/dist/image-bin/registry-notary-cel-worker" \ "${context_dir}/LICENSE" build_layout "${image}" "${second_layout}" "${revision_label}" "${version_label}" python3 "${layout_comparator}" "${first_layout}" "${second_layout}" diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index cb141fd3d..467a5741d 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -788,13 +788,13 @@ def test_missing_openid_conformance_runner_tests_are_reported(self) -> None: "OpenID conformance runner tests", self.module.missing_gates(text) ) - def test_missing_external_integration_runner_tests_are_reported(self) -> None: + def test_missing_external_integration_retirement_guard_is_reported(self) -> None: text = self.workflow.replace( "python3 -m unittest release/scripts/test_integration_e2_runner.py", "python3 release/scripts/integration-e2-runner.py dry-run", ) self.assertIn( - "External integration evidence runner tests", + "External integration retirement guard", self.module.missing_gates(text), ) @@ -819,16 +819,6 @@ def test_missing_first_country_release_form_runner_tests_are_reported(self) -> N self.module.missing_gates(text), ) - def test_missing_external_integration_packet_validation_is_reported(self) -> None: - text = self.workflow.replace( - "python3 release/scripts/integration-e2-runner.py validate", - "python3 release/scripts/integration-e2-runner.py plan", - ) - self.assertIn( - "External integration evidence packet", - self.module.missing_gates(text), - ) - def test_missing_relay_oidc_smoke_tests_are_reported(self) -> None: text = self.workflow.replace( "python3 -m unittest release/scripts/test_relay_oidc_smoke.py", diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 33019be84..6c177e024 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -519,7 +519,7 @@ def test_reused_builder_rejects_nonstandard_container_shape(self) -> None: class ReleaseImageOciLabelsSmokeTest(unittest.TestCase): - def test_smoke_builds_both_release_dockerfiles_via_shared_wrapper(self) -> None: + def test_smoke_builds_relay_release_dockerfile_via_shared_wrapper(self) -> None: with tempfile.TemporaryDirectory() as temporary: fake_bin = Path(temporary) / "bin" fake_bin.mkdir() @@ -601,7 +601,7 @@ def read_calls(path: Path) -> list[list[str]]: build_calls = [ call for call in read_calls(docker_log) if call[:2] == ["buildx", "build"] ] - self.assertEqual(6, len(build_calls)) + self.assertEqual(4, len(build_calls)) dockerfiles = [] for call in build_calls: self.assertEqual(["buildx", "build"], call[:2]) @@ -626,17 +626,10 @@ def read_calls(path: Path) -> list[list[str]]: self.assertEqual( { - str(ROOT / "release/docker/Dockerfile.registry-notary"), str(ROOT / "release/docker/Dockerfile.registry-relay"), }, set(dockerfiles), ) - self.assertEqual( - 2, - dockerfiles.count( - str(ROOT / "release/docker/Dockerfile.registry-notary") - ), - ) self.assertEqual( 4, dockerfiles.count( @@ -653,7 +646,6 @@ def read_calls(path: Path) -> list[list[str]]: } self.assertEqual( { - "correct-registry-notary-first", "correct-registry-relay-first", }, { @@ -681,7 +673,7 @@ def read_calls(path: Path) -> list[list[str]]: for call in python_calls if call and call[0].endswith("compare-release-image-layouts.py") ] - self.assertEqual(3, len(comparisons)) + self.assertEqual(2, len(comparisons)) self.assertEqual(1, sum("--rootfs-only" in call for call in comparisons)) diff --git a/release/scripts/test_check_release_source_model.py b/release/scripts/test_check_release_source_model.py index edbaecd9c..55e639a69 100644 --- a/release/scripts/test_check_release_source_model.py +++ b/release/scripts/test_check_release_source_model.py @@ -55,6 +55,14 @@ def test_monorepo_mode_passes_without_lab_directory(self) -> None: self.assertIn("release-source registry-stack", result.stdout) self.assertNotIn("lab", result.stdout) + def test_monorepo_mode_passes_without_retired_notary_crates(self) -> None: + with MonorepoFixture() as stack_root: + shutil.rmtree(stack_root / "crates" / "registry-notary-server") + + result = run_monorepo_validator(stack_root) + + self.assertEqual(0, result.returncode, result.stderr) + def test_monorepo_mode_rejects_legacy_vendor_mode(self) -> None: with MonorepoFixture() as stack_root: result = run_validator(stack_root, "vendor") @@ -245,6 +253,9 @@ def __enter__(self) -> Path: "crates/registry-manifest-core", "crates/registry-notary-server", "crates/registry-relay", + "crates/registry-evidence", + "crates/registry-evidencectl", + "crates/registry-mint", "crates/registryctl", ): (stack_root / crate_dir).mkdir(parents=True) diff --git a/release/scripts/test_check_stable_surface_compatibility.py b/release/scripts/test_check_stable_surface_compatibility.py index d907501f0..fc1d2ecc0 100644 --- a/release/scripts/test_check_stable_surface_compatibility.py +++ b/release/scripts/test_check_stable_surface_compatibility.py @@ -39,6 +39,26 @@ def test_error_registry_requires_one_stack_wide_meaning(self) -> None: with self.assertRaisesRegex(self.module.ContractError, "stack-wide meaning"): self.module.parse_error_registry(text) + def test_retired_notary_errors_do_not_enter_the_current_contract(self) -> None: + text = """\ +## Registry Notary +| Code | Meaning | Cause | +| --- | --- | --- | +| `notary.retired` | historical Notary error | x | +## Registry Relay +| Code | Meaning | Cause | +| --- | --- | --- | +| `relay.active` | maintained Relay error | x | +""" + self.assertEqual( + { + "relay.active": self.module.ErrorContract( + "maintained Relay error", frozenset({"registry-relay"}) + ) + }, + self.module.parse_error_registry(text), + ) + def test_error_additions_are_allowed_but_removal_and_change_are_not(self) -> None: old = { "request.invalid": self.module.ErrorContract( @@ -78,7 +98,7 @@ def test_metric_contract_is_anchored_in_source(self) -> None: "release_line": 1, "metrics": [ { - "product": "registry-notary", + "product": "registry-relay", "name": "product_requests_total", "type": "counter", "meaning": "Completed requests.", @@ -88,11 +108,58 @@ def test_metric_contract_is_anchored_in_source(self) -> None: ], } validated = self.module.validate_metrics_contract(contract, root) - self.assertIn(("registry-notary", "product_requests_total"), validated) + self.assertIn(("registry-relay", "product_requests_total"), validated) contract["metrics"][0]["labels"] = {"route": "Raw route."} with self.assertRaisesRegex(self.module.ContractError, "selected label"): self.module.validate_metrics_contract(contract, root) + def test_current_stable_surfaces_accept_only_maintained_products(self) -> None: + self.assertEqual({"registry-relay"}, set(self.module.OPENAPI_SPECS)) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "metrics.rs" + source.write_text("# TYPE retired_total counter\n", encoding="utf-8") + contract = { + "schema": "registry-stack.selected-metrics/v1", + "release_line": 1, + "metrics": [ + { + "product": "registry-notary", + "name": "retired_total", + "type": "counter", + "meaning": "A retired metric.", + "labels": {}, + "source": "metrics.rs", + } + ], + } + with self.assertRaisesRegex(self.module.ContractError, "maintained product"): + self.module.validate_metrics_contract(contract, root) + + def test_historical_notary_metrics_do_not_block_current_retirement(self) -> None: + relay = { + "product": "registry-relay", + "name": "relay_requests_total", + "type": "counter", + "meaning": "Completed Relay requests.", + "labels": {}, + "source": "relay.rs", + } + notary = { + "product": "registry-notary", + "name": "notary_requests_total", + "type": "counter", + "meaning": "Completed Notary requests.", + "labels": {}, + "source": "notary.rs", + } + base = { + (relay["product"], relay["name"]): relay, + (notary["product"], notary["name"]): notary, + } + current = {(relay["product"], relay["name"]): relay} + self.assertEqual([], self.module.compare_metrics_contracts(base, current)) + def test_metric_additions_are_allowed_but_protected_fields_do_not_change(self) -> None: metric = { "product": "registry-relay", @@ -206,6 +273,18 @@ def test_diagnostic_additions_are_allowed_but_removal_and_semantic_drift_are_not errors = self.module.compare_diagnostic_contracts(old, changed) self.assertTrue(any(f"changed {field}" in error for error in errors)) + def test_historical_notary_diagnostics_do_not_block_current_retirement(self) -> None: + key = ("notary_activation", "registry_notary", "registry_notary.retired") + base = { + key: self.module.DiagnosticContract( + "registry_notary", + "The retired Notary activation failed.", + "the historical activation must satisfy the retired rule", + "/reference/diagnostics/operator/#registry_notary--retired", + ) + } + self.assertEqual([], self.module.compare_diagnostic_contracts(base, {})) + def test_diagnostic_catalog_rejects_shape_duplicates_reordering_and_lifecycle_drift( self, ) -> None: diff --git a/release/scripts/test_conformance_candidate.py b/release/scripts/test_conformance_candidate.py index 59f0288fd..40203570c 100644 --- a/release/scripts/test_conformance_candidate.py +++ b/release/scripts/test_conformance_candidate.py @@ -52,38 +52,42 @@ def make_binding_fixture( ) -> tuple[dict[str, object], dict[str, object], Path, str, Path]: candidate = self.root / candidate_name candidate.mkdir() - images = { - "registry-relay": self.relay, - "registry-notary": self.notary, - } - if schema_version == image_lock.SCHEMA_V2: + tag = "v0.17.0" if schema_version == image_lock.SCHEMA_V3 else self.tag + version = tag.removeprefix("v") + images = {"registry-relay": self.relay} + if schema_version != image_lock.SCHEMA_V3: + images["registry-notary"] = self.notary + if schema_version in {image_lock.SCHEMA_V2, image_lock.SCHEMA_V3}: images["postgresql"] = self.postgresql lock = { "schema_version": schema_version, - "release_tag": self.tag, + "release_tag": tag, "manifest_source_ref": "4" * 40, "tag_target": "1" * 40, "platform": image_lock.PLATFORM, "images": images, } - lock_name = f"registryctl-{self.tag}-image-lock.json" + lock_name = f"registryctl-{tag}-image-lock.json" lock_path = candidate / lock_name self.write_json(lock_path, lock) lock_sha256 = hashlib.sha256(lock_path.read_bytes()).hexdigest() - capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" + capsule_path = candidate / f"registry-stack-{tag}-release-capsule.json" capsule_images = [ { "name": "registry-relay", "role": "released-product-image", "digest_ref": self.relay, }, - { - "name": "registry-notary", - "role": "released-product-image", - "digest_ref": self.notary, - }, ] - if schema_version == image_lock.SCHEMA_V2: + if schema_version != image_lock.SCHEMA_V3: + capsule_images.append( + { + "name": "registry-notary", + "role": "released-product-image", + "digest_ref": self.notary, + } + ) + if schema_version in {image_lock.SCHEMA_V2, image_lock.SCHEMA_V3}: capsule_images.append( { "name": "postgresql", @@ -94,11 +98,11 @@ def make_binding_fixture( self.write_json( capsule_path, { - "release_tag": self.tag, - "version": self.version, + "release_tag": tag, + "version": version, "repository": self.module.CAPSULE_REPOSITORY, "source": { - "source_tag": self.tag, + "source_tag": tag, "source_ref": lock["manifest_source_ref"], "source_commit": lock["tag_target"], "lineage": { @@ -122,7 +126,7 @@ def make_binding_fixture( f"{lock_sha256} {lock_name}\n", encoding="utf-8" ) return ( - {"version": self.version}, + {"version": version}, lock, lock_path, lock_sha256, @@ -157,6 +161,62 @@ def test_v2_capsule_binds_postgresql_from_validated_image_lock(self) -> None: self.assertRegex(capsule_sha256, r"^[0-9a-f]{64}$") + def test_v3_capsule_binds_relay_and_postgresql_without_notary(self) -> None: + stack, lock, lock_path, lock_sha256, _capsule_path = self.make_binding_fixture( + schema_version=image_lock.SCHEMA_V3 + ) + + capsule_sha256 = self.verify_fixture(stack, lock, lock_path, lock_sha256) + + self.assertRegex(capsule_sha256, r"^[0-9a-f]{64}$") + self.assertEqual({"registry-relay", "postgresql"}, set(lock["images"])) + + def test_v3_candidate_loader_returns_relay_without_notary(self) -> None: + manifest_path = self.root / "release/manifests/registry-stack-beta-27.yaml" + manifest_path.parent.mkdir(parents=True) + image_lock_path = self.root / "registryctl-v0.17.0-image-lock.json" + manifest = { + "stack": { + "release": "beta-27", + "version": "0.17.0", + "source_repo": "registrystack/registry-stack", + "source_ref": "4" * 40, + "source_tag": "v0.17.0", + "status": "released", + }, + "artifacts": {"registry-relay": "0.17.0"}, + } + lock = { + "schema_version": image_lock.SCHEMA_V3, + "release_tag": "v0.17.0", + "manifest_source_ref": "4" * 40, + "tag_target": "1" * 40, + "platform": image_lock.PLATFORM, + "images": { + "registry-relay": self.relay, + "postgresql": self.postgresql, + }, + } + + with ( + mock.patch.object(self.module, "REPO_ROOT", self.root), + mock.patch.object(self.module, "verify_git_binding"), + mock.patch.object( + self.module, + "verify_release_asset_binding", + return_value="5" * 64, + ), + ): + candidate = self.module._load_candidate_snapshot( + manifest_path, + image_lock_path, + json.dumps(manifest).encode(), + json.dumps(lock).encode(), + ) + + self.assertEqual(self.relay, candidate["relay_image"]) + self.assertNotIn("notary_image", candidate) + def test_v2_capsule_rejects_missing_drifted_extra_or_wrong_role_image(self) -> None: mutations = ( ( diff --git a/release/scripts/test_integration_e2_runner.py b/release/scripts/test_integration_e2_runner.py old mode 100644 new mode 100755 index 54b7fe6c8..815e630d3 --- a/release/scripts/test_integration_e2_runner.py +++ b/release/scripts/test_integration_e2_runner.py @@ -1,962 +1,37 @@ #!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Retirement contract for the Notary-only external integration runner.""" + from __future__ import annotations -import hashlib -import importlib.util -import io -import json -import os -import shlex -import stat -import subprocess -import sys -import tempfile -from contextlib import redirect_stderr +import unittest from pathlib import Path -from unittest import TestCase, main, mock ROOT = Path(__file__).resolve().parents[2] -SCRIPT = ROOT / "release" / "scripts" / "integration-e2-runner.py" -sys.path.insert(0, str(SCRIPT.parent)) -import registryctl_image_lock as image_lock # noqa: E402 - - -def load_module(): - spec = importlib.util.spec_from_file_location("integration_e2_runner", SCRIPT) - if spec is None or spec.loader is None: - raise ImportError(f"could not load module spec from {SCRIPT}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module -class IntegrationE2RunnerTest(TestCase): - def setUp(self) -> None: - self.module = load_module() - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) - self.tag = "v0.13.0" - self.commit = "1" * 40 - self.relay = "ghcr.io/registrystack/registry-relay@sha256:" + "2" * 64 - self.notary = "ghcr.io/registrystack/registry-notary@sha256:" + "3" * 64 +class RetiredIntegrationE2RunnerTest(unittest.TestCase): + def test_notary_integration_runner_and_packet_are_absent(self) -> None: + self.assertFalse((ROOT / "release/scripts/integration-e2-runner.py").exists()) + packet = ROOT / "release/conformance/integrations" + self.assertEqual([], [path for path in packet.rglob("*") if path.is_file()]) - def tearDown(self) -> None: - self.temporary.cleanup() + def test_relay_oidc_smoke_remains(self) -> None: + self.assertTrue((ROOT / "release/scripts/relay-oidc-smoke.py").is_file()) - @staticmethod - def write_json(path: Path, value: object) -> None: - path.write_text(json.dumps(value, sort_keys=True) + "\n", encoding="utf-8") - - def make_candidate( - self, - *, - candidate_name: str = "candidate", - include_postgresql: bool = True, - postgresql_ref: str | None = None, - ) -> Path: - candidate = self.root / candidate_name - candidate.mkdir() - version = self.tag.removeprefix("v") - binary_name = f"registryctl-{self.tag}-linux-amd64" - lock_name = f"registryctl-{self.tag}-image-lock.json" - capsule_name = f"registry-stack-{self.tag}-release-capsule.json" - (candidate / binary_name).write_text( - f"#!/bin/sh\nprintf 'registryctl {version}\\n'\n", - encoding="utf-8", - ) - schema_version = self.module.schema_for_release_version(version) - images = { - "registry-relay": self.relay, - "registry-notary": self.notary, - } - if schema_version == image_lock.SCHEMA_V2 and include_postgresql: - images["postgresql"] = ( - postgresql_ref or image_lock.reviewed_postgresql_image_ref() - ) - lock = { - "schema_version": schema_version, - "release_tag": self.tag, - "manifest_source_ref": "4" * 40, - "tag_target": self.commit, - "platform": "linux/amd64", - "images": images, - } - self.write_json(candidate / lock_name, lock) - lock_sha = hashlib.sha256((candidate / lock_name).read_bytes()).hexdigest() - lock_sbom_name = f"{lock_name}.spdx.json" - lock_subject_id = "SPDXRef-lock-subject" - self.write_json( - candidate / lock_sbom_name, - { - "documentDescribes": [lock_subject_id], - "packages": [ - { - "SPDXID": lock_subject_id, - "name": lock_name, - "packageFileName": lock_name, - "checksums": [ - {"algorithm": "SHA256", "checksumValue": lock_sha} - ], - } - ], - }, - ) - (candidate / "registry-relay.digest").write_text( - self.relay + "\n", encoding="utf-8" - ) - (candidate / "registry-notary.digest").write_text( - self.notary + "\n", encoding="utf-8" - ) - capsule = { - "release_tag": self.tag, - "version": version, - "repository": self.module.CAPSULE_REPOSITORY, - "source": { - "source_tag": self.tag, - "source_ref": "4" * 40, - "source_commit": self.commit, - "lineage": { - "tag_matches_source_tag": True, - "head_matches_tag_target": True, - "source_ref_ancestor_or_equal": True, - "default_branch_reachable": True, - }, - }, - "binaries": [ - { - "name": binary_name, - "sha256": hashlib.sha256( - (candidate / binary_name).read_bytes() - ).hexdigest(), - } - ], - "release_files": [ - { - "name": lock_name, - "kind": "registryctl-release-image-lock", - "sha256": lock_sha, - "sbom": { - "asset_name": lock_sbom_name, - "subject": lock_name, - "format": "spdx-json", - "sha256": hashlib.sha256( - (candidate / lock_sbom_name).read_bytes() - ).hexdigest(), - }, - } - ], - "images": [ - { - "name": "registry-relay", - "role": "released-product-image", - "digest_ref": self.relay, - }, - { - "name": "registry-notary", - "role": "released-product-image", - "digest_ref": self.notary, - }, - *( - [ - { - "name": "postgresql", - "role": "supporting-runtime-image", - "digest_ref": images["postgresql"], - } - ] - if ( - schema_version == image_lock.SCHEMA_V2 - and "postgresql" in images - ) - else [] - ), - ], - } - self.write_json(candidate / capsule_name, capsule) - binary_sha = hashlib.sha256((candidate / binary_name).read_bytes()).hexdigest() - (candidate / "SHA256SUMS").write_text( - f"{binary_sha} {binary_name}\n{lock_sha} {lock_name}\n", - encoding="utf-8", - ) - for name in self.module.signed_subject_names(self.tag): - self.assertTrue((candidate / name).exists()) - (candidate / f"{name}.sig").write_text( - "fixture signature\n", encoding="utf-8" - ) - (candidate / f"{name}.pem").write_text( - "fixture certificate\n", encoding="utf-8" - ) - ( - candidate / f"registry-stack-{self.tag}-release-provenance.intoto.jsonl" - ).write_text("fixture provenance\n", encoding="utf-8") - self.assertEqual( - self.module.required_asset_names(self.tag), - {path.name for path in candidate.iterdir()}, - ) - return candidate - - def binary_result(self, *_args, **_kwargs): - return subprocess.CompletedProcess( - [], - 0, - f"registryctl {self.tag.removeprefix('v')}\n", - "", - ) - - def candidate_metadata(self, candidate: Path) -> dict[str, str]: - return self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=lambda _directory, _tag: None, - binary_runner=self.binary_result, - ) - - def make_canary_file(self) -> Path: - path = self.root / "canaries" - path.write_text("registry-secret-canary-72\n", encoding="utf-8") - path.chmod(0o600) - return path - - def make_result(self, profile_id: str, candidate: Path) -> dict[str, object]: - profile = self.module.load_profile(profile_id) - operation = next( - item for item in profile["source"]["operations"] if item["role"] == "data" - ) - hash_value = "sha256:" + "a" * 64 - cases = [] - for expected in profile["cases"]: - not_applicable = expected["expected_source_data_access"] == "not_applicable" - cases.append( - { - "case_id": expected["id"], - "outcome": "not_applicable" if not_applicable else "passed", - "started_at": "2026-07-19T01:00:00Z", - "completed_at": "2026-07-19T01:00:01Z", - "duration_ms": 1000, - "result_code": expected["expected_result_code"], - "source_data_access": expected["expected_source_data_access"], - "source_data_access_evidence_sha256": hash_value, - "audit_correlation_sha256": hash_value, - "evidence_sha256": hash_value, - } - ) - candidate_metadata = self.candidate_metadata(candidate) - return { - "schema_version": self.module.RESULT_SCHEMA, - "record_kind": "candidate_evidence", - "run_id": "candidate-1", - "profile_id": profile_id, - "support_status": self.module.SUPPORT_STATUS, - "status": "passed", - "started_at": "2026-07-19T01:00:00Z", - "completed_at": "2026-07-19T01:03:00Z", - "release": { - **candidate_metadata, - "candidate_assets_verified": True, - "authenticity_verified": True, - }, - "source": { - "product": profile["source"]["product"], - "baseline": profile["source"]["baseline"], - "operation_id": operation["id"], - "method": operation["method"], - "path": operation["path"], - "owner_attestation_sha256": hash_value, - }, - "project": { - "starter": profile["starter"], - "starter_content_digest": hash_value, - "authored_inputs_sha256": hash_value, - "build_review_sha256": hash_value, - "relay_closure_sha256": hash_value, - "notary_closure_sha256": hash_value, - "generated_files_unchanged": True, - }, - "cases": cases, - "redaction": { - "passed": True, - "scanned_artifacts": 1, - "scanned_bytes": 65536, - "seeded_canaries": 1, - "forbidden_values_found": 0, - "restricted_raw_evidence_bytes": 4096, - "raw_evidence_retained_restricted": True, - "report_sha256": hash_value, - }, - "teardown": { - "attempted": True, - "status": "completed", - "started_at": "2026-07-19T01:01:59Z", - "duration_ms": 1000, - "completed_at": "2026-07-19T01:02:00Z", - "evidence_sha256": hash_value, - }, - "limitations": [ - "unofficial-integration-profile", - "single-pinned-product-version", - "single-reviewed-read-operation", - "non-production-instance", - "not-product-certification", - "not-general-country-system-conformance", - ], - } - - def write_result(self, result: dict[str, object]) -> Path: - path = self.root / "public-result.json" - self.write_json(path, result) - return path - - def test_checked_in_packet_is_closed_and_valid(self) -> None: - self.module.validate_packet() - - def test_pilot_report_template_preserves_the_public_contract(self) -> None: - readme = (self.module.CONFIG_DIR / "README.md").read_text(encoding="utf-8") - template = ( - self.module.CONFIG_DIR / "pilot-report.template.md" - ).read_text(encoding="utf-8") - normalized_template = " ".join(template.split()) - self.assertIn("(pilot-report.template.md)", readme) - self.assertIn( - "Do not include credentials, network origins, operator or source " - "identifiers, record identifiers, raw audits, private evidence, or " - "links to restricted evidence.", - normalized_template, - ) - self.assertNotIn("Solmara", template) - self.assertIn( - "python3 release/scripts/integration-e2-runner.py validate", - normalized_template, - ) - self.assertNotIn("schema-valid public result", normalized_template) - self.assertIn( - "Issue closure still requires a frozen published candidate, an " - "independent operator, an owner-approved source, and a confirmed " - "maintainer comparison of the public hashes and flags with the " - "generated project, source audit records, redaction report, and " - "teardown evidence.", - normalized_template, - ) - self.assertIn( - "It cannot close unless the maintainer comparison above is confirmed.", - normalized_template, - ) - for text in ( - "Sanitized run result:", - "Plans, dry runs", - "One completed pilot is not proof of broad production readiness.", - "Frozen Registry Stack candidate:", - "Independent operator:", - "Owner-approved non-production source:", - "Maintainer comparison of public hashes and flags with restricted " - "evidence:", - "### Blocking findings", - "### Accepted limitations and narrowed support", - "Operator handoff and independence", - "Install or deployment", - "Configuration and environment binding", - "Diagnostics and ordinary source failures", - "Upgrade or rollback", - "Restart, teardown, and other operations", - "Security boundaries and redaction", - "Documentation and operator journey", - ): - self.assertIn(text, template) - - def test_nested_result_objects_must_remain_closed(self) -> None: - schema = self.module.load_json(self.module.SCHEMA_PATH) - schema["$defs"]["case"]["additionalProperties"] = True - with self.assertRaisesRegex(self.module.RunnerError, "open object schema"): - self.module.assert_closed_schema(schema) - - def test_profile_rejects_a_drifted_upstream_pin(self) -> None: - profile = self.module.load_profile("opencrvs-dci-v1.9") - profile["source"]["baseline"][0]["commit"] = "0" * 40 - with self.assertRaisesRegex( - self.module.RunnerError, "exact reviewed upstream baseline" - ): - self.module.validate_profile(profile, "tampered profile") - - def test_dry_run_is_explicitly_not_evidence_and_hides_values(self) -> None: - profile = self.module.load_profile("opencrvs-dci-v1.9") - plan = self.module.plan_document(profile) - self.assertFalse(plan["candidate_evidence"]) - self.assertEqual("planned_not_executed", plan["status"]) - self.assertEqual("approved_operator_wrapper", plan["executor"]) - self.assertIn("OPENCRVS_CLIENT_SECRET", plan["required_input_names"]) - self.assertEqual( - self.module.CASE_IDS, tuple(case["id"] for case in plan["cases"]) - ) - self.assertIn( - "/registry/sync/search", - [operation["path"] for operation in plan["source_operations"]], - ) - serialized = json.dumps(plan) - self.assertNotIn("secret value", serialized) - self.assertTrue( - any("compatibility probe" in item for item in plan["prerequisites"]) - ) - - def test_dhis2_plan_includes_reviewed_child_health_metadata(self) -> None: - profile = self.module.load_profile("dhis2-tracker-2.41.9") - plan = self.module.plan_document(profile) - required = set(plan["required_input_names"]) - self.assertTrue( - { - "DHIS2_CHILD_PROGRAM_UID", - "DHIS2_CHILD_VISIT_STAGE_UID", - "DHIS2_BCG_BIRTH_STAGE_UID", - "DHIS2_OPV_BIRTH_STAGE_UID", - "DHIS2_MEASLES_STAGE_UID", - }.issubset(required) - ) - self.assertTrue( - { - "DHIS2_MATERNAL_PROGRAM_UID", - "DHIS2_TB_PROGRAM_UID", - "DHIS2_FIRST_NAME_ATTRIBUTE_UID", - "DHIS2_BIRTH_DATE_ATTRIBUTE_UID", - }.issubset(required) - ) - - def test_candidate_assets_cross_validate_all_release_bindings(self) -> None: - candidate = self.make_candidate() - metadata = self.candidate_metadata(candidate) - self.assertEqual(self.relay, metadata["relay_image"]) - self.assertEqual(self.commit, metadata["source_commit"]) - - def test_candidate_assets_accept_v2_with_reviewed_postgresql(self) -> None: - self.tag = "v0.14.0" - candidate = self.make_candidate() - - metadata = self.candidate_metadata(candidate) - - self.assertEqual(self.relay, metadata["relay_image"]) - self.assertEqual("0.14.0", metadata["version"]) - - def test_candidate_assets_reject_v2_without_reviewed_postgresql(self) -> None: - self.tag = "v0.14.0" - missing = self.make_candidate(include_postgresql=False) - with self.assertRaisesRegex(self.module.RunnerError, "must contain exactly"): - self.candidate_metadata(missing) - - drifted = self.make_candidate( - candidate_name="drifted-candidate", - postgresql_ref="docker.io/library/postgres@sha256:" + "9" * 64, - ) - with self.assertRaisesRegex( - self.module.RunnerError, - "reviewed release-tooling pin", - ): - self.candidate_metadata(drifted) - - def test_candidate_assets_reject_v2_capsule_missing_postgresql(self) -> None: - self.tag = "v0.14.0" - candidate = self.make_candidate() - capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" - capsule = json.loads(capsule_path.read_text(encoding="utf-8")) - capsule["images"] = [ - image for image in capsule["images"] if image["name"] != "postgresql" - ] - self.write_json(capsule_path, capsule) - - with self.assertRaisesRegex(self.module.RunnerError, "image-lock images"): - self.candidate_metadata(candidate) - - def test_candidate_assets_rejects_v2_capsule_postgresql_drift_or_role(self) -> None: - self.tag = "v0.14.0" - for mutation, message in ( - ( - lambda image: image.__setitem__( - "digest_ref", "docker.io/library/postgres@sha256:" + "9" * 64 - ), - "do not match the candidate image lock", - ), - ( - lambda image: image.__setitem__("role", "released-product-image"), - "supporting-runtime-image", - ), - ): - with self.subTest(message=message): - candidate = self.make_candidate(candidate_name=message) - capsule_path = ( - candidate / f"registry-stack-{self.tag}-release-capsule.json" - ) - capsule = json.loads(capsule_path.read_text(encoding="utf-8")) - postgresql = next( - image - for image in capsule["images"] - if image["name"] == "postgresql" - ) - mutation(postgresql) - self.write_json(capsule_path, capsule) - - with self.assertRaisesRegex(self.module.RunnerError, message): - self.candidate_metadata(candidate) - - def test_candidate_assets_rejects_extra_v2_capsule_image(self) -> None: - self.tag = "v0.14.0" - candidate = self.make_candidate() - capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" - capsule = json.loads(capsule_path.read_text(encoding="utf-8")) - capsule["images"].append( - { - "name": "unreviewed-image", - "digest_ref": "docker.io/example/unreviewed@sha256:" + "9" * 64, - } - ) - self.write_json(capsule_path, capsule) - - with self.assertRaisesRegex(self.module.RunnerError, "image-lock images"): - self.candidate_metadata(candidate) - - def test_candidate_assets_rejects_wrong_product_image_role(self) -> None: - self.tag = "v0.14.0" - for component in ("registry-relay", "registry-notary"): - with self.subTest(component=component): - candidate = self.make_candidate(candidate_name=component) - capsule_path = ( - candidate / f"registry-stack-{self.tag}-release-capsule.json" - ) - capsule = json.loads(capsule_path.read_text(encoding="utf-8")) - image = next( - item for item in capsule["images"] if item["name"] == component - ) - image["role"] = "supporting-runtime-image" - self.write_json(capsule_path, capsule) - - with self.assertRaisesRegex( - self.module.RunnerError, "released-product-image" - ): - self.candidate_metadata(candidate) - - def test_authenticity_precedes_candidate_binary_execution(self) -> None: - candidate = self.make_candidate() - events = [] - - def authenticate(directory, _tag): - self.assertNotEqual(candidate, directory) - events.append("authenticated") - - def execute(*_args, **_kwargs): - self.assertEqual(["authenticated"], events) - events.append("executed") - return self.binary_result() - - self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=authenticate, - binary_runner=execute, - ) - self.assertEqual(["authenticated", "executed"], events) - - def test_authenticity_failure_prevents_candidate_binary_execution(self) -> None: - candidate = self.make_candidate() - events = [] - snapshots = [] - - def reject_authenticity(directory, _tag): - snapshots.append(directory) - events.append("authenticity-rejected") - raise self.module.RunnerError("invalid signature fixture") - - def execute(*_args, **_kwargs): - events.append("executed") - return self.binary_result() - - with self.assertRaisesRegex(self.module.RunnerError, "invalid signature"): - self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=reject_authenticity, - binary_runner=execute, - ) - self.assertEqual(["authenticity-rejected"], events) - self.assertEqual(1, len(snapshots)) - self.assertFalse(snapshots[0].exists()) - - def test_subject_change_during_authenticity_prevents_binary_execution(self) -> None: - candidate = self.make_candidate() - events = [] - - def mutate_during_authenticity(directory, _tag): - binary = directory / f"registryctl-{self.tag}-linux-amd64" - binary.chmod(0o700) - binary.write_bytes(b"changed-after-passive-checks") - events.append("mutated") - - def execute(*_args, **_kwargs): - events.append("executed") - return self.binary_result() - - with self.assertRaisesRegex(self.module.RunnerError, "changed during"): - self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=mutate_during_authenticity, - binary_runner=execute, - ) - self.assertEqual(["mutated"], events) - - def test_original_binary_replacement_after_final_hash_never_executes(self) -> None: - candidate = self.make_candidate() - original_binary = candidate / f"registryctl-{self.tag}-linux-amd64" - replacement_marker = self.root / "replacement-executed" - snapshots = [] - - def authenticate(directory, _tag): - snapshots.append(directory) - self.assertEqual(0o500, stat.S_IMODE(directory.stat().st_mode)) - self.assertEqual(os.geteuid(), directory.stat().st_uid) - for asset in directory.iterdir(): - self.assertEqual(0, asset.stat().st_mode & 0o222) - - def replace_original_then_execute(command, **kwargs): - self.assertNotEqual(original_binary, Path(command[0])) - original_binary.write_text( - "#!/bin/sh\n" - f": > {shlex.quote(str(replacement_marker))}\n" - f"printf 'registryctl {self.tag.removeprefix('v')}\\n'\n", - encoding="utf-8", - ) - original_binary.chmod(0o700) - return subprocess.run(command, **kwargs) - - self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=authenticate, - binary_runner=replace_original_then_execute, - ) - self.assertFalse(replacement_marker.exists()) - self.assertEqual(1, len(snapshots)) - self.assertFalse(snapshots[0].exists()) - - def test_late_passive_binding_failure_prevents_binary_execution(self) -> None: - candidate = self.make_candidate() - capsule_path = candidate / f"registry-stack-{self.tag}-release-capsule.json" - capsule = json.loads(capsule_path.read_text(encoding="utf-8")) - capsule["images"][1]["digest_ref"] = ( - "ghcr.io/registrystack/registry-notary@sha256:" + "9" * 64 - ) - self.write_json(capsule_path, capsule) - events = [] - - def authenticate(_directory, _tag): - events.append("authenticated") - - def execute(*_args, **_kwargs): - events.append("executed") - return self.binary_result() - - with self.assertRaisesRegex(self.module.RunnerError, "capsule images"): - self.module.verify_candidate_assets( - candidate, - self.tag, - authenticate=authenticate, - binary_runner=execute, - ) - self.assertEqual([], events) - - def test_candidate_rejects_an_unexpected_asset(self) -> None: - candidate = self.make_candidate() - (candidate / "unreviewed-output.txt").write_text("no\n", encoding="utf-8") - with self.assertRaisesRegex(self.module.RunnerError, "asset set is not closed"): - self.candidate_metadata(candidate) - - def test_candidate_rejects_digest_not_bound_by_image_lock(self) -> None: - candidate = self.make_candidate() - (candidate / "registry-relay.digest").write_text( - "ghcr.io/registrystack/registry-relay@sha256:" + "9" * 64 + "\n", - encoding="utf-8", - ) - with self.assertRaisesRegex( - self.module.RunnerError, "do not match the image lock" - ): - self.candidate_metadata(candidate) - - def test_candidate_rejects_an_image_lock_sbom_for_the_wrong_subject(self) -> None: - candidate = self.make_candidate() - sbom = candidate / f"registryctl-{self.tag}-image-lock.json.spdx.json" - document = json.loads(sbom.read_text(encoding="utf-8")) - document["packages"][0]["checksums"][0]["checksumValue"] = "0" * 64 - self.write_json(sbom, document) - with self.assertRaisesRegex(self.module.RunnerError, "does not describe"): - self.candidate_metadata(candidate) - - def test_candidate_authenticity_cannot_silently_skip_missing_tools(self) -> None: - candidate = self.make_candidate() - with mock.patch.object(self.module.shutil, "which", return_value=None): - with self.assertRaisesRegex(self.module.RunnerError, "requires installed"): - self.module.verify_authenticity(candidate, self.tag) - - def test_candidate_authenticity_binds_every_subject_to_tagged_workflow( - self, - ) -> None: - candidate = self.make_candidate() - commands = [] - with mock.patch.object( - self.module.shutil, - "which", - side_effect=["/tools/cosign", "/tools/slsa-verifier"], - ): - self.module.verify_authenticity( - candidate, self.tag, command_runner=commands.append - ) - self.assertEqual(12, len(commands)) - cosign_commands = [ - command for command in commands if command[0].endswith("cosign") - ] - self.assertEqual(6, len(cosign_commands)) - identity = self.module.RELEASE_WORKFLOW.format(tag=self.tag) - self.assertTrue( - all( - command[command.index("--certificate-identity") + 1] == identity - for command in cosign_commands - ) - ) - - def test_cli_rejects_symlink_candidate_directory_before_normalization(self) -> None: - candidate = self.make_candidate() - candidate_link = self.root / "candidate-link" - candidate_link.symlink_to(candidate, target_is_directory=True) - stderr = io.StringIO() - with redirect_stderr(stderr): - status = self.module.main( - [ - "validate", - "--candidate-dir", - str(candidate_link), - "--tag", - self.tag, - ] - ) - self.assertEqual(1, status) - self.assertIn("non-symlink directory", stderr.getvalue()) - - def test_json_const_does_not_accept_integer_for_true(self) -> None: - with self.assertRaisesRegex(self.module.RunnerError, "must equal True"): - self.module.validate_against_schema(1, {"const": True}, {}) - - def test_json_boolean_enum_does_not_accept_integer(self) -> None: - with self.assertRaisesRegex(self.module.RunnerError, "closed allowed set"): - self.module.validate_against_schema(1, {"enum": [True, False]}, {}) - - def test_full_result_rejects_integer_for_boolean_attestation(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["project"]["generated_files_unchanged"] = 1 - with self.assertRaisesRegex(self.module.RunnerError, "must equal True"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_valid_opencrvs_result_passes(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - path = self.write_result(result) - validated = self.module.validate_result( - path, self.module.load_profile("opencrvs-dci-v1.9"), self.make_canary_file() - ) - self.assertEqual("passed", validated["status"]) - - def test_valid_dhis2_result_records_singleton_ambiguity_as_not_applicable( + def test_current_ci_and_gate_inventory_do_not_reference_retired_runner( self, ) -> None: - candidate = self.make_candidate() - result = self.make_result("dhis2-tracker-2.41.9", candidate) - path = self.write_result(result) - self.module.validate_result( - path, - self.module.load_profile("dhis2-tracker-2.41.9"), - self.make_canary_file(), - ) - - def test_pre_source_denial_cannot_pass_after_source_contact(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - invalid_selector = next( - case for case in result["cases"] if case["case_id"] == "invalid-selector" - ) - invalid_selector["source_data_access"] = "contacted_once" - with self.assertRaisesRegex( - self.module.RunnerError, "expected source-side access evidence" - ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_passed_case_requires_the_reviewed_safe_result_code(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["cases"][0]["result_code"] = "unexpected-result" - with self.assertRaisesRegex( - self.module.RunnerError, "reviewed safe result code" - ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_unknown_public_raw_evidence_field_is_rejected(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["raw_output"] = "must never be public" - with self.assertRaisesRegex(self.module.RunnerError, "unknown fields"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_seeded_canary_in_public_result_is_rejected_before_schema_validation( - self, - ) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["run_id"] = "registry-secret-canary-72" - with self.assertRaisesRegex( - self.module.RunnerError, "seeded restricted-value canary" - ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_canary_file_must_be_owner_only(self) -> None: - canaries = self.make_canary_file() - canaries.chmod(0o644) - with self.assertRaisesRegex(self.module.RunnerError, "group or other"): - self.module.read_canaries(canaries) - - def test_failed_run_is_accepted_as_honest_non_closing_evidence(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["status"] = "failed" - result["cases"][0]["outcome"] = "failed" - result["cases"][0]["source_data_access"] = "unknown" - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_passed_status_requires_successful_teardown(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["teardown"]["status"] = "failed" - with self.assertRaisesRegex(self.module.RunnerError, "status is inconsistent"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_case_duration_must_match_timestamps(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["cases"][0]["duration_ms"] = 999 - with self.assertRaisesRegex( - self.module.RunnerError, "duration_ms does not match" - ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_case_duration_supports_millisecond_timestamp_precision(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["cases"][0]["completed_at"] = "2026-07-19T01:00:00.250Z" - result["cases"][0]["duration_ms"] = 250 - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_case_timestamp_elapsed_time_must_respect_profile_bound(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["cases"][0]["completed_at"] = "2026-07-19T01:01:01Z" - result["cases"][0]["duration_ms"] = 61000 - with self.assertRaisesRegex(self.module.RunnerError, "profile case timeout"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_teardown_started_at_is_required_by_closed_schema(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - del result["teardown"]["started_at"] - with self.assertRaisesRegex(self.module.RunnerError, "started_at"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_teardown_duration_must_match_timestamps(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["teardown"]["duration_ms"] = 999 - with self.assertRaisesRegex(self.module.RunnerError, "teardown duration_ms"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_teardown_timestamp_elapsed_time_must_respect_profile_bound(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["teardown"]["completed_at"] = "2026-07-19T01:07:00Z" - result["teardown"]["duration_ms"] = 301000 - result["completed_at"] = "2026-07-19T01:08:00Z" - with self.assertRaisesRegex(self.module.RunnerError, "teardown exceeds"): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_teardown_cannot_start_before_cases_complete(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["teardown"]["started_at"] = "2026-07-19T01:00:00Z" - result["teardown"]["completed_at"] = "2026-07-19T01:00:01Z" - with self.assertRaisesRegex( - self.module.RunnerError, "before.*test cases complete" - ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) - - def test_public_result_must_retain_all_claim_limitations(self) -> None: - candidate = self.make_candidate() - result = self.make_result("opencrvs-dci-v1.9", candidate) - result["limitations"].remove("non-production-instance") - with self.assertRaisesRegex( - self.module.RunnerError, "every profile limitation" + for path in ( + ROOT / ".github/workflows/ci.yml", + ROOT / "release/scripts/check-gates-inventory.py", ): - self.module.validate_result( - self.write_result(result), - self.module.load_profile("opencrvs-dci-v1.9"), - self.make_canary_file(), - ) + with self.subTest(path=path): + text = path.read_text(encoding="utf-8") + self.assertNotIn("integration-e2", text) + self.assertNotIn("conformance/integrations", text) if __name__ == "__main__": - main() + unittest.main() diff --git a/release/scripts/test_openid_conformance_runner.py b/release/scripts/test_openid_conformance_runner.py index 1f5723f16..d35f99868 100755 --- a/release/scripts/test_openid_conformance_runner.py +++ b/release/scripts/test_openid_conformance_runner.py @@ -1,2030 +1,35 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 +"""Retirement contract for the Notary-only OpenID conformance wrapper.""" + from __future__ import annotations -import base64 -import hashlib -import importlib.util -import io -import json -import re -import shlex -import shutil -import socket -import ssl -import stat -import subprocess -import sys -import tempfile -import threading -import urllib.parse -import warnings -import zipfile -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import unittest from pathlib import Path -from unittest import TestCase, main -from unittest.mock import MagicMock, patch - - -SCRIPT_DIR = Path(__file__).resolve().parent -RUNNER_PATH = SCRIPT_DIR / "openid-conformance-runner.py" -NGINX_DOCKERFILE = SCRIPT_DIR.parent / "conformance" / "openid" / "nginx.Dockerfile" -TEST_RSA_N = int( - "db2a64f46dd9923ff3b52759ded5af43f36ac62ed1c71e889156ea8359d894" - "cb80734c311c42dfac407feb6c2cb34c28e2906dd4c5af7b5ee2146e60eb5" - "77f786c5ab5fbbe05171cd5214cb4cc7ac9eed3706c74d376beb4cb1404692" - "95ace0d72a1fb8024f9978132e3943142314b8e2ed1f2af28df57f1e48955" - "5ff59056637bafe88fe5e77074d61f2e9a7e89b93d765e2ca59b93e1b47c" - "6662b2dbb7faf37610102e01fc3560555799785afe3963f63939e8cd2654a" - "2587fd4828b54724eb7714830dba1e784cd0729e2d90cc8c54da61771022e" - "4af010de8aa45555c9eca47f6b757c358bb5b0e5a0bffe0d26aa17ff1e0f" - "571c9ade855064cb9d1bfb3f", - 16, -) -TEST_RSA_D = int( - "02cffb75ab87343a3fdd5e40e7fc2400a23a078b08441edf2fc646c222c005" - "c0cac82ffd1d58ba581287d1b494aa445aedf55e837179fc024eb2666c35f8" - "ec78d6231fdcb82686926725c33f3ab484acdce7bf6c8c5e24ba5b34c98db" - "3eb2763c2c9d35964a01352a41d89844c4e27a30e74c141802bc58c241ba3" - "0dd52fe1fbe4c0ca9876497f1bf7d623c9dc0f58fd6089b45746c6799b9da" - "cb42b01fe5b964127d92e7c1d20bb8fee227a835e5b524d26debd01f5139a" - "a8ce3cfa571b5284bf8332df8c94e65ba1173c33113d47f40a653d408f427" - "a70573a7e77dbfd31f5c0d1caf1e53acc5cbae17e2de2b36ba7a7382987e7" - "3277ad8105aeb575a680c9", - 16, -) -sys.path.insert(0, str(SCRIPT_DIR)) - - -def load_runner(): - spec = importlib.util.spec_from_file_location( - "openid_conformance_runner", RUNNER_PATH - ) - if not spec or not spec.loader: - raise RuntimeError(f"could not load {RUNNER_PATH}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def nginx_certificate_command(certificate: Path, private_key: Path) -> list[str]: - dockerfile = NGINX_DOCKERFILE.read_text(encoding="utf-8") - recipe = dockerfile.split("RUN ", 1)[1].split("\nCOPY ", 1)[0] - command = shlex.split(recipe.replace("\\\n", " ")) - command[command.index("-out") + 1] = str(certificate) - command[command.index("-keyout") + 1] = str(private_key) - return command -class EmptyHttpsHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - self.send_response(204) - self.end_headers() +ROOT = Path(__file__).resolve().parents[2] - def log_message(self, _format: str, *_args) -> None: - return - -class OpenIdConformanceRunnerTest(TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.runner = load_runner() - cls.plan_map = cls.runner.load_plan_map() - - def offer_uri( - self, issuer: str = "https://issuer.example.test" - ) -> tuple[str, str]: - grant = "urn:ietf:params:oauth:grant-type:pre-authorized_code" - inline = json.dumps( - { - "credential_issuer": issuer, - "credential_configuration_ids": ["person_is_alive_sd_jwt"], - "grants": {grant: {"pre-authorized_code": "owner-only-code"}}, - } - ) - return inline, "openid-credential-offer://?" + urllib.parse.urlencode( - {"credential_offer": inline} +class RetiredOpenIdConformanceRunnerTest(unittest.TestCase): + def test_notary_oid4vci_wrapper_is_absent(self) -> None: + self.assertFalse( + (ROOT / "release/scripts/openid-conformance-runner.py").exists() ) - - def suite_jwks(self) -> dict[str, object]: - def encoded_integer(value: int) -> str: - raw = value.to_bytes((value.bit_length() + 7) // 8, "big") - return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") - - return { - "keys": [ - { - "alg": "RS256", - "e": encoded_integer(65_537), - "kid": "suite-test-key", - "kty": "RSA", - "n": encoded_integer(TEST_RSA_N), - "use": "sig", - } - ] + operational_assets = { + path.name + for path in (ROOT / "release/conformance/openid").iterdir() + if path.name != "initial-report.md" } - - def sign_suite_export(self, content: bytes) -> str: - encoded_size = (TEST_RSA_N.bit_length() + 7) // 8 - digest_info = ( - self.runner.SHA256_DIGEST_INFO_PREFIX + hashlib.sha256(content).digest() - ) - padding_size = encoded_size - len(digest_info) - 3 - encoded = b"\x00\x01" + b"\xff" * padding_size + b"\x00" + digest_info - signature = pow(int.from_bytes(encoded), TEST_RSA_D, TEST_RSA_N).to_bytes( - encoded_size, "big" - ) - return base64.urlsafe_b64encode(signature).decode("ascii") - - def suite_jwks_sha256(self) -> str: - return self.runner.canonical_sha256(self.suite_jwks()) - - def write_private_jwks(self, directory: Path) -> Path: - path = directory / "suite-jwks.json" - path.write_text(json.dumps(self.suite_jwks()), encoding="utf-8") - path.chmod(0o600) - return path - - def candidate(self) -> dict[str, object]: - return { - "release_id": "beta-17", - "version": "1.0.0", - "source_repo": "registrystack/registry-stack", - "source_ref": "a" * 40, - "source_tag": "v1.0.0", - "tag_target": "b" * 40, - "manifest_sha256": f"sha256:{'c' * 64}", - "image_lock_sha256": f"sha256:{'d' * 64}", - "release_capsule_sha256": f"sha256:{'e' * 64}", - "notary_image": ( - "ghcr.io/registrystack/registry-notary@sha256:" + "f" * 64 - ), - "relay_image": ("ghcr.io/registrystack/registry-relay@sha256:" + "1" * 64), - "topology": "release-owned", - "solmara_source_ref": None, - } - - def suite_export( - self, - *, - result: str = "FAILED", - terminal_result: str | None = None, - secret: str = "RS_OPENID_SECRET_CANARY_6d5a1f0bc2", - transaction_code: str | None = None, - warning_source: str = "CredentialMetadataWarning", - test_info_version: str = "5.2.0", - exported_version: str = "5.2.0", - exported_from: str = "https://localhost.emobix.co.uk:8443", - issuer_url: str = "https://issuer.example.test", - ) -> tuple[str, bytes]: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - test_id = "Ab3dE5fG7hI9jK1" - terminal_result = terminal_result or result - payload = { - "testInfo": { - "_id": test_id, - "testId": test_id, - "testName": module, - "variant": scenario["variants"], - "started": "2026-07-25T04:00:00.123456Z", - "config": { - "alias": "registry-stack-notary-oid4vci-issuer", - "description": ( - "Registry Stack Notary OID4VCI issuer conformance slice " - f"[{module}]" - ), - "vci": { - "credential_issuer_url": issuer_url, - "authorization_server": "https://issuer.example.test", - "credential_configuration_id": "person_is_alive_sd_jwt", - "credential_proof_type_hint": "jwt", - "static_tx_code": transaction_code or secret, - }, - "client": {"client_id": "client-a"}, - "client2": {"client_id": "client-b"}, - }, - "description": "private suite description", - "alias": "registry-stack-notary-oid4vci-issuer", - "owner": {"sub": "private-owner"}, - "planId": "private-plan-id", - "status": "FINISHED", - "version": test_info_version, - "summary": "private suite summary", - "publish": "private", - "result": result, - }, - "exportedFrom": exported_from, - "exportedBy": {"sub": "private-owner"}, - "exportedVersion": exported_version, - "exportedAt": "Jul 25, 2026, 4:01:00 AM", - "results": [ - { - "src": "MetadataCondition", - "result": "SUCCESS", - "testId": test_id, - "time": 1_784_952_001_000, - }, - { - "src": "MetadataContext", - "result": "INFO", - "testId": test_id, - "time": 1_784_952_001_500, - }, - { - "src": "CredentialMetadataFailure", - "result": "FAILURE", - "testId": test_id, - "msg": "private failure message", - "access_token": secret, - "time": 1_784_952_002_000, - }, - { - "src": warning_source, - "result": "WARNING", - "testId": test_id, - "proof": f"{secret}.proof.payload", - "civil_id": secret, - "time": 1_784_952_003_000, - }, - { - "src": "CredentialMetadataReview", - "result": "REVIEW", - "testId": test_id, - "msg": "private review message", - "time": 1_784_952_004_000, - }, - { - "src": module, - "result": "FINISHED", - "testId": test_id, - "testmodule_result": terminal_result, - "time": 1_784_952_060_000, - }, - ], - } - encoded_payload = json.dumps(payload).encode("utf-8") - json_name = f"test-log-{module}-{test_id}.json" - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.writestr(json_name, encoded_payload) - archive.writestr( - json_name.removesuffix(".json") + ".sig", - self.sign_suite_export(encoded_payload), - ) - return json_name, buffer.getvalue() - - def write_private_export(self, directory: Path, content: bytes) -> Path: - path = directory / "suite-export.zip" - path.write_bytes(content) - path.chmod(0o600) - return path - - def test_plan_map_has_unique_scenarios_and_pinned_suite_ref(self) -> None: - scenarios = self.plan_map["scenarios"] - self.assertEqual(len(scenarios), len({scenario["id"] for scenario in scenarios})) - suite = self.plan_map["suite"] - self.assertEqual(40, len(suite["ref"])) - self.assertEqual( - "https://gitlab.com/openid/conformance-suite.git", suite["repo"] - ) - self.assertEqual( - "registry.release.openid_conformance_plan_map.v1", - self.plan_map["schema_version"], - ) - - def test_release_defaults_do_not_reference_retired_lab_paths(self) -> None: - self.assertEqual( - self.runner.REPO_ROOT / "release" / "conformance" / "openid", - self.runner.CONFIG_DIR, - ) + self.assertEqual(set(), operational_assets) self.assertTrue( - self.runner.DEFAULT_OUTPUT_ROOT.is_relative_to( - self.runner.REPO_ROOT / "target" - ) - ) - serialized = json.dumps(self.plan_map) - self.assertNotIn("REGISTRY_LAB_", serialized) - self.assertNotIn("blocked-by-lab", serialized) - - def test_list_does_not_require_candidate_yaml_dependencies(self) -> None: - result = subprocess.run( - [sys.executable, "-S", str(RUNNER_PATH), "list"], - cwd=self.runner.REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("notary-oid4vci-issuer-metadata", result.stdout) - - def test_readme_documents_the_required_suite_jwks_trust_flow(self) -> None: - readme = ( - self.runner.CONFIG_DIR / "README.md" - ).read_text(encoding="utf-8") - self.assertIn("export-suite-jwks", readme) - self.assertIn("--suite-ca-certificate", readme) - self.assertIn("--suite-jwks", readme) - self.assertIn("/jwks", readme) - self.assertIn("canonical", readme) - self.assertIn("signature", readme) - self.assertIn("--output-dir", readme) - self.assertIn("--export-dir", readme) - self.assertIn("operator-attested", readme) - self.assertIn("no separate\nUI download step", readme) - - def test_evidence_schema_matches_the_builder_contract(self) -> None: - schema = json.loads( - self.runner.EVIDENCE_SCHEMA_PATH.read_text(encoding="utf-8") - ) - properties = schema["properties"] - self.assertEqual( - self.runner.EVIDENCE_SCHEMA_VERSION, - properties["schema_version"]["const"], - ) - self.assertEqual( - self.runner.EVIDENCE_CLASSIFICATION, - properties["classification"]["const"], - ) - self.assertEqual( - self.runner.SUITE_RESULTS, - set(schema["$defs"]["run"]["properties"]["result"]["enum"]), - ) - self.assertEqual( - [ - {"scenario_id": scenario_id, "status": status} - for scenario_id, status in self.runner.EVIDENCE_UNSUPPORTED_SCENARIOS - ], - properties["unsupported_scenarios"]["const"], - ) - scenario = self.runner.find_scenario( - self.plan_map, self.runner.EVIDENCE_SCENARIO_ID - ) - scenario_schema = schema["$defs"]["scenario"]["properties"] - self.assertEqual(scenario["id"], scenario_schema["scenario_id"]["const"]) - self.assertEqual( - scenario["suite_plan"], scenario_schema["expected_plan"]["const"] - ) - self.assertEqual( - scenario["suite_modules"], scenario_schema["modules"]["const"] - ) - self.assertEqual( - scenario["variants"], - { - name: definition["const"] - for name, definition in scenario_schema["variants"][ - "properties" - ].items() - }, - ) - self.assertEqual( - self.plan_map["suite"]["repo"], - schema["$defs"]["suite"]["properties"]["repository"]["const"], - ) - suite_properties = schema["$defs"]["suite"]["properties"] - self.assertEqual( - self.plan_map["suite"]["release_tag"], - suite_properties["release_tag"]["const"], - ) - self.assertEqual( - self.plan_map["suite"]["release_tag"].removeprefix("release-v"), - suite_properties["reported_version"]["const"], - ) - self.assertEqual( - self.plan_map["suite"]["base_url"], - suite_properties["exported_from"]["const"], - ) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - schema["$defs"]["deployment"]["properties"][ - "candidate_association" - ]["const"], - ) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - scenario_schema["plan_association"]["const"], - ) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - schema["$defs"]["suite"]["properties"]["commit_association"]["const"], - ) - def test_notary_mapping_is_candidate_only_and_matches_the_1_0_profile(self) -> None: - metadata = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - full = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-full" - ) - - self.assertEqual("candidate-only", metadata["status"]) - self.assertEqual( - "pre_authorization_code", metadata["variants"]["vci_grant_type"] - ) - self.assertIn("registry-backed", metadata["surface"]) - metadata_notes = " ".join(metadata["notes"]) - self.assertIn("does not support or claim DPoP", metadata_notes) - self.assertIn("frozen candidate artifact", metadata_notes) - - self.assertEqual("blocked-by-suite-profile", full["status"]) - self.assertEqual( - "pre_authorization_code", full["variants"]["vci_grant_type"] - ) - full_contract = " ".join(full["requires"] + full["notes"]) - self.assertIn("pre-authorized offer", full_contract) - self.assertIn("is not a wallet grant", full_contract) - self.assertIn("adapter now closes that transport gap", full_contract) - self.assertNotIn( - "blocked by the suite callback adapter", json.dumps(self.plan_map) - ) - self.assertNotIn( - "policy decision on whether the first full run targets", - full_contract, - ) - verifier = next( - item - for item in self.plan_map["non_oidf_surfaces"] - if item["surface"] == "Registry Notary Rust SD-JWT verifier" - ) - self.assertIn("not an OID4VP endpoint", verifier["reason"]) - - def test_promote_evidence_emits_only_candidate_bound_allowlisted_summary( - self, - ) -> None: - secret = "RS_OPENID_SECRET_CANARY_6d5a1f0bc2" - _, raw_export = self.suite_export(secret=secret) - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - suite_export = self.write_private_export(root, raw_export) - suite_jwks = self.write_private_jwks(root) - output = root / "review" / "summary.json" - manifest = ( - self.runner.REPO_ROOT / "release" / "manifests" / "candidate.yaml" - ) - image_lock = root / "registryctl-v1.0.0-image-lock.json" - args = self.runner.parse_args( - [ - "promote-evidence", - "--suite-export", - str(suite_export), - "--suite-jwks", - str(suite_jwks), - "--release-manifest", - str(manifest), - "--image-lock", - str(image_lock), - "--output", - str(output), - ] - ) - candidate = self.candidate() - with patch.object( - self.runner, "load_authenticated_candidate", return_value=candidate - ) as load_candidate: - with patch("builtins.print"): - self.assertEqual(0, self.runner.cmd_promote_evidence(args)) - - load_candidate.assert_called_once_with(manifest, image_lock) - summary_bytes = output.read_bytes() - summary = json.loads(summary_bytes) - self.assertEqual(0o600, output.stat().st_mode & 0o777) - self.assertEqual("FAILED", summary["run"]["result"]) - self.assertEqual("FINISHED", summary["run"]["terminal_status"]) - self.assertEqual( - "2026-07-25T04:00:00.123456Z", summary["run"]["started_at"] - ) - self.assertEqual("2026-07-25T04:01:00.000Z", summary["run"]["completed_at"]) - self.assertEqual( - { - "info": 1, - "success": 1, - "review": 1, - "warning": 1, - "failure": 1, - }, - summary["run"]["conditions"]["counts"], - ) - self.assertEqual( - candidate["tag_target"], summary["candidate"]["tag_target"] - ) - self.assertEqual( - candidate["notary_image"], summary["candidate"]["notary_image"] - ) - self.assertTrue( - summary["candidate"]["release_assets_authenticity_verified"] - ) - self.assertEqual( - "https://issuer.example.test", - summary["deployment"]["issuer_url"], - ) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - summary["deployment"]["candidate_association"], - ) - self.assertEqual(self.plan_map["suite"]["ref"], summary["suite"]["commit"]) - self.assertEqual( - self.plan_map["suite"]["release_tag"], - summary["suite"]["release_tag"], - ) - self.assertEqual("5.2.0", summary["suite"]["reported_version"]) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - summary["suite"]["commit_association"], - ) - self.assertEqual( - self.suite_jwks_sha256(), summary["suite"]["jwks_sha256"] - ) - self.assertTrue(summary["suite"]["export_signature_verified"]) - self.assertEqual( - "oid4vci-1_0-issuer-test-plan", - summary["scenario"]["expected_plan"], - ) - self.assertEqual( - self.runner.EVIDENCE_ASSOCIATION, - summary["scenario"]["plan_association"], - ) - self.assertEqual( - ["oid4vci-1_0-issuer-metadata-test"], - summary["scenario"]["modules"], - ) - self.assertEqual( - [ - { - "scenario_id": "notary-oid4vci-issuer-full", - "status": "blocked-by-suite-profile", - } - ], - summary["unsupported_scenarios"], - ) - schema = json.loads( - self.runner.EVIDENCE_SCHEMA_PATH.read_text(encoding="utf-8") - ) - self.assertFalse(schema["additionalProperties"]) - self.assertEqual(set(schema["required"]), set(summary)) - for definition, field in ( - ("candidate", "candidate"), - ("deployment", "deployment"), - ("suite", "suite"), - ("scenario", "scenario"), - ("configuration", "configuration"), - ("run", "run"), - ): - self.assertEqual( - set(schema["$defs"][definition]["required"]), - set(summary[field]), - ) - self.assertEqual( - set(schema["$defs"]["conditions"]["required"]), - set(summary["run"]["conditions"]), - ) - self.assertEqual( - set( - schema["$defs"]["conditions"]["properties"]["counts"]["required"] - ), - set(summary["run"]["conditions"]["counts"]), - ) - self.assertEqual( - set( - schema["$defs"]["scenario"]["properties"]["variants"][ - "required" - ] - ), - set(summary["scenario"]["variants"]), - ) - self.assertNotIn(secret.encode(), summary_bytes) - self.assertNotIn(b"private failure message", summary_bytes) - self.assertNotIn(b"private review message", summary_bytes) - self.assertNotIn(b"Ab3dE5fG7hI9jK1", summary_bytes) - self.assertNotIn(b"private-plan-id", summary_bytes) - self.assertFalse(summary["raw_suite_export_included"]) - self.assertFalse(summary["contains_sensitive_material"]) - - def test_promote_evidence_reports_excessive_nesting_as_runner_error(self) -> None: - _, raw_export = self.suite_export() - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - args = self.runner.parse_args( - [ - "promote-evidence", - "--suite-export", - str(self.write_private_export(root, raw_export)), - "--suite-jwks", - str(self.write_private_jwks(root)), - "--release-manifest", - str(root / "candidate.yaml"), - "--image-lock", - str(root / "image-lock.json"), - "--output", - str(root / "summary.json"), - ] - ) - with patch.object( - self.runner, - "load_authenticated_candidate", - return_value=self.candidate(), - ): - with patch.object( - self.runner, - "collect_sensitive_raw_values", - side_effect=RecursionError, - ): - with self.assertRaisesRegex( - self.runner.RunnerError, "too deeply nested" - ): - self.runner.cmd_promote_evidence(args) - - def test_promote_evidence_rejects_schema_invalid_generated_summary( - self, - ) -> None: - _, raw_export = self.suite_export() - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - output = root / "summary.json" - args = self.runner.parse_args( - [ - "promote-evidence", - "--suite-export", - str(self.write_private_export(root, raw_export)), - "--suite-jwks", - str(self.write_private_jwks(root)), - "--release-manifest", - str(root / "candidate.yaml"), - "--image-lock", - str(root / "image-lock.json"), - "--output", - str(output), - ] - ) - build_summary = self.runner.build_evidence_summary - - def schema_invalid_summary(*arguments): - summary, sensitive = build_summary(*arguments) - summary["run"]["conditions"]["counts"]["failure"] = -1 - return summary, sensitive - - with patch.object( - self.runner, - "load_authenticated_candidate", - return_value=self.candidate(), - ): - with patch.object( - self.runner, - "build_evidence_summary", - side_effect=schema_invalid_summary, - ): - with self.assertRaisesRegex( - self.runner.RunnerError, "does not match its schema" - ): - self.runner.cmd_promote_evidence(args) - self.assertFalse(output.exists()) - - def test_promote_evidence_preserves_each_terminal_suite_result(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - for suite_result in sorted(self.runner.SUITE_RESULTS): - with self.subTest(suite_result=suite_result): - _, raw_export = self.suite_export(result=suite_result) - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - summary, _ = self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - self.assertEqual(suite_result, summary["run"]["result"]) - - def test_promote_evidence_rejects_mismatched_suite_provenance(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - cases = { - "testInfo.version": {"test_info_version": "5.2.1"}, - "exportedVersion": {"exported_version": "5.2.1"}, - "exportedFrom": {"exported_from": "https://other.example.test"}, - } - for label, overrides in cases.items(): - with self.subTest(label=label): - _, raw_export = self.suite_export(**overrides) - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - with self.assertRaisesRegex( - self.runner.RunnerError, "version|identity" - ): - self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - def test_issuer_url_runtime_and_schema_reject_the_same_unsafe_shapes( - self, - ) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - schema = json.loads( - self.runner.EVIDENCE_SCHEMA_PATH.read_text(encoding="utf-8") - ) - _, valid_raw_export = self.suite_export() - with tempfile.TemporaryDirectory() as tmp: - exported = self.runner.load_suite_export( - self.write_private_export(Path(tmp), valid_raw_export), - scenario["suite_modules"][0], - self.suite_jwks(), - ) - valid_summary, _ = self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - for unsafe_url in ( - "https://user:secret@issuer.example.test", - "https://issuer.example.test/path\ninjected", - "https://issuer.example.test/path\\confused", - "https://issuer.example.test:65536", - "https://[:::]/path", - "https://issuer.example.test/" + ("a" * 2048), - ): - with self.subTest(unsafe_url=repr(unsafe_url)): - _, raw_export = self.suite_export(issuer_url=unsafe_url) - with tempfile.TemporaryDirectory() as tmp: - exported = self.runner.load_suite_export( - self.write_private_export(Path(tmp), raw_export), - scenario["suite_modules"][0], - self.suite_jwks(), - ) - with self.assertRaisesRegex( - self.runner.RunnerError, "issuer URL is invalid" - ): - self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - invalid_summary = json.loads(json.dumps(valid_summary)) - invalid_summary["deployment"]["issuer_url"] = unsafe_url - with self.assertRaises(self.runner.SchemaValidationError): - self.runner.validate_against_schema( - invalid_summary, - schema, - schema, - "evidence summary", - ) - - ipv6_summary = json.loads(json.dumps(valid_summary)) - ipv6_summary["deployment"]["issuer_url"] = ( - "https://[2001:db8::1]:443/issuer" - ) - self.runner.validate_against_schema( - ipv6_summary, - schema, - schema, - "evidence summary", - ) - - def test_promote_evidence_rejects_changed_terminal_result(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - _, raw_export = self.suite_export(result="FAILED", terminal_result="PASSED") - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - with self.assertRaisesRegex( - self.runner.RunnerError, "matching terminal module record" - ): - self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - def test_suite_export_rejects_invalid_signature(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - json_name, raw_export = self.suite_export() - signature_name = json_name.removesuffix(".json") + ".sig" - with zipfile.ZipFile(io.BytesIO(raw_export)) as source: - payload = source.read(json_name) - signature = bytearray(source.read(signature_name)) - signature[0] = ord("A") if signature[0] != ord("A") else ord("B") - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.writestr(json_name, payload) - archive.writestr(signature_name, signature) - - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), buffer.getvalue()) - with self.assertRaisesRegex( - self.runner.RunnerError, "exactly one trusted suite key" - ): - self.runner.load_suite_export(path, module, self.suite_jwks()) - - def test_suite_jwks_rejects_invalid_rsa_key(self) -> None: - jwks = self.suite_jwks() - jwks["keys"][0]["e"] = "Ag" - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "suite-jwks.json" - path.write_text(json.dumps(jwks), encoding="utf-8") - path.chmod(0o600) - with self.assertRaisesRegex( - self.runner.RunnerError, "invalid RSA signing key" - ): - self.runner.load_suite_jwks(path) - - def test_suite_export_rejects_nonmatching_valid_shape_key(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - _, raw_export = self.suite_export() - jwks = self.suite_jwks() - nonmatching_modulus = TEST_RSA_N - 2 - raw_modulus = nonmatching_modulus.to_bytes( - (nonmatching_modulus.bit_length() + 7) // 8, "big" - ) - jwks["keys"][0]["n"] = ( - base64.urlsafe_b64encode(raw_modulus).decode("ascii").rstrip("=") - ) - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - export_path = self.write_private_export(root, raw_export) - jwks_path = root / "nonmatching-jwks.json" - jwks_path.write_text(json.dumps(jwks), encoding="utf-8") - jwks_path.chmod(0o600) - validated_jwks, _ = self.runner.load_suite_jwks(jwks_path) - with self.assertRaisesRegex( - self.runner.RunnerError, "exactly one trusted suite key" - ): - self.runner.load_suite_export( - export_path, module, validated_jwks - ) - - def test_suite_export_rejects_multiple_matching_jwks_keys(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - _, raw_export = self.suite_export() - jwks = self.suite_jwks() - duplicate = dict(jwks["keys"][0]) - duplicate["kid"] = "duplicate-suite-test-key" - jwks["keys"].append(duplicate) - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), raw_export) - with self.assertRaisesRegex( - self.runner.RunnerError, "exactly one trusted suite key" - ): - self.runner.load_suite_export(path, module, jwks) - - def test_suite_export_binds_run_identifiers_and_considered_logs(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - json_name, raw_export = self.suite_export() - signature_name = json_name.removesuffix(".json") + ".sig" - with zipfile.ZipFile(io.BytesIO(raw_export)) as source: - original = json.loads(source.read(json_name)) - - def repack(payload: dict[str, object]) -> bytes: - encoded = json.dumps(payload).encode("utf-8") - buffer = io.BytesIO() - with zipfile.ZipFile( - buffer, "w", compression=zipfile.ZIP_DEFLATED - ) as archive: - archive.writestr(json_name, encoded) - archive.writestr(signature_name, self.sign_suite_export(encoded)) - return buffer.getvalue() - - changed_id = json.loads(json.dumps(original)) - changed_id["testInfo"]["_id"] = "Zm9xN8pL2rS4tV6" - long_plan_id = json.loads(json.dumps(original)) - long_plan_id["testInfo"]["planId"] = "p" * 129 - for label, payload in { - "mismatched test id": changed_id, - "oversized plan id": long_plan_id, - }.items(): - with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), repack(payload)) - with self.assertRaisesRegex( - self.runner.RunnerError, "run identifiers do not match" - ): - self.runner.load_suite_export(path, module, self.suite_jwks()) - - changed_log = json.loads(json.dumps(original)) - changed_log["results"][0]["testId"] = "Zm9xN8pL2rS4tV6" - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), repack(changed_log)) - exported = self.runner.load_suite_export( - path, module, self.suite_jwks() - ) - with self.assertRaisesRegex( - self.runner.RunnerError, "log entry does not match" - ): - self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - def test_suite_export_zip_rejects_unsafe_or_unexpected_entries(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - json_name, raw_export = self.suite_export() - - def mutate( - entries: list[tuple[zipfile.ZipInfo | str, bytes | str]], - *, - compression: int = zipfile.ZIP_DEFLATED, - ) -> bytes: - buffer = io.BytesIO() - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - with zipfile.ZipFile(buffer, "w", compression=compression) as archive: - for name, content in entries: - archive.writestr(name, content) - return buffer.getvalue() - - with zipfile.ZipFile(io.BytesIO(raw_export)) as archive: - payload = archive.read(json_name) - signature_name = json_name.removesuffix(".json") + ".sig" - symlink = zipfile.ZipInfo(signature_name) - symlink.create_system = 3 - symlink.external_attr = stat.S_IFLNK << 16 - cases = { - "path traversal": mutate( - [(json_name, payload), ("../" + signature_name, "signature")] - ), - "symlink": mutate([(json_name, payload), (symlink, "target")]), - "duplicate": mutate( - [ - (json_name, payload), - (json_name, payload), - ] - ), - "unexpected": mutate( - [ - (json_name, payload), - (signature_name, "signature"), - ("raw.log", "private"), - ] - ), - } - encrypted = bytearray(raw_export) - local_header = encrypted.find(b"PK\x03\x04") - central_header = encrypted.find(b"PK\x01\x02") - self.assertNotEqual(-1, local_header) - self.assertNotEqual(-1, central_header) - encrypted[local_header + 6] |= 0x01 - encrypted[central_header + 8] |= 0x01 - cases["encrypted"] = bytes(encrypted) - - for label, content in cases.items(): - with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), content) - with self.assertRaises(self.runner.RunnerError): - self.runner.load_suite_export(path, module, self.suite_jwks()) - - def test_suite_export_zip_rejects_corrupt_member_data_as_runner_error(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - json_name, raw_export = self.suite_export() - signature_name = json_name.removesuffix(".json") + ".sig" - with zipfile.ZipFile(io.BytesIO(raw_export)) as source: - payload = source.read(json_name) - signature = source.read(signature_name) - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive: - archive.writestr(json_name, payload) - archive.writestr(signature_name, signature) - stored = buffer.getvalue() - for target_name in (json_name, signature_name): - with self.subTest(target_name=target_name): - corrupt = bytearray(stored) - with zipfile.ZipFile(io.BytesIO(corrupt)) as archive: - entry = archive.getinfo(target_name) - name_size = int.from_bytes( - corrupt[ - entry.header_offset + 26 : entry.header_offset + 28 - ], - "little", - ) - extra_size = int.from_bytes( - corrupt[ - entry.header_offset + 28 : entry.header_offset + 30 - ], - "little", - ) - data_offset = entry.header_offset + 30 + name_size + extra_size - corrupt[data_offset] ^= 0x01 - - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), bytes(corrupt)) - with self.assertRaisesRegex( - self.runner.RunnerError, "invalid compressed data" - ): - self.runner.load_suite_export( - path, module, self.suite_jwks() - ) - - def test_suite_export_zip_rejects_size_and_compression_bombs(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - module = scenario["suite_modules"][0] - json_name, _ = self.suite_export() - signature_name = json_name.removesuffix(".json") + ".sig" - - aggregate = io.BytesIO() - with zipfile.ZipFile(aggregate, "w") as archive: - archive.writestr(json_name, "{}" + " " * 70) - archive.writestr(signature_name, "s" * 70) - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), aggregate.getvalue()) - with patch.object( - self.runner, - "read_owner_only_file", - return_value=aggregate.getvalue(), - ): - with patch.object(self.runner, "MAX_SUITE_EXPORT_BYTES", 100): - with self.assertRaisesRegex( - self.runner.RunnerError, "uncompressed size" - ): - self.runner.load_suite_export(path, module, self.suite_jwks()) - - compressed = io.BytesIO() - with zipfile.ZipFile( - compressed, "w", compression=zipfile.ZIP_DEFLATED - ) as archive: - archive.writestr(json_name, " " * (2 * 1024 * 1024)) - archive.writestr(signature_name, "signature") - with tempfile.TemporaryDirectory() as tmp: - path = self.write_private_export(Path(tmp), compressed.getvalue()) - with self.assertRaisesRegex( - self.runner.RunnerError, "suspicious compression ratio" - ): - self.runner.load_suite_export(path, module, self.suite_jwks()) - - def test_evidence_summary_rejects_unsupported_scenario_contract_drift(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - _, raw_export = self.suite_export() - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - changed_plan_map = json.loads(json.dumps(self.plan_map)) - changed_plan_map["scenarios"].append( - {"id": "unreviewed-scenario", "status": "blocked"} - ) - with self.assertRaisesRegex( - self.runner.RunnerError, "unsupported scenario contract changed" - ): - self.runner.build_evidence_summary( - changed_plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - - def test_public_summary_guard_rejects_raw_sensitive_fields(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - _, raw_export = self.suite_export() - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - summary, sensitive = self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - summary["run"]["access_token"] = "copied-private-token" - with self.assertRaisesRegex(self.runner.RunnerError, "forbidden field"): - self.runner.assert_public_summary_safe(summary, sensitive) - - def test_public_summary_omits_condition_identifiers_and_transaction_code( - self, - ) -> None: - transaction_code = "LeakyCode123" - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - _, raw_export = self.suite_export( - transaction_code=transaction_code, - warning_source=transaction_code, - ) - with tempfile.TemporaryDirectory() as tmp: - suite_export = self.write_private_export(Path(tmp), raw_export) - exported = self.runner.load_suite_export( - suite_export, - scenario["suite_modules"][0], - self.suite_jwks(), - ) - summary, sensitive = self.runner.build_evidence_summary( - self.plan_map, - scenario, - exported, - self.candidate(), - self.suite_jwks_sha256(), - ) - self.assertEqual(1, summary["run"]["conditions"]["counts"]["warning"]) - self.assertNotIn(transaction_code, json.dumps(summary)) - self.assertIn(transaction_code, sensitive) - self.runner.assert_public_summary_safe(summary, sensitive) - - def test_export_suite_jwks_uses_authenticated_origin_and_owner_only_output( - self, - ) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - output = root / "suite-jwks.json" - ca_certificate = root / "suite-ca.pem" - args = self.runner.parse_args( - [ - "export-suite-jwks", - "--conformance-server", - "https://suite.example.test", - "--suite-ca-certificate", - str(ca_certificate), - "--output", - str(output), - ] - ) - response = MagicMock() - response.status = 200 - response.read.return_value = json.dumps(self.suite_jwks()).encode("utf-8") - response.__enter__.return_value = response - opener = MagicMock() - opener.open.return_value = response - tls_context = MagicMock() - with patch.object( - self.runner, - "suite_tls_context", - return_value=tls_context, - ) as suite_tls_context: - with patch.object( - self.runner.urllib.request, - "build_opener", - return_value=opener, - ) as build_opener: - with patch("builtins.print"): - self.assertEqual(0, self.runner.cmd_export_suite_jwks(args)) - - suite_tls_context.assert_called_once_with(ca_certificate) - opener.open.assert_called_once_with( - "https://suite.example.test/jwks", timeout=10 - ) - handlers = build_opener.call_args.args - self.assertEqual({}, handlers[0].proxies) - self.assertIsInstance(handlers[1], self.runner.urllib.request.HTTPSHandler) - self.assertIsInstance(handlers[2], self.runner.NoRedirect) - response.read.assert_called_once_with( - self.runner.MAX_SUITE_JWKS_BYTES + 1 - ) - self.assertEqual(self.suite_jwks(), json.loads(output.read_bytes())) - self.assertEqual(0o600, output.stat().st_mode & 0o777) - - def test_submit_offer_forwards_only_the_real_notary_preauthorized_offer( - self, - ) -> None: - issuer = "https://issuer.example.test" - inline, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - offer_file = Path(tmp) / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - args = self.runner.parse_args( - [ - "submit-offer", - "--offer-file", - str(offer_file), - "--issuer-url", - issuer, - "--suite-offer-endpoint", - "https://suite.example.test/run/credential_offer", - "--conformance-server", - "https://suite.example.test", - ] - ) - response = MagicMock() - response.__enter__.return_value.status = 204 - opener = MagicMock() - opener.open.return_value = response - tls_context = MagicMock() - with patch.object( - self.runner.ssl, - "create_default_context", - return_value=tls_context, - ) as create_context: - with patch.object( - self.runner.ssl, - "_create_unverified_context", - side_effect=AssertionError("unverified TLS must not be used"), - ): - with patch.object( - self.runner.urllib.request, - "build_opener", - return_value=opener, - ) as build_opener: - with patch("builtins.print") as printed: - self.assertEqual(0, self.runner.cmd_submit_offer(args)) - - submitted = urllib.parse.urlsplit(opener.open.call_args.args[0]) - self.assertEqual( - [inline], - urllib.parse.parse_qs(submitted.query)["credential_offer"], - ) - create_context.assert_called_once_with() - https_handler = next( - handler - for handler in build_opener.call_args.args - if isinstance(handler, self.runner.urllib.request.HTTPSHandler) - ) - self.assertIs(tls_context, https_handler._context) - printed.assert_called_once_with("credential offer submitted") - - opener.open.side_effect = self.runner.urllib.error.URLError(inline) - with patch.object( - self.runner.urllib.request, "build_opener", return_value=opener - ): - with self.assertRaisesRegex( - self.runner.RunnerError, "submission failed" - ) as caught: - self.runner.cmd_submit_offer(args) - self.assertNotIn("owner-only-code", str(caught.exception)) - - def test_submit_offer_rejects_untrusted_remote_tls(self) -> None: - issuer = "https://issuer.example.test" - inline, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - offer_file = Path(tmp) / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - args = self.runner.parse_args( - [ - "submit-offer", - "--offer-file", - str(offer_file), - "--issuer-url", - issuer, - "--suite-offer-endpoint", - "https://suite.example.test/run/credential_offer", - "--conformance-server", - "https://suite.example.test", - ] - ) - opener = MagicMock() - opener.open.side_effect = self.runner.urllib.error.URLError( - self.runner.ssl.SSLCertVerificationError( - 1, "self-signed certificate" - ) - ) - with patch.object( - self.runner.urllib.request, "build_opener", return_value=opener - ): - with self.assertRaisesRegex( - self.runner.RunnerError, "submission failed" - ) as caught: - self.runner.cmd_submit_offer(args) - - self.assertNotIn(inline, str(caught.exception)) - - def test_submit_offer_accepts_an_explicit_local_suite_ca(self) -> None: - issuer = "https://issuer.example.test" - _, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - offer_file = Path(tmp) / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - suite_ca = Path(tmp) / "suite-ca.pem" - suite_ca.write_text("local test CA", encoding="utf-8") - args = self.runner.parse_args( - [ - "submit-offer", - "--offer-file", - str(offer_file), - "--issuer-url", - issuer, - "--suite-offer-endpoint", - "https://localhost.emobix.co.uk:8443/run/credential_offer", - "--conformance-server", - "https://localhost.emobix.co.uk:8443", - "--suite-ca-certificate", - str(suite_ca), - ] - ) - response = MagicMock() - response.__enter__.return_value.status = 204 - opener = MagicMock() - opener.open.return_value = response - tls_context = MagicMock() - with patch.object( - self.runner.ssl, - "SSLContext", - return_value=tls_context, - ) as create_context: - with patch.object( - self.runner.urllib.request, - "build_opener", - return_value=opener, - ): - with patch("builtins.print"): - self.assertEqual(0, self.runner.cmd_submit_offer(args)) - - create_context.assert_called_once_with( - self.runner.ssl.PROTOCOL_TLS_CLIENT - ) - tls_context.load_verify_locations.assert_called_once_with( - cadata=b"local test CA" - ) - - def test_suite_ca_read_holds_one_descriptor_across_path_replacement( - self, - ) -> None: - original = ( - b"-----BEGIN CERTIFICATE-----\n" - b"captured-original\n" - b"-----END CERTIFICATE-----\n" - ) - replacement = ( - b"-----BEGIN CERTIFICATE-----\n" - b"replacement\n" - b"-----END CERTIFICATE-----\n" - ) - with tempfile.TemporaryDirectory() as tmp: - ca_path = Path(tmp) / "suite-ca.pem" - replacement_path = Path(tmp) / "replacement.pem" - ca_path.write_bytes(original) - replacement_path.write_bytes(replacement) - real_open = self.runner.os.open - - def open_then_replace(path, flags): - descriptor = real_open(path, flags) - self.runner.os.replace(replacement_path, ca_path) - return descriptor - - tls_context = MagicMock() - with patch.object( - self.runner.os, "open", side_effect=open_then_replace - ) as secure_open: - with patch.object( - self.runner.ssl, - "SSLContext", - return_value=tls_context, - ): - self.runner.suite_tls_context(ca_path) - - self.assertEqual(replacement, ca_path.read_bytes()) - secure_open.assert_called_once() - flags = secure_open.call_args.args[1] - for required_flag in ("O_NOFOLLOW", "O_CLOEXEC"): - value = getattr(self.runner.os, required_flag, 0) - if value: - self.assertEqual(value, flags & value) - tls_context.load_verify_locations.assert_called_once_with( - cadata=original.decode("ascii") - ) - - def test_suite_ca_loader_preserves_der_bytes(self) -> None: - tls_context = MagicMock() - certificate = b"\x30\x82\x01\x00\xff" - - self.runner.add_suite_ca(tls_context, certificate) - - tls_context.load_verify_locations.assert_called_once_with( - cadata=certificate - ) - - def test_suite_ca_read_rejects_symlink(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - target = Path(tmp) / "suite-ca.pem" - target.write_text("certificate", encoding="utf-8") - link = Path(tmp) / "suite-ca-link.pem" - link.symlink_to(target) - - with self.assertRaisesRegex( - self.runner.RunnerError, "opened securely" - ): - self.runner.read_suite_ca_certificate(link) - - def test_exported_certificate_recipe_authenticates_documented_suite_host( - self, - ) -> None: - openssl = shutil.which("openssl") - if not openssl: - self.skipTest("openssl is required for the checked-in certificate recipe") - issuer = "https://issuer.example.test" - _, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - work = Path(tmp) - certificate = work / "recipe.crt" - private_key = work / "recipe.key" - command = nginx_certificate_command(certificate, private_key) - self.assertEqual(openssl, shutil.which(command[0])) - self.assertIn( - ( - "subjectAltName=DNS:localhost.emobix.co.uk,DNS:localhost," - "IP:127.0.0.1,IP:::1" - ), - command, - ) - subprocess.run( - command, - check=True, - capture_output=True, - text=True, - ) - - suite_dir = work / "suite" - suite_dir.mkdir() - exported = work / "conformance-suite-ca.pem" - export_args = self.runner.parse_args( - [ - "export-suite-ca", - "--suite-dir", - str(suite_dir), - "--output", - str(exported), - ] - ) - compose_commands: list[list[str]] = [] - - def copy_container_certificate( - compose_command: list[str], **_kwargs - ) -> None: - compose_commands.append(compose_command) - Path(compose_command[-1]).write_bytes(certificate.read_bytes()) - - with patch.object( - self.runner, - "run_checked", - side_effect=copy_container_certificate, - ): - with patch("builtins.print"): - self.assertEqual(0, self.runner.cmd_export_suite_ca(export_args)) - - self.assertEqual(certificate.read_bytes(), exported.read_bytes()) - self.assertEqual(0o600, exported.stat().st_mode & 0o777) - self.assertEqual( - f"nginx:{self.runner.SUITE_CA_CONTAINER_PATH}", - compose_commands[0][-2], - ) - - offer_file = work / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - server = ThreadingHTTPServer(("127.0.0.1", 0), EmptyHttpsHandler) - server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - server_context.minimum_version = ssl.TLSVersion.TLSv1_2 - server_context.load_cert_chain(certificate, private_key) - server.socket = server_context.wrap_socket( - server.socket, server_side=True - ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - host = "localhost.emobix.co.uk" - port = server.server_port - submit_args = self.runner.parse_args( - [ - "submit-offer", - "--offer-file", - str(offer_file), - "--issuer-url", - issuer, - "--suite-offer-endpoint", - f"https://{host}:{port}/run/credential_offer", - "--conformance-server", - f"https://{host}:{port}", - "--suite-ca-certificate", - str(exported), - ] - ) - real_getaddrinfo = socket.getaddrinfo - - def loopback_suite(hostname, *args, **kwargs): - if hostname == host: - hostname = "127.0.0.1" - return real_getaddrinfo(hostname, *args, **kwargs) - - try: - with patch.object( - socket, "getaddrinfo", side_effect=loopback_suite - ): - with patch("builtins.print"): - self.assertEqual( - 0, self.runner.cmd_submit_offer(submit_args) - ) - finally: - server.shutdown() - server.server_close() - thread.join(timeout=2) - - def test_submit_offer_rejects_cleartext_suite_endpoint(self) -> None: - issuer = "https://issuer.example.test" - _, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - offer_file = Path(tmp) / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - args = self.runner.parse_args( - [ - "submit-offer", - "--offer-file", - str(offer_file), - "--issuer-url", - issuer, - "--suite-offer-endpoint", - "http://suite.example.test/run/credential_offer", - "--conformance-server", - "http://suite.example.test", - ] - ) - with patch.object( - self.runner.urllib.request, "build_opener" - ) as build_opener: - with self.assertRaisesRegex(self.runner.RunnerError, "HTTPS"): - self.runner.cmd_submit_offer(args) - - build_opener.assert_not_called() - - def test_read_offer_uses_one_no_follow_descriptor(self) -> None: - issuer = "https://issuer.example.test" - inline, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - offer_file = Path(tmp) / "offer.txt" - offer_file.write_text(offer_uri, encoding="utf-8") - offer_file.chmod(0o600) - real_open = self.runner.os.open - with patch.object(Path, "read_text", side_effect=AssertionError): - with patch.object( - self.runner.os, "open", wraps=real_open - ) as secure_open: - self.assertEqual( - inline, self.runner.read_offer(offer_file, issuer) - ) - - secure_open.assert_called_once_with( - offer_file, - self.runner.os.O_RDONLY - | self.runner.os.O_CLOEXEC - | self.runner.os.O_NOFOLLOW, - ) - - def test_read_offer_rejects_symlink(self) -> None: - issuer = "https://issuer.example.test" - _, offer_uri = self.offer_uri(issuer) - with tempfile.TemporaryDirectory() as tmp: - target = Path(tmp) / "offer.txt" - target.write_text(offer_uri, encoding="utf-8") - target.chmod(0o600) - link = Path(tmp) / "offer-link.txt" - link.symlink_to(target) - with self.assertRaisesRegex( - self.runner.RunnerError, "could not be opened securely" - ): - self.runner.read_offer(link, issuer) - - def test_builder_override_pins_maven_image_by_digest(self) -> None: - override = self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.read_text( - encoding="utf-8" - ) - self.assertIn("maven:3-eclipse-temurin-21@sha256:", override) - self.assertIn( - str(self.runner.BUILDER_COMPOSE_OVERRIDE_PATH), - self.runner.builder_command(Path("/suite"), "run", "builder"), - ) - - def test_dependency_inputs_are_dependabot_discoverable(self) -> None: - compose_filename = re.compile( - r"(docker-)?compose(-[\w]+)?(?:\.[\w-]+)?\.ya?ml", - re.IGNORECASE, - ) - self.assertIsNotNone( - compose_filename.fullmatch( - self.runner.BUILDER_COMPOSE_OVERRIDE_PATH.name - ) - ) - self.assertEqual(".txt", self.runner.SUITE_REQUIREMENTS_LOCK_PATH.suffix) - dependabot_path = self.runner.REPO_ROOT / ".github" / "dependabot.yml" - dependabot = dependabot_path.read_text(encoding="utf-8") - self.assertIn("package-ecosystem: docker-compose", dependabot) - self.assertIn("package-ecosystem: pip", dependabot) - - def test_runtime_override_pins_built_image_bases(self) -> None: - override = self.runner.COMPOSE_OVERRIDE_PATH.read_text(encoding="utf-8") - nginx = (self.runner.CONFIG_DIR / "nginx.Dockerfile").read_text( - encoding="utf-8" - ) - server = (self.runner.CONFIG_DIR / "server-dev.Dockerfile").read_text( - encoding="utf-8" - ) - self.assertIn("REGISTRY_OPENID_CONFORMANCE_CONFIG_DIR", override) - self.assertIn("nginx:1.27.3@sha256:", nginx) - self.assertIn("eclipse-temurin:21@sha256:", server) - - def test_metadata_scenario_cli_selects_single_oid4vci_module(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - plan_arg = self.runner.scenario_plan_arg(scenario) - self.assertTrue(plan_arg.startswith("oid4vci-1_0-issuer-test-plan[")) - self.assertIn("[client_auth_type=private_key_jwt]", plan_arg) - self.assertIn("[sender_constrain=dpop]", plan_arg) - self.assertIn("[fapi_profile=vci]", plan_arg) - self.assertIn("[fapi_request_method=unsigned]", plan_arg) - self.assertIn("[authorization_request_type=simple]", plan_arg) - self.assertIn("[credential_format=sd_jwt_vc]", plan_arg) - self.assertIn("[vci_credential_encryption=plain]", plan_arg) - self.assertTrue(plan_arg.endswith(":oid4vci-1_0-issuer-metadata-test")) - - def test_rendered_config_is_valid_json_and_uses_supplied_issuer(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - rendered = self.runner.render_config( - scenario, - { - "issuer_url": "https://issuer.example.test", - "authorization_server": "https://issuer.example.test/auth", - "credential_configuration_id": "person_is_alive_sd_jwt", - "static_tx_code": "1234", - "client_id": "client-a", - "client2_id": "client-b", - }, - ) - config = json.loads(rendered) - self.assertEqual( - "registry-stack-notary-oid4vci-issuer", config["alias"] - ) - self.assertEqual( - "https://issuer.example.test", config["vci"]["credential_issuer_url"] - ) - self.assertEqual( - "person_is_alive_sd_jwt", - config["vci"]["credential_configuration_id"], - ) - self.assertEqual("client-a", config["client"]["client_id"]) - self.assertNotIn("${", rendered) - - def test_build_run_uses_export_dir_and_conformance_environment(self) -> None: - scenario = self.runner.find_scenario( - self.plan_map, "notary-oid4vci-issuer-metadata" - ) - with tempfile.TemporaryDirectory() as tmp: - args = self.runner.parse_args( - [ - "run", - "notary-oid4vci-issuer-metadata", - "--issuer-url", - "https://issuer.example.test", - "--output-dir", - tmp, - "--suite-dir", - str(Path(tmp) / "suite"), - "--no-prepare", - "--dry-run", - ] - ) - output_dir, env, command = self.runner.build_run( - self.plan_map, scenario, args - ) - self.assertEqual(Path(tmp).resolve(), output_dir) - self.assertEqual( - self.plan_map["suite"]["base_url"], env["CONFORMANCE_SERVER"] - ) - self.assertEqual("1", env["CONFORMANCE_DEV_MODE"]) - self.assertIn("--export-dir", command) - self.assertIn(str(output_dir), command) - self.assertIn("oid4vci-1_0-issuer-metadata-test", " ".join(command)) - self.assertTrue( - (output_dir / "notary-oid4vci-issuer-metadata.config.json").exists() - ) - self.assertEqual( - 0o600, - ( - output_dir / "notary-oid4vci-issuer-metadata.config.json" - ).stat().st_mode - & 0o777, - ) - - def test_suite_artifact_build_uses_docker_builder_and_maven_cache(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - jar = checkout / self.runner.SUITE_JAR - jar.parent.mkdir(parents=True) - args = self.runner.parse_args( - [ - "prepare", - "--suite-dir", - str(checkout), - "--maven-cache-dir", - str(Path(tmp) / "maven"), - ] - ) - calls = [] - - def fake_run_checked(command, cwd=None, env=None): - calls.append((command, cwd, env)) - jar.write_text("jar", encoding="utf-8") - - with patch.object(shutil, "which", return_value="/usr/bin/docker"): - with patch.object(self.runner, "suite_checkout_ref", return_value="a" * 40): - with patch.object( - self.runner, "run_checked", side_effect=fake_run_checked - ): - self.runner.ensure_suite_artifact(checkout, args) - - self.assertEqual( - self.runner.builder_command(checkout, "run", "--rm", "builder"), - calls[0][0], - ) - self.assertEqual(checkout, calls[0][1]) - self.assertEqual( - str((Path(tmp) / "maven").resolve()), calls[0][2]["MAVEN_CACHE"] - ) - stamp = json.loads( - (checkout / self.runner.SUITE_JAR_STAMP).read_text(encoding="utf-8") - ) - self.assertEqual("a" * 40, stamp["source_ref"]) - self.assertEqual( - self.runner.file_sha256(jar), stamp["jar_sha256"] - ) - self.assertEqual( - self.runner.file_sha256( - self.runner.BUILDER_COMPOSE_OVERRIDE_PATH - ), - stamp["builder_override_sha256"], - ) - - def test_existing_suite_artifact_skips_build_by_default(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - jar = checkout / self.runner.SUITE_JAR - jar.parent.mkdir(parents=True) - jar.write_text("jar", encoding="utf-8") - stamp = checkout / self.runner.SUITE_JAR_STAMP - with patch.object(self.runner, "suite_checkout_ref", return_value="a" * 40): - stamp.write_text( - json.dumps( - self.runner.expected_suite_artifact_stamp(checkout, jar), - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - args = self.runner.parse_args(["prepare", "--suite-dir", str(checkout)]) - - with patch.object(self.runner, "suite_checkout_ref", return_value="a" * 40): - with patch.object(self.runner, "run_checked") as run_checked: - self.runner.ensure_suite_artifact(checkout, args) - - run_checked.assert_not_called() - - def test_suite_artifact_rebuilds_when_checkout_ref_changes(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - jar = checkout / self.runner.SUITE_JAR - jar.parent.mkdir(parents=True) - jar.write_text("old", encoding="utf-8") - stamp = checkout / self.runner.SUITE_JAR_STAMP - with patch.object(self.runner, "suite_checkout_ref", return_value="a" * 40): - stamp.write_text( - json.dumps( - self.runner.expected_suite_artifact_stamp(checkout, jar), - sort_keys=True, - ) - + "\n", - encoding="utf-8", - ) - args = self.runner.parse_args(["prepare", "--suite-dir", str(checkout)]) - - def fake_run_checked(command, cwd=None, env=None): - jar.write_text("new", encoding="utf-8") - - with patch.object(shutil, "which", return_value="/usr/bin/docker"): - with patch.object( - self.runner, "suite_checkout_ref", return_value="b" * 40 - ): - with patch.object( - self.runner, "run_checked", side_effect=fake_run_checked - ) as run_checked: - self.runner.ensure_suite_artifact(checkout, args) - - run_checked.assert_called_once() - self.assertEqual("new", jar.read_text(encoding="utf-8")) - self.assertEqual( - "b" * 40, - json.loads(stamp.read_text(encoding="utf-8"))["source_ref"], - ) - - def test_suite_python_venv_installs_requirements_and_records_digest(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - requirements = checkout / "scripts" / "requirements.txt" - requirements.parent.mkdir(parents=True) - requirements.write_bytes( - self.runner.SUITE_REQUIREMENTS_INPUT_PATH.read_bytes() - ) - venv_dir = Path(tmp) / "venv" - args = self.runner.parse_args( - [ - "prepare", - "--suite-dir", - str(checkout), - "--python-venv-dir", - str(venv_dir), - ] - ) - - calls = [] - - def fake_run_checked(command, cwd=None, env=None): - calls.append(command) - if command[1:3] == ["-m", "venv"]: - Path(command[-1]).mkdir(parents=True) - - with patch.object( - self.runner, "run_checked", side_effect=fake_run_checked - ): - python = self.runner.ensure_suite_python(checkout, args) - - self.assertEqual(venv_dir.resolve(), python.parents[2]) - self.assertTrue(python.parent.parent.name.startswith("py")) - self.assertEqual( - [sys.executable, "-m", "venv", str(python.parents[1])], calls[0] - ) - self.assertEqual(str(python), calls[1][0]) - self.assertIn("--require-hashes", calls[1]) - self.assertIn("--only-binary=:all:", calls[1]) - self.assertEqual("-r", calls[1][-2]) - self.assertEqual( - str(self.runner.SUITE_REQUIREMENTS_LOCK_PATH), calls[1][-1] - ) - self.assertEqual( - self.runner.requirements_digest( - self.runner.SUITE_REQUIREMENTS_INPUT_PATH, - self.runner.SUITE_REQUIREMENTS_LOCK_PATH, - ), - (python.parents[1] / ".requirements.sha256") - .read_text(encoding="utf-8") - .strip(), - ) - - def test_suite_python_cache_key_changes_with_lock_digest(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - args = self.runner.parse_args( - ["prepare", "--python-venv-dir", str(Path(tmp) / "venvs")] - ) - first = self.runner.suite_python(args) - with patch.object( - self.runner, "requirements_digest", return_value="b" * 64 - ): - second = self.runner.suite_python(args) - self.assertNotEqual(first, second) - - def test_suite_python_recreates_incomplete_digest_cache(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - requirements = checkout / "scripts" / "requirements.txt" - requirements.parent.mkdir(parents=True) - requirements.write_bytes( - self.runner.SUITE_REQUIREMENTS_INPUT_PATH.read_bytes() - ) - args = self.runner.parse_args( - [ - "prepare", - "--suite-dir", - str(checkout), - "--python-venv-dir", - str(Path(tmp) / "venvs"), - ] - ) - python = self.runner.suite_python(args) - python.parent.mkdir(parents=True) - python.touch() - stale = python.parents[1] / "stale-package" - stale.touch() - - def fake_run_checked(command, cwd=None, env=None): - if command[1:3] == ["-m", "venv"]: - Path(command[-1]).mkdir(parents=True) - - with patch.object( - self.runner, "run_checked", side_effect=fake_run_checked - ): - self.runner.ensure_suite_python(checkout, args) - - self.assertFalse(stale.exists()) - self.assertTrue( - (python.parents[1] / ".requirements.sha256").is_file() - ) - - def test_suite_python_rejects_changed_upstream_requirements(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - checkout = Path(tmp) / "suite" - requirements = checkout / "scripts" / "requirements.txt" - requirements.parent.mkdir(parents=True) - requirements.write_text("httpx\npyparsing\nunreviewed\n", encoding="utf-8") - args = self.runner.parse_args( - ["prepare", "--suite-dir", str(checkout)] - ) - - with self.assertRaisesRegex( - self.runner.RunnerError, "differ from the checked-in locked input" - ): - self.runner.ensure_suite_python(checkout, args) - - def test_blocked_full_scenario_requires_explicit_override(self) -> None: - args = self.runner.parse_args( - [ - "run", - "notary-oid4vci-issuer-full", - "--issuer-url", - "https://issuer.example.test", - "--no-prepare", - "--dry-run", - ] + (ROOT / "release/conformance/openid/initial-report.md").is_file() ) - with self.assertRaisesRegex( - self.runner.RunnerError, "blocked-by-suite-profile" - ): - self.runner.cmd_run(args) - def test_candidate_only_metadata_scenario_runs_without_blocked_override(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - args = self.runner.parse_args( - [ - "run", - "notary-oid4vci-issuer-metadata", - "--issuer-url", - "https://issuer.example.test", - "--output-dir", - tmp, - "--suite-dir", - str(Path(tmp) / "suite"), - "--no-prepare", - "--dry-run", - ] - ) - with patch("builtins.print") as printed: - self.assertEqual(0, self.runner.cmd_run(args)) - invocation = json.loads(printed.call_args.args[0]) - self.assertIn( - "oid4vci-1_0-issuer-metadata-test", " ".join(invocation["command"]) - ) + def test_relay_oidc_smoke_remains_the_supported_openid_check(self) -> None: + self.assertTrue((ROOT / "release/scripts/relay-oidc-smoke.py").is_file()) + self.assertTrue((ROOT / "release/conformance/relay-oidc/README.md").is_file()) if __name__ == "__main__": - main() + unittest.main() diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index eec596550..be4d41814 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -2,11 +2,13 @@ from __future__ import annotations import importlib.util +import io import json import stat import subprocess import sys import tempfile +from contextlib import redirect_stderr, redirect_stdout from importlib.machinery import SourceFileLoader from pathlib import Path from unittest import TestCase, main, mock @@ -312,6 +314,10 @@ def test_registryctl_image_lock_schema_boundary_preserves_historical_v1( image_lock.SCHEMA_V2, image_lock.schema_for_release_version("0.14.0"), ) + self.assertEqual( + image_lock.SCHEMA_V3, + image_lock.schema_for_release_version("0.17.0"), + ) def test_registryctl_image_lock_validates_v1_and_reviewed_v2_images( self, @@ -340,6 +346,15 @@ def test_registryctl_image_lock_validates_v1_and_reviewed_v2_images( image_lock.validate_images(image_lock.SCHEMA_V2, v2_images), ) + v3_images = { + "registry-relay": product_images["registry-relay"], + "postgresql": image_lock.reviewed_postgresql_image_ref(), + } + self.assertEqual( + v3_images, + image_lock.validate_images(image_lock.SCHEMA_V3, v3_images), + ) + without_postgresql = dict(v2_images) del without_postgresql["postgresql"] with self.assertRaisesRegex(ValueError, "must contain exactly"): @@ -646,7 +661,6 @@ def test_release_image_packaging_uses_release_dockerfiles(self) -> None: encoding="utf-8" ) release_dockerfiles = [ - "release/docker/Dockerfile.registry-notary", "release/docker/Dockerfile.registry-relay", ] @@ -1411,40 +1425,23 @@ def test_relay_packaging_includes_dedicated_rhai_worker(self) -> None: ) self.assertIn(f"dist/image-bin/{worker}", binary_recipe) - def test_notary_packaging_includes_dedicated_cel_worker(self) -> None: + def test_release_packaging_excludes_retired_notary(self) -> None: binary_recipe = (ROOT / "release/scripts/build-release-binaries.sh").read_text( encoding="utf-8" ) - worker = "registry-notary-cel-worker" - - product_dockerfile = (ROOT / "products/notary/Dockerfile").read_text( + image_recipe = (ROOT / "release/scripts/build-release-image.sh").read_text( encoding="utf-8" ) - self.assertIn(worker, product_dockerfile) - - self.assertIn( - f'"dist/bin/{worker}-${{RELEASE_TAG}}-linux-amd64"', - binary_recipe, - ) - self.assertIn(f"dist/image-bin/{worker}", binary_recipe) - self.assertIn( - f"--bin {worker}", - binary_recipe, - ) - release_dockerfile = ( - ROOT / "release/docker/Dockerfile.registry-notary" - ).read_text(encoding="utf-8") - self.assertIn( - f"install -m 0755 /workspace/image-bin/{worker} " - f"/workspace/runtime-root/usr/local/bin/{worker}", - release_dockerfile, + self.assertNotIn("registry-notary", binary_recipe) + self.assertNotIn("registry-notary", image_recipe) + self.assertFalse( + (ROOT / "release/docker/Dockerfile.registry-notary").exists() ) - self.assertIn(f"dist/image-bin/{worker}", binary_recipe) def test_release_product_images_preown_managed_audit_and_state_directories( self, ) -> None: - for product in ("relay", "notary"): + for product in ("relay",): dockerfile = ( ROOT / f"release/docker/Dockerfile.registry-{product}" ).read_text(encoding="utf-8") @@ -2296,6 +2293,40 @@ def test_validate_accepts_declared_evidence_toolset_artifacts(self) -> None: self.assertEqual(0, result.returncode, result.stderr) + def test_validate_accepts_post_notary_v0_17_artifact_inventory(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + manifest = write_manifest( + Path(tmp), + version="0.17.0", + include_evidence_toolset=True, + include_retired_notary=False, + ) + accepted = run_tool("validate", str(manifest)) + + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data["artifacts"]["registry-notary"] = "0.17.0" + manifest.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + rejected = run_tool("validate", str(manifest)) + + self.assertEqual(0, accepted.returncode, accepted.stderr) + self.assertNotEqual(0, rejected.returncode) + self.assertIn("unexpected registry-notary", rejected.stderr) + + def test_beta_27_manifest_is_the_notary_free_current_inventory(self) -> None: + manifest = ROOT / "release/manifests/registry-stack-beta-27.yaml" + result = run_tool("validate", str(manifest)) + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("beta-27", data["stack"]["release"]) + self.assertEqual("0.17.0", data["stack"]["version"]) + self.assertNotIn("registry-notary", data["artifacts"]) + self.assertNotIn("registry-notary-cel-worker", data["artifacts"]) + self.assertTrue( + {"registry-relay", "evidence", "evidencectl", "mint"} + <= set(data["artifacts"]) + ) + def test_validate_still_rejects_unknown_artifacts_beside_the_evidence_toolset( self, ) -> None: @@ -2357,6 +2388,69 @@ def test_render_registryctl_image_lock_from_exact_release_evidence(self) -> None document, ) + def test_render_post_notary_v3_image_lock_without_notary_input(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + manifest = write_manifest( + root, + version="0.17.0", + include_evidence_toolset=True, + include_retired_notary=False, + ) + data = yaml.safe_load(manifest.read_text(encoding="utf-8")) + data["stack"].pop("source_ref") + data["stack"].pop("status") + manifest.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + relay_digest = root / "registry-relay.digest" + relay_ref = f"ghcr.io/registrystack/registry-relay@{IMAGE_DIGEST}" + relay_digest.write_text(f"{relay_ref}\n", encoding="utf-8") + output = root / "registryctl-v0.17.0-image-lock.json" + + rendered = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--postgresql-ref-file", + str(ROOT / "release/registryctl-postgresql-image.ref"), + "--tag-target", + "b" * 40, + "--source-sha", + "b" * 40, + "--output", + str(output), + ) + document = json.loads(output.read_text(encoding="utf-8")) + + notary_digest = root / "registry-notary.digest" + notary_digest.write_text( + f"ghcr.io/registrystack/registry-notary@{IMAGE_DIGEST}\n", + encoding="utf-8", + ) + rejected = run_tool( + "render-registryctl-image-lock", + str(manifest), + "--relay-digest", + str(relay_digest), + "--notary-digest", + str(notary_digest), + "--postgresql-ref-file", + str(ROOT / "release/registryctl-postgresql-image.ref"), + "--tag-target", + "b" * 40, + "--source-sha", + "b" * 40, + "--output", + str(output), + ) + + self.assertEqual(0, rendered.returncode, rendered.stderr) + self.assertEqual("registryctl.release_image_lock.v3", document["schema_version"]) + self.assertEqual({"postgresql", "registry-relay"}, set(document["images"])) + self.assertEqual(relay_ref, document["images"]["registry-relay"]) + self.assertNotEqual(0, rejected.returncode) + self.assertIn("does not accept --notary-digest", rejected.stderr) + def test_active_manifest_validates_and_renders_image_lock_from_explicit_source( self, ) -> None: @@ -2551,7 +2645,7 @@ def test_render_registryctl_image_lock_v2_requires_postgresql_ref( ) self.assertNotEqual(0, result.returncode) - self.assertIn("v2 requires --postgresql-ref-file", result.stderr) + self.assertIn("v2 or later requires --postgresql-ref-file", result.stderr) def test_render_registryctl_image_lock_rejects_pre_0_9_release(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -3716,6 +3810,89 @@ def legacy_stage_capsule_backfill_assets_rejects_missing_release_asset(self) -> self.assertNotEqual(0, result.returncode) self.assertIn("missing release asset registry-relay.digest", result.stderr) + def test_stage_capsule_backfill_assets_excludes_retired_notary_from_v0_17( + self, + ) -> None: + registry_release = load_registry_release() + for include_retired_assets in (False, True): + with ( + self.subTest(include_retired_assets=include_retired_assets), + tempfile.TemporaryDirectory() as tmp, + ): + root = Path(tmp) + asset_dir = write_release_asset_fixture( + root, + tag="v0.17.0", + include_image_lock=True, + ) + if not include_retired_assets: + for retired_name in ( + "registry-notary-v0.17.0-linux-amd64", + "registry-notary-cel-worker-v0.17.0-linux-amd64", + "registry-notary.digest", + ): + (asset_dir / retired_name).unlink() + binary_dir = root / "staged-bin" + image_dir = root / "staged-images" + + with redirect_stdout(io.StringIO()): + result = registry_release.stage_capsule_backfill_assets( + asset_dir, + "v0.17.0", + binary_dir, + image_dir, + ) + + self.assertEqual(0, result) + self.assertTrue( + ( + binary_dir / "registry-relay-rhai-worker-v0.17.0-linux-amd64" + ).is_file() + ) + self.assertTrue((image_dir / "registry-relay.digest").is_file()) + self.assertFalse( + (binary_dir / "registry-notary-v0.17.0-linux-amd64").exists() + ) + self.assertFalse( + ( + binary_dir / "registry-notary-cel-worker-v0.17.0-linux-amd64" + ).exists() + ) + self.assertFalse((image_dir / "registry-notary.digest").exists()) + + def test_stage_capsule_backfill_assets_keeps_beta_26_notary_requirements( + self, + ) -> None: + registry_release = load_registry_release() + for missing_name in ( + "registry-notary-v0.16.3-linux-amd64", + "registry-notary-cel-worker-v0.16.3-linux-amd64", + "registry-notary.digest", + ): + with ( + self.subTest(missing_name=missing_name), + tempfile.TemporaryDirectory() as tmp, + ): + root = Path(tmp) + asset_dir = write_release_asset_fixture( + root, + tag="v0.16.3", + include_image_lock=True, + ) + (asset_dir / missing_name).unlink() + + error = io.StringIO() + with redirect_stderr(error): + result = registry_release.stage_capsule_backfill_assets( + asset_dir, + "v0.16.3", + root / "staged-bin", + root / "staged-images", + ) + + self.assertEqual(1, result) + self.assertIn(f"missing release asset {missing_name}", error.getvalue()) + def test_bind_spdx_subject_adds_digest_bound_described_package(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -3856,6 +4033,7 @@ def write_manifest( include_registryctl_image_lock: bool | None = None, include_registryctl_installer: bool | None = None, include_evidence_toolset: bool = False, + include_retired_notary: bool | None = None, ) -> Path: if source_tag is None: source_tag = f"v{version}" @@ -3891,6 +4069,11 @@ def write_manifest( artifacts["evidencectl"] = version artifacts["mint"] = version artifacts["evidencectl-installer"] = version + if include_retired_notary is None: + include_retired_notary = version_tuple < (0, 17, 0) + if not include_retired_notary: + artifacts.pop("registry-notary", None) + artifacts.pop("registry-notary-cel-worker", None) manifest = { "stack": { "release": "beta-6", diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 8f92efb86..d93b3da83 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -17,8 +17,6 @@ EXACT_ARTIFACT_INVENTORY = ( "registry-docs", "registry-manifest-cli", - "registry-notary", - "registry-notary-cel-worker", "registry-relay", "registry-relay-rhai-worker", "registryctl", diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index 69480e9af..7c75bae84 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -45,10 +45,12 @@ def json_bytes(value: object) -> bytes: return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n" -def security_evidence_members() -> dict[str, bytes]: +def security_evidence_members( + image_names: tuple[str, ...] = ("registry-relay",), +) -> dict[str, bytes]: refs = { name: f"ghcr.io/registrystack/{name}-candidate@{IMAGE_DIGEST}" - for name in ("registry-notary", "registry-relay") + for name in image_names } refs["postgresql"] = POSTGRESQL_REF members = { @@ -58,11 +60,10 @@ def security_evidence_members() -> dict[str, bytes]: { "schema_version": "registry-stack.advisory-verdict.v2", "verdict": "passed", - "subjects": [ - "registry-notary-image", - "registry-relay-image", - "postgresql-runtime", - ], + "subjects": sorted( + [f"{name}-image" for name in image_names] + + ["postgresql-runtime"] + ), } ), } @@ -966,16 +967,13 @@ def replace_security_evidence( return path def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: - bundle_root = self.root.parent / "v2-bundle" + bundle_root = self.root / "v2-bundle" evidence_members = security_evidence_members() evidence_name = "registry-stack-v1.2.3-security-evidence.tar.gz" files = { "registryctl-v1.2.3-linux-amd64": b"registryctl", "registry-docs-v1.2.3.tar.gz": b"docs", "registry-stack-v1.2.3.sbom.spdx.json": b"sbom", - "security/registry-notary.grype.json": evidence_members[ - "grype/registry-notary.grype.json" - ], "security/registry-relay.grype.json": evidence_members[ "grype/registry-relay.grype.json" ], @@ -990,7 +988,7 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: path = bundle_root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(payload) - bundle_path = self.root.parent / "registry-stack-v1.2.3-candidate.tar.gz" + bundle_path = self.root / "registry-stack-v1.2.3-candidate.tar.gz" with tarfile.open(bundle_path, "w:gz") as archive: for name in sorted(files): archive.add(bundle_root / name, arcname=name) @@ -1040,7 +1038,7 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: "digest": IMAGE_DIGEST, "final_ref": f"ghcr.io/registrystack/{name}:v1.2.3", } - for name in ("registry-notary", "registry-relay") + for name in ("registry-relay",) ], "docs": { "name": "registry-docs-v1.2.3.tar.gz", @@ -1059,7 +1057,7 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: "sha256": sha256(files[f"security/{name}.grype.json"]), "status": "passed", } - for name in ("registry-notary", "registry-relay") + for name in ("registry-relay",) ], "advisory": { "name": "security/advisory-verdict.json", @@ -1113,6 +1111,123 @@ def test_v2_candidate_requires_source_to_equal_workflow_revision(self) -> None: now=self.now, ) + def test_v2_candidate_image_inventory_is_version_aware(self) -> None: + current, _, _, _ = self.make_v2_candidate() + self.module.validate_candidate_manifest(current, now=self.now) + + historical = copy.deepcopy(current) + historical["release"]["version"] = "0.16.3" + historical["release"]["tag"] = "v0.16.3" + historical_evidence = next( + item + for item in historical["payloads"] + if item["kind"] == "security-evidence" + ) + historical_evidence["name"] = ( + "registry-stack-v0.16.3-security-evidence.tar.gz" + ) + historical["bundle"]["name"] = ( + "registry-stack-v0.16.3-candidate.tar.gz" + ) + historical["images"][0]["final_ref"] = ( + "ghcr.io/registrystack/registry-relay:v0.16.3" + ) + + with self.assertRaisesRegex( + self.module.CandidateError, + "image inventory must be exactly.*registry-notary", + ): + self.module.validate_candidate_manifest(historical, now=self.now) + + historical["images"].append( + { + "name": "registry-notary", + "candidate_ref": ( + "ghcr.io/registrystack/registry-notary-candidate@" + f"{CONFIG_DIGEST}" + ), + "digest": CONFIG_DIGEST, + "final_ref": "ghcr.io/registrystack/registry-notary:v0.16.3", + } + ) + historical["scans"].append( + { + "image": "registry-notary", + "name": "security/registry-notary.grype.json", + "sha256": ARCHIVE_SHA, + "status": "passed", + } + ) + self.module.validate_candidate_manifest(historical, now=self.now) + + def test_v2_security_evidence_members_follow_candidate_images(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + current_path = root / "current.tar.gz" + current_members = security_evidence_members() + current_path.write_bytes( + security_evidence_tar(sorted(current_members.items())) + ) + current_refs = { + "registry-relay": ( + "ghcr.io/registrystack/registry-relay-candidate@" + f"{IMAGE_DIGEST}" + ) + } + self.module.validate_security_evidence_archive( + current_path, + product_image_refs=current_refs, + product_scan_sha256={ + "registry-relay": sha256( + current_members["grype/registry-relay.grype.json"] + ) + }, + advisory_sha256=sha256( + current_members["advisory-verdict.json"] + ), + ) + + historical_refs = { + **current_refs, + "registry-notary": ( + "ghcr.io/registrystack/registry-notary-candidate@" + f"{IMAGE_DIGEST}" + ), + } + with self.assertRaisesRegex( + self.module.CandidateError, + "security evidence archive is incomplete", + ): + self.module.validate_security_evidence_archive( + current_path, + product_image_refs=historical_refs, + product_scan_sha256={}, + advisory_sha256=sha256( + current_members["advisory-verdict.json"] + ), + ) + + historical_path = root / "historical.tar.gz" + historical_members = security_evidence_members( + ("registry-notary", "registry-relay") + ) + historical_path.write_bytes( + security_evidence_tar(sorted(historical_members.items())) + ) + self.module.validate_security_evidence_archive( + historical_path, + product_image_refs=historical_refs, + product_scan_sha256={ + name: sha256( + historical_members[f"grype/{name}.grype.json"] + ) + for name in historical_refs + }, + advisory_sha256=sha256( + historical_members["advisory-verdict.json"] + ), + ) + def test_v2_candidate_allows_only_current_in_progress_run_before_oidc( self, ) -> None: @@ -1346,12 +1461,12 @@ def test_v2_security_evidence_archive_rejects_unbound_contents(self) -> None: unbound_spdx["image-sbom/postgresql.spdx.json"] = json_bytes(spdx) unbound_syft = dict(base) - syft = json.loads(unbound_syft["syft/registry-notary.syft.json"]) + syft = json.loads(unbound_syft["syft/registry-relay.syft.json"]) syft["source"]["metadata"]["userInput"] = ( - "ghcr.io/registrystack/registry-notary-candidate@sha256:" + "ghcr.io/registrystack/registry-relay-candidate@sha256:" + "9" * 64 ) - unbound_syft["syft/registry-notary.syft.json"] = json_bytes(syft) + unbound_syft["syft/registry-relay.syft.json"] = json_bytes(syft) incomplete_verdict = dict(base) verdict = json.loads(incomplete_verdict["advisory-verdict.json"]) @@ -1359,15 +1474,15 @@ def test_v2_security_evidence_archive_rejects_unbound_contents(self) -> None: incomplete_verdict["advisory-verdict.json"] = json_bytes(verdict) substituted_scan = dict(base) - scan = json.loads(substituted_scan["grype/registry-notary.grype.json"]) + scan = json.loads(substituted_scan["grype/registry-relay.grype.json"]) scan["substituted"] = True - substituted_scan["grype/registry-notary.grype.json"] = json_bytes(scan) + substituted_scan["grype/registry-relay.grype.json"] = json_bytes(scan) for members, message in ( (invalid_digest, "PostgreSQL digest is not canonical or immutable"), (unreviewed_digest, "does not match the reviewed release image"), (unbound_spdx, "PostgreSQL SPDX subject is not bound"), - (unbound_syft, "registry-notary.syft.json.*is not bound"), + (unbound_syft, "registry-relay.syft.json.*is not bound"), (incomplete_verdict, "does not cover every runtime"), (substituted_scan, "does not match its scan payload"), ): @@ -1500,7 +1615,7 @@ def test_v2_candidate_rejects_scan_advisory_image_and_bundle_substitution( (("advisory", "verdict"), "failed", "verdict must be passed"), ( ("images", 0, "final_ref"), - "ghcr.io/registrystack/registry-notary:v9.9.9", + "ghcr.io/registrystack/registry-relay:v9.9.9", "final_ref", ), (("bundle", "sha256"), "9" * 64, "bundle sha256 mismatch"), diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 5e3398a92..8204ebe42 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import importlib.util import subprocess import tempfile import unittest @@ -48,6 +49,39 @@ def verify_latest_release_fixture(metadata: dict, expected_tag: str) -> subproce class CandidateWorkflowStructureTest(unittest.TestCase): + def test_current_release_pipeline_has_no_retired_notary_surface(self) -> None: + paths = ( + WORKFLOWS / "release-candidate.yml", + WORKFLOWS / "release.yml", + WORKFLOWS / "release-canary.yml", + ROOT / "release/scripts/build-release-binaries.sh", + ROOT / "release/scripts/build-release-image.sh", + ) + for path in paths: + with self.subTest(path=path.relative_to(ROOT)): + self.assertNotIn( + "registry-notary", + path.read_text(encoding="utf-8").lower(), + ) + self.assertFalse((ROOT / "release/docker/Dockerfile.registry-notary").exists()) + + repeatability = (WORKFLOWS / "release-repeatability.yml").read_text( + encoding="utf-8" + ) + self.assertIn('.images | keys[] | select(startswith("registry-"))', repeatability) + self.assertNotIn("for name in registry-notary registry-relay", repeatability) + + module_path = ROOT / "release/scripts/release_candidate.py" + spec = importlib.util.spec_from_file_location("release_candidate", module_path) + self.assertIsNotNone(spec) + self.assertIsNotNone(spec.loader) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + self.assertEqual({"registry-relay"}, module.CURRENT_IMAGE_NAMES) + self.assertFalse( + any("registry-notary" in name for name in module.SECURITY_EVIDENCE_REQUIRED_FILES) + ) + def test_keeps_one_candidate_pipeline_with_narrow_permissions(self) -> None: _, document = workflow("release-candidate.yml") self.assertEqual( diff --git a/release/scripts/test_validate_upgrade_exercise.py b/release/scripts/test_validate_upgrade_exercise.py index a41968ea2..270a7ecce 100644 --- a/release/scripts/test_validate_upgrade_exercise.py +++ b/release/scripts/test_validate_upgrade_exercise.py @@ -307,6 +307,28 @@ def test_candidate_must_be_a_forward_version_upgrade(self) -> None: with self.assertRaisesRegex(self.module.ExerciseError, "must be newer"): self.validate_record(record, allow_template=False) + def test_v1_rejects_post_notary_target_before_historical_path_binding( + self, + ) -> None: + record = self.candidate() + record["target_release"]["version"] = "v0.17.0" + del record["target_release"]["notary_image_digest"] + self.module.load_candidate.reset_mock() + + with mock.patch.object( + self.module, + "git_bytes", + side_effect=AssertionError("historical paths must not be read"), + ) as git_bytes: + with self.assertRaisesRegex( + self.module.ExerciseError, + "historical Notary-era contract.*before v0.17.0", + ): + self.validate_record(record, allow_template=False) + + git_bytes.assert_not_called() + self.module.load_candidate.assert_not_called() + def test_prerelease_version_order_uses_semver_precedence(self) -> None: for lower, higher in ( ("v1.0.0-rc.1", "v1.0.0-rc.2"), diff --git a/release/scripts/validate-upgrade-exercise.py b/release/scripts/validate-upgrade-exercise.py index d7fa10445..fad6a4401 100644 --- a/release/scripts/validate-upgrade-exercise.py +++ b/release/scripts/validate-upgrade-exercise.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate a redaction-safe Registry Stack upgrade exercise record.""" +"""Validate a historical Notary-era Registry Stack upgrade exercise record.""" from __future__ import annotations @@ -24,6 +24,7 @@ SCHEMA = "registry-stack.upgrade-exercise/v1" +POST_NOTARY_VERSION = "v0.17.0" SOLMARA_REPOSITORY = "registrystack/solmara-lab" STACK_REPOSITORY = "registrystack/registry-stack" CONFIG_SCHEMAS = { @@ -235,6 +236,22 @@ def version_order( return core_order, tuple(identifiers) +def reject_post_notary_v1_target(value: Any) -> None: + if not isinstance(value, dict): + return + version = value.get("version") + if ( + isinstance(version, str) + and VERSION.fullmatch(version) is not None + and version_order(version) >= version_order(POST_NOTARY_VERSION) + ): + raise ExerciseError( + f"{SCHEMA} is a historical Notary-era contract and accepts only " + f"target_release.version values before {POST_NOTARY_VERSION}; " + "post-Notary upgrades require a successor contract" + ) + + def validate_config_schemas( value: Any, *, template: bool, root: Path, target_commit: str | None ) -> None: @@ -734,6 +751,7 @@ def validate_record( raise ExerciseError("--template accepts only a template record") bounded_string(record["exercise_id"], "exercise_id", SLUG, template=template) bounded_string(record["recorded_at"], "recorded_at", TIMESTAMP, template=template) + reject_post_notary_v1_target(record["target_release"]) validate_release(record["source_release"], "source_release", template=template) validate_release(record["target_release"], "target_release", template=template) if not template and version_order(record["target_release"]["version"]) <= version_order( From d15e9918b9677da10fb4af69374a373dfe70d391 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 20:07:39 +0700 Subject: [PATCH 090/136] docs: present Relay and Evidence as maintained products Signed-off-by: Jeremi Joslin --- AGENTS.md | 62 ++++++++----------- CONTRIBUTING.md | 19 +++--- README.md | 45 +++++++------- ...tary-retirement-and-evidence-onboarding.md | 13 +++- 4 files changed, 69 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9bfc1f8f8..642c6be10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,20 +3,21 @@ This is the Registry Stack monorepo: registry-facing services over data institutions already hold. Pre-1.0; APIs and deployment contracts may change. -Three independent runtime patterns are relevant: +Two independent runtime patterns are relevant: - **Registry Relay** exposes protected, scoped, read-only HTTP APIs over existing sources. -- **Registry Notary** certifies evidence: claim evaluation, credential - issuance, disclosure policy, audit provenance. -- **Evidence** is a separate minimum-disclosure assertion service. It is not a - Notary mode or rewrite and does not inherit the Notary product model. +- **Evidence** returns signed, minimum-disclosure assertions from fixed + requests to authoritative sources. + +The two patterns compose without merging their product boundaries: Evidence +may use a Relay-protected API as a fixed HTTP source. Registry Manifest describes sources portably; Relay is its consumer in code -(Notary does not depend on the manifest crates). `registry-platform-*` crates -are shared primitives. `registryctl` is adopter tooling. +and `registry-platform-*` crates are shared primitives. `registryctl` is Relay +adopter tooling; `registry-evidencectl` is Evidence adopter tooling. -Registry Mint is a supporting service, not a fourth pattern: it issues the +Registry Mint is a supporting service, not a third pattern: it issues the access tokens a resource server such as Evidence verifies, for deployments with no identity provider. The dependency runs one way only. Mint's tests drive Evidence's authenticator; Evidence does not depend on Mint. @@ -26,13 +27,12 @@ Evidence's authenticator; Evidence does not depend on Mint. | Area | Owns | |---|---| | `crates/registry-relay` | Protected read APIs (Relay) | -| `crates/registry-notary*` | Evidence gateway: server, core, client, source adapters, worker harness (Notary) | | `crates/registry-evidence` | Single-crate Evidence runtime and `evidence` binary | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, project scaffolds, fixture runs | | `crates/registry-mint` | Short-lived access tokens for registered clients, and the `mint` binary | | `crates/registry-manifest-*` | Manifest core types and CLI | -| `crates/registry-platform-*` | Shared primitives: audit, authcommon, cache, config, crypto, httpsec, httputil, oid4vci, oidc, ops, pdp, replay, sdjwt, sts, testing | -| `crates/registryctl` | Adopter tooling | +| `crates/registry-platform-*` | Shared primitives used by the maintained runtimes and tooling | +| `crates/registryctl` | Relay adopter tooling | | `products/` | Product-owned specs, examples, fixtures, docs (not crates) | | `docs/site/` | Public docs site (Astro). Has its own `AGENTS.md`; read it before touching this subtree | | `release/` | Release manifests, schemas, notes, validation and conformance tooling, and the release source-model proof | @@ -40,27 +40,25 @@ Evidence's authenticator; Evidence does not depend on Mint. ## Evidence product boundary -Evidence work must remain independent from `registry-notary*`. Do not copy or -depend on Notary product abstractions merely because both products use the word -evidence. In particular, Evidence version one does not inherit credential -issuance lifecycle, OID4VCI, PDP, replay, federation, worker, or document -subsystems. Evidence serializes the same stateless assertion as an SD-JWT VC -response format under its own frozen profile; that is a second encoding of one -response, never a credential lifecycle. +Evidence is its own minimum-disclosure assertion product, not a Relay mode. +Evidence may consume a Relay-protected API through its ordinary fixed HTTP +source contract, but it does not inherit Relay's authorization or policy +model. Evidence serializes the same stateless assertion as a signed flattened +JWS or, under its own frozen profile, as an SD-JWT VC response. The latter is a +second encoding of one response, never a credential lifecycle. The runtime implementation is one `registry-evidence` crate and one `evidence` binary. It may reuse narrowly applicable `registry-platform-*` primitives such as audit, crypto, OIDC, HTTP security, SD-JWT serialization, -and testing. It must not depend on `registry-notary*`. +and testing. `registry-evidencectl` (`evidencectl`) is adopter tooling beside the runtime, like `registryctl` is for the rest of the stack. It sits outside the frozen Version 1 runtime contract: it generates key material and deployment-project scaffolds and drives fixture runs, but it shells out to the `evidence` binary for every Evidence semantic decision and never re-implements evaluation, -signing, or verification. It must not depend on `registry-notary*`, and its -source and scaffold templates are covered by the same source-product and -domain neutrality checks as the runtime. +signing, or verification. Its source and scaffold templates are covered by the +same source-product and domain neutrality checks as the runtime. Evidence configuration and scripts are trusted, startup-only deployment artifacts. Rust owns authentication, authorization, fixed source execution, @@ -89,10 +87,6 @@ DHIS2 and OpenCRVS names and behavior are test-only. Evidence production code, dependencies, Cargo features, public configuration schemas, routes, and CLI options must remain source-product neutral. -The `changing-notary-endpoints` skill and Notary-specific OpenAPI commands do -not apply to Evidence. Use the Evidence-specific guidance and verification -commands rather than extending Notary guidance by analogy. - The adopter demo is maintained separately in [`registrystack/solmara-lab`](https://github.com/registrystack/solmara-lab). @@ -113,10 +107,9 @@ Root CI's `rust` job runs `cargo fmt --check`, `cargo check --locked --workspace --all-targets`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --locked --workspace`, the full `cargo deny check` (advisories included; unresolvable RUSTSEC advisories carry scoped ignores in -`deny.toml` with review triggers), and the Notary and Relay OpenAPI drift checks -(`just openapi-check` from `products/notary`, `just openapi-contract` from -`crates/registry-relay`). cargo-deny needs v0.19+ to parse this -`deny.toml`; CI pins 0.19.8. +`deny.toml` with review triggers), and the Relay OpenAPI drift check +(`just openapi-contract` from `crates/registry-relay`). cargo-deny needs v0.19+ +to parse this `deny.toml`; CI pins 0.19.8. Evidence-specific contracts and source neutrality: @@ -129,7 +122,6 @@ Release source checks: ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py release/scripts/registry-release validate release/manifests/.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -140,15 +132,15 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. ## Rules that bite - Every commit needs a DCO sign-off: `git commit -s`. -- Commit subjects: imperative mood; `fix(notary):`, `feat(relay):`, and - `feat(evidence):` style prefixes are the norm for product-scoped changes. +- Commit subjects: imperative mood; `feat(relay):` and `feat(evidence):` style + prefixes are the norm for product-scoped changes. - History may be rewritten during review (session commits get squashed). In durable docs, cite only commits reachable from pushed `main`, and prefer stable facts plus dates over commit SHAs. - Major functionality and bug fixes require automated tests with the change. - Keep a change scoped to one owning area (`crates/`, `products/`, `docs/site/`, `release/`). -- Changes to authentication, authorization, credential issuance, signing, +- Changes to authentication, authorization, assertion evaluation or signing, audit integrity, release provenance, deployment defaults, or data minimization are security-sensitive and need explicit review notes. - Generated outputs (OpenAPI under `docs/site/openapi/`, `docs/site` @@ -156,7 +148,7 @@ Docs site (from `docs/site/`): `npm test` and `npm run check`. generator commands, never hand-edited, and must be bit-for-bit repeatable. If you change an HTTP endpoint, regenerating and committing the OpenAPI documents is part of the change, not a follow-up. -- Suspected vulnerabilities (credential disclosure, auth bypass, audit +- Suspected vulnerabilities (minimum-disclosure failure, auth bypass, audit redaction failure, connector data leakage, signing key handling) go through `SECURITY.md`, never public issues or PRs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3e9a5a77..0e10d131d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,10 +55,10 @@ Every pull request should make the review path clear: check was skipped and why. - Include tests for major functionality and bug fixes. If a test cannot be added, explain why in the pull request. -- Treat changes to authentication, authorization, credential issuance, signing, - audit integrity, release provenance, deployment defaults, or data minimization - as security-sensitive. These changes need explicit maintainer review notes, - even when the maintainer is also the author. +- Treat changes to authentication, authorization, assertion evaluation or + signing, audit integrity, release provenance, deployment defaults, or data + minimization as security-sensitive. These changes need explicit maintainer + review notes, even when the maintainer is also the author. ## Dependency Changes @@ -129,7 +129,6 @@ cargo check --locked --workspace --all-targets cargo clippy --workspace --all-targets -- -D warnings cargo test --locked --workspace cargo deny check -(cd products/notary && just openapi-check) (cd crates/registry-relay && just openapi-contract) ``` @@ -144,8 +143,7 @@ These checks require Python 3.11 or later. ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-17.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-27.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -183,6 +181,7 @@ same source can reproduce it exactly. ## Security Reports -Do not open public issues or pull requests for suspected credential disclosure, -auth bypass, audit redaction failure, source connector data leakage, signing key -handling bugs, or other vulnerabilities. Follow [SECURITY.md](SECURITY.md). +Do not open public issues or pull requests for suspected minimum-disclosure +failure, auth bypass, audit redaction failure, source connector data leakage, +signing key handling bugs, or other vulnerabilities. Follow +[SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index 4faaf586a..b0d04b05b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) Registry Stack helps institutions build registry-facing services over data they -already hold: protected read APIs, governed evidence responses, credentials, and +already hold: protected read APIs, signed minimum-disclosure assertions, and audit records, without turning the registry into a shared database. This repository is the monorepo source of truth for Registry Stack product code, @@ -25,6 +25,7 @@ release manifests, and docs. |---|---| | Understand the product | [registrystack.org](https://registrystack.org/) | | Read the technical docs | [docs.registrystack.org](https://docs.registrystack.org/) | +| Get a first minimum-disclosure assertion | [Evidence quickstart](https://docs.registrystack.org/dev/start/evidence-quickstart/) | | Build and run the maintained HTTP project | [Registry Stack 1.0 first run](https://docs.registrystack.org/dev/tutorials/author-registry-project/) | | Move a pre-1.0 project | [Pre-1.0 cutover](https://docs.registrystack.org/dev/start/pre-1.0-cutover/) | | Install VS Code or Zed integration | [Editor integrations](editors/README.md) | @@ -34,48 +35,45 @@ release manifests, and docs. ## What It Includes -Registry Stack contains three independent runtime patterns: +Registry Stack contains two independent runtime patterns: - **Protected Registry APIs:** scoped, read-only HTTP APIs over existing files, extracts, databases, or legacy registry systems. Registry Relay implements this surface. -- **Evidence Gateway:** governed evidence responses, claim evaluation, - credential issuance, disclosure policy, and audit provenance. Registry Notary - implements claim evaluation and credential issuance; governed Registry Relay - routes use the same Policy Decision Point pattern for protected reads. -- **Evidence:** a small service that returns signed, - minimum-disclosure assertion evidence from fixed authoritative-source - requests. Evidence is not a Registry Notary mode or rewrite. Its first - version excludes credentials, documents, federation, and a general policy +- **Evidence:** a small service that returns signed, minimum-disclosure + assertions from fixed authoritative-source requests. Its first version + excludes credential lifecycles, documents, federation, and a general policy engine. -The stack also includes Registry Manifest for portable metadata, Registry -Platform shared primitives, `registryctl` adopter tooling, and release tooling -for validating the public source model. +Evidence can use a Relay-protected API as one of its fixed sources. The stack +also includes Registry Mint for short-lived access tokens, Registry Manifest +for portable metadata, Registry Platform shared primitives, `registryctl` and +`evidencectl` adopter tooling, and release tooling for validating the public +source model. ```mermaid flowchart LR source["Existing registry source
file, extract, database, platform"] manifest["Registry Manifest
describe"] relay["Registry Relay
expose protected reads"] - notary["Registry Notary
certify evidence"] evidence["Evidence
minimum-disclosure assertions"] - caller["Approved service, verifier, or wallet"] + mint["Registry Mint
issue short-lived tokens"] + caller["Approved service or verifier"] source --> relay manifest --> relay relay --> caller - relay --> notary - notary --> caller source -. fixed request .-> evidence + relay -. protected fixed request .-> evidence evidence -. signed assertion .-> caller + mint -. access token .-> caller ``` ## Repository Layout -- `crates/`: Rust crates and runnable binaries for Platform, Manifest, Notary, - Relay, Evidence, `registryctl`, and shared release tooling. Evidence lives in - one `crates/registry-evidence` crate with one `evidence` binary. +- `crates/`: Rust crates and runnable binaries for Platform, Manifest, Relay, + Evidence, Mint, `registryctl`, and `evidencectl`. Evidence lives in one + `crates/registry-evidence` crate with one `evidence` binary. - `products/`: product-owned docs, examples, Docker inputs, specs, security material, scripts, performance harnesses, and fixtures that are not normal workspace crates. @@ -107,8 +105,7 @@ Release source checks: ```bash python3 -m unittest release/scripts/test_registry_release.py -python3 -m unittest release/scripts/test_openid_conformance_runner.py -release/scripts/registry-release validate release/manifests/registry-stack-beta-6.yaml +release/scripts/registry-release validate release/manifests/registry-stack-beta-27.yaml release/scripts/registry-release audit release/manifests/import-map-2026-06-24.yaml REGISTRY_RELEASE_SOURCE_MODE=monorepo release/scripts/check-release-source-model.sh python3 -m unittest release/scripts/test_check_release_source_model.py @@ -167,8 +164,8 @@ described in [CONTRIBUTING.md](CONTRIBUTING.md#issue-labels). ## Security Report vulnerabilities privately. See [SECURITY.md](SECURITY.md) before opening -a public issue for suspected credential disclosure, auth bypass, audit redaction -failure, source connector data leakage, or signing key handling bugs. +a public issue for suspected minimum-disclosure failure, auth bypass, audit +redaction failure, source connector data leakage, or signing key handling bugs. ## License diff --git a/plans/notary-retirement-and-evidence-onboarding.md b/plans/notary-retirement-and-evidence-onboarding.md index 0c9e8bc4b..f4487e925 100644 --- a/plans/notary-retirement-and-evidence-onboarding.md +++ b/plans/notary-retirement-and-evidence-onboarding.md @@ -116,7 +116,7 @@ gates for its area (see Verification), and committed. every removed URL redirects to its Evidence equivalent or the retirement page; retirement page published; site description updated. Ships with or after B1, in the same release as the code-pin advance. -- [ ] C8. Repo docs updated: `AGENTS.md` (Evidence boundary section +- [x] C8. Repo docs updated: `AGENTS.md` (Evidence boundary section simplified), `CONTRIBUTING.md`, `README.md`. ### D. solmara-lab rebuild (separate repo: registrystack/solmara-lab) @@ -533,3 +533,14 @@ is parallel; B has no upstream dependencies and is the standing priority current plus historical-base stable-surface checks. Evidence artifacts stay optional until F3; C6 does not claim that separate workstream complete. Next in C: C3 approval, then C5 can complete against the deleted workspace graph. +- 2026-08-03: C8 done. Root `AGENTS.md`, `CONTRIBUTING.md`, and `README.md` + now describe the two maintained runtime patterns, Relay and Evidence, plus + their optional composition and Mint's supporting role. Current crate maps, + verification commands, security-review language, architecture diagram, and + onboarding links no longer present Notary as an available product; release + validation points at the C6 beta-27 manifest and the retired OpenID command + is absent. Historical decision, changelog, and release evidence were not + rewritten, and no docs-site or solmara-lab file changed. The focused + deployment-documentation tests, offline link check, stale-surface scan, + beta-27 validation, and diff check passed. Next in C: the mandatory C3 + approval, then C3/C4 and prepared C5. From aad4b27957e77444f341dc342c45d4f10695d333 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 3 Aug 2026 20:36:45 +0700 Subject: [PATCH 091/136] refactor(notary): remove retired product Signed-off-by: Jeremi Joslin --- Cargo.lock | 220 - Cargo.toml | 14 - crates/registry-notary-client/Cargo.toml | 50 - crates/registry-notary-client/README.md | 138 - crates/registry-notary-client/src/auth.rs | 81 - crates/registry-notary-client/src/client.rs | 1403 --- crates/registry-notary-client/src/error.rs | 384 - crates/registry-notary-client/src/facade.rs | 144 - .../src/federation/mod.rs | 4 - crates/registry-notary-client/src/headers.rs | 12 - crates/registry-notary-client/src/lib.rs | 94 - .../src/oid4vci/metadata.rs | 6 - .../registry-notary-client/src/oid4vci/mod.rs | 4 - crates/registry-notary-client/src/options.rs | 137 - .../registry-notary-client/src/responses.rs | 343 - crates/registry-notary-client/src/verifier.rs | 1434 --- .../tests/client_contract.rs | 1491 ---- .../tests/facade_contract.rs | 230 - .../tests/status_verifier_contract.rs | 527 -- .../tests/verifier_contract.rs | 638 -- crates/registry-notary-core/Cargo.toml | 38 - crates/registry-notary-core/README.md | 45 - .../config/documentation-intent.json | 7804 ----------------- crates/registry-notary-core/src/config.rs | 60 - .../registry-notary-core/src/config/audit.rs | 59 - .../registry-notary-core/src/config/auth.rs | 237 - crates/registry-notary-core/src/config/cel.rs | 157 - .../src/config/credential_status.rs | 88 - .../registry-notary-core/src/config/errors.rs | 163 - .../src/config/evidence/claims.rs | 1117 --- .../src/config/evidence/disclosure.rs | 44 - .../src/config/evidence/limits.rs | 67 - .../src/config/evidence/mod.rs | 195 - .../src/config/evidence/relay.rs | 296 - .../src/config/evidence/signing.rs | 323 - .../src/config/federation.rs | 469 - .../registry-notary-core/src/config/http.rs | 175 - .../src/config/oid4vci.rs | 1340 --- .../registry-notary-core/src/config/root.rs | 813 -- .../registry-notary-core/src/config/schema.rs | 204 - .../registry-notary-core/src/config/state.rs | 163 - .../src/config/subject_access.rs | 1204 --- .../registry-notary-core/src/config/tests.rs | 9 - .../src/config/tests/auth.rs | 319 - .../src/config/tests/credentials.rs | 1111 --- .../src/config/tests/infrastructure.rs | 508 -- .../src/config/tests/issuance.rs | 1532 ---- .../src/config/tests/preauth.rs | 561 -- .../src/config/tests/relay.rs | 1789 ---- .../src/config/tests/root.rs | 1279 --- .../src/config/tests/support.rs | 54 - crates/registry-notary-core/src/deployment.rs | 1660 ---- crates/registry-notary-core/src/error.rs | 188 - crates/registry-notary-core/src/lib.rs | 14 - crates/registry-notary-core/src/model.rs | 2818 ------ crates/registry-notary-core/src/sd_jwt.rs | 1114 --- crates/registry-notary-core/src/tokens.rs | 1080 --- crates/registry-notary-server/Cargo.toml | 101 - crates/registry-notary-server/README.md | 263 - .../benches/auth_bench.rs | 77 - .../benches/json_bench.rs | 139 - .../benches/sd_jwt_bench.rs | 179 - .../resources/scalar/api-reference.js | 2364 ----- crates/registry-notary-server/src/api.rs | 232 - .../registry-notary-server/src/api/admin.rs | 306 - .../registry-notary-server/src/api/audit.rs | 774 -- .../registry-notary-server/src/api/catalog.rs | 132 - .../src/api/credentials.rs | 629 -- .../src/api/evaluations.rs | 670 -- .../registry-notary-server/src/api/oid4vci.rs | 15 - .../src/api/oid4vci/credential.rs | 1199 --- .../src/api/oid4vci/metadata.rs | 438 - .../src/api/oid4vci/preauth.rs | 3565 -------- .../src/api/oid4vci/problem.rs | 129 - .../src/api/oid4vci/proof.rs | 259 - .../registry-notary-server/src/api/probes.rs | 401 - .../registry-notary-server/src/api/request.rs | 174 - .../registry-notary-server/src/api/state.rs | 582 -- .../registry-notary-server/src/api/status.rs | 338 - .../src/api/subject_access_policy.rs | 1513 ---- .../src/api/tests/admin.rs | 19 - .../src/api/tests/audit.rs | 268 - .../src/api/tests/credentials.rs | 1186 --- .../src/api/tests/evaluations.rs | 343 - .../src/api/tests/mod.rs | 13 - .../src/api/tests/oid4vci.rs | 3526 -------- .../src/api/tests/state.rs | 396 - .../src/api/tests/status.rs | 170 - .../src/api/tests/subject_access.rs | 1337 --- .../src/api/tests/support.rs | 1568 ---- .../src/authz_details.rs | 448 - .../src/bin/registry_notary_cel_worker.rs | 6 - .../bin/registry_notary_cel_worker_fixture.rs | 181 - .../registry-notary-server/src/cel_worker.rs | 761 -- .../src/config_governed.rs | 77 - .../src/credential_status.rs | 882 -- crates/registry-notary-server/src/digest.rs | 45 - crates/registry-notary-server/src/docs.rs | 269 - .../src/federation/audit.rs | 190 - .../src/federation/claims.rs | 272 - .../src/federation/errors.rs | 120 - .../src/federation/mod.rs | 645 -- .../src/federation/runtime.rs | 203 - .../src/federation/signing.rs | 220 - .../registry-notary-server/src/json_path.rs | 46 - crates/registry-notary-server/src/lib.rs | 62 - .../src/machine_quota.rs | 1130 --- crates/registry-notary-server/src/metrics.rs | 472 - crates/registry-notary-server/src/openapi.rs | 5089 ----------- crates/registry-notary-server/src/posture.rs | 892 -- .../src/preauth_state.rs | 2454 ------ crates/registry-notary-server/src/problem.rs | 130 - .../src/relay_client.rs | 1623 ---- .../src/relay_client/tests.rs | 2493 ------ .../src/relay_contract.rs | 1048 --- .../src/relay_contract_test_support.rs | 52 - crates/registry-notary-server/src/replay.rs | 384 - .../src/request_context.rs | 51 - .../src/response_context.rs | 54 - crates/registry-notary-server/src/runtime.rs | 102 - .../src/runtime/access.rs | 385 - .../src/runtime/catalog.rs | 318 - .../registry-notary-server/src/runtime/cel.rs | 694 -- .../src/runtime/consultation.rs | 2015 ----- .../src/runtime/disclosure.rs | 126 - .../src/runtime/evaluation.rs | 1799 ---- .../src/runtime/render.rs | 694 -- .../src/runtime/store.rs | 1309 --- .../src/runtime/tests/access.rs | 69 - .../src/runtime/tests/catalog.rs | 440 - .../src/runtime/tests/cel.rs | 472 - .../src/runtime/tests/disclosure.rs | 179 - .../src/runtime/tests/evaluation.rs | 3617 -------- .../src/runtime/tests/render.rs | 622 -- .../src/runtime/tests/support.rs | 243 - .../src/runtime/types.rs | 109 - .../registry-notary-server/src/standalone.rs | 29 - .../src/standalone/activation.rs | 942 -- .../src/standalone/assembly.rs | 798 -- .../src/standalone/auth/audit.rs | 278 - .../src/standalone/auth/credentials.rs | 197 - .../src/standalone/auth/middleware.rs | 360 - .../src/standalone/auth/mod.rs | 309 - .../src/standalone/auth/notary_tokens.rs | 145 - .../src/standalone/auth/oidc.rs | 435 - .../src/standalone/compat.rs | 5 - .../src/standalone/cors.rs | 135 - .../src/standalone/deployment.rs | 143 - .../src/standalone/offline_fixture.rs | 721 -- .../src/standalone/preauth.rs | 916 -- .../src/standalone/relay.rs | 801 -- .../src/standalone/runtime.rs | 144 - .../src/standalone/signing/mod.rs | 10 - .../src/standalone/signing/pkcs11.rs | 422 - .../src/standalone/signing/providers.rs | 813 -- .../src/standalone/tests/assembly.inc | 348 - .../src/standalone/tests/audit.inc | 563 -- .../src/standalone/tests/auth.inc | 1288 --- .../src/standalone/tests/deployment_gates.rs | 1677 ---- .../src/standalone/tests/preauth.inc | 33 - .../src/standalone/tests/signing.inc | 932 -- .../src/standalone/tests/support.inc | 29 - .../src/standalone/transport/mod.rs | 140 - .../src/state_plane/handle.rs | 269 - .../src/state_plane/migration.rs | 1058 --- .../migration/postgres_state_plane_v1.sql | 2391 ----- .../src/state_plane/migration/tests.rs | 3401 ------- .../src/state_plane/mod.rs | 30 - .../src/state_plane/runtime.rs | 1486 ---- .../src/state_plane/sensitive.rs | 1381 --- .../src/subject_access_rate_limit.rs | 1253 --- .../tests/cel_worker.rs | 646 -- .../tests/offline_fixture.rs | 693 -- .../tests/sd_jwt_vc_verifier_compat.rs | 419 - .../tests/standalone_http.rs | 23 - .../tests/standalone_http/admin.rs | 919 -- .../tests/standalone_http/audit.rs | 287 - .../tests/standalone_http/auth.rs | 460 - .../tests/standalone_http/credentials.rs | 1625 ---- .../tests/standalone_http/federation.rs | 1418 --- .../tests/standalone_http/http_contracts.rs | 712 -- .../tests/standalone_http/oid4vci.rs | 1497 ---- .../tests/standalone_http/preauth.rs | 2473 ------ .../tests/standalone_http/preauth_support.rs | 548 -- .../tests/standalone_http/support.rs | 686 -- .../registry-notary-worker-harness/Cargo.toml | 27 - .../registry-notary-worker-harness/README.md | 6 - .../src/bin/worker_harness_fixture.rs | 205 - .../registry-notary-worker-harness/src/lib.rs | 1353 --- .../tests/worker_pool.rs | 793 -- crates/registry-notary/Cargo.toml | 56 - crates/registry-notary/README.md | 94 - crates/registry-notary/src/boot.rs | 527 -- crates/registry-notary/src/boot/tests.rs | 818 -- .../registry-notary/src/commands/api_key.rs | 70 - .../src/commands/api_key/tests.rs | 18 - crates/registry-notary/src/commands/audit.rs | 63 - .../src/commands/audit/tests.rs | 161 - .../src/commands/config_bundle.rs | 165 - .../src/commands/config_bundle/tests.rs | 65 - .../src/commands/healthcheck.rs | 22 - .../src/commands/healthcheck/tests.rs | 95 - crates/registry-notary/src/commands/mod.rs | 11 - crates/registry-notary/src/commands/state.rs | 94 - .../src/commands/state/tests.rs | 115 - crates/registry-notary/src/config_loader.rs | 644 -- .../src/config_loader/tests.rs | 996 --- crates/registry-notary/src/doctor.rs | 907 -- crates/registry-notary/src/doctor/tests.rs | 574 -- crates/registry-notary/src/env_file.rs | 135 - crates/registry-notary/src/env_file/tests.rs | 79 - crates/registry-notary/src/explain_config.rs | 291 - .../src/explain_config/tests.rs | 208 - crates/registry-notary/src/logging.rs | 66 - crates/registry-notary/src/logging/tests.rs | 39 - crates/registry-notary/src/main.rs | 585 -- crates/registry-notary/src/product_action.rs | 602 -- crates/registry-notary/src/serve.rs | 333 - crates/registry-notary/src/test_support.rs | 245 - crates/registry-notary/tests/config_schema.rs | 826 -- .../tests/config_verify_bundle_cli.rs | 774 -- crates/registry-notary/tests/doctor_cli.rs | 1346 --- .../tests/startup_redaction.rs | 309 - crates/registry-notary/tests/state_cli.rs | 101 - crates/registry-notary/tests/version_cli.rs | 21 - crates/registry-relay/Cargo.toml | 2 - .../dhis2-2.41.9-enrollment-status/README.md | 87 +- .../notary-config.example.yaml | 90 - .../notary-config.example.yaml | 92 - .../scripts/run-live-consultation-journey.sh | 296 - .../tests/live_consultation_journeys.rs | 2596 ------ ...tary-retirement-and-evidence-onboarding.md | 13 +- products/notary/.cargo/config.toml | 19 - products/notary/.dockerignore | 14 - products/notary/.github/dependabot.yml | 26 - products/notary/.gitignore | 16 - products/notary/.gitleaks.toml | 20 - products/notary/.gitleaksignore | 4 - products/notary/.mise.toml | 2 - products/notary/.semgrep.yml | 32 - products/notary/AGENTS.md | 19 - products/notary/CHANGELOG.md | 764 -- products/notary/CODEOWNERS | 4 - products/notary/Dockerfile | 52 - products/notary/LICENSE | 158 - products/notary/README.md | 82 - products/notary/agent-skills/README.md | 11 - .../evidence-request-subject-model-spec.md | 1645 ---- .../specs/federated-notary-manifest-spec.md | 1837 ---- .../notary/archive/specs/scalability-spec.md | 156 - .../source-adapter-sidecar-source-spec.md | 271 - products/notary/bindings/node/.gitignore | 3 - .../notary/bindings/node/package-lock.json | 32 - products/notary/bindings/node/package.json | 27 - products/notary/bindings/node/src/case.js | 53 - products/notary/bindings/node/src/client.js | 949 -- products/notary/bindings/node/src/errors.js | 105 - products/notary/bindings/node/src/index.d.ts | 237 - products/notary/bindings/node/src/index.js | 2 - .../notary/bindings/node/test/client.test.js | 699 -- products/notary/bindings/node/test/types.ts | 90 - products/notary/bindings/node/tsconfig.json | 15 - .../notary/bindings/python/pyproject.toml | 17 - .../python/registry_notary/__init__.py | 12 - .../bindings/python/registry_notary/client.py | 897 -- .../bindings/python/registry_notary/errors.py | 53 - .../bindings/python/registry_notary/py.typed | 1 - .../bindings/python/tests/test_client.py | 618 -- products/notary/docs/README.md | 48 - products/notary/docs/api-reference.md | 93 - products/notary/docs/architecture-overview.md | 74 - products/notary/docs/client-sdk-guide.md | 1198 --- .../docs/configuration-trust-and-integrity.md | 69 - .../docs/credential-issuance-migration.md | 123 - .../docs/credential-lifecycle-status.md | 255 - .../docs/deployment-hardening-runbook.md | 142 - .../federated-evaluation-operator-guide.md | 195 - .../docs/identity-and-record-matching.md | 103 - .../notary/docs/notary-capability-matrix.md | 90 - .../notary/docs/notary-scenario-patterns.md | 79 - .../notary/docs/oid4vci-wallet-interop.md | 331 - .../notary/docs/operator-config-reference.md | 915 -- ...gresql-correctness-state-execution-spec.md | 516 -- .../docs/postgresql-state-operations.md | 544 -- products/notary/docs/release-notes.md | 439 - .../representative-credential-issuance.md | 277 - .../docs/sd-jwt-vc-conformance-profile.md | 194 - products/notary/docs/security-assurance.md | 290 - products/notary/docs/signing-key-provider.md | 313 - .../docs/source-claim-modeling-guide.md | 127 - .../docs/subject-access-operator-guide.md | 191 - products/notary/fuzz/.gitignore | 4 - products/notary/fuzz/Cargo.lock | 2863 ------ products/notary/fuzz/Cargo.toml | 24 - products/notary/fuzz/README.md | 58 - .../core_request_bodies/batch_evaluate.json | 1 - .../core_request_bodies/credential_issue.json | 1 - .../corpus/core_request_bodies/evaluate.json | 1 - .../corpus/core_request_bodies/holder.json | 1 - .../corpus/core_request_bodies/render.json | 1 - .../render_evaluation.json | 1 - .../fuzz/fuzz_targets/core_request_bodies.rs | 16 - products/notary/justfile | 97 - .../notary/openapi/oasdiff-err-ignore.txt | 6 - .../openapi/registry-notary.openapi.json | 5322 ----------- products/notary/scripts/cargo-deny-check.sh | 24 - .../notary/scripts/check-openapi-contract.sh | 66 - products/notary/scripts/check-security.sh | 69 - .../scripts/check_advisory_baselines.py | 735 -- .../scripts/check_security_assurance.py | 1005 --- products/notary/scripts/ci-preflight.sh | 21 - .../notary/scripts/postgresql-conformance.sh | 1243 --- .../notary/security/advisory-baseline.json | 70 - .../notary/security/auth-none-allowlist.yml | 54 - .../notary/security/exposure-manifest.json | 663 -- products/notary/security/route-inventory.json | 250 - products/notary/specs/README.md | 42 - .../specs/adr-audit-pseudonym-redesign.md | 190 - .../specs/bounded-batch-evaluation-v1.md | 105 - .../specs/federated-evaluation-mvp-spec.md | 744 -- .../notary/specs/gitb-conformance-suite.md | 76 - .../notary-api-v1-route-cleanup-proposal.md | 188 - .../notary/specs/notary-capability-gaps.md | 146 - .../specs/openid4vci-wallet-facade-spec.md | 174 - .../tests/advisory_baseline_check_test.py | 322 - .../fixtures/sd_jwt_vc/algorithm-profile.json | 44 - .../tests/fixtures/sd_jwt_vc/expired.sd-jwt | 1 - .../holder-eddsa-private.test.jwk.json | 8 - .../fixtures/sd_jwt_vc/holder-proof-eddsa.jwt | 1 - .../holder-proof-es256-unsupported.jwt | 1 - .../sd_jwt_vc/holder-proof-mismatch.sd-jwt | 1 - .../fixtures/sd_jwt_vc/holder-public-jwk.json | 7 - .../issuer-eddsa-private.test.jwk.json | 8 - .../issuer-es256-private.test.jwk.json | 9 - .../tests/fixtures/sd_jwt_vc/issuer-jwks.json | 19 - .../sd_jwt_vc/malformed-disclosure.sd-jwt | 1 - .../notary/tests/fixtures/sd_jwt_vc/meta.json | 10 - .../missing-cnf-when-binding-required.sd-jwt | 1 - .../sd_jwt_vc/tampered-disclosure.sd-jwt | 1 - .../fixtures/sd_jwt_vc/unsupported-alg.sd-jwt | 1 - .../fixtures/sd_jwt_vc/valid-es256.sd-jwt | 1 - .../sd_jwt_vc/valid-holder-bound.sd-jwt | 1 - .../tests/fixtures/sd_jwt_vc/valid.sd-jwt | 1 - .../tests/fixtures/sd_jwt_vc/wrong-kid.sd-jwt | 1 - .../tests/fixtures/sd_jwt_vc/wrong-vct.sd-jwt | 1 - .../tests/security_assurance_check_test.py | 601 -- products/notary/xtask/Cargo.toml | 17 - products/notary/xtask/src/main.rs | 495 -- 348 files changed, 24 insertions(+), 177889 deletions(-) delete mode 100644 crates/registry-notary-client/Cargo.toml delete mode 100644 crates/registry-notary-client/README.md delete mode 100644 crates/registry-notary-client/src/auth.rs delete mode 100644 crates/registry-notary-client/src/client.rs delete mode 100644 crates/registry-notary-client/src/error.rs delete mode 100644 crates/registry-notary-client/src/facade.rs delete mode 100644 crates/registry-notary-client/src/federation/mod.rs delete mode 100644 crates/registry-notary-client/src/headers.rs delete mode 100644 crates/registry-notary-client/src/lib.rs delete mode 100644 crates/registry-notary-client/src/oid4vci/metadata.rs delete mode 100644 crates/registry-notary-client/src/oid4vci/mod.rs delete mode 100644 crates/registry-notary-client/src/options.rs delete mode 100644 crates/registry-notary-client/src/responses.rs delete mode 100644 crates/registry-notary-client/src/verifier.rs delete mode 100644 crates/registry-notary-client/tests/client_contract.rs delete mode 100644 crates/registry-notary-client/tests/facade_contract.rs delete mode 100644 crates/registry-notary-client/tests/status_verifier_contract.rs delete mode 100644 crates/registry-notary-client/tests/verifier_contract.rs delete mode 100644 crates/registry-notary-core/Cargo.toml delete mode 100644 crates/registry-notary-core/README.md delete mode 100644 crates/registry-notary-core/config/documentation-intent.json delete mode 100644 crates/registry-notary-core/src/config.rs delete mode 100644 crates/registry-notary-core/src/config/audit.rs delete mode 100644 crates/registry-notary-core/src/config/auth.rs delete mode 100644 crates/registry-notary-core/src/config/cel.rs delete mode 100644 crates/registry-notary-core/src/config/credential_status.rs delete mode 100644 crates/registry-notary-core/src/config/errors.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/claims.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/disclosure.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/limits.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/mod.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/relay.rs delete mode 100644 crates/registry-notary-core/src/config/evidence/signing.rs delete mode 100644 crates/registry-notary-core/src/config/federation.rs delete mode 100644 crates/registry-notary-core/src/config/http.rs delete mode 100644 crates/registry-notary-core/src/config/oid4vci.rs delete mode 100644 crates/registry-notary-core/src/config/root.rs delete mode 100644 crates/registry-notary-core/src/config/schema.rs delete mode 100644 crates/registry-notary-core/src/config/state.rs delete mode 100644 crates/registry-notary-core/src/config/subject_access.rs delete mode 100644 crates/registry-notary-core/src/config/tests.rs delete mode 100644 crates/registry-notary-core/src/config/tests/auth.rs delete mode 100644 crates/registry-notary-core/src/config/tests/credentials.rs delete mode 100644 crates/registry-notary-core/src/config/tests/infrastructure.rs delete mode 100644 crates/registry-notary-core/src/config/tests/issuance.rs delete mode 100644 crates/registry-notary-core/src/config/tests/preauth.rs delete mode 100644 crates/registry-notary-core/src/config/tests/relay.rs delete mode 100644 crates/registry-notary-core/src/config/tests/root.rs delete mode 100644 crates/registry-notary-core/src/config/tests/support.rs delete mode 100644 crates/registry-notary-core/src/deployment.rs delete mode 100644 crates/registry-notary-core/src/error.rs delete mode 100644 crates/registry-notary-core/src/lib.rs delete mode 100644 crates/registry-notary-core/src/model.rs delete mode 100644 crates/registry-notary-core/src/sd_jwt.rs delete mode 100644 crates/registry-notary-core/src/tokens.rs delete mode 100644 crates/registry-notary-server/Cargo.toml delete mode 100644 crates/registry-notary-server/README.md delete mode 100644 crates/registry-notary-server/benches/auth_bench.rs delete mode 100644 crates/registry-notary-server/benches/json_bench.rs delete mode 100644 crates/registry-notary-server/benches/sd_jwt_bench.rs delete mode 100644 crates/registry-notary-server/resources/scalar/api-reference.js delete mode 100644 crates/registry-notary-server/src/api.rs delete mode 100644 crates/registry-notary-server/src/api/admin.rs delete mode 100644 crates/registry-notary-server/src/api/audit.rs delete mode 100644 crates/registry-notary-server/src/api/catalog.rs delete mode 100644 crates/registry-notary-server/src/api/credentials.rs delete mode 100644 crates/registry-notary-server/src/api/evaluations.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci/credential.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci/metadata.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci/preauth.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci/problem.rs delete mode 100644 crates/registry-notary-server/src/api/oid4vci/proof.rs delete mode 100644 crates/registry-notary-server/src/api/probes.rs delete mode 100644 crates/registry-notary-server/src/api/request.rs delete mode 100644 crates/registry-notary-server/src/api/state.rs delete mode 100644 crates/registry-notary-server/src/api/status.rs delete mode 100644 crates/registry-notary-server/src/api/subject_access_policy.rs delete mode 100644 crates/registry-notary-server/src/api/tests/admin.rs delete mode 100644 crates/registry-notary-server/src/api/tests/audit.rs delete mode 100644 crates/registry-notary-server/src/api/tests/credentials.rs delete mode 100644 crates/registry-notary-server/src/api/tests/evaluations.rs delete mode 100644 crates/registry-notary-server/src/api/tests/mod.rs delete mode 100644 crates/registry-notary-server/src/api/tests/oid4vci.rs delete mode 100644 crates/registry-notary-server/src/api/tests/state.rs delete mode 100644 crates/registry-notary-server/src/api/tests/status.rs delete mode 100644 crates/registry-notary-server/src/api/tests/subject_access.rs delete mode 100644 crates/registry-notary-server/src/api/tests/support.rs delete mode 100644 crates/registry-notary-server/src/authz_details.rs delete mode 100644 crates/registry-notary-server/src/bin/registry_notary_cel_worker.rs delete mode 100644 crates/registry-notary-server/src/bin/registry_notary_cel_worker_fixture.rs delete mode 100644 crates/registry-notary-server/src/cel_worker.rs delete mode 100644 crates/registry-notary-server/src/config_governed.rs delete mode 100644 crates/registry-notary-server/src/credential_status.rs delete mode 100644 crates/registry-notary-server/src/digest.rs delete mode 100644 crates/registry-notary-server/src/docs.rs delete mode 100644 crates/registry-notary-server/src/federation/audit.rs delete mode 100644 crates/registry-notary-server/src/federation/claims.rs delete mode 100644 crates/registry-notary-server/src/federation/errors.rs delete mode 100644 crates/registry-notary-server/src/federation/mod.rs delete mode 100644 crates/registry-notary-server/src/federation/runtime.rs delete mode 100644 crates/registry-notary-server/src/federation/signing.rs delete mode 100644 crates/registry-notary-server/src/json_path.rs delete mode 100644 crates/registry-notary-server/src/lib.rs delete mode 100644 crates/registry-notary-server/src/machine_quota.rs delete mode 100644 crates/registry-notary-server/src/metrics.rs delete mode 100644 crates/registry-notary-server/src/openapi.rs delete mode 100644 crates/registry-notary-server/src/posture.rs delete mode 100644 crates/registry-notary-server/src/preauth_state.rs delete mode 100644 crates/registry-notary-server/src/problem.rs delete mode 100644 crates/registry-notary-server/src/relay_client.rs delete mode 100644 crates/registry-notary-server/src/relay_client/tests.rs delete mode 100644 crates/registry-notary-server/src/relay_contract.rs delete mode 100644 crates/registry-notary-server/src/relay_contract_test_support.rs delete mode 100644 crates/registry-notary-server/src/replay.rs delete mode 100644 crates/registry-notary-server/src/request_context.rs delete mode 100644 crates/registry-notary-server/src/response_context.rs delete mode 100644 crates/registry-notary-server/src/runtime.rs delete mode 100644 crates/registry-notary-server/src/runtime/access.rs delete mode 100644 crates/registry-notary-server/src/runtime/catalog.rs delete mode 100644 crates/registry-notary-server/src/runtime/cel.rs delete mode 100644 crates/registry-notary-server/src/runtime/consultation.rs delete mode 100644 crates/registry-notary-server/src/runtime/disclosure.rs delete mode 100644 crates/registry-notary-server/src/runtime/evaluation.rs delete mode 100644 crates/registry-notary-server/src/runtime/render.rs delete mode 100644 crates/registry-notary-server/src/runtime/store.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/access.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/catalog.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/cel.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/disclosure.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/evaluation.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/render.rs delete mode 100644 crates/registry-notary-server/src/runtime/tests/support.rs delete mode 100644 crates/registry-notary-server/src/runtime/types.rs delete mode 100644 crates/registry-notary-server/src/standalone.rs delete mode 100644 crates/registry-notary-server/src/standalone/activation.rs delete mode 100644 crates/registry-notary-server/src/standalone/assembly.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/audit.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/credentials.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/middleware.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/mod.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/notary_tokens.rs delete mode 100644 crates/registry-notary-server/src/standalone/auth/oidc.rs delete mode 100644 crates/registry-notary-server/src/standalone/compat.rs delete mode 100644 crates/registry-notary-server/src/standalone/cors.rs delete mode 100644 crates/registry-notary-server/src/standalone/deployment.rs delete mode 100644 crates/registry-notary-server/src/standalone/offline_fixture.rs delete mode 100644 crates/registry-notary-server/src/standalone/preauth.rs delete mode 100644 crates/registry-notary-server/src/standalone/relay.rs delete mode 100644 crates/registry-notary-server/src/standalone/runtime.rs delete mode 100644 crates/registry-notary-server/src/standalone/signing/mod.rs delete mode 100644 crates/registry-notary-server/src/standalone/signing/pkcs11.rs delete mode 100644 crates/registry-notary-server/src/standalone/signing/providers.rs delete mode 100644 crates/registry-notary-server/src/standalone/tests/assembly.inc delete mode 100644 crates/registry-notary-server/src/standalone/tests/audit.inc delete mode 100644 crates/registry-notary-server/src/standalone/tests/auth.inc delete mode 100644 crates/registry-notary-server/src/standalone/tests/deployment_gates.rs delete mode 100644 crates/registry-notary-server/src/standalone/tests/preauth.inc delete mode 100644 crates/registry-notary-server/src/standalone/tests/signing.inc delete mode 100644 crates/registry-notary-server/src/standalone/tests/support.inc delete mode 100644 crates/registry-notary-server/src/standalone/transport/mod.rs delete mode 100644 crates/registry-notary-server/src/state_plane/handle.rs delete mode 100644 crates/registry-notary-server/src/state_plane/migration.rs delete mode 100644 crates/registry-notary-server/src/state_plane/migration/postgres_state_plane_v1.sql delete mode 100644 crates/registry-notary-server/src/state_plane/migration/tests.rs delete mode 100644 crates/registry-notary-server/src/state_plane/mod.rs delete mode 100644 crates/registry-notary-server/src/state_plane/runtime.rs delete mode 100644 crates/registry-notary-server/src/state_plane/sensitive.rs delete mode 100644 crates/registry-notary-server/src/subject_access_rate_limit.rs delete mode 100644 crates/registry-notary-server/tests/cel_worker.rs delete mode 100644 crates/registry-notary-server/tests/offline_fixture.rs delete mode 100644 crates/registry-notary-server/tests/sd_jwt_vc_verifier_compat.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/admin.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/audit.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/auth.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/credentials.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/federation.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/http_contracts.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/oid4vci.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/preauth.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/preauth_support.rs delete mode 100644 crates/registry-notary-server/tests/standalone_http/support.rs delete mode 100644 crates/registry-notary-worker-harness/Cargo.toml delete mode 100644 crates/registry-notary-worker-harness/README.md delete mode 100644 crates/registry-notary-worker-harness/src/bin/worker_harness_fixture.rs delete mode 100644 crates/registry-notary-worker-harness/src/lib.rs delete mode 100644 crates/registry-notary-worker-harness/tests/worker_pool.rs delete mode 100644 crates/registry-notary/Cargo.toml delete mode 100644 crates/registry-notary/README.md delete mode 100644 crates/registry-notary/src/boot.rs delete mode 100644 crates/registry-notary/src/boot/tests.rs delete mode 100644 crates/registry-notary/src/commands/api_key.rs delete mode 100644 crates/registry-notary/src/commands/api_key/tests.rs delete mode 100644 crates/registry-notary/src/commands/audit.rs delete mode 100644 crates/registry-notary/src/commands/audit/tests.rs delete mode 100644 crates/registry-notary/src/commands/config_bundle.rs delete mode 100644 crates/registry-notary/src/commands/config_bundle/tests.rs delete mode 100644 crates/registry-notary/src/commands/healthcheck.rs delete mode 100644 crates/registry-notary/src/commands/healthcheck/tests.rs delete mode 100644 crates/registry-notary/src/commands/mod.rs delete mode 100644 crates/registry-notary/src/commands/state.rs delete mode 100644 crates/registry-notary/src/commands/state/tests.rs delete mode 100644 crates/registry-notary/src/config_loader.rs delete mode 100644 crates/registry-notary/src/config_loader/tests.rs delete mode 100644 crates/registry-notary/src/doctor.rs delete mode 100644 crates/registry-notary/src/doctor/tests.rs delete mode 100644 crates/registry-notary/src/env_file.rs delete mode 100644 crates/registry-notary/src/env_file/tests.rs delete mode 100644 crates/registry-notary/src/explain_config.rs delete mode 100644 crates/registry-notary/src/explain_config/tests.rs delete mode 100644 crates/registry-notary/src/logging.rs delete mode 100644 crates/registry-notary/src/logging/tests.rs delete mode 100644 crates/registry-notary/src/main.rs delete mode 100644 crates/registry-notary/src/product_action.rs delete mode 100644 crates/registry-notary/src/serve.rs delete mode 100644 crates/registry-notary/src/test_support.rs delete mode 100644 crates/registry-notary/tests/config_schema.rs delete mode 100644 crates/registry-notary/tests/config_verify_bundle_cli.rs delete mode 100644 crates/registry-notary/tests/doctor_cli.rs delete mode 100644 crates/registry-notary/tests/startup_redaction.rs delete mode 100644 crates/registry-notary/tests/state_cli.rs delete mode 100644 crates/registry-notary/tests/version_cli.rs delete mode 100644 crates/registry-relay/profiles/dhis2-2.41.9-enrollment-status/notary-config.example.yaml delete mode 100644 crates/registry-relay/profiles/synthetic-snapshot-exact-person-status/notary-config.example.yaml delete mode 100755 crates/registry-relay/scripts/run-live-consultation-journey.sh delete mode 100644 crates/registry-relay/tests/live_consultation_journeys.rs delete mode 100644 products/notary/.cargo/config.toml delete mode 100644 products/notary/.dockerignore delete mode 100644 products/notary/.github/dependabot.yml delete mode 100644 products/notary/.gitignore delete mode 100644 products/notary/.gitleaks.toml delete mode 100644 products/notary/.gitleaksignore delete mode 100644 products/notary/.mise.toml delete mode 100644 products/notary/.semgrep.yml delete mode 100644 products/notary/AGENTS.md delete mode 100644 products/notary/CHANGELOG.md delete mode 100644 products/notary/CODEOWNERS delete mode 100644 products/notary/Dockerfile delete mode 100644 products/notary/LICENSE delete mode 100644 products/notary/README.md delete mode 100644 products/notary/agent-skills/README.md delete mode 100644 products/notary/archive/specs/evidence-request-subject-model-spec.md delete mode 100644 products/notary/archive/specs/federated-notary-manifest-spec.md delete mode 100644 products/notary/archive/specs/scalability-spec.md delete mode 100644 products/notary/archive/specs/source-adapter-sidecar-source-spec.md delete mode 100644 products/notary/bindings/node/.gitignore delete mode 100644 products/notary/bindings/node/package-lock.json delete mode 100644 products/notary/bindings/node/package.json delete mode 100644 products/notary/bindings/node/src/case.js delete mode 100644 products/notary/bindings/node/src/client.js delete mode 100644 products/notary/bindings/node/src/errors.js delete mode 100644 products/notary/bindings/node/src/index.d.ts delete mode 100644 products/notary/bindings/node/src/index.js delete mode 100644 products/notary/bindings/node/test/client.test.js delete mode 100644 products/notary/bindings/node/test/types.ts delete mode 100644 products/notary/bindings/node/tsconfig.json delete mode 100644 products/notary/bindings/python/pyproject.toml delete mode 100644 products/notary/bindings/python/registry_notary/__init__.py delete mode 100644 products/notary/bindings/python/registry_notary/client.py delete mode 100644 products/notary/bindings/python/registry_notary/errors.py delete mode 100644 products/notary/bindings/python/registry_notary/py.typed delete mode 100644 products/notary/bindings/python/tests/test_client.py delete mode 100644 products/notary/docs/README.md delete mode 100644 products/notary/docs/api-reference.md delete mode 100644 products/notary/docs/architecture-overview.md delete mode 100644 products/notary/docs/client-sdk-guide.md delete mode 100644 products/notary/docs/configuration-trust-and-integrity.md delete mode 100644 products/notary/docs/credential-issuance-migration.md delete mode 100644 products/notary/docs/credential-lifecycle-status.md delete mode 100644 products/notary/docs/deployment-hardening-runbook.md delete mode 100644 products/notary/docs/federated-evaluation-operator-guide.md delete mode 100644 products/notary/docs/identity-and-record-matching.md delete mode 100644 products/notary/docs/notary-capability-matrix.md delete mode 100644 products/notary/docs/notary-scenario-patterns.md delete mode 100644 products/notary/docs/oid4vci-wallet-interop.md delete mode 100644 products/notary/docs/operator-config-reference.md delete mode 100644 products/notary/docs/postgresql-correctness-state-execution-spec.md delete mode 100644 products/notary/docs/postgresql-state-operations.md delete mode 100644 products/notary/docs/release-notes.md delete mode 100644 products/notary/docs/representative-credential-issuance.md delete mode 100644 products/notary/docs/sd-jwt-vc-conformance-profile.md delete mode 100644 products/notary/docs/security-assurance.md delete mode 100644 products/notary/docs/signing-key-provider.md delete mode 100644 products/notary/docs/source-claim-modeling-guide.md delete mode 100644 products/notary/docs/subject-access-operator-guide.md delete mode 100644 products/notary/fuzz/.gitignore delete mode 100644 products/notary/fuzz/Cargo.lock delete mode 100644 products/notary/fuzz/Cargo.toml delete mode 100644 products/notary/fuzz/README.md delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/batch_evaluate.json delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/credential_issue.json delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/evaluate.json delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/holder.json delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/render.json delete mode 100644 products/notary/fuzz/corpus/core_request_bodies/render_evaluation.json delete mode 100644 products/notary/fuzz/fuzz_targets/core_request_bodies.rs delete mode 100644 products/notary/justfile delete mode 100644 products/notary/openapi/oasdiff-err-ignore.txt delete mode 100644 products/notary/openapi/registry-notary.openapi.json delete mode 100755 products/notary/scripts/cargo-deny-check.sh delete mode 100755 products/notary/scripts/check-openapi-contract.sh delete mode 100755 products/notary/scripts/check-security.sh delete mode 100644 products/notary/scripts/check_advisory_baselines.py delete mode 100755 products/notary/scripts/check_security_assurance.py delete mode 100755 products/notary/scripts/ci-preflight.sh delete mode 100755 products/notary/scripts/postgresql-conformance.sh delete mode 100644 products/notary/security/advisory-baseline.json delete mode 100644 products/notary/security/auth-none-allowlist.yml delete mode 100644 products/notary/security/exposure-manifest.json delete mode 100644 products/notary/security/route-inventory.json delete mode 100644 products/notary/specs/README.md delete mode 100644 products/notary/specs/adr-audit-pseudonym-redesign.md delete mode 100644 products/notary/specs/bounded-batch-evaluation-v1.md delete mode 100644 products/notary/specs/federated-evaluation-mvp-spec.md delete mode 100644 products/notary/specs/gitb-conformance-suite.md delete mode 100644 products/notary/specs/notary-api-v1-route-cleanup-proposal.md delete mode 100644 products/notary/specs/notary-capability-gaps.md delete mode 100644 products/notary/specs/openid4vci-wallet-facade-spec.md delete mode 100644 products/notary/tests/advisory_baseline_check_test.py delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/algorithm-profile.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/expired.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-eddsa-private.test.jwk.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-proof-eddsa.jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-proof-es256-unsupported.jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-proof-mismatch.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/holder-public-jwk.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/issuer-eddsa-private.test.jwk.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/issuer-es256-private.test.jwk.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/issuer-jwks.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/malformed-disclosure.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/meta.json delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/missing-cnf-when-binding-required.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/tampered-disclosure.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/unsupported-alg.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/valid-es256.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/valid-holder-bound.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/valid.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/wrong-kid.sd-jwt delete mode 100644 products/notary/tests/fixtures/sd_jwt_vc/wrong-vct.sd-jwt delete mode 100644 products/notary/tests/security_assurance_check_test.py delete mode 100644 products/notary/xtask/Cargo.toml delete mode 100644 products/notary/xtask/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b0cfa8c3b..1f15f6243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,28 +1473,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "cryptoki" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" -dependencies = [ - "bitflags 2.13.0", - "cryptoki-sys", - "libloading", - "log", - "secrecy", -] - -[[package]] -name = "cryptoki-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121" -dependencies = [ - "libloading", -] - [[package]] name = "csv" version = "1.4.0" @@ -2287,9 +2265,6 @@ name = "deadpool-runtime" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" -dependencies = [ - "tokio", -] [[package]] name = "debug_unsafe" @@ -4052,16 +4027,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - [[package]] name = "liblzma" version = "0.4.6" @@ -5585,169 +5550,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "registry-notary" -version = "0.16.3" -dependencies = [ - "axum", - "axum-test", - "base64", - "chrono", - "clap", - "ed25519-dalek", - "getrandom 0.4.3", - "hyper", - "hyper-util", - "jsonschema 0.18.3", - "registry-config-report", - "registry-notary-core", - "registry-notary-server", - "registry-platform-audit", - "registry-platform-authcommon", - "registry-platform-config", - "registry-platform-crypto", - "registry-platform-ops", - "reqwest 0.12.28", - "serde_json", - "serde_norway", - "sha2 0.11.0", - "tempfile", - "time", - "tokio", - "tower", - "tower-http 0.7.0", - "tracing", - "tracing-subscriber", - "ulid", - "wiremock", -] - -[[package]] -name = "registry-notary-client" -version = "0.16.3" -dependencies = [ - "async-trait", - "axum", - "axum-test", - "base64", - "flate2", - "registry-notary-core", - "registry-notary-server", - "registry-platform-crypto", - "registry-platform-httputil", - "registry-platform-oid4vci", - "registry-platform-sdjwt", - "reqwest 0.12.28", - "secrecy", - "serde", - "serde_json", - "serde_norway", - "sha2 0.11.0", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "registry-notary-core" -version = "0.16.3" -dependencies = [ - "base64", - "humantime-serde", - "ipnet", - "registry-platform-authcommon", - "registry-platform-config", - "registry-platform-crypto", - "registry-platform-httputil", - "registry-platform-oid4vci", - "registry-platform-ops", - "registry-platform-sdjwt", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_norway", - "sha2 0.11.0", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "ulid", - "url", - "utoipa", -] - -[[package]] -name = "registry-notary-server" -version = "0.16.3" -dependencies = [ - "async-trait", - "aws-lc-rs", - "axum", - "axum-test", - "base64", - "chrono", - "criterion", - "crosswalk-core", - "cryptoki", - "deadpool", - "getrandom 0.4.3", - "hex", - "hmac 0.13.0", - "jsonschema 0.18.3", - "jsonwebtoken", - "native-tls", - "postgres-native-tls", - "registry-notary-client", - "registry-notary-core", - "registry-notary-worker-harness", - "registry-platform-audit", - "registry-platform-authcommon", - "registry-platform-cache", - "registry-platform-config", - "registry-platform-crypto", - "registry-platform-httpsec", - "registry-platform-httputil", - "registry-platform-oid4vci", - "registry-platform-oidc", - "registry-platform-ops", - "registry-platform-pdp", - "registry-platform-replay", - "registry-platform-sdjwt", - "registry-platform-testing", - "reqwest 0.12.28", - "rustix", - "serde", - "serde_json", - "serde_norway", - "sha2 0.11.0", - "subtle", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "tokio-postgres", - "tower-http 0.7.0", - "tracing", - "ulid", - "utoipa", - "wiremock", - "zeroize", -] - -[[package]] -name = "registry-notary-worker-harness" -version = "0.16.3" -dependencies = [ - "libc", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "registry-platform-audit" version = "0.16.3" @@ -6036,8 +5838,6 @@ dependencies = [ "rcgen", "registry-config-report", "registry-manifest-core", - "registry-notary-core", - "registry-notary-server", "registry-platform-audit", "registry-platform-authcommon", "registry-platform-config", @@ -6527,15 +6327,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -8568,17 +8359,6 @@ dependencies = [ "der", ] -[[package]] -name = "xtask" -version = "0.1.0" -dependencies = [ - "base64", - "registry-platform-crypto", - "registry-platform-sdjwt", - "serde_json", - "tokio", -] - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 7f9ef7875..42fb2d617 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,15 +21,9 @@ members = [ "crates/registry-manifest-core", "crates/registry-manifest-cli", "crates/registry-mint", - "crates/registry-notary-core", - "crates/registry-notary-client", - "crates/registry-notary-server", - "crates/registry-notary", - "crates/registry-notary-worker-harness", "crates/registry-relay", "crates/registry-language-server", "crates/registryctl", - "products/notary/xtask", ] exclude = [ # Parked until Assisted Access or the delegation profile work supplies a @@ -37,7 +31,6 @@ exclude = [ # workspace CI. "crates/registry-platform-sts", "products/platform/fuzz", - "products/notary/fuzz", "products/manifest/fuzz", ] resolver = "2" @@ -59,11 +52,7 @@ registry-evidence = { path = "crates/registry-evidence", version = "0.16.3" } registry-language-server = { path = "crates/registry-language-server", version = "0.16.3" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.16.3" } registry-mint = { path = "crates/registry-mint", version = "0.16.3" } -registry-notary-client = { path = "crates/registry-notary-client", version = "0.16.3" } -registry-notary-core = { path = "crates/registry-notary-core", version = "0.16.3" } registry-relay = { path = "crates/registry-relay", version = "0.16.3" } -registry-notary-server = { path = "crates/registry-notary-server", version = "0.16.3", default-features = false } -registry-notary-worker-harness = { path = "crates/registry-notary-worker-harness", version = "0.16.3" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.16.3" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.16.3" } registry-platform-cache = { path = "crates/registry-platform-cache", version = "0.16.3" } @@ -99,10 +88,8 @@ chrono-tz = { version = "0.10.4" } clap = { version = "4", features = ["derive", "env"] } crc32fast = { version = "1.4" } criterion = { version = "0.8", features = ["html_reports", "async_tokio"] } -cryptoki = { version = "0.12" } csv = { version = "1" } datafusion = { version = "53.1" } -deadpool = { version = "0.12.3", default-features = false, features = ["managed", "rt_tokio_1"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } fs2 = { version = "0.4" } futures = { version = "0.3" } @@ -136,7 +123,6 @@ rhai = { version = "=1.25.1", features = ["sync", "serde"] } rustix = { version = "1", features = ["fs", "process"] } rustls-native-certs = { version = "0.8.4" } ryu-js = { version = "=1.0.3" } -secrecy = { version = "0.10" } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } schemars = { version = "=1.2.1" } diff --git a/crates/registry-notary-client/Cargo.toml b/crates/registry-notary-client/Cargo.toml deleted file mode 100644 index eda1ce25b..000000000 --- a/crates/registry-notary-client/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "registry-notary-client" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Registry Notary HTTP client and binding facade." -readme = "README.md" -repository.workspace = true -publish = false - -[lints] -workspace = true - -[features] -default = ["rustls"] -rustls = [] -native-tls = [] -oid4vci = ["dep:registry-platform-oid4vci"] -federation = [] -json-facade = [] -test-support = [] -verifier = ["dep:base64", "dep:flate2", "dep:registry-platform-crypto", "dep:sha2"] - -[dependencies] -async-trait.workspace = true -base64 = { workspace = true, optional = true } -flate2 = { version = "1", optional = true } -registry-notary-core.workspace = true -registry-platform-crypto = { workspace = true, optional = true } -registry-platform-httputil.workspace = true -registry-platform-oid4vci = { workspace = true, optional = true } -reqwest.workspace = true -secrecy.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2 = { workspace = true, optional = true } -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -tracing.workspace = true - -[dev-dependencies] -axum.workspace = true -axum-test = { version = "20" } -registry-platform-crypto.workspace = true -registry-platform-sdjwt.workspace = true -registry-notary-server = { workspace = true, features = ["registry-notary-cel"] } -serde_norway.workspace = true -tempfile = { version = "3" } -tokio.workspace = true diff --git a/crates/registry-notary-client/README.md b/crates/registry-notary-client/README.md deleted file mode 100644 index 98247977d..000000000 --- a/crates/registry-notary-client/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# registry-notary-client - -Typed Rust HTTP client for Registry Notary. - -Use this crate when Rust application code needs to call Registry Notary without -reimplementing request shapes, purpose handling, route-specific retry, -bounded-response reads, JWKS refresh behavior, or redacted error mapping. - -## Quick Start - -```rust -use registry_notary_client::RegistryNotaryClient; - -let client = RegistryNotaryClient::builder("https://notary.example.gov") - .bearer_token("access-token") - .default_purpose("benefits_eligibility") - .user_agent("benefits-api/1.0") - .build()?; -``` - -Evaluate one target: - -```rust -let response = client - .evaluate_target("Person") - .target_identifier("national_id", "person-1") - .target_identifier_issuer("civil_registry") - .relationship("self") - .claims(["person-is-alive"]) - .disclosure("predicate") - .send() - .await?; - -if let Some(result) = response.body.first_result() { - println!("{} = {:?}", result.claim_id, result.satisfied); -} -``` - -## Main API - -- `RegistryNotaryClient::builder(base_url)` creates a client. -- `evaluate_target(target_type)` starts the ergonomic evaluation builder. -- `evaluate_request`, `batch_evaluate_request`, `render_request`, and - `issue_credential_request` accept core wire request types. `render_request` - extracts `evaluation_id` into the route path before sending the body. -- `health`, `ready`, `admin_reload`, `openapi_json`, `metrics`, `list_claims`, - `get_claim`, and `list_formats` cover operational, discovery, claim catalog, - and format routes. -- `service_document`, `issuer_jwks`, `refresh_jwks`, and `raw_issuer_jwks` - cover discovery and key rotation workflows. -- `credential_status` and `update_credential_status` cover minimal credential - lifecycle status. -- `oid4vci_*` methods are available with the `oid4vci` feature. -- `federation_evaluate_jws` is available with the `federation` feature. -- `verify_sd_jwt_vc`, `verify_credential_response`, and - `verify_oid4vci_credential` are available with the `verifier` feature. These - methods are explicit and opt-in; response decoding never verifies - credentials implicitly. -- `facade::NotaryClientHandle` is available with the `json-facade` feature for - binding authors. - -See [`docs/client-sdk-guide.md`](../../docs/client-sdk-guide.md) for examples in -Rust, Python, and Node.js. - -## Features - -- `oid4vci`: OpenID4VCI endpoint helpers. -- `federation`: delegated evaluation JWS submission. -- `json-facade`: canonical wire-shape JSON facade. -- `verifier`: explicit SD-JWT VC verification against trusted issuer JWKS. -- `test-support`: test-only HTTP client override and loopback HTTP allowance. - -## Explicit SD-JWT VC Verification - -Enable the `verifier` feature to verify credential material after a caller has -chosen the trust policy: - -```rust -use registry_notary_client::{HolderBindingPolicy, VerifyOptions}; - -let options = VerifyOptions::new("did:web:notary.example") - .expected_vct("https://credentials.example/vct/person-is-alive") - .holder_binding(HolderBindingPolicy::Required); - -let verified = client - .verify_credential_response(&credential.body, options) - .await?; -``` - -Verification resolves the JWS `kid` against the client's trusted issuer JWKS, -uses the normal JWKS TTL cache, forces one refresh on `key.unknown`, and then -stops. It checks the allowed algorithm list, header type, issuer, `vct`, -`exp`/`nbf`/`iat` with bounded skew, disclosure digests, and required -holder-binding confirmation. When an SD-JWT VC presentation includes a -key-binding JWT, the verifier separates it from disclosures and verifies its -holder proof signature against `cnf.jwk`. When required holder binding is paired -with `VerifyOptions::key_binding_challenge`, the trailing key-binding JWT is -mandatory and must match the expected audience and nonce. Verifier errors expose -stable redacted codes such as `signature.invalid`, `key.unknown`, -`algorithm.disallowed`, `claim.issuer_mismatch`, `claim.time_invalid`, -`disclosure.digest_mismatch`, and `holder_binding.required`. - -Python and Node wrappers do not expose verifier wrappers in this first phase; -use the Rust verifier or perform verification in application-specific wallet -code. - -## Safety Contract - -The client: - -- rejects multiple auth modes at build time; -- requires HTTPS for non-loopback hosts, with HTTP loopback allowed only in - debug or `test-support` builds; -- disables redirects and ignores proxy environment variables; -- bounds every response body; -- returns successful response status in `NotaryResponse`; -- captures `X-Request-Id` before body decoding; -- rejects `Idempotency-Key` on routes that do not honor it; -- retries only GET routes and idempotent batch evaluation; -- redacts raw Problem Details `detail`, compact credentials, holder proofs, - nonces, SD-JWT disclosures, token material, and credential response bodies - from `Debug`, `Display`, and portable errors. - -## Wire Contract - -Request types come from `registry-notary-core` where the server wire contract is -shared. Batch evaluation uses `registry_notary_core::BatchEvaluateResponse` -directly. Client-owned wrappers such as `Evaluation` and -`CredentialIssueResponse` exist for ergonomic accessors or redacted formatting, -not as compatibility workarounds. - -## Verification - -```bash -cargo test -p registry-notary-client -cargo test -p registry-notary-client --features json-facade,oid4vci,federation,verifier -cargo doc -p registry-notary-client --no-deps --all-features -``` diff --git a/crates/registry-notary-client/src/auth.rs b/crates/registry-notary-client/src/auth.rs deleted file mode 100644 index a1a60abb7..000000000 --- a/crates/registry-notary-client/src/auth.rs +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Authentication primitives for the Registry Notary client. - -use async_trait::async_trait; -use secrecy::{ExposeSecret, SecretString}; - -use crate::error::NotaryClientError; - -/// Static authentication material configured on a client. -/// -/// `Debug` output redacts the underlying secret. The public builder exposes -/// this through [`crate::NotaryClientBuilder::bearer_token`] and -/// [`crate::NotaryClientBuilder::api_key`]. -#[derive(Clone)] -pub enum Auth { - /// Send an `Authorization: Bearer ...` header. - Bearer(SecretString), - /// Send an `X-Api-Key` header. - ApiKey(SecretString), -} - -impl std::fmt::Debug for Auth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Bearer(_) => f.write_str("Bearer()"), - Self::ApiKey(_) => f.write_str("ApiKey()"), - } - } -} - -/// One resolved authentication header for a single request. -/// -/// Implementations of [`AuthProvider`] return this type when credentials are -/// minted or refreshed dynamically. `Debug` and `Display` never print the -/// secret value. -#[derive(Clone)] -pub enum AuthHeader { - /// A complete `Authorization` header value, for example `Bearer `. - Authorization(SecretString), - /// An `X-Api-Key` header value. - ApiKey(SecretString), -} - -impl std::fmt::Debug for AuthHeader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Authorization(_) => f.write_str("Authorization()"), - Self::ApiKey(_) => f.write_str("ApiKey()"), - } - } -} - -impl std::fmt::Display for AuthHeader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl Auth { - pub(crate) fn header(&self) -> AuthHeader { - match self { - Self::Bearer(token) => AuthHeader::Authorization(SecretString::from(format!( - "Bearer {}", - token.expose_secret() - ))), - Self::ApiKey(token) => AuthHeader::ApiKey(token.clone()), - } - } -} - -#[async_trait] -/// Dynamic per-request authentication provider. -/// -/// Use this when a caller needs to refresh an access token, consult a secure -/// credential store, or mint short-lived credentials before each request. The -/// client awaits the provider before sending the request and still enforces the -/// single-auth-mode rule at build time. -pub trait AuthProvider: Send + Sync { - /// Return the authentication header to attach to the next request. - async fn auth_header(&self) -> Result; -} diff --git a/crates/registry-notary-client/src/client.rs b/crates/registry-notary-client/src/client.rs deleted file mode 100644 index 6f2dca23c..000000000 --- a/crates/registry-notary-client/src/client.rs +++ /dev/null @@ -1,1403 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Typed Registry Notary HTTP client. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use registry_notary_core::{ - BatchEvaluateRequest, ClaimRef, CredentialIssueRequest, EvaluateRequest, EvidenceEntity, - EvidenceIdentifier, EvidenceOnBehalfOf, EvidenceRelationship, RenderEvaluationRequest, - RenderRequest, RequestVariables, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, -}; -use registry_platform_httputil::read_bounded; -use reqwest::{Method, StatusCode, Url}; -use secrecy::SecretString; -use serde::de::DeserializeOwned; -use serde::Serialize; - -use crate::auth::{Auth, AuthHeader, AuthProvider}; -use crate::error::{ - parse_retry_after, NotaryClientBuildError, NotaryClientError, Oid4vciError, ProblemDetails, -}; -use crate::headers; -use crate::options::{RequestOptions, RetryPolicy}; -use crate::responses::{ - AdminReloadResponse, CredentialIssueResponse, CredentialStatusResponse, - CredentialStatusUpdateRequest, EvaluateResponse, Evaluation, FormatsResponse, HealthResponse, - ListClaimsResponse, NotaryResponse, ReadinessResponse, -}; -#[cfg(feature = "verifier")] -use crate::verifier::{VerificationError, VerifiedCredential, VerifyOptions}; - -const LIMIT_SMALL: u64 = 64 * 1024; -const LIMIT_DISCOVERY: u64 = 2 * 1024 * 1024; -const LIMIT_OPERATION: u64 = 8 * 1024 * 1024; -const LIMIT_BATCH: u64 = 16 * 1024 * 1024; -const JWKS_TTL: Duration = Duration::from_secs(10 * 60); - -#[derive(Clone)] -enum AuthState { - Static(Auth), - Provider(Arc), -} - -impl std::fmt::Debug for AuthState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Static(auth) => f.debug_tuple("Static").field(auth).finish(), - Self::Provider(_) => f.write_str("Provider()"), - } - } -} - -/// Cloneable typed HTTP client for a Registry Notary service. -/// -/// Construct with [`RegistryNotaryClient::builder`]. Clones share the same -/// underlying `reqwest::Client` and JWKS cache. -#[derive(Debug, Clone)] -pub struct RegistryNotaryClient { - base_url: Url, - http: reqwest::Client, - auth: Option, - default_purpose: Option, - retry_policy: RetryPolicy, - jwks_cache: Arc>>, -} - -#[derive(Debug, Clone)] -struct CachedJwks { - body: serde_json::Value, - expires_at: Instant, -} - -/// Builder for [`RegistryNotaryClient`]. -/// -/// Exactly one authentication mode may be configured. The builder rejects -/// non-HTTPS base URLs except HTTP loopback in debug or `test-support` builds. -#[derive(Default)] -pub struct NotaryClientBuilder { - base_url: Option, - bearer_token: Option, - api_key: Option, - auth_provider: Option>, - default_purpose: Option, - timeout: Option, - user_agent: Option, - retry_policy: Option, - #[cfg(any(test, feature = "test-support"))] - reqwest_client: Option, -} - -impl std::fmt::Debug for NotaryClientBuilder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("NotaryClientBuilder") - .field("base_url", &self.base_url) - .field( - "bearer_token", - &self.bearer_token.as_ref().map(|_| ""), - ) - .field("api_key", &self.api_key.as_ref().map(|_| "")) - .field( - "auth_provider", - &self.auth_provider.as_ref().map(|_| ""), - ) - .field("default_purpose", &self.default_purpose) - .field("timeout", &self.timeout) - .field("user_agent", &self.user_agent) - .field("retry_policy", &self.retry_policy) - .finish() - } -} - -impl RegistryNotaryClient { - /// Start building a client for `base_url`. - /// - /// `base_url` may include a path prefix; route paths are joined under that - /// prefix. - #[must_use] - pub fn builder(base_url: impl Into) -> NotaryClientBuilder { - NotaryClientBuilder { - base_url: Some(base_url.into()), - ..NotaryClientBuilder::default() - } - } - - /// Fetch `GET /healthz`. - pub async fn health(&self) -> Result, NotaryClientError> { - self.get_json( - "/healthz", - RequestOptions::default(), - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Fetch `GET /ready`. - pub async fn ready(&self) -> Result, NotaryClientError> { - self.get_json( - "/ready", - RequestOptions::default(), - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Trigger `POST /admin/v1/reload`. - /// - /// Requires the server-side admin scope or equivalent API key. - pub async fn admin_reload( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.post_json( - "/admin/v1/reload", - &serde_json::json!({}), - options, - LIMIT_SMALL, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Fetch the generated OpenAPI document from `GET /openapi.json`. - pub async fn openapi_json( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/openapi.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// Fetch `GET /.well-known/evidence-service`. - pub async fn service_document( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/.well-known/evidence-service", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// Fetch and cache `GET /.well-known/evidence/jwks.json`. - /// - /// Calls without request options use a short in-process cache. Use - /// [`Self::refresh_jwks`] to force a refresh, or [`Self::raw_issuer_jwks`] - /// to fetch without updating the cache. - pub async fn issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - if options.is_empty() { - let cached = self - .jwks_cache - .lock() - .expect("jwks cache lock poisoned") - .as_ref() - .filter(|cached| cached.expires_at > Instant::now()) - .map(|cached| cached.body.clone()); - if let Some(body) = cached { - return Ok(NotaryResponse { - body, - status: StatusCode::OK, - request_id: None, - retry_after: None, - }); - } - } - self.fetch_issuer_jwks(options).await - } - - /// Force-refresh the evidence issuer JWKS cache. - pub async fn refresh_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.fetch_issuer_jwks(options).await - } - - /// Fetch Prometheus metrics from `GET /metrics`. - /// - /// Metrics are operational data and are returned as text. - pub async fn metrics( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let response = self - .execute( - Method::GET, - "/metrics", - None, - options, - LIMIT_DISCOVERY, - RouteRetry::Get, - ErrorKind::Problem, - None, - None, - ) - .await?; - let request_id = response.request_id.clone(); - let status = response.status; - String::from_utf8(response.body).map_or_else( - |_| Err(NotaryClientError::Decode { status, request_id }), - |body| { - Ok(NotaryResponse { - body, - status, - request_id: response.request_id, - retry_after: response.retry_after, - }) - }, - ) - } - - async fn fetch_issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - let response: NotaryResponse = self - .get_json( - "/.well-known/evidence/jwks.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await?; - let cached = CachedJwks { - body: response.body.clone(), - expires_at: Instant::now() + JWKS_TTL, - }; - *self.jwks_cache.lock().expect("jwks cache lock poisoned") = Some(cached); - Ok(response) - } - - /// Fetch the issuer JWKS without using or updating the client cache. - pub async fn raw_issuer_jwks( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - "/.well-known/evidence/jwks.json", - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - #[cfg(feature = "verifier")] - /// Explicitly verify an SD-JWT VC compact credential against issuer JWKS. - /// - /// This method is opt-in. Transport methods continue to decode response - /// bodies without performing verification. Verification reuses the - /// `issuer_jwks` TTL cache, forces one `refresh_jwks` on `key.unknown`, - /// and never performs an unbounded refresh loop. A status-bearing - /// credential additionally requires [`crate::StatusListPolicy`] in the - /// options. Its signed status list is fetched through a DNS-pinned, - /// no-redirect, no-proxy, bounded HTTPS request and fails closed. - pub async fn verify_sd_jwt_vc( - &self, - compact: &str, - options: VerifyOptions, - ) -> Result { - let mut jwks = self - .issuer_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - let pending = match crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options) { - Err(error) if error.is_unknown_key() => { - jwks = self - .refresh_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - crate::verifier::verify_sd_jwt_vc_pending(compact, &jwks, &options)? - } - Err(error) => return Err(error), - Ok(pending) => pending, - }; - if let Some(status) = &pending.status { - let status_token = crate::verifier::fetch_status_list_token(status, &options).await?; - match crate::verifier::verify_status_list_token(&status_token, status, &jwks, &options) - { - Err(error) if error.is_status_unknown_key() => { - jwks = self - .refresh_jwks(RequestOptions::default()) - .await - .map_err(|_| VerificationError::jwks_unavailable())? - .body; - crate::verifier::verify_status_list_token( - &status_token, - status, - &jwks, - &options, - )?; - } - Err(error) => return Err(error), - Ok(()) => {} - } - } - Ok(pending.credential) - } - - #[cfg(feature = "verifier")] - /// Explicitly verify a direct credential-issuance response. - pub async fn verify_credential_response( - &self, - response: &CredentialIssueResponse, - options: VerifyOptions, - ) -> Result { - self.verify_sd_jwt_vc(&response.credential, options).await - } - - #[cfg(feature = "oid4vci")] - /// Fetch OpenID4VCI issuer metadata. - /// - /// This helper wraps the endpoint only. It does not generate holder proofs. - pub async fn oid4vci_issuer_metadata( - &self, - options: RequestOptions, - ) -> Result< - NotaryResponse, - NotaryClientError, - > { - self.reject_idempotency(&options)?; - self.get_json( - "/.well-known/openid-credential-issuer", - options, - LIMIT_DISCOVERY, - ErrorKind::Oid4vci, - ) - .await - } - - #[cfg(feature = "oid4vci")] - /// Submit an OpenID4VCI credential request. - /// - /// The caller is responsible for holder-key custody and proof JWT creation. - pub async fn oid4vci_credential( - &self, - request: registry_platform_oid4vci::CredentialRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> - { - self.reject_idempotency(&options)?; - self.post_json( - "/oid4vci/credential", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Oid4vci, - ) - .await - } - - #[cfg(all(feature = "oid4vci", feature = "verifier"))] - /// Explicitly verify an OpenID4VCI credential response. - pub async fn verify_oid4vci_credential( - &self, - response: ®istry_platform_oid4vci::CredentialResponse, - options: VerifyOptions, - ) -> Result { - self.verify_sd_jwt_vc(oid4vci_compact_credential(&response.credential)?, options) - .await - } - - /// List configured claim definitions with `GET /v1/claims`. - /// - /// The current server contract returns a bounded, unpaginated list. - pub async fn list_claims( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json("/v1/claims", options, LIMIT_DISCOVERY, ErrorKind::Problem) - .await - } - - /// Fetch one claim definition by claim id. - pub async fn get_claim( - &self, - claim_id: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - &format!("/v1/claims/{}", encode_path_segment(claim_id)), - options, - LIMIT_DISCOVERY, - ErrorKind::Problem, - ) - .await - } - - /// List evidence formats supported by the service. - pub async fn list_formats( - &self, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json("/v1/formats", options, LIMIT_DISCOVERY, ErrorKind::Problem) - .await - } - - /// Start the ergonomic evaluation builder for one target entity. - #[must_use] - pub fn evaluate_target(&self, target_type: impl Into) -> EvaluateBuilder<'_> { - EvaluateBuilder { - client: self, - target: EvidenceEntity::new(target_type), - requester: None, - relationship: None, - on_behalf_of: None, - variables: BTreeMap::new(), - claims: Vec::new(), - disclosure: None, - format: None, - purpose: None, - request_id: None, - traceparent: None, - } - } - - /// Start the ergonomic evaluation builder for a Person target id. - /// - /// This convenience helper maps to the v1 `target` request model. Prefer - /// [`Self::evaluate_target`] when the caller needs identifiers, attributes, - /// requester context, or non-person targets. - #[must_use] - pub fn evaluate(&self, subject_id: impl Into) -> EvaluateBuilder<'_> { - self.evaluate_target("Person").target_id(subject_id) - } - - /// Submit a raw typed [`EvaluateRequest`]. - /// - /// This method is best when the caller already has the core wire request. - /// It applies default purpose and claim-result format handling before - /// sending. - pub async fn evaluate_request( - &self, - mut request: EvaluateRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - let mut options = self.prepare_purpose(options, request.purpose.as_deref())?; - options.accept = options - .accept - .or_else(|| Some(FORMAT_CLAIM_RESULT_JSON.to_string())); - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - let mut request = request; - if request.format.is_none() { - request.format = Some(FORMAT_CLAIM_RESULT_JSON.to_string()); - } - self.post_json( - "/v1/evaluations", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Submit a raw typed [`BatchEvaluateRequest`]. - /// - /// Batch evaluation is the only POST route where the client allows - /// `Idempotency-Key`; retries require that key. Requests above the hard - /// 100-member platform ceiling fail locally before transport. - pub async fn batch_evaluate_request( - &self, - mut request: BatchEvaluateRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> - { - if request.items.len() > MAX_BATCH_EVALUATION_MEMBERS_V1 { - return Err(NotaryClientBuildError::BatchTooLarge { - actual: request.items.len(), - maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, - } - .into()); - } - let mut options = self.prepare_purpose(options, request.purpose.as_deref())?; - options.accept = options - .accept - .or_else(|| Some(FORMAT_CLAIM_RESULT_JSON.to_string())); - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - if request.format.is_none() { - request.format = Some(FORMAT_CLAIM_RESULT_JSON.to_string()); - } - self.post_json( - "/v1/batch-evaluations", - &request, - options, - LIMIT_BATCH, - RouteRetry::PostBatch, - ErrorKind::Problem, - ) - .await - } - - /// Render a stored evaluation into a requested evidence format. - /// - /// The server models `evaluation_id` as a path parameter. This method accepts - /// the core [`RenderRequest`] DTO for caller ergonomics, then moves - /// `evaluation_id` into `/v1/evaluations/{evaluation_id}/render` before the - /// request body is serialized. - pub async fn render_request( - &self, - request: RenderRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let path = format!( - "/v1/evaluations/{}/render", - encode_path_segment(&request.evaluation_id) - ); - let body = RenderEvaluationRequest::from(request); - self.post_json( - &path, - &body, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Issue a credential from a stored evaluation. - /// - /// Returned credential material is present in typed fields but redacted from - /// `Debug` output. - pub async fn issue_credential_request( - &self, - mut request: CredentialIssueRequest, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let options = self.prepare_purpose(options, request.purpose.as_deref())?; - if request.purpose.is_none() { - request.purpose = options.purpose.clone(); - } - self.post_json( - "/v1/credentials", - &request, - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - /// Fetch minimal credential status by credential id. - pub async fn credential_status( - &self, - credential_id: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.get_json( - &format!( - "/v1/credentials/{}/status", - encode_path_segment(credential_id) - ), - options, - LIMIT_SMALL, - ErrorKind::Problem, - ) - .await - } - - /// Update minimal credential status through the admin route. - pub async fn update_credential_status( - &self, - credential_id: &str, - status: impl Into, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - self.post_json( - &format!( - "/admin/v1/credentials/{}/status", - encode_path_segment(credential_id) - ), - &CredentialStatusUpdateRequest { - status: status.into(), - }, - options, - LIMIT_SMALL, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - ) - .await - } - - #[cfg(feature = "federation")] - /// Submit an already-signed federation evaluation JWS. - /// - /// The client does not mint or sign federation JWTs. - pub async fn federation_evaluate_jws( - &self, - compact_jws: &str, - options: RequestOptions, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - self.execute( - Method::POST, - "/federation/v1/evaluations", - Some(compact_jws.as_bytes().to_vec()), - options, - LIMIT_OPERATION, - RouteRetry::PostNoRetry, - ErrorKind::Problem, - Some(headers::APPLICATION_JWT), - None, - ) - .await - .and_then(|response| { - let request_id = response.request_id.clone(); - let status = response.status; - String::from_utf8(response.body).map_or_else( - |_| Err(NotaryClientError::Decode { status, request_id }), - |body| { - Ok(NotaryResponse { - body, - status, - request_id: response.request_id, - retry_after: response.retry_after, - }) - }, - ) - }) - } - - async fn get_json( - &self, - path: &str, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - ) -> Result, NotaryClientError> { - self.get_json_accepting_status(path, options, limit, error_kind, None) - .await - } - - async fn get_json_accepting_status( - &self, - path: &str, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - accepted_status: Option, - ) -> Result, NotaryClientError> { - self.reject_idempotency(&options)?; - let response = self - .execute( - Method::GET, - path, - None, - options, - limit, - RouteRetry::Get, - error_kind, - None, - accepted_status, - ) - .await?; - let body = - serde_json::from_slice(&response.body).map_err(|_| NotaryClientError::Decode { - status: response.status, - request_id: response.request_id.clone(), - })?; - Ok(response.map(body)) - } - - async fn post_json( - &self, - path: &str, - body: &B, - mut options: RequestOptions, - limit: u64, - retry: RouteRetry, - error_kind: ErrorKind, - ) -> Result, NotaryClientError> { - if matches!(retry, RouteRetry::PostNoRetry) { - self.reject_idempotency(&options)?; - } - options.accept = options - .accept - .or_else(|| Some(headers::APPLICATION_JSON.to_string())); - let raw = - serde_json::to_vec(body).map_err(|_| NotaryClientBuildError::RequestSerialization)?; - let response = self - .execute( - Method::POST, - path, - Some(raw), - options, - limit, - retry, - error_kind, - Some(headers::APPLICATION_JSON), - None, - ) - .await?; - let body = - serde_json::from_slice(&response.body).map_err(|_| NotaryClientError::Decode { - status: response.status, - request_id: response.request_id.clone(), - })?; - Ok(response.map(body)) - } - - #[allow(clippy::too_many_arguments)] - async fn execute( - &self, - method: Method, - path: &str, - body: Option>, - options: RequestOptions, - limit: u64, - route_retry: RouteRetry, - error_kind: ErrorKind, - content_type: Option<&str>, - accepted_status: Option, - ) -> Result>, NotaryClientError> { - let attempts = allowed_attempts(&self.retry_policy, route_retry, &options); - let mut attempt = 0; - loop { - attempt += 1; - let result = self - .send_once( - method.clone(), - path, - body.clone(), - options.clone(), - limit, - error_kind, - content_type, - accepted_status, - ) - .await; - match result { - Ok(response) => return Ok(response), - Err(error) if attempt < attempts && should_retry(&self.retry_policy, &error) => { - let delay = retry_delay(&self.retry_policy, attempt, &error); - tokio::time::sleep(delay).await; - } - Err(error) => return Err(error), - } - } - } - - #[allow(clippy::too_many_arguments)] - async fn send_once( - &self, - method: Method, - path: &str, - body: Option>, - options: RequestOptions, - limit: u64, - error_kind: ErrorKind, - content_type: Option<&str>, - accepted_status: Option, - ) -> Result>, NotaryClientError> { - let url = self.url(path)?; - let auth_header = match &self.auth { - Some(AuthState::Static(auth)) => Some(auth.header()), - Some(AuthState::Provider(provider)) => Some(provider.auth_header().await?), - None => None, - }; - let mut request = self.http.request(method, url); - if let Some(auth) = auth_header { - request = match auth { - AuthHeader::Authorization(value) => request.header( - reqwest::header::AUTHORIZATION, - secrecy::ExposeSecret::expose_secret(&value), - ), - AuthHeader::ApiKey(value) => { - request.header("x-api-key", secrecy::ExposeSecret::expose_secret(&value)) - } - }; - } - if let Some(accept) = &options.accept { - request = request.header(reqwest::header::ACCEPT, accept); - } - if let Some(content_type) = content_type { - request = request.header(reqwest::header::CONTENT_TYPE, content_type); - } - if let Some(purpose) = &options.purpose { - request = request.header(headers::DATA_PURPOSE, purpose); - } - if let Some(request_id) = &options.request_id { - request = request.header(headers::REQUEST_ID, request_id); - } - if let Some(traceparent) = &options.traceparent { - request = request.header(headers::TRACEPARENT, traceparent); - } - if let Some(idempotency_key) = &options.idempotency_key { - request = request.header(headers::IDEMPOTENCY_KEY, idempotency_key); - } - if let Some(body) = body { - request = request.body(body); - } - let response = request.send().await.map_err(NotaryClientError::Transport)?; - let status = response.status(); - let request_id = response - .headers() - .get(headers::REQUEST_ID) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let retry_after = parse_retry_after( - response - .headers() - .get(headers::RETRY_AFTER) - .and_then(|value| value.to_str().ok()), - response - .headers() - .get(headers::DATE) - .and_then(|value| value.to_str().ok()), - ); - let bytes = - read_bounded(response, limit) - .await - .map_err(|_| NotaryClientError::BodyTooLarge { - request_id: request_id.clone(), - })?; - if status.is_success() || accepted_status == Some(status) { - return Ok(NotaryResponse { - body: bytes, - status, - request_id, - retry_after, - }); - } - match error_kind { - ErrorKind::Problem => { - let problem = serde_json::from_slice::(&bytes).map_err(|_| { - NotaryClientError::Decode { - status, - request_id: request_id.clone(), - } - })?; - Err(NotaryClientError::Problem { - status, - problem: Box::new(problem), - request_id, - retry_after, - }) - } - ErrorKind::Oid4vci => { - let error = serde_json::from_slice::(&bytes).map_err(|_| { - NotaryClientError::Decode { - status, - request_id: request_id.clone(), - } - })?; - Err(NotaryClientError::Oid4vci { - status, - error, - request_id, - retry_after, - }) - } - } - } - - fn prepare_purpose( - &self, - mut options: RequestOptions, - body_purpose: Option<&str>, - ) -> Result { - if options.purpose.is_none() { - options.purpose = self - .default_purpose - .clone() - .or_else(|| body_purpose.map(ToOwned::to_owned)); - } - if let (Some(header), Some(body)) = (options.purpose.as_deref(), body_purpose) { - if header != body { - return Err(NotaryClientBuildError::PurposeConflict); - } - } - Ok(options) - } - - fn reject_idempotency(&self, options: &RequestOptions) -> Result<(), NotaryClientBuildError> { - if options.idempotency_key.is_some() { - Err(NotaryClientBuildError::UnsupportedIdempotencyKey) - } else { - Ok(()) - } - } - - fn url(&self, path: &str) -> Result { - self.base_url - .join(path.trim_start_matches('/')) - .map_err(|err| NotaryClientBuildError::Url(err.to_string())) - } -} - -#[cfg(all(feature = "oid4vci", feature = "verifier"))] -fn oid4vci_compact_credential( - credential: ®istry_platform_oid4vci::CredentialValue, -) -> Result<&str, VerificationError> { - match credential { - registry_platform_oid4vci::CredentialValue::String(compact) => Ok(compact.as_str()), - registry_platform_oid4vci::CredentialValue::Object(_) => { - Err(VerificationError::UnsupportedCredentialShape { - code: "credential.unsupported_shape", - }) - } - } -} - -impl NotaryClientBuilder { - /// Configure bearer-token authentication. - #[must_use] - pub fn bearer_token(mut self, token: impl Into) -> Self { - self.bearer_token = Some(SecretString::from(token.into())); - self - } - - /// Configure API-key authentication. - #[must_use] - pub fn api_key(mut self, token: impl Into) -> Self { - self.api_key = Some(SecretString::from(token.into())); - self - } - - /// Configure dynamic authentication. - #[must_use] - pub fn auth_provider(mut self, provider: Arc) -> Self { - self.auth_provider = Some(provider); - self - } - - /// Configure the default data purpose for evaluation requests. - /// - /// A body purpose must match this value unless overridden through - /// [`RequestOptions`]. - #[must_use] - pub fn default_purpose(mut self, purpose: impl Into) -> Self { - self.default_purpose = Some(purpose.into()); - self - } - - /// Configure request timeout. Defaults to 30 seconds. - #[must_use] - pub fn timeout(mut self, timeout: Duration) -> Self { - self.timeout = Some(timeout); - self - } - - /// Configure the `User-Agent` header. - #[must_use] - pub fn user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = Some(user_agent.into()); - self - } - - /// Configure route-aware retry behavior. - #[must_use] - pub fn retry_policy(mut self, retry_policy: RetryPolicy) -> Self { - self.retry_policy = Some(retry_policy); - self - } - - #[cfg(any(test, feature = "test-support"))] - /// Override the HTTP client in tests. - /// - /// This is intentionally unavailable in production builds because it can - /// bypass transport safety defaults. - #[must_use] - pub fn reqwest_client(mut self, client: reqwest::Client) -> Self { - self.reqwest_client = Some(client); - self - } - - /// Build the client and validate base URL and auth configuration. - pub fn build(self) -> Result { - let base_url = self.base_url.unwrap_or_default(); - let mut base_url = - Url::parse(&base_url).map_err(|err| NotaryClientBuildError::Url(err.to_string()))?; - if !base_url.path().ends_with('/') { - base_url - .path_segments_mut() - .map_err(|_| NotaryClientBuildError::Url("base URL cannot be a base".to_string()))? - .push(""); - } - validate_base_url(&base_url)?; - let auth_count = usize::from(self.bearer_token.is_some()) - + usize::from(self.api_key.is_some()) - + usize::from(self.auth_provider.is_some()); - if auth_count > 1 { - return Err(NotaryClientBuildError::MultipleAuthModes); - } - let auth = if let Some(token) = self.bearer_token { - Some(AuthState::Static(Auth::Bearer(token))) - } else if let Some(token) = self.api_key { - Some(AuthState::Static(Auth::ApiKey(token))) - } else { - self.auth_provider.map(AuthState::Provider) - }; - #[cfg(any(test, feature = "test-support"))] - let http = if let Some(client) = self.reqwest_client { - client - } else { - build_http_client(self.timeout, self.user_agent) - }; - #[cfg(not(any(test, feature = "test-support")))] - let http = build_http_client(self.timeout, self.user_agent); - Ok(RegistryNotaryClient { - base_url, - http, - auth, - default_purpose: self.default_purpose, - retry_policy: self.retry_policy.unwrap_or_default(), - jwks_cache: Arc::new(Mutex::new(None)), - }) - } -} - -#[derive(Debug, Clone, Copy)] -enum ErrorKind { - Problem, - #[cfg_attr(not(feature = "oid4vci"), allow(dead_code))] - Oid4vci, -} - -#[derive(Debug, Clone, Copy)] -enum RouteRetry { - Get, - PostBatch, - PostNoRetry, -} - -fn allowed_attempts(policy: &RetryPolicy, route: RouteRetry, options: &RequestOptions) -> usize { - match route { - RouteRetry::Get => policy.max_attempts.max(1), - RouteRetry::PostBatch if options.idempotency_key.is_some() => policy.max_attempts.max(1), - RouteRetry::PostBatch | RouteRetry::PostNoRetry => 1, - } -} - -fn should_retry(policy: &RetryPolicy, error: &NotaryClientError) -> bool { - match error { - NotaryClientError::Transport(_) => policy.retry_transport_errors, - NotaryClientError::Problem { status, .. } | NotaryClientError::Oid4vci { status, .. } => { - (*status == StatusCode::TOO_MANY_REQUESTS && policy.retry_rate_limited) - || (*status == StatusCode::SERVICE_UNAVAILABLE && policy.retry_unavailable) - } - _ => false, - } -} - -fn retry_delay(policy: &RetryPolicy, attempt: usize, error: &NotaryClientError) -> Duration { - if let Some(crate::RetryAfter::Delta(delay)) = error.retry_after() { - return (*delay).min(policy.max_delay); - } - let multiplier = 1_u32 - .checked_shl(attempt.saturating_sub(1) as u32) - .unwrap_or(u32::MAX); - policy - .base_delay - .saturating_mul(multiplier) - .min(policy.max_delay) -} - -fn build_http_client(timeout: Option, user_agent: Option) -> reqwest::Client { - let mut builder = reqwest::Client::builder() - .timeout(timeout.unwrap_or(Duration::from_secs(30))) - .redirect(reqwest::redirect::Policy::none()) - .no_proxy(); - if let Some(user_agent) = user_agent { - builder = builder.user_agent(user_agent); - } - builder - .build() - .expect("registry notary client options are valid") -} - -fn validate_base_url(url: &Url) -> Result<(), NotaryClientBuildError> { - if url.scheme() == "https" { - return Ok(()); - } - #[cfg(any(debug_assertions, feature = "test-support"))] - { - if url.scheme() == "http" - && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) - { - return Ok(()); - } - } - Err(NotaryClientBuildError::InsecureBaseUrl) -} - -fn encode_path_segment(segment: &str) -> String { - segment - .bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - vec![byte as char] - } - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() -} - -/// Fluent builder for one high-level evaluation request. -pub struct EvaluateBuilder<'a> { - client: &'a RegistryNotaryClient, - target: EvidenceEntity, - requester: Option, - relationship: Option, - on_behalf_of: Option, - variables: BTreeMap, - claims: Vec, - disclosure: Option, - format: Option, - purpose: Option, - request_id: Option, - traceparent: Option, -} - -impl<'a> EvaluateBuilder<'a> { - /// Set the target entity id. - #[must_use] - pub fn target_id(mut self, id: impl Into) -> Self { - self.target.id = Some(id.into()); - self - } - - /// Add a target identifier. - #[must_use] - pub fn target_identifier( - mut self, - scheme: impl Into, - value: impl Into, - ) -> Self { - self.target.identifiers.push(EvidenceIdentifier { - scheme: scheme.into(), - value: value.into(), - issuer: None, - country: None, - }); - self - } - - /// Set the issuer on the most recently added target identifier. - #[must_use] - pub fn target_identifier_issuer(mut self, issuer: impl Into) -> Self { - if let Some(identifier) = self.target.identifiers.last_mut() { - identifier.issuer = Some(issuer.into()); - } - self - } - - /// Set the country on the most recently added target identifier. - #[must_use] - pub fn target_identifier_country(mut self, country: impl Into) -> Self { - if let Some(identifier) = self.target.identifiers.last_mut() { - identifier.country = Some(country.into()); - } - self - } - - /// Add a target matching attribute. - #[must_use] - pub fn target_attribute( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - self.target.attributes.insert(name.into(), value.into()); - self - } - - /// Set a pre-built target entity. - #[must_use] - pub fn target(mut self, target: EvidenceEntity) -> Self { - self.target = target; - self - } - - /// Set the requester entity. - #[must_use] - pub fn requester(mut self, requester: EvidenceEntity) -> Self { - self.requester = Some(requester); - self - } - - /// Set the relationship type between requester and target. - #[must_use] - pub fn relationship(mut self, relationship_type: impl Into) -> Self { - self.relationship = Some(EvidenceRelationship { - relationship_type: relationship_type.into(), - attributes: BTreeMap::new(), - }); - self - } - - /// Add an attribute to the requester-target relationship. - #[must_use] - pub fn relationship_attribute( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - let relationship = self - .relationship - .get_or_insert_with(|| EvidenceRelationship { - relationship_type: "unspecified".to_string(), - attributes: BTreeMap::new(), - }); - relationship.attributes.insert(name.into(), value.into()); - self - } - - /// Set the delegated/on-behalf-of context using the frozen minimal actor - /// envelope. Simple deployments omit this entirely. - #[must_use] - pub fn on_behalf_of(mut self, on_behalf_of: EvidenceOnBehalfOf) -> Self { - self.on_behalf_of = Some(on_behalf_of); - self - } - - /// Add one declared RFC 3339 full-date request variable. - #[must_use] - pub fn request_variable_date( - mut self, - name: impl Into, - value: impl Into, - ) -> Self { - self.variables.insert(name.into(), value.into()); - self - } - - /// Set the identifier type for the first target identifier. - /// - /// Prefer [`Self::target_identifier`] for new code. - #[must_use] - pub fn id_type(mut self, id_type: impl Into) -> Self { - let subject_id = self.target.id.take().unwrap_or_default(); - self.target.identifiers.insert( - 0, - EvidenceIdentifier { - scheme: id_type.into(), - value: subject_id, - issuer: None, - country: None, - }, - ); - self - } - - /// Add one claim id. - #[must_use] - pub fn claim(mut self, claim: impl Into) -> Self { - self.claims.push(ClaimRef::new(claim.into())); - self - } - - /// Add multiple claim ids. - #[must_use] - pub fn claims(mut self, claims: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.claims - .extend(claims.into_iter().map(|claim| ClaimRef::new(claim.into()))); - self - } - - /// Set the disclosure mode. - #[must_use] - pub fn disclosure(mut self, disclosure: impl Into) -> Self { - self.disclosure = Some(disclosure.into()); - self - } - - /// Set the requested response format. - #[must_use] - pub fn format(mut self, format: impl Into) -> Self { - self.format = Some(format.into()); - self - } - - /// Set the data purpose for this request. - #[must_use] - pub fn purpose(mut self, purpose: impl Into) -> Self { - self.purpose = Some(purpose.into()); - self - } - - /// Set `X-Request-Id` for this request. - #[must_use] - pub fn request_id(mut self, request_id: impl Into) -> Self { - self.request_id = Some(request_id.into()); - self - } - - /// Set W3C `traceparent` for this request. - #[must_use] - pub fn traceparent(mut self, traceparent: impl Into) -> Self { - self.traceparent = Some(traceparent.into()); - self - } - - /// Send the evaluation request. - pub async fn send(self) -> Result, NotaryClientError> { - let variables = RequestVariables::try_new(self.variables) - .map_err(|_| NotaryClientBuildError::RequestSerialization)?; - let request = EvaluateRequest { - requester: self.requester, - target: Some(self.target), - relationship: self.relationship, - on_behalf_of: self.on_behalf_of, - variables, - claims: self.claims, - disclosure: self.disclosure, - format: self.format, - purpose: self.purpose.clone(), - }; - let options = RequestOptions { - purpose: self.purpose, - request_id: self.request_id, - traceparent: self.traceparent, - accept: Some(FORMAT_CLAIM_RESULT_JSON.to_string()), - ..RequestOptions::default() - }; - self.client - .evaluate_request(request, options) - .await - .map(|response| { - let request_id = response.request_id; - let retry_after = response.retry_after; - let results = response.body.results; - NotaryResponse { - body: Evaluation { results }, - status: response.status, - request_id, - retry_after, - } - }) - } -} diff --git a/crates/registry-notary-client/src/error.rs b/crates/registry-notary-client/src/error.rs deleted file mode 100644 index c3cdec691..000000000 --- a/crates/registry-notary-client/src/error.rs +++ /dev/null @@ -1,384 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Error types for the Registry Notary client. - -use crate::options::RetryAfter; -use crate::responses::ReadinessChecks; - -use std::time::Duration; - -use time::format_description::well_known::Rfc2822; -use time::OffsetDateTime; - -/// Errors raised while constructing a client or preparing a request. -#[derive(Debug, thiserror::Error)] -pub enum NotaryClientBuildError { - /// The base URL could not be parsed. - #[error("invalid base URL")] - Url(String), - /// The base URL is not HTTPS. Debug and `test-support` builds allow HTTP - /// loopback for local tests. - #[error("base URL must use https unless test-support HTTP loopback is enabled")] - InsecureBaseUrl, - /// More than one auth mode was configured. - #[error("multiple authentication modes configured")] - MultipleAuthModes, - /// The purpose in [`crate::RequestOptions`] conflicts with the request body. - #[error("request purpose conflicts with request body purpose")] - PurposeConflict, - /// The request body failed to serialize before sending. - #[error("request body could not be serialized")] - RequestSerialization, - /// An idempotency key was supplied on a route that ignores it. - #[error("idempotency key is not supported for this route")] - UnsupportedIdempotencyKey, - /// The request exceeds the hard Registry Notary batch ceiling. - #[error("batch contains {actual} members; the hard maximum is {maximum}")] - BatchTooLarge { actual: usize, maximum: usize }, -} - -/// RFC 9457 Problem Details emitted by Registry Notary. -/// -/// The server may include sensitive details such as subject identifiers or -/// source-field names in `detail`. `Debug`, `Display`, and portable errors do -/// not render that field. -#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct ProblemDetails { - /// Problem type URI, deserialized from the JSON `type` field. - #[serde(rename = "type")] - pub problem_type: Option, - /// Human-readable title. - pub title: String, - /// HTTP status code. - pub status: u16, - /// Sensitive detail. Do not log this directly. - pub detail: String, - /// Stable machine-readable code. - pub code: String, - /// Server request/correlation id, when included in the problem body. - #[serde(default)] - pub request_id: Option, - /// Readiness status for `GET /ready` failures. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub readiness_status: Option, - /// Typed readiness checks for `GET /ready` failures. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub checks: Option, -} - -impl std::fmt::Debug for ProblemDetails { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ProblemDetails") - .field("problem_type", &self.problem_type) - .field("title", &self.title) - .field("status", &self.status) - .field("detail", &"") - .field("code", &self.code) - .field("request_id", &self.request_id) - .field("readiness_status", &self.readiness_status) - .finish() - } -} - -impl std::fmt::Display for ProblemDetails { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ({})", self.title, self.code) - } -} - -/// OpenID4VCI error envelope. -/// -/// `error_description` can include holder or credential details and is redacted -/// from incidental formatting. -#[derive(Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Oid4vciError { - /// OAuth/OID4VCI error code. - pub error: String, - /// Optional sensitive description. - #[serde(default)] - pub error_description: Option, -} - -impl std::fmt::Debug for Oid4vciError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Oid4vciError") - .field("error", &self.error) - .field( - "error_description", - &self.error_description.as_ref().map(|_| ""), - ) - .finish() - } -} - -impl std::fmt::Display for Oid4vciError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.error) - } -} - -/// Language-binding-safe error family. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum PortableErrorKind { - /// Registry Notary Problem Details. - Problem, - /// OpenID4VCI error envelope. - Oid4vci, - /// Response body could not be decoded. - Decode, - /// Response body exceeded the client limit. - BodyTooLarge, - /// Transport failure before a response was decoded. - Transport, - /// Client build or request preparation failure. - Build, -} - -/// Redacted error envelope intended for Python, Node, and FFI boundaries. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct PortableClientError { - /// Broad error family. - pub kind: PortableErrorKind, - /// HTTP status when a response was available. - #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, - /// Stable problem or client error code. - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - /// Safe title suitable for application logs. - pub title: String, - /// Whether retry may be useful if the route also allows retry. - pub retryable: bool, - /// Server request id, when present. - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, -} - -/// Errors returned by client operations. -/// -/// `Display` is intentionally opaque for decode/body failures and redacts -/// sensitive problem or OID4VCI detail. Use [`Self::request_id`], -/// [`Self::status`], and [`Self::problem_code`] for safe logging. -#[derive(Debug, thiserror::Error)] -pub enum NotaryClientError { - /// Build or request-preparation failure. - #[error(transparent)] - Build(#[from] NotaryClientBuildError), - /// Request transport failed. - #[error("transport error")] - Transport(#[source] reqwest::Error), - /// Registry Notary returned Problem Details. - #[error("registry notary problem: {problem}")] - Problem { - status: reqwest::StatusCode, - problem: Box, - request_id: Option, - retry_after: Option, - }, - /// OpenID4VCI endpoint returned an OID4VCI error envelope. - #[error("openid4vci error: {error}")] - Oid4vci { - status: reqwest::StatusCode, - error: Oid4vciError, - request_id: Option, - retry_after: Option, - }, - /// Response body could not be decoded. - #[error("failed to decode response body")] - Decode { - status: reqwest::StatusCode, - request_id: Option, - }, - /// Response body exceeded the configured route limit. - #[error("response body exceeded configured size limit")] - BodyTooLarge { request_id: Option }, -} - -impl NotaryClientError { - /// HTTP status associated with the error, when available. - #[must_use] - pub fn status(&self) -> Option { - match self { - Self::Problem { status, .. } | Self::Oid4vci { status, .. } => Some(*status), - Self::Decode { status, .. } => Some(*status), - _ => None, - } - } - - /// Stable server or OID4VCI problem code, when available. - #[must_use] - pub fn problem_code(&self) -> Option<&str> { - match self { - Self::Problem { problem, .. } => Some(problem.code.as_str()), - Self::Oid4vci { error, .. } => Some(error.error.as_str()), - _ => None, - } - } - - /// Server request id captured before decoding the response body. - #[must_use] - pub fn request_id(&self) -> Option<&str> { - match self { - Self::Problem { request_id, .. } - | Self::Oid4vci { request_id, .. } - | Self::Decode { request_id, .. } - | Self::BodyTooLarge { request_id } => request_id.as_deref(), - _ => None, - } - } - - /// Parsed `Retry-After` header, when the server provided one. - #[must_use] - pub fn retry_after(&self) -> Option<&RetryAfter> { - match self { - Self::Problem { retry_after, .. } | Self::Oid4vci { retry_after, .. } => { - retry_after.as_ref() - } - _ => None, - } - } - - /// Whether the error class is retryable in principle. - /// - /// Route-specific retry rules still apply. - #[must_use] - pub fn is_retryable(&self) -> bool { - matches!(self.status().map(|status| status.as_u16()), Some(429 | 503)) - || matches!(self, Self::Transport(_)) - } - - /// Convert to a redacted portable envelope for bindings or FFI. - #[must_use] - pub fn portable(&self) -> PortableClientError { - match self { - Self::Problem { - status, - problem, - request_id, - .. - } => PortableClientError { - kind: PortableErrorKind::Problem, - status: Some(status.as_u16()), - code: Some(problem.code.clone()), - title: problem.title.clone(), - retryable: self.is_retryable(), - request_id: request_id.clone(), - }, - Self::Oid4vci { - status, - error, - request_id, - .. - } => PortableClientError { - kind: PortableErrorKind::Oid4vci, - status: Some(status.as_u16()), - code: Some(error.error.clone()), - title: "OpenID4VCI error".to_string(), - retryable: self.is_retryable(), - request_id: request_id.clone(), - }, - Self::Decode { status, request_id } => PortableClientError { - kind: PortableErrorKind::Decode, - status: Some(status.as_u16()), - code: Some("decode.failed".to_string()), - title: "Failed to decode response body".to_string(), - retryable: false, - request_id: request_id.clone(), - }, - Self::BodyTooLarge { request_id } => PortableClientError { - kind: PortableErrorKind::BodyTooLarge, - status: None, - code: Some("body.too_large".to_string()), - title: "Response body exceeded configured size limit".to_string(), - retryable: false, - request_id: request_id.clone(), - }, - Self::Transport(_) => PortableClientError { - kind: PortableErrorKind::Transport, - status: None, - code: Some("transport.failed".to_string()), - title: "Transport error".to_string(), - retryable: true, - request_id: None, - }, - Self::Build(error) => PortableClientError { - kind: PortableErrorKind::Build, - status: None, - code: Some( - match error { - NotaryClientBuildError::Url(_) => "build.invalid_url", - NotaryClientBuildError::InsecureBaseUrl => "build.insecure_base_url", - NotaryClientBuildError::MultipleAuthModes => "build.multiple_auth_modes", - NotaryClientBuildError::PurposeConflict => "request.purpose_conflict", - NotaryClientBuildError::RequestSerialization => { - "request.serialization_failed" - } - NotaryClientBuildError::UnsupportedIdempotencyKey => { - "request.unsupported_idempotency_key" - } - NotaryClientBuildError::BatchTooLarge { .. } => "batch.too_large", - } - .to_string(), - ), - title: error.to_string(), - retryable: false, - request_id: None, - }, - } - } -} - -pub(crate) fn parse_retry_after(raw: Option<&str>, date_raw: Option<&str>) -> Option { - let raw = raw?.trim(); - if raw.is_empty() { - return None; - } - if let Ok(seconds) = raw.parse::() { - return Some(RetryAfter::Delta(Duration::from_secs(seconds))); - } - if let (Some(retry_at), Some(server_at)) = ( - parse_http_date(raw), - date_raw.and_then(|value| parse_http_date(value.trim())), - ) { - let delta = retry_at - server_at; - if delta <= time::Duration::ZERO { - return Some(RetryAfter::Delta(Duration::ZERO)); - } - return Some(RetryAfter::Delta(Duration::new( - delta.whole_seconds() as u64, - delta.subsec_nanoseconds() as u32, - ))); - } - Some(RetryAfter::HttpDate(raw.to_string())) -} - -fn parse_http_date(raw: &str) -> Option { - OffsetDateTime::parse(raw, &Rfc2822).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retry_after_http_date_uses_server_date_for_delta() { - let retry_after = parse_retry_after( - Some("Wed, 31 Dec 2099 00:00:02 GMT"), - Some("Wed, 31 Dec 2099 00:00:00 GMT"), - ); - - assert_eq!(retry_after, Some(RetryAfter::Delta(Duration::from_secs(2)))); - } - - #[test] - fn retry_after_http_date_without_valid_date_preserves_raw_value() { - let retry_after = parse_retry_after(Some("Wed, 31 Dec 2099 00:00:02 GMT"), None); - - assert_eq!( - retry_after, - Some(RetryAfter::HttpDate( - "Wed, 31 Dec 2099 00:00:02 GMT".to_string() - )) - ); - } -} diff --git a/crates/registry-notary-client/src/facade.rs b/crates/registry-notary-client/src/facade.rs deleted file mode 100644 index 63820ab9a..000000000 --- a/crates/registry-notary-client/src/facade.rs +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Binding-safe JSON facade over the typed client. - -use registry_notary_core::{ - BatchEvaluateRequest, CredentialIssueRequest, EvaluateRequest, RenderRequest, -}; - -use crate::{PortableClientError, RegistryNotaryClient, RequestOptions}; - -/// JSON facade for language bindings. -/// -/// Inputs and outputs use canonical wire JSON shape: snake_case field names and -/// the same request/response structure as the HTTP API. The facade converts typed client -/// errors into redacted [`PortableClientError`] values. -#[derive(Debug, Clone)] -pub struct NotaryClientHandle { - client: RegistryNotaryClient, -} - -impl NotaryClientHandle { - /// Wrap a typed client. - #[must_use] - pub fn new(client: RegistryNotaryClient) -> Self { - Self { client } - } - - /// Submit canonical JSON for `POST /v1/evaluations`. - pub async fn evaluate_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .evaluate_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/batch-evaluations`. - pub async fn batch_evaluate_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .batch_evaluate_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/evaluations/{evaluation_id}/render`. - pub async fn render_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .render_request(request, options) - .await - .map(|response| response.body) - .map_err(|error| error.portable()) - } - - /// Submit canonical JSON for `POST /v1/credentials`. - pub async fn issue_credential_json( - &self, - request: serde_json::Value, - options: serde_json::Value, - ) -> Result { - let request = parse_value::(request)?; - let options = parse_options(options)?; - self.client - .issue_credential_request(request, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/claims`. - pub async fn list_claims_json( - &self, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .list_claims(options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/claims/{claim_id}`. - pub async fn get_claim_json( - &self, - claim_id: String, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .get_claim(&claim_id, options) - .await - .map(|response| response.body) - .map_err(|error| error.portable()) - } - - /// Fetch `GET /v1/credentials/{credential_id}/status`. - pub async fn credential_status_json( - &self, - credential_id: String, - options: serde_json::Value, - ) -> Result { - let options = parse_options(options)?; - self.client - .credential_status(&credential_id, options) - .await - .map(|response| serde_json::to_value(response.body).expect("response serializes")) - .map_err(|error| error.portable()) - } -} - -fn parse_options(options: serde_json::Value) -> Result { - parse_value::(options) -} - -fn parse_value( - value: serde_json::Value, -) -> Result { - serde_json::from_value(value).map_err(|_| PortableClientError { - kind: crate::PortableErrorKind::Decode, - status: None, - code: Some("decode.failed".to_string()), - title: "Failed to decode request JSON".to_string(), - retryable: false, - request_id: None, - }) -} diff --git a/crates/registry-notary-client/src/federation/mod.rs b/crates/registry-notary-client/src/federation/mod.rs deleted file mode 100644 index 525ad15d7..000000000 --- a/crates/registry-notary-client/src/federation/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Federation helper module. - -pub use crate::client::RegistryNotaryClient; diff --git a/crates/registry-notary-client/src/headers.rs b/crates/registry-notary-client/src/headers.rs deleted file mode 100644 index af384ad3c..000000000 --- a/crates/registry-notary-client/src/headers.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Header and media-type constants for Registry Notary clients. - -pub const DATA_PURPOSE: &str = "data-purpose"; -pub const IDEMPOTENCY_KEY: &str = "Idempotency-Key"; -pub const REQUEST_ID: &str = "x-request-id"; -pub const TRACEPARENT: &str = "traceparent"; -pub const RETRY_AFTER: &str = "retry-after"; -pub const DATE: &str = "date"; -pub const APPLICATION_JSON: &str = "application/json"; -pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json"; -pub const APPLICATION_JWT: &str = "application/jwt"; diff --git a/crates/registry-notary-client/src/lib.rs b/crates/registry-notary-client/src/lib.rs deleted file mode 100644 index 587dcc958..000000000 --- a/crates/registry-notary-client/src/lib.rs +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Typed Registry Notary HTTP client. -//! -//! This crate is the Rust client for Registry Notary. It wraps the HTTP API -//! with typed request and response types, strict transport defaults, bounded -//! response reads, route-aware retries, and redacted error surfaces. -//! -//! # Quick start -//! -//! ```no_run -//! # async fn run() -> Result<(), Box> { -//! use registry_notary_client::RegistryNotaryClient; -//! -//! let client = RegistryNotaryClient::builder("https://notary.example.gov") -//! .bearer_token("token") -//! .default_purpose("benefits_eligibility") -//! .user_agent("benefits-api/1.0") -//! .build()?; -//! -//! let evaluation = client -//! .evaluate_target("Person") -//! .target_identifier("national_id", "person-1") -//! .relationship("self") -//! .claims(["person-is-alive"]) -//! .disclosure("predicate") -//! .send() -//! .await?; -//! -//! if let Some(result) = evaluation.body.first_result() { -//! println!("{} = {:?}", result.claim_id, result.satisfied); -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! # Wire request types and helper wrappers -//! -//! Request types come from `registry-notary-core` for routes whose wire shapes -//! are part of the service contract. Batch evaluation now uses -//! [`registry_notary_core::BatchEvaluateResponse`] directly. The helper -//! wrappers in [`responses`] are not compatibility workarounds; they add -//! redacted formatting or ergonomic accessors on top of the wire responses. -//! -//! # Feature flags -//! -//! - `oid4vci` enables OpenID4VCI endpoint helpers. -//! - `federation` enables delegated evaluation JWS submission. -//! - `json-facade` enables a binding-safe JSON facade for Python and Node -//! wrappers. -//! - `verifier` enables explicit, opt-in SD-JWT VC verification helpers. -//! - `test-support` exposes the test-only `reqwest::Client` override and -//! loopback HTTP allowance. -//! -//! # Safety contract -//! -//! The client rejects multiple authentication modes, disables redirects, -//! ignores proxy environment variables, bounds every response body, and redacts -//! raw Problem Details `detail`, compact credentials, holder proofs, nonces, -//! SD-JWT disclosures, and token material from incidental formatting surfaces. - -pub mod auth; -mod client; -pub mod error; -pub mod headers; -pub mod options; -pub mod responses; - -#[cfg(feature = "json-facade")] -pub mod facade; -#[cfg(feature = "federation")] -pub mod federation; -#[cfg(feature = "oid4vci")] -pub mod oid4vci; -#[cfg(feature = "verifier")] -pub mod verifier; - -pub use client::{EvaluateBuilder, NotaryClientBuilder, RegistryNotaryClient}; -pub use error::{ - NotaryClientBuildError, NotaryClientError, Oid4vciError, PortableClientError, - PortableErrorKind, ProblemDetails, -}; -pub use options::{RequestOptions, RetryAfter, RetryPolicy}; -pub use responses::{ - AdminReloadResponse, CredentialIssueResponse, CredentialStatusResponse, - CredentialStatusUpdateRequest, EnabledSignerSurfaceChecks, EvaluateResponse, Evaluation, - FormatsResponse, HealthResponse, ListClaimsResponse, NotaryResponse, ReadinessChecks, - ReadinessResponse, SignerCustodyChecks, SignerCustodySurfaces, SignerSurfaceChecks, - SigningProviderReadinessChecks, -}; -#[cfg(feature = "verifier")] -pub use verifier::{ - HolderBindingPolicy, StatusListPolicy, StatusListPolicyError, VerificationError, - VerifiedCredential, VerifyOptions, -}; diff --git a/crates/registry-notary-client/src/oid4vci/metadata.rs b/crates/registry-notary-client/src/oid4vci/metadata.rs deleted file mode 100644 index 0c46adf9d..000000000 --- a/crates/registry-notary-client/src/oid4vci/metadata.rs +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI metadata re-exports. - -pub use registry_platform_oid4vci::{ - CredentialIssuerMetadata, CredentialOffer, CredentialRequest, CredentialResponse, -}; diff --git a/crates/registry-notary-client/src/oid4vci/mod.rs b/crates/registry-notary-client/src/oid4vci/mod.rs deleted file mode 100644 index b7cede456..000000000 --- a/crates/registry-notary-client/src/oid4vci/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI helper module. - -pub mod metadata; diff --git a/crates/registry-notary-client/src/options.rs b/crates/registry-notary-client/src/options.rs deleted file mode 100644 index 28888cb3e..000000000 --- a/crates/registry-notary-client/src/options.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Per-request options and retry policy. - -use std::time::Duration; - -/// Per-request options shared by route methods. -/// -/// These map to safe request headers. Unsupported combinations, such as an -/// idempotency key on a route that does not honor it, are rejected before a -/// request is sent. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -#[non_exhaustive] -pub struct RequestOptions { - /// Data-purpose value sent as the `Data-Purpose` header. - pub purpose: Option, - /// Caller-supplied request id sent as `X-Request-Id`. - pub request_id: Option, - /// `Idempotency-Key` for routes that explicitly support replay-safe POST. - pub idempotency_key: Option, - /// Override the route's default `Accept` header. - pub accept: Option, - /// W3C trace context header to propagate to the server. - pub traceparent: Option, -} - -impl RequestOptions { - /// Start building request options fluently. - #[must_use] - pub fn builder() -> RequestOptionsBuilder { - RequestOptionsBuilder::default() - } - - pub(crate) fn is_empty(&self) -> bool { - self.purpose.is_none() - && self.request_id.is_none() - && self.idempotency_key.is_none() - && self.accept.is_none() - && self.traceparent.is_none() - } -} - -/// Builder for [`RequestOptions`]. -#[derive(Debug, Clone, Default)] -pub struct RequestOptionsBuilder { - options: RequestOptions, -} - -impl RequestOptionsBuilder { - /// Set the `Data-Purpose` header. - #[must_use] - pub fn purpose(mut self, purpose: impl Into) -> Self { - self.options.purpose = Some(purpose.into()); - self - } - - /// Set the `X-Request-Id` header. - #[must_use] - pub fn request_id(mut self, request_id: impl Into) -> Self { - self.options.request_id = Some(request_id.into()); - self - } - - /// Set the `Idempotency-Key` header. - /// - /// The client only permits this on batch evaluation, where the server has a - /// replay contract. - #[must_use] - pub fn idempotency_key(mut self, key: impl Into) -> Self { - self.options.idempotency_key = Some(key.into()); - self - } - - /// Override the `Accept` header for this request. - #[must_use] - pub fn accept(mut self, accept: impl Into) -> Self { - self.options.accept = Some(accept.into()); - self - } - - /// Set the W3C `traceparent` header. - #[must_use] - pub fn traceparent(mut self, traceparent: impl Into) -> Self { - self.options.traceparent = Some(traceparent.into()); - self - } - - /// Finish building the options. - #[must_use] - pub fn build(self) -> RequestOptions { - self.options - } -} - -/// Route-aware retry policy. -/// -/// Retries are conservative by default. GET routes may retry when the selected -/// error class is enabled. Batch evaluation may retry only when an -/// `Idempotency-Key` is supplied. Non-deduplicated POST routes such as -/// evaluation, render, credential issuance, OID4VCI credential, and federation -/// submission are not retried even when this policy allows retryable errors. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RetryPolicy { - /// Maximum attempts, including the first request. - pub max_attempts: usize, - /// Base exponential-backoff delay. - pub base_delay: Duration, - /// Maximum delay between attempts. - pub max_delay: Duration, - /// Retry transport errors on retry-eligible routes. - pub retry_transport_errors: bool, - /// Retry HTTP 429 on retry-eligible routes. - pub retry_rate_limited: bool, - /// Retry HTTP 503 on retry-eligible routes. - pub retry_unavailable: bool, -} - -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_attempts: 1, - base_delay: Duration::from_millis(50), - max_delay: Duration::from_secs(1), - retry_transport_errors: false, - retry_rate_limited: false, - retry_unavailable: false, - } - } -} - -/// Parsed `Retry-After` header value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RetryAfter { - /// Delta-seconds form. - Delta(Duration), - /// HTTP-date form. Callers can log or interpret this if needed. - HttpDate(String), -} diff --git a/crates/registry-notary-client/src/responses.rs b/crates/registry-notary-client/src/responses.rs deleted file mode 100644 index e73e92c12..000000000 --- a/crates/registry-notary-client/src/responses.rs +++ /dev/null @@ -1,343 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Client-owned response types and ergonomic wrappers. - -use std::{collections::BTreeMap, fmt}; - -use registry_notary_core::{BatchEvaluateResponse, ClaimResultView}; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; - -use crate::options::RetryAfter; - -#[doc(hidden)] -pub trait SafeDebug { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result; -} - -macro_rules! impl_safe_debug { - ($($t:ty),* $(,)?) => { - $( - impl SafeDebug for $t { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } - } - )* - }; -} - -/// Response body for `POST /v1/evaluations`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvaluateResponse { - /// Claim results returned by the server. - pub results: Vec, -} - -/// Response body for `GET /v1/claims`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ListClaimsResponse { - /// Claim definitions as server-owned JSON documents. - pub data: Vec, -} - -/// Response body for `GET /v1/formats`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FormatsResponse { - /// Supported evidence formats. - pub formats: Vec, -} - -/// Response body for direct credential issuance. -/// -/// This contains credential material intentionally. `Debug` redacts the compact -/// credential, issuer-signed JWT, and disclosures. -#[derive(Clone, Serialize, Deserialize)] -pub struct CredentialIssueResponse { - /// Server credential id. - pub credential_id: String, - /// Credential profile used for issuance. - pub credential_profile: String, - /// Credential format, for example SD-JWT VC. - pub format: String, - /// Issuer identifier. - pub issuer: String, - /// Credential expiry timestamp. - pub expires_at: String, - /// Compact credential body. - pub credential: String, - /// Issuer-signed JWT component. - pub issuer_signed_jwt: String, - /// SD-JWT disclosures. - pub disclosures: Vec, -} - -impl fmt::Debug for CredentialIssueResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("CredentialIssueResponse") - .field("credential_id", &self.credential_id) - .field("credential_profile", &self.credential_profile) - .field("format", &self.format) - .field("issuer", &self.issuer) - .field("expires_at", &self.expires_at) - .field("credential", &"") - .field("issuer_signed_jwt", &"") - .field("disclosures", &"") - .finish() - } -} - -/// Response body for credential status lookup and update. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CredentialStatusResponse { - /// Server credential id. - pub credential_id: String, - /// Issuer identifier. - pub issuer: String, - /// Credential profile used for issuance. - pub credential_profile: String, - /// Current lifecycle status. - pub status: String, - /// Issuance timestamp. - pub issued_at: String, - /// Expiry timestamp. - pub expires_at: String, - /// Last status update timestamp. - pub updated_at: String, -} - -/// Request body for admin credential status update. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CredentialStatusUpdateRequest { - /// New status value. - pub status: String, -} - -/// Response body for `POST /admin/v1/reload`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AdminReloadResponse { - /// Whether reload executed. - pub reloaded: bool, - /// Reload status. - pub status: String, - /// Human-readable detail. - pub detail: String, -} - -/// Health response body. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct HealthResponse { - /// Overall status. - pub status: String, - /// Service-specific checks. - pub checks: serde_json::Value, -} - -/// Readiness response body. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ReadinessResponse { - /// Overall readiness status. - pub status: String, - /// Readiness checks, including signer-custody facts. - pub checks: ReadinessChecks, -} - -/// Aggregate readiness checks returned on both success and failure. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ReadinessChecks { - /// Total number of readiness checks. - pub total: usize, - /// Checks that are ready. - pub ok: usize, - /// Checks that are serving with reduced assurance. - pub degraded: usize, - /// Checks that are not ready. - pub failed: usize, - /// Signing-provider health and custody facts. - pub signing_providers: SigningProviderReadinessChecks, -} - -/// Runtime signer health and custody checks. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SigningProviderReadinessChecks { - /// Active signing providers checked by the runtime. - pub total: usize, - /// Signing providers that are ready. - pub ok: usize, - /// Signing providers that are not ready. - pub failed: usize, - /// Custody facts for configured signing roles. - pub custody: SignerCustodyChecks, -} - -/// Public, non-secret signer-custody facts. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerCustodyChecks { - /// Active provider kinds and their counts. - pub active_provider_counts: BTreeMap, - /// Distinct providers bound to custody-relevant signing roles. - pub signing_provider_count: usize, - /// Custody-relevant providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Whether the deployment profile requires explicit custody approval. - pub custody_approval_required: bool, - /// Whether the operator declared that custody review has approved the signers. - pub custody_approved: bool, - /// Custody-relevant providers not covered by approval. - pub unapproved_signing_provider_count: usize, - /// Counts grouped by signing surface. - pub surfaces: SignerCustodySurfaces, -} - -/// Signer-custody facts grouped by Notary signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerCustodySurfaces { - /// Credential issuance signer facts. - pub credential_issuance: SignerSurfaceChecks, - /// Access-token issuance signer facts. - pub access_token_issuance: EnabledSignerSurfaceChecks, - /// Federation response signer facts. - pub federation: EnabledSignerSurfaceChecks, -} - -/// Signer counts for an always-available signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SignerSurfaceChecks { - /// Distinct signing providers used by the surface. - pub signing_provider_count: usize, - /// Surface providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Surface providers not covered by custody approval. - pub unapproved_signing_provider_count: usize, -} - -/// Signer counts for an optional signing surface. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct EnabledSignerSurfaceChecks { - /// Whether the optional surface is enabled. - pub enabled: bool, - /// Distinct signing providers used by the surface. - pub signing_provider_count: usize, - /// Surface providers using local JWK or file material. - pub local_software_signing_provider_count: usize, - /// Surface providers not covered by custody approval. - pub unapproved_signing_provider_count: usize, -} - -impl_safe_debug!( - EvaluateResponse, - ListClaimsResponse, - FormatsResponse, - CredentialIssueResponse, - CredentialStatusResponse, - AdminReloadResponse, - HealthResponse, - ReadinessResponse, - ReadinessChecks, - SigningProviderReadinessChecks, - SignerCustodyChecks, - SignerCustodySurfaces, - SignerSurfaceChecks, - EnabledSignerSurfaceChecks, - BatchEvaluateResponse, - serde_json::Value, - String, -); - -impl SafeDebug for Vec { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } -} - -#[cfg(feature = "oid4vci")] -impl_safe_debug!( - registry_platform_oid4vci::CredentialIssuerMetadata, - registry_platform_oid4vci::CredentialOffer, -); - -#[cfg(feature = "oid4vci")] -impl SafeDebug for registry_platform_oid4vci::CredentialResponse { - fn fmt_debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("") - } -} - -/// HTTP response wrapper returned by all typed client methods. -/// -/// The wrapper preserves selected response metadata captured before body -/// decoding. `Debug` uses [`SafeDebug`] to avoid accidental credential leaks for -/// sensitive body types. -#[derive(Clone)] -pub struct NotaryResponse { - /// Decoded response body. - pub body: T, - /// HTTP status returned by the server. - pub status: StatusCode, - /// Server `X-Request-Id`, when present. - pub request_id: Option, - /// Server `Retry-After`, when present. - pub retry_after: Option, -} - -struct SafeDebugBody<'a, T>(&'a T); - -impl fmt::Debug for SafeDebugBody<'_, T> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt_debug(f) - } -} - -impl fmt::Debug for NotaryResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("NotaryResponse") - .field("body", &SafeDebugBody(&self.body)) - .field("status", &self.status) - .field("request_id", &self.request_id) - .field("retry_after", &self.retry_after) - .finish() - } -} - -impl NotaryResponse { - pub(crate) fn map(self, body: U) -> NotaryResponse { - NotaryResponse { - body, - status: self.status, - request_id: self.request_id, - retry_after: self.retry_after, - } - } -} - -/// Ergonomic wrapper over an evaluation response. -#[derive(Debug, Clone)] -pub struct Evaluation { - /// Claim results returned by the server. - pub results: Vec, -} - -impl Evaluation { - /// Return the first result's evaluation id. - #[must_use] - pub fn evaluation_id(&self) -> Option<&str> { - self.results - .first() - .map(|result| result.evaluation_id.as_str()) - } - - /// Return the first claim result. - #[must_use] - pub fn first_result(&self) -> Option<&ClaimResultView> { - self.results.first() - } - - /// Return the first result matching `claim_id`. - #[must_use] - pub fn result_for(&self, claim_id: &str) -> Option<&ClaimResultView> { - self.results - .iter() - .find(|result| result.claim_id == claim_id) - } -} - -impl_safe_debug!(Evaluation); diff --git a/crates/registry-notary-client/src/verifier.rs b/crates/registry-notary-client/src/verifier.rs deleted file mode 100644 index b446f8b16..000000000 --- a/crates/registry-notary-client/src/verifier.rs +++ /dev/null @@ -1,1434 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Explicit SD-JWT VC verification helpers. - -use std::collections::BTreeSet; -use std::fmt; -use std::io::Read; -use std::time::Duration; - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use flate2::read::ZlibDecoder; -use registry_notary_core::SD_JWT_VC_JWT_TYP; -use registry_platform_crypto::{verify, PublicJwk, SigningAlgorithm}; -use registry_platform_httputil::{read_bounded, BoundedReadError, FetchUrlError, FetchUrlPolicy}; -use reqwest::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_ENCODING, CONTENT_TYPE}; -use reqwest::{StatusCode, Url}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use thiserror::Error; -use time::OffsetDateTime; - -const STATUS_LIST_MEDIA_TYPE: &str = "application/statuslist+jwt"; -const STATUS_LIST_FETCH_TIMEOUT: Duration = Duration::from_secs(10); -const STATUS_LIST_DNS_TIMEOUT: Duration = Duration::from_secs(3); -const MAX_STATUS_LIST_RESPONSE_BYTES: u64 = 256 * 1024; -const MAX_STATUS_LIST_COMPRESSED_BYTES: usize = 128 * 1024; -const MAX_STATUS_LIST_DECOMPRESSED_BYTES: usize = 128 * 1024; -const MAX_STATUS_LIST_URI_BYTES: usize = 4_096; -const MAX_STATUS_LIST_ORIGINS: usize = 16; -const MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS: i64 = 300; -const MAX_STATUS_LIST_CLOCK_SKEW_SECONDS: u64 = 60; - -/// Trusted status-list origins associated with exactly one credential issuer. -/// -/// The primary origin is required. Additional origins are accepted only after -/// the caller adds each exact HTTPS origin explicitly. Paths, queries, -/// fragments, URL credentials, localhost, private networks, and unsafe DNS -/// answers are still rejected by the fetch path. -#[derive(Clone, PartialEq, Eq)] -pub struct StatusListPolicy { - issuer: String, - origins: BTreeSet, - #[cfg(any(test, feature = "test-support"))] - allow_loopback_http: bool, -} - -impl fmt::Debug for StatusListPolicy { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("StatusListPolicy") - .field("issuer", &"") - .field("trusted_origin_count", &self.origins.len()) - .finish() - } -} - -impl StatusListPolicy { - /// Bind an issuer to its primary trusted HTTPS status origin. - pub fn new( - issuer: impl Into, - trusted_origin: impl AsRef, - ) -> Result { - let issuer = issuer.into(); - if issuer.trim().is_empty() { - return Err(StatusListPolicyError::InvalidIssuer); - } - let origin = parse_status_origin(trusted_origin.as_ref(), false)?; - Ok(Self { - issuer, - origins: BTreeSet::from([origin]), - #[cfg(any(test, feature = "test-support"))] - allow_loopback_http: false, - }) - } - - /// Add one exact HTTPS origin that is trusted for the same issuer. - pub fn allow_origin(mut self, origin: impl AsRef) -> Result { - if self.origins.len() >= MAX_STATUS_LIST_ORIGINS { - return Err(StatusListPolicyError::TooManyOrigins); - } - let origin = parse_status_origin(origin.as_ref(), false)?; - self.origins.insert(origin); - Ok(self) - } - - /// Construct a loopback-only HTTP policy for local test harnesses. - /// - /// This escape hatch is absent from production release builds. It does not - /// admit non-loopback HTTP origins. - #[cfg(any(test, feature = "test-support"))] - pub fn loopback_for_testing( - issuer: impl Into, - trusted_origin: impl AsRef, - ) -> Result { - let issuer = issuer.into(); - if issuer.trim().is_empty() { - return Err(StatusListPolicyError::InvalidIssuer); - } - let origin = parse_status_origin(trusted_origin.as_ref(), true)?; - Ok(Self { - issuer, - origins: BTreeSet::from([origin]), - allow_loopback_http: true, - }) - } - - fn permits(&self, issuer: &str, url: &Url) -> Result<(), VerificationError> { - if self.issuer != issuer { - return Err(VerificationError::StatusPolicy { - code: "status.policy_issuer_mismatch", - }); - } - let origin = url.origin().ascii_serialization(); - if !self.origins.contains(&origin) { - return Err(VerificationError::StatusPolicy { - code: "status.origin_untrusted", - }); - } - Ok(()) - } - - fn fetch_url_policy(&self) -> FetchUrlPolicy { - #[cfg(any(test, feature = "test-support"))] - if self.allow_loopback_http { - return FetchUrlPolicy::dev(); - } - FetchUrlPolicy::strict() - } -} - -/// Invalid caller-owned status trust configuration. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum StatusListPolicyError { - #[error("status-list issuer must not be empty")] - InvalidIssuer, - #[error("status-list origin must be an exact HTTPS origin")] - InvalidOrigin, - #[error("status-list origin contains URL credentials or resource components")] - OriginHasResourceComponents, - #[error("status-list origin allow-list exceeds the supported bound")] - TooManyOrigins, -} - -/// Caller-owned policy for explicit SD-JWT VC verification. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VerifyOptions { - /// Issuer identifier expected in the `iss` claim. - pub expected_issuer: String, - /// Accepted JWS algorithms. Defaults to `EdDSA`. - pub accepted_algorithms: BTreeSet, - /// Expected SD-JWT VC `vct`, when the caller wants a concrete profile. - pub expected_vct: Option, - /// Allowed skew for `exp`, `nbf`, and future `iat` checks. - pub clock_skew: Duration, - /// Holder-binding policy for the embedded `cnf` confirmation. - pub holder_binding: HolderBindingPolicy, - /// Expected key-binding JWT audience for verifier-controlled challenges. - pub expected_key_binding_audience: Option, - /// Expected key-binding JWT nonce for verifier-controlled challenges. - pub expected_key_binding_nonce: Option, - /// Mandatory trust policy when the credential carries a status reference. - pub status_list: Option, - /// Test hook for deterministic time checks. Production callers should leave - /// this unset. - pub now: Option, -} - -impl VerifyOptions { - #[must_use] - pub fn new(expected_issuer: impl Into) -> Self { - Self { - expected_issuer: expected_issuer.into(), - accepted_algorithms: BTreeSet::from(["EdDSA".to_string()]), - expected_vct: None, - clock_skew: Duration::from_secs(60), - holder_binding: HolderBindingPolicy::NotRequired, - expected_key_binding_audience: None, - expected_key_binding_nonce: None, - status_list: None, - now: None, - } - } - - #[must_use] - pub fn expected_vct(mut self, expected_vct: impl Into) -> Self { - self.expected_vct = Some(expected_vct.into()); - self - } - - #[must_use] - pub fn accepted_algorithms( - mut self, - algorithms: impl IntoIterator>, - ) -> Self { - self.accepted_algorithms = algorithms.into_iter().map(Into::into).collect(); - self - } - - #[must_use] - pub fn clock_skew(mut self, clock_skew: Duration) -> Self { - self.clock_skew = clock_skew; - self - } - - #[must_use] - pub fn holder_binding(mut self, holder_binding: HolderBindingPolicy) -> Self { - self.holder_binding = holder_binding; - self - } - - #[must_use] - pub fn key_binding_challenge( - mut self, - expected_audience: impl Into, - expected_nonce: impl Into, - ) -> Self { - self.expected_key_binding_audience = Some(expected_audience.into()); - self.expected_key_binding_nonce = Some(expected_nonce.into()); - self - } - - /// Configure fail-closed verification for status-bearing credentials. - #[must_use] - pub fn status_list(mut self, policy: StatusListPolicy) -> Self { - self.status_list = Some(policy); - self - } - - #[must_use] - pub fn now(mut self, now: OffsetDateTime) -> Self { - self.now = Some(now); - self - } -} - -/// Holder-binding expectation for the credential confirmation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum HolderBindingPolicy { - /// Do not require a holder confirmation. - NotRequired, - /// Require `cnf.jwk`, and validate that it is a public JWK. - Required, - /// Require `cnf.jwk` and this exact `cnf.kid`. - RequiredKid(String), -} - -/// Verified credential metadata returned after successful verification. -#[derive(Clone, PartialEq, Eq)] -pub struct VerifiedCredential { - pub issuer: String, - pub subject: Option, - pub credential_id: Option, - pub vct: String, - pub key_id: String, - pub algorithm: String, - pub expires_at: i64, - pub not_before: Option, - pub issued_at: i64, - pub disclosure_count: usize, - pub holder_key_id: Option, -} - -impl fmt::Debug for VerifiedCredential { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("VerifiedCredential") - .field("issuer", &self.issuer) - .field("subject", &self.subject.as_ref().map(|_| "")) - .field("credential_id", &self.credential_id) - .field("vct", &self.vct) - .field("key_id", &self.key_id) - .field("algorithm", &self.algorithm) - .field("expires_at", &self.expires_at) - .field("not_before", &self.not_before) - .field("issued_at", &self.issued_at) - .field("disclosure_count", &self.disclosure_count) - .field("holder_key_id", &self.holder_key_id) - .finish() - } -} - -/// Redacted verifier error with a stable policy code. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum VerificationError { - #[error("SD-JWT VC is malformed")] - Malformed { code: &'static str }, - #[error("SD-JWT VC header uses an unsupported type")] - HeaderType { code: &'static str }, - #[error("SD-JWT VC header contains an untrusted key reference")] - UntrustedKeyReference { code: &'static str }, - #[error("SD-JWT VC algorithm is not accepted")] - AlgorithmDisallowed { code: &'static str }, - #[error("SD-JWT VC signing key metadata does not match the header")] - AlgorithmKeyMismatch { code: &'static str }, - #[error("SD-JWT VC signing key is missing")] - MissingKeyId { code: &'static str }, - #[error("SD-JWT VC signing key is unknown")] - UnknownKey { code: &'static str }, - #[error("SD-JWT VC signature is invalid")] - InvalidSignature { code: &'static str }, - #[error("SD-JWT VC issuer does not match policy")] - IssuerMismatch { code: &'static str }, - #[error("SD-JWT VC credential profile does not match policy")] - VctMismatch { code: &'static str }, - #[error("SD-JWT VC time claim is invalid")] - TimeClaim { code: &'static str }, - #[error("SD-JWT VC disclosure digest does not match")] - DisclosureDigestMismatch { code: &'static str }, - #[error("SD-JWT VC holder binding does not match policy")] - HolderBinding { code: &'static str }, - #[error("OID4VCI credential response does not contain a compact SD-JWT VC")] - UnsupportedCredentialShape { code: &'static str }, - #[error("issuer JWKS could not be loaded")] - JwksUnavailable { code: &'static str }, - #[error("credential status trust policy is missing or does not match")] - StatusPolicy { code: &'static str }, - #[error("status-bearing credentials require the asynchronous client verifier")] - StatusVerificationRequired { code: &'static str }, - #[error("credential status endpoint could not be reached safely")] - StatusFetch { code: &'static str }, - #[error("credential status response does not satisfy transport policy")] - StatusResponse { code: &'static str }, - #[error("credential status-list token is invalid")] - StatusToken { code: &'static str }, - #[error("credential status is not valid")] - CredentialStatus { code: &'static str }, -} - -impl VerificationError { - #[must_use] - pub const fn code(&self) -> &'static str { - match self { - Self::Malformed { code } - | Self::HeaderType { code } - | Self::UntrustedKeyReference { code } - | Self::AlgorithmDisallowed { code } - | Self::AlgorithmKeyMismatch { code } - | Self::MissingKeyId { code } - | Self::UnknownKey { code } - | Self::InvalidSignature { code } - | Self::IssuerMismatch { code } - | Self::VctMismatch { code } - | Self::TimeClaim { code } - | Self::DisclosureDigestMismatch { code } - | Self::HolderBinding { code } - | Self::UnsupportedCredentialShape { code } - | Self::JwksUnavailable { code } - | Self::StatusPolicy { code } - | Self::StatusVerificationRequired { code } - | Self::StatusFetch { code } - | Self::StatusResponse { code } - | Self::StatusToken { code } - | Self::CredentialStatus { code } => code, - } - } - - #[must_use] - pub const fn is_unknown_key(&self) -> bool { - matches!(self, Self::UnknownKey { .. }) - } - - #[must_use] - pub(crate) fn is_status_unknown_key(&self) -> bool { - matches!(self, Self::StatusToken { code } if *code == "status.key.unknown") - } - - pub(crate) const fn jwks_unavailable() -> Self { - Self::JwksUnavailable { - code: "jwks.unavailable", - } - } -} - -/// Verify one status-free SD-JWT VC against caller-supplied trusted JWKS. -/// -/// A status-bearing credential always fails with `status.policy_required` or -/// `status.fetch_required`. Use [`crate::RegistryNotaryClient::verify_sd_jwt_vc`] -/// so the status material is fetched and verified under [`StatusListPolicy`]. -pub fn verify_sd_jwt_vc( - compact: &str, - jwks: &Value, - options: &VerifyOptions, -) -> Result { - let pending = verify_sd_jwt_vc_pending(compact, jwks, options)?; - if pending.status.is_some() { - return if options.status_list.is_some() { - Err(VerificationError::StatusVerificationRequired { - code: "status.fetch_required", - }) - } else { - Err(VerificationError::StatusPolicy { - code: "status.policy_required", - }) - }; - } - Ok(pending.credential) -} - -pub(crate) struct PendingCredentialVerification { - pub(crate) credential: VerifiedCredential, - pub(crate) status: Option, -} - -#[derive(Clone)] -pub(crate) struct StatusListReference { - uri: Url, - index: u64, -} - -pub(crate) fn verify_sd_jwt_vc_pending( - compact: &str, - jwks: &Value, - options: &VerifyOptions, -) -> Result { - let parsed = ParsedSdJwt::parse(compact)?; - let header = decode_segment(parsed.header_b64)?; - let payload = decode_segment(parsed.payload_b64)?; - - reject_untrusted_header_references(&header)?; - let alg = required_string(&header, "alg")?; - if !options.accepted_algorithms.contains(alg) { - return Err(VerificationError::AlgorithmDisallowed { - code: "algorithm.disallowed", - }); - } - if header.get("typ").and_then(Value::as_str) != Some(SD_JWT_VC_JWT_TYP) { - return Err(VerificationError::HeaderType { - code: "header.typ_mismatch", - }); - } - let kid = required_string(&header, "kid").map_err(|_| VerificationError::MissingKeyId { - code: "key.missing", - })?; - let jwk = find_jwk(jwks, kid)?; - if jwk_alg(&jwk) != Some(alg) || jwk.alg.as_deref().is_some_and(|jwk_alg| jwk_alg != alg) { - return Err(VerificationError::AlgorithmKeyMismatch { - code: "algorithm.key_mismatch", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(parsed.signature_b64) - .map_err(|_| VerificationError::Malformed { - code: "token.malformed", - })?; - verify(parsed.signing_input().as_bytes(), &signature, &jwk).map_err(|_| { - VerificationError::InvalidSignature { - code: "signature.invalid", - } - })?; - - verify_claims(&payload, &parsed, options, alg, kid) -} - -fn verify_claims( - payload: &Value, - parsed: &ParsedSdJwt<'_>, - options: &VerifyOptions, - alg: &str, - kid: &str, -) -> Result { - let issuer = required_string(payload, "iss")?; - if issuer != options.expected_issuer { - return Err(VerificationError::IssuerMismatch { - code: "claim.issuer_mismatch", - }); - } - let vct = required_string(payload, "vct")?; - if options - .expected_vct - .as_deref() - .is_some_and(|expected| expected != vct) - { - return Err(VerificationError::VctMismatch { - code: "claim.vct_mismatch", - }); - } - - let now = options - .now - .unwrap_or_else(OffsetDateTime::now_utc) - .unix_timestamp(); - let skew = i64::try_from(options.clock_skew.as_secs()).unwrap_or(i64::MAX); - let exp = required_i64(payload, "exp")?; - let iat = required_i64(payload, "iat")?; - let nbf = optional_i64(payload, "nbf")?; - if exp <= now.saturating_sub(skew) || iat > now.saturating_add(skew) { - return Err(VerificationError::TimeClaim { - code: "claim.time_invalid", - }); - } - if let Some(nbf) = nbf { - if nbf > now.saturating_add(skew) { - return Err(VerificationError::TimeClaim { - code: "claim.time_invalid", - }); - } - } - - let disclosed_status = verify_disclosures(payload, &parsed.disclosures)?; - let holder_key_id = verify_holder_binding(HolderBindingContext { - payload, - policy: &options.holder_binding, - key_binding_jwt: parsed.key_binding_jwt, - sd_hash_input: parsed.sd_hash_input, - expected_audience: options.expected_key_binding_audience.as_deref(), - expected_nonce: options.expected_key_binding_nonce.as_deref(), - now, - skew, - })?; - let status = parse_status_reference(payload, disclosed_status.as_ref())?; - let credential = VerifiedCredential { - issuer: issuer.to_string(), - subject: payload - .get("sub") - .and_then(Value::as_str) - .map(ToString::to_string), - credential_id: payload - .get("jti") - .or_else(|| payload.get("id")) - .and_then(Value::as_str) - .map(ToString::to_string), - vct: vct.to_string(), - key_id: kid.to_string(), - algorithm: alg.to_string(), - expires_at: exp, - not_before: nbf, - issued_at: iat, - disclosure_count: parsed.disclosures.len(), - holder_key_id, - }; - Ok(PendingCredentialVerification { credential, status }) -} - -fn parse_status_reference( - payload: &Value, - disclosed_status: Option<&Value>, -) -> Result, VerificationError> { - let status = match (payload.get("status"), disclosed_status) { - (None, None) => return Ok(None), - (Some(status), None) | (None, Some(status)) => status, - (Some(_), Some(_)) => { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }) - } - }; - let status_list = status - .as_object() - .and_then(|status| status.get("status_list")) - .and_then(Value::as_object) - .ok_or(VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - let index = - status_list - .get("idx") - .and_then(Value::as_u64) - .ok_or(VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let raw_uri = status_list - .get("uri") - .and_then(Value::as_str) - .filter(|uri| !uri.is_empty() && uri.len() <= MAX_STATUS_LIST_URI_BYTES) - .ok_or(VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - let uri = Url::parse(raw_uri).map_err(|_| VerificationError::StatusToken { - code: "status.reference_malformed", - })?; - if uri.host().is_none() - || !uri.username().is_empty() - || uri.password().is_some() - || uri.fragment().is_some() - { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }); - } - Ok(Some(StatusListReference { uri, index })) -} - -pub(crate) async fn fetch_status_list_token( - reference: &StatusListReference, - options: &VerifyOptions, -) -> Result { - let policy = options - .status_list - .as_ref() - .ok_or(VerificationError::StatusPolicy { - code: "status.policy_required", - })?; - policy.permits(&options.expected_issuer, &reference.uri)?; - let validated = policy - .fetch_url_policy() - .validate_for_immediate_fetch_with_timeout(&reference.uri, STATUS_LIST_DNS_TIMEOUT) - .await - .map_err(map_status_fetch_url_error)?; - let response = validated - .immediate_get_with_timeout(STATUS_LIST_FETCH_TIMEOUT) - .map_err(map_status_fetch_url_error)? - .header(ACCEPT, STATUS_LIST_MEDIA_TYPE) - .header(ACCEPT_ENCODING, "identity") - .send() - .await - .map_err(|_| VerificationError::StatusFetch { - code: "status.unreachable", - })?; - - if response.status().is_redirection() { - return Err(VerificationError::StatusFetch { - code: "status.redirect_denied", - }); - } - if response.status() != StatusCode::OK { - return Err(VerificationError::StatusResponse { - code: "status.http_status_invalid", - }); - } - if !has_exact_status_list_media_type(response.headers()) { - return Err(VerificationError::StatusResponse { - code: "status.media_type_invalid", - }); - } - if !has_identity_content_encoding(response.headers()) { - return Err(VerificationError::StatusResponse { - code: "status.content_encoding_denied", - }); - } - let body = read_bounded(response, MAX_STATUS_LIST_RESPONSE_BYTES) - .await - .map_err(map_status_body_error)?; - let compact = String::from_utf8(body).map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - if compact.trim() != compact || !is_compact_jws(&compact) { - return Err(VerificationError::StatusToken { - code: "status.token_malformed", - }); - } - Ok(compact) -} - -pub(crate) fn verify_status_list_token( - compact: &str, - reference: &StatusListReference, - jwks: &Value, - options: &VerifyOptions, -) -> Result<(), VerificationError> { - let mut parts = compact.split('.'); - let header_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let payload_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let signature_b64 = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - })?; - if parts.next().is_some() { - return Err(VerificationError::StatusToken { - code: "status.token_malformed", - }); - } - - let header = decode_status_segment(header_b64)?; - if ["crit", "jku", "jwk", "x5u", "x5c"] - .iter() - .any(|forbidden| header.get(forbidden).is_some()) - { - return Err(VerificationError::StatusToken { - code: "status.header.untrusted_key_reference", - }); - } - if header.get("typ").and_then(Value::as_str) != Some("statuslist+jwt") { - return Err(VerificationError::StatusToken { - code: "status.header.typ_mismatch", - }); - } - let algorithm = status_required_string(&header, "alg")?; - if !options.accepted_algorithms.contains(algorithm) { - return Err(VerificationError::StatusToken { - code: "status.algorithm.disallowed", - }); - } - let kid = status_required_string(&header, "kid")?; - let jwk = find_jwk(jwks, kid).map_err(|_| VerificationError::StatusToken { - code: "status.key.unknown", - })?; - if jwk_alg(&jwk) != Some(algorithm) - || jwk - .alg - .as_deref() - .is_some_and(|jwk_algorithm| jwk_algorithm != algorithm) - { - return Err(VerificationError::StatusToken { - code: "status.algorithm.key_mismatch", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - let signing_input = format!("{header_b64}.{payload_b64}"); - verify(signing_input.as_bytes(), &signature, &jwk).map_err(|_| { - VerificationError::StatusToken { - code: "status.signature.invalid", - } - })?; - - let payload = decode_status_segment(payload_b64)?; - let status_uri = reference.uri.as_str(); - if status_required_string(&payload, "iss")? != options.expected_issuer { - return Err(VerificationError::StatusToken { - code: "status.claim.issuer_mismatch", - }); - } - if status_required_string(&payload, "sub")? != status_uri { - return Err(VerificationError::StatusToken { - code: "status.claim.uri_mismatch", - }); - } - if !exact_audience_matches(&payload, status_uri) { - return Err(VerificationError::StatusToken { - code: "status.claim.audience_mismatch", - }); - } - verify_status_time_claims(&payload, options)?; - let status = indexed_status(&payload, reference.index)?; - match status { - 0 => Ok(()), - 1 => Err(VerificationError::CredentialStatus { - code: "status.revoked", - }), - 2 => Err(VerificationError::CredentialStatus { - code: "status.suspended", - }), - _ => Err(VerificationError::CredentialStatus { - code: "status.unknown", - }), - } -} - -fn verify_status_time_claims( - payload: &Value, - options: &VerifyOptions, -) -> Result<(), VerificationError> { - let now = options - .now - .unwrap_or_else(OffsetDateTime::now_utc) - .unix_timestamp(); - let skew = i64::try_from( - options - .clock_skew - .as_secs() - .min(MAX_STATUS_LIST_CLOCK_SKEW_SECONDS), - ) - .expect("bounded status clock skew fits i64"); - let iat = payload - .get("iat") - .and_then(Value::as_i64) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let exp = payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let ttl = payload - .get("ttl") - .and_then(Value::as_i64) - .filter(|ttl| *ttl > 0 && *ttl <= MAX_STATUS_LIST_TOKEN_LIFETIME_SECONDS) - .ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - let nbf = match payload.get("nbf") { - None => None, - Some(value) => Some(value.as_i64().ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?), - }; - let lifetime = exp.checked_sub(iat).ok_or(VerificationError::StatusToken { - code: "status.claim.time_invalid", - })?; - if lifetime <= 0 - || lifetime > ttl - || exp <= now.saturating_sub(skew) - || iat > now.saturating_add(skew) - || nbf.is_some_and(|not_before| not_before > now.saturating_add(skew)) - || nbf.is_some_and(|not_before| not_before >= exp) - { - return Err(VerificationError::StatusToken { - code: "status.claim.time_invalid", - }); - } - Ok(()) -} - -fn indexed_status(payload: &Value, index: u64) -> Result { - let status_list = payload - .get("status_list") - .and_then(Value::as_object) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })?; - let bits = status_list - .get("bits") - .and_then(Value::as_u64) - .filter(|bits| matches!(bits, 1 | 2 | 4 | 8)) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })? as u8; - let encoded = status_list - .get("lst") - .and_then(Value::as_str) - .filter(|encoded| !encoded.is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.list.malformed", - })?; - let compressed = - URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| VerificationError::StatusToken { - code: "status.list.malformed", - })?; - if compressed.len() > MAX_STATUS_LIST_COMPRESSED_BYTES { - return Err(VerificationError::StatusToken { - code: "status.list.compressed_too_large", - }); - } - let list = decompress_status_list(&compressed)?; - let entries_per_byte = u64::from(8 / bits); - let byte_index = index / entries_per_byte; - let byte_index = usize::try_from(byte_index).map_err(|_| VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let byte = list - .get(byte_index) - .copied() - .ok_or(VerificationError::StatusToken { - code: "status.index.invalid", - })?; - let shift = u8::try_from((index % entries_per_byte) * u64::from(bits)).map_err(|_| { - VerificationError::StatusToken { - code: "status.index.invalid", - } - })?; - let mask = if bits == 8 { - u8::MAX - } else { - (1_u8 << bits) - 1 - }; - Ok((byte >> shift) & mask) -} - -fn decompress_status_list(compressed: &[u8]) -> Result, VerificationError> { - let mut decoder = ZlibDecoder::new(compressed); - let mut decompressed = Vec::with_capacity(MAX_STATUS_LIST_DECOMPRESSED_BYTES.min(8_192)); - (&mut decoder) - .take((MAX_STATUS_LIST_DECOMPRESSED_BYTES + 1) as u64) - .read_to_end(&mut decompressed) - .map_err(|_| VerificationError::StatusToken { - code: "status.list.malformed", - })?; - if decompressed.len() > MAX_STATUS_LIST_DECOMPRESSED_BYTES - || decoder.total_in() != compressed.len() as u64 - { - return Err(VerificationError::StatusToken { - code: "status.list.decompression_limit", - }); - } - Ok(decompressed) -} - -fn decode_status_segment(segment: &str) -> Result { - let decoded = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - })?; - serde_json::from_slice(&decoded).map_err(|_| VerificationError::StatusToken { - code: "status.token_malformed", - }) -} - -fn status_required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, VerificationError> { - value - .get(field) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .ok_or(VerificationError::StatusToken { - code: "status.token_malformed", - }) -} - -fn exact_audience_matches(payload: &Value, expected: &str) -> bool { - match payload.get("aud") { - Some(Value::String(audience)) => audience == expected, - Some(Value::Array(audiences)) if audiences.len() == 1 => { - audiences[0].as_str() == Some(expected) - } - _ => false, - } -} - -fn has_exact_status_list_media_type(headers: &reqwest::header::HeaderMap) -> bool { - let mut values = headers.get_all(CONTENT_TYPE).iter(); - matches!( - (values.next(), values.next()), - (Some(value), None) - if value - .to_str() - .is_ok_and(|value| value.eq_ignore_ascii_case(STATUS_LIST_MEDIA_TYPE)) - ) -} - -fn has_identity_content_encoding(headers: &reqwest::header::HeaderMap) -> bool { - let mut values = headers.get_all(CONTENT_ENCODING).iter(); - match (values.next(), values.next()) { - (None, None) => true, - (Some(value), None) => value - .to_str() - .is_ok_and(|value| value.eq_ignore_ascii_case("identity")), - _ => false, - } -} - -fn map_status_fetch_url_error(error: FetchUrlError) -> VerificationError { - match error { - FetchUrlError::Dns { .. } - | FetchUrlError::NoAddresses - | FetchUrlError::ValidationTimeout { .. } - | FetchUrlError::ValidationTask(_) => VerificationError::StatusFetch { - code: "status.unreachable", - }, - _ => VerificationError::StatusFetch { - code: "status.destination_unsafe", - }, - } -} - -fn map_status_body_error(error: BoundedReadError) -> VerificationError { - match error { - BoundedReadError::ContentLengthExceeded { .. } - | BoundedReadError::BodyTooLarge { .. } - | BoundedReadError::LengthOverflow => VerificationError::StatusResponse { - code: "status.response_too_large", - }, - BoundedReadError::Transport(_) => VerificationError::StatusFetch { - code: "status.unreachable", - }, - _ => VerificationError::StatusResponse { - code: "status.response_invalid", - }, - } -} - -fn parse_status_origin( - raw_origin: &str, - allow_loopback_http: bool, -) -> Result { - if raw_origin.len() > MAX_STATUS_LIST_URI_BYTES { - return Err(StatusListPolicyError::InvalidOrigin); - } - let origin = Url::parse(raw_origin).map_err(|_| StatusListPolicyError::InvalidOrigin)?; - if origin.host().is_none() || origin.port() == Some(0) { - return Err(StatusListPolicyError::InvalidOrigin); - } - if !origin.username().is_empty() - || origin.password().is_some() - || origin.path() != "/" - || origin.query().is_some() - || origin.fragment().is_some() - { - return Err(StatusListPolicyError::OriginHasResourceComponents); - } - let valid_scheme = origin.scheme() == "https" - || (allow_loopback_http - && origin.scheme() == "http" - && matches!(origin.host_str(), Some("127.0.0.1" | "localhost" | "::1"))); - if !valid_scheme { - return Err(StatusListPolicyError::InvalidOrigin); - } - Ok(origin.origin().ascii_serialization()) -} - -fn verify_disclosures( - payload: &Value, - disclosures: &[&str], -) -> Result, VerificationError> { - if disclosures.is_empty() && payload.get("_sd").is_none() { - return Ok(None); - } - if payload.get("_sd_alg").and_then(Value::as_str) != Some("sha-256") { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - } - let expected = payload - .get("_sd") - .and_then(Value::as_array) - .ok_or(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - })? - .iter() - .map(|digest| { - digest.as_str().map(ToString::to_string).ok_or( - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }, - ) - }) - .collect::, _>>()?; - - let mut actual = BTreeSet::new(); - let mut disclosed_status = None; - for disclosure in disclosures { - let decoded = URL_SAFE_NO_PAD.decode(disclosure).map_err(|_| { - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - } - })?; - let value: Value = serde_json::from_slice(&decoded).map_err(|_| { - VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - } - })?; - let Some(items) = value.as_array().filter(|items| items.len() >= 3) else { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - }; - let digest = URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes())); - if !expected.contains(&digest) || !actual.insert(digest) { - return Err(VerificationError::DisclosureDigestMismatch { - code: "disclosure.digest_mismatch", - }); - } - if items.get(1).and_then(Value::as_str) == Some("status") - && disclosed_status.replace(items[2].clone()).is_some() - { - return Err(VerificationError::StatusToken { - code: "status.reference_malformed", - }); - } - } - Ok(disclosed_status) -} - -struct HolderBindingContext<'a> { - payload: &'a Value, - policy: &'a HolderBindingPolicy, - key_binding_jwt: Option<&'a str>, - sd_hash_input: &'a str, - expected_audience: Option<&'a str>, - expected_nonce: Option<&'a str>, - now: i64, - skew: i64, -} - -fn verify_holder_binding( - context: HolderBindingContext<'_>, -) -> Result, VerificationError> { - if matches!(context.policy, HolderBindingPolicy::NotRequired) - && (context.key_binding_jwt.is_none() - || context.expected_audience.is_none() - || context.expected_nonce.is_none()) - { - return Ok(context - .payload - .get("cnf") - .and_then(|cnf| cnf.get("kid")) - .and_then(Value::as_str) - .map(ToString::to_string)); - } - let cnf = context - .payload - .get("cnf") - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.required", - })?; - let jwk_value = cnf.get("jwk").ok_or(VerificationError::HolderBinding { - code: "holder_binding.required", - })?; - let jwk_json = - serde_json::to_string(jwk_value).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.invalid", - })?; - let holder_jwk = PublicJwk::parse(&jwk_json).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.invalid", - })?; - let actual_kid = cnf - .get("kid") - .and_then(Value::as_str) - .map(ToString::to_string); - if let HolderBindingPolicy::RequiredKid(expected) = context.policy { - if actual_kid.as_deref() != Some(expected.as_str()) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.kid_mismatch", - }); - } - } - if context.key_binding_jwt.is_none() - && context.expected_audience.is_some() - && context.expected_nonce.is_some() - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.challenge_required", - }); - } - if let Some(key_binding_jwt) = context.key_binding_jwt { - verify_key_binding_jwt( - key_binding_jwt, - context.sd_hash_input, - &holder_jwk, - context.expected_audience, - context.expected_nonce, - context.now, - context.skew, - )?; - } - Ok(actual_kid) -} - -fn verify_key_binding_jwt( - compact: &str, - sd_hash_input: &str, - holder_jwk: &PublicJwk, - expected_audience: Option<&str>, - expected_nonce: Option<&str>, - now: i64, - skew: i64, -) -> Result<(), VerificationError> { - let mut parts = compact.split('.'); - let header_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let payload_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let signature_b64 = parts.next().ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if parts.next().is_some() - || header_b64.is_empty() - || payload_b64.is_empty() - || signature_b64.is_empty() - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let header = decode_segment(header_b64).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - reject_untrusted_header_references(&header)?; - if header.get("alg").and_then(Value::as_str) != Some("EdDSA") - || header.get("typ").and_then(Value::as_str) != Some("kb+jwt") - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let signature = - URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let signing_input = format!("{header_b64}.{payload_b64}"); - verify(signing_input.as_bytes(), &signature, holder_jwk).map_err(|_| { - VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - } - })?; - let payload = decode_segment(payload_b64).map_err(|_| VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - let iat = - payload - .get("iat") - .and_then(Value::as_i64) - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if iat > now.saturating_add(skew) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let exp = - payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - })?; - if exp <= now.saturating_sub(skew) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - if payload - .get("nbf") - .and_then(Value::as_i64) - .is_some_and(|nbf| nbf > now.saturating_add(skew)) - { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let expected_sd_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(sd_hash_input.as_bytes())); - if payload.get("sd_hash").and_then(Value::as_str) != Some(expected_sd_hash.as_str()) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - let (Some(expected_audience), Some(expected_nonce)) = (expected_audience, expected_nonce) - else { - return Err(VerificationError::HolderBinding { - code: "holder_binding.challenge_required", - }); - }; - if !audience_matches(&payload, expected_audience) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - if payload.get("nonce").and_then(Value::as_str) != Some(expected_nonce) { - return Err(VerificationError::HolderBinding { - code: "holder_binding.proof_invalid", - }); - } - Ok(()) -} - -fn audience_matches(payload: &Value, expected_audience: &str) -> bool { - match payload.get("aud") { - Some(Value::String(audience)) => audience == expected_audience, - Some(Value::Array(audiences)) => audiences - .iter() - .any(|audience| audience.as_str() == Some(expected_audience)), - _ => false, - } -} - -fn reject_untrusted_header_references(header: &Value) -> Result<(), VerificationError> { - for forbidden in ["crit", "jku", "jwk", "x5u", "x5c"] { - if header.get(forbidden).is_some() { - return Err(VerificationError::UntrustedKeyReference { - code: "header.untrusted_key_reference", - }); - } - } - Ok(()) -} - -fn find_jwk(jwks: &Value, kid: &str) -> Result { - let keys = jwks - .get("keys") - .and_then(Value::as_array) - .ok_or(VerificationError::UnknownKey { - code: "key.unknown", - })?; - for key in keys { - if key.get("kid").and_then(Value::as_str) == Some(kid) { - let json = serde_json::to_string(key).map_err(|_| VerificationError::UnknownKey { - code: "key.unknown", - })?; - return PublicJwk::parse(&json).map_err(|_| VerificationError::UnknownKey { - code: "key.unknown", - }); - } - } - Err(VerificationError::UnknownKey { - code: "key.unknown", - }) -} - -fn jwk_alg(jwk: &PublicJwk) -> Option<&'static str> { - match jwk.algorithm().ok()? { - SigningAlgorithm::EdDsa => Some("EdDSA"), - SigningAlgorithm::Rs256 => Some("RS256"), - SigningAlgorithm::Es256 => Some("ES256"), - } -} - -fn decode_segment(segment: &str) -> Result { - let decoded = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| VerificationError::Malformed { - code: "token.malformed", - })?; - serde_json::from_slice(&decoded).map_err(|_| VerificationError::Malformed { - code: "token.malformed", - }) -} - -fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, VerificationError> { - value - .get(field) - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - }) -} - -fn required_i64(value: &Value, field: &str) -> Result { - value - .get(field) - .and_then(Value::as_i64) - .ok_or(VerificationError::TimeClaim { - code: "claim.time_invalid", - }) -} - -fn optional_i64(value: &Value, field: &str) -> Result, VerificationError> { - value - .get(field) - .map(|raw| { - raw.as_i64().ok_or(VerificationError::TimeClaim { - code: "claim.time_invalid", - }) - }) - .transpose() -} - -struct ParsedSdJwt<'a> { - header_b64: &'a str, - payload_b64: &'a str, - signature_b64: &'a str, - disclosures: Vec<&'a str>, - key_binding_jwt: Option<&'a str>, - sd_hash_input: &'a str, -} - -impl<'a> ParsedSdJwt<'a> { - fn parse(compact: &'a str) -> Result { - let mut parts = compact.split('~'); - let issuer_jwt = - parts - .next() - .filter(|part| !part.is_empty()) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let mut presentation_parts = parts.filter(|part| !part.is_empty()).collect::>(); - let key_binding_jwt = presentation_parts - .last() - .copied() - .filter(|part| is_compact_jws(part)); - if key_binding_jwt.is_some() { - presentation_parts.pop(); - } - if presentation_parts.iter().any(|part| is_compact_jws(part)) { - return Err(VerificationError::Malformed { - code: "token.malformed", - }); - } - let sd_hash_input = if let Some(key_binding_jwt) = key_binding_jwt { - compact - .strip_suffix(key_binding_jwt) - .ok_or(VerificationError::Malformed { - code: "token.malformed", - })? - } else { - compact - }; - let mut jwt_parts = issuer_jwt.split('.'); - let header_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let payload_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - let signature_b64 = jwt_parts.next().ok_or(VerificationError::Malformed { - code: "token.malformed", - })?; - if jwt_parts.next().is_some() - || header_b64.is_empty() - || payload_b64.is_empty() - || signature_b64.is_empty() - { - return Err(VerificationError::Malformed { - code: "token.malformed", - }); - } - Ok(Self { - header_b64, - payload_b64, - signature_b64, - disclosures: presentation_parts, - key_binding_jwt, - sd_hash_input, - }) - } - - fn signing_input(&self) -> String { - format!("{}.{}", self.header_b64, self.payload_b64) - } -} - -fn is_compact_jws(value: &str) -> bool { - let mut parts = value.split('.'); - let Some(header) = parts.next() else { - return false; - }; - let Some(payload) = parts.next() else { - return false; - }; - let Some(signature) = parts.next() else { - return false; - }; - parts.next().is_none() && !header.is_empty() && !payload.is_empty() && !signature.is_empty() -} diff --git a/crates/registry-notary-client/tests/client_contract.rs b/crates/registry-notary-client/tests/client_contract.rs deleted file mode 100644 index c98ce2790..000000000 --- a/crates/registry-notary-client/tests/client_contract.rs +++ /dev/null @@ -1,1491 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -use std::net::SocketAddr; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; -use std::time::{Duration, Instant}; - -use axum::body::Bytes; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, StatusCode, Uri}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use registry_notary_client::auth::{AuthHeader, AuthProvider}; -use registry_notary_client::{ - CredentialIssueResponse, NotaryClientBuildError, NotaryClientError, NotaryResponse, - RegistryNotaryClient, RequestOptions, RetryPolicy, -}; -use registry_notary_core::{ - BatchEvaluateResponse, BatchStatus, FORMAT_CLAIM_RESULT_JSON, MAX_BATCH_EVALUATION_MEMBERS_V1, -}; -use secrecy::SecretString; -use serde_json::json; -use tokio::net::TcpListener; - -#[tokio::test] -async fn builder_rejects_multiple_auth_modes() { - let error = RegistryNotaryClient::builder("https://notary.example") - .bearer_token("bearer-secret") - .api_key("api-secret") - .build() - .expect_err("multiple auth modes are rejected"); - - assert!(matches!(error, NotaryClientBuildError::MultipleAuthModes)); -} - -#[tokio::test] -async fn builder_rejects_non_loopback_http() { - let error = RegistryNotaryClient::builder("http://example.com") - .bearer_token("bearer-secret") - .build() - .expect_err("non-loopback http is rejected"); - - assert!(matches!(error, NotaryClientBuildError::InsecureBaseUrl)); -} - -#[tokio::test] -async fn builder_preserves_encoded_base_path_when_adding_trailing_slash() { - let app = Router::new().fallback(get(base_path_health_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(format!("{base}/tenant%20one")) - .build() - .expect("client builds"); - - let response = client.health().await.expect("health response"); - - assert_eq!(response.body.status, "ok"); -} - -#[tokio::test] -async fn debug_redacts_auth_material() { - let builder = RegistryNotaryClient::builder("https://notary.example") - .bearer_token("super-secret-token") - .api_key("another-secret"); - - let rendered = format!("{builder:?}"); - assert!(!rendered.contains("super-secret-token")); - assert!(!rendered.contains("another-secret")); - assert!(rendered.contains("")); -} - -#[test] -fn credential_issue_response_debug_redacts_credential_material() { - let response = CredentialIssueResponse { - credential_id: "cred-1".to_string(), - credential_profile: "profile-1".to_string(), - format: "application/dc+sd-jwt".to_string(), - issuer: "did:web:notary.example".to_string(), - expires_at: "2026-05-29T00:00:00Z".to_string(), - credential: "issuer.jwt~disclosure-secret~".to_string(), - issuer_signed_jwt: "issuer.jwt".to_string(), - disclosures: vec!["disclosure-secret".to_string()], - }; - - let debug = format!("{response:?}"); - - assert!(debug.contains("cred-1")); - assert!(debug.contains("")); - assert!(!debug.contains("issuer.jwt")); - assert!(!debug.contains("disclosure-secret")); - assert!(!debug.contains("issuer.jwt~disclosure-secret~")); - - let wrapped = NotaryResponse { - body: response, - status: StatusCode::OK, - request_id: Some("req-credential".to_string()), - retry_after: None, - }; - let wrapped_debug = format!("{wrapped:?}"); - - assert!(wrapped_debug.contains("req-credential")); - assert!(wrapped_debug.contains("")); - assert!(!wrapped_debug.contains("issuer.jwt")); - assert!(!wrapped_debug.contains("disclosure-secret")); -} - -#[test] -fn serialization_build_error_has_specific_portable_code() { - let error = NotaryClientError::Build(NotaryClientBuildError::RequestSerialization); - - assert_eq!( - error.portable().code.as_deref(), - Some("request.serialization_failed") - ); -} - -#[test] -fn notary_response_debug_keeps_non_sensitive_body_metadata() { - let response = NotaryResponse { - body: registry_notary_client::HealthResponse { - status: "ok".to_string(), - checks: json!({ "database": "ready" }), - }, - status: StatusCode::OK, - request_id: Some("req-health".to_string()), - retry_after: None, - }; - - let debug = format!("{response:?}"); - - assert!(debug.contains("req-health")); - assert!(debug.contains("ok")); - assert!(debug.contains("database")); - assert!(!debug.contains("")); -} - -struct FixedAuthProvider; - -#[async_trait::async_trait] -impl AuthProvider for FixedAuthProvider { - async fn auth_header(&self) -> Result { - Ok(AuthHeader::ApiKey(SecretString::from( - "provider-secret".to_string(), - ))) - } -} - -#[tokio::test] -async fn auth_provider_sends_redacted_dynamic_header() { - let app = Router::new().route( - "/healthz", - get(|headers: HeaderMap| async move { - assert_eq!( - headers - .get("x-api-key") - .and_then(|value| value.to_str().ok()), - Some("provider-secret") - ); - Json(json!({ "status": "ok", "checks": {} })) - }), - ); - let base = spawn(app).await; - let provider: Arc = Arc::new(FixedAuthProvider); - let client = RegistryNotaryClient::builder(base) - .auth_provider(provider) - .build() - .expect("client builds"); - - let response = client.health().await.expect("health succeeds"); - - assert_eq!(response.body.status, "ok"); -} - -#[tokio::test] -async fn batch_response_family_deserializes_from_wire_json() { - let value = json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - }); - - let parsed: BatchEvaluateResponse = - serde_json::from_value(value).expect("batch response deserializes"); - assert_eq!(parsed.batch_id, "batch-1"); - assert!(matches!(parsed.status, BatchStatus::Completed)); -} - -#[tokio::test] -async fn evaluate_sends_safe_headers_and_parses_metadata() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .build() - .expect("client builds"); - - let response = client - .evaluate_target("Person") - .target_identifier("NATIONAL_ID", "subject-1") - .target_identifier_issuer("civil_registry") - .relationship("self") - .request_variable_date("as_of_date", "2026-01-01") - .claim("claim-a") - .request_id("req-123") - .send() - .await - .expect("evaluate succeeds"); - - assert_eq!(response.request_id.as_deref(), Some("req-123")); - assert!(response.body.results.is_empty()); -} - -#[tokio::test] -async fn ready_200_returns_typed_custody_checks() { - let app = Router::new().route( - "/ready", - get(|| async { - Json(json!({ - "status": "ready", - "checks": readiness_checks_json(true, 0), - })) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let response = client.ready().await.expect("ready response parses"); - - assert_eq!(response.body.status, "ready"); - let custody = response.body.checks.signing_providers.custody; - assert!(custody.custody_approval_required); - assert!(custody.custody_approved); - assert_eq!(custody.unapproved_signing_provider_count, 0); - assert_eq!(custody.active_provider_counts.get("pkcs11"), Some(&1)); -} - -#[tokio::test] -async fn ready_503_returns_problem_details() { - let app = Router::new().route( - "/ready", - get(|| async { - ( - StatusCode::SERVICE_UNAVAILABLE, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/readiness/not-ready", - "title": "Evidence runtime is not ready", - "status": 503, - "detail": "one or more readiness checks are not ready", - "code": "readiness.not_ready", - "request_id": "01J00000000000000000000000", - "readiness_status": "not_ready", - "checks": readiness_checks_json(false, 1) - })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.ready().await.expect_err("ready 503 is a problem"); - - match error { - NotaryClientError::Problem { - status, problem, .. - } => { - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(problem.code, "readiness.not_ready"); - assert_eq!( - problem.request_id.as_deref(), - Some("01J00000000000000000000000") - ); - assert_eq!(problem.readiness_status.as_deref(), Some("not_ready")); - let custody = &problem - .checks - .as_ref() - .expect("readiness checks are retained") - .signing_providers - .custody; - assert!(custody.custody_approval_required); - assert!(!custody.custody_approved); - assert_eq!(custody.unapproved_signing_provider_count, 1); - } - other => panic!("expected readiness problem, got {other:?}"), - } -} - -fn readiness_checks_json(custody_approved: bool, unapproved_count: usize) -> serde_json::Value { - json!({ - "total": 3, - "ok": if unapproved_count == 0 { 3 } else { 1 }, - "degraded": 0, - "failed": usize::from(unapproved_count > 0), - "signing_providers": { - "total": 1, - "ok": 1, - "failed": 0, - "custody": { - "active_provider_counts": { - "pkcs11": 1, - }, - "signing_provider_count": 1, - "local_software_signing_provider_count": 0, - "custody_approval_required": true, - "custody_approved": custody_approved, - "unapproved_signing_provider_count": unapproved_count, - "surfaces": { - "credential_issuance": { - "signing_provider_count": 1, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": unapproved_count, - }, - "access_token_issuance": { - "enabled": false, - "signing_provider_count": 0, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": 0, - }, - "federation": { - "enabled": false, - "signing_provider_count": 0, - "local_software_signing_provider_count": 0, - "unapproved_signing_provider_count": 0, - }, - }, - }, - }, - }) -} - -#[tokio::test] -async fn jwks_uses_ttl_cache_and_refresh_forces_reload() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/.well-known/evidence/jwks.json", get(jwks_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let first = client - .issuer_jwks(RequestOptions::default()) - .await - .expect("first fetch succeeds"); - let second = client - .issuer_jwks(RequestOptions::default()) - .await - .expect("second fetch uses cache"); - let refreshed = client - .refresh_jwks(RequestOptions::default()) - .await - .expect("refresh fetches network"); - - assert_eq!(first.body["keys"][0]["kid"], "kid-1"); - assert_eq!(second.body["keys"][0]["kid"], "kid-1"); - assert_eq!(refreshed.body["keys"][0]["kid"], "kid-2"); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn metrics_returns_text_body() { - let app = Router::new().route("/metrics", get(|| async { "requests_total 1\n" })); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let response = client - .metrics(RequestOptions::default()) - .await - .expect("metrics parses"); - - assert_eq!(response.body, "requests_total 1\n"); -} - -#[tokio::test] -async fn typed_route_methods_parse_success_responses_and_escape_paths() { - let app = Router::new() - .route("/healthz", get(health_handler)) - .route("/admin/v1/reload", post(admin_reload_handler)) - .route( - "/openapi.json", - get(|| async { Json(json!({ "openapi": "3.1.0" })) }), - ) - .route( - "/.well-known/evidence-service", - get(|| async { Json(json!({ "issuer": "notary.example" })) }), - ) - .route("/.well-known/evidence/jwks.json", get(jwks_static_handler)) - .route("/v1/claims/{claim_id}", get(claim_handler)) - .route( - "/v1/formats", - get(|| async { - Json(json!({ - "formats": [ - { "id": FORMAT_CLAIM_RESULT_JSON, "kind": "json", "status": "active" } - ] - })) - }), - ) - .route( - "/v1/evaluations/{evaluation_id}/render", - post(render_handler), - ) - .route("/v1/credentials", post(issue_credential_handler)) - .route( - "/v1/credentials/{credential_id}/status", - get(credential_status_handler), - ) - .route( - "/admin/v1/credentials/{credential_id}/status", - post(update_credential_status_handler), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .api_key("api-secret") - .build() - .expect("client builds"); - - assert_eq!(client.health().await.expect("health").body.status, "ok"); - assert_eq!( - client - .admin_reload(RequestOptions::default()) - .await - .expect("admin reload") - .body - .status, - "noop" - ); - assert_eq!( - client - .openapi_json(RequestOptions::default()) - .await - .expect("openapi") - .body["openapi"], - "3.1.0" - ); - assert_eq!( - client - .service_document(RequestOptions::default()) - .await - .expect("service document") - .body["issuer"], - "notary.example" - ); - assert_eq!( - client - .raw_issuer_jwks(RequestOptions::default()) - .await - .expect("raw jwks") - .body["keys"][0]["kid"], - "kid-static" - ); - assert_eq!( - client - .get_claim("claim one", RequestOptions::default()) - .await - .expect("claim") - .body["id"], - "claim one" - ); - assert_eq!( - client - .list_formats(RequestOptions::default()) - .await - .expect("formats") - .body - .formats[0] - .id, - FORMAT_CLAIM_RESULT_JSON - ); - assert_eq!( - client - .render_request( - registry_notary_core::RenderRequest { - evaluation_id: "eval-1".to_string(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - disclosure: None, - claims: None, - purpose: None, - }, - RequestOptions::default(), - ) - .await - .expect("render") - .body["rendered"], - true - ); - assert_eq!( - client - .issue_credential_request( - registry_notary_core::CredentialIssueRequest { - evaluation_id: "eval-1".to_string(), - credential_profile: None, - format: None, - claims: None, - disclosure: None, - purpose: None, - holder: None, - }, - RequestOptions::default(), - ) - .await - .expect("credential issue") - .body - .credential_id, - "cred-1" - ); - assert_eq!( - client - .credential_status("cred 1", RequestOptions::default()) - .await - .expect("credential status") - .body - .status, - "valid" - ); - assert_eq!( - client - .update_credential_status("cred 1", "revoked", RequestOptions::default()) - .await - .expect("credential status update") - .body - .status, - "revoked" - ); -} - -#[tokio::test] -async fn purpose_conflict_fails_client_side() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("header-purpose") - .build() - .expect("client builds"); - - let error = client - .evaluate_request( - registry_notary_core::EvaluateRequest { - requester: None, - target: Some(registry_notary_core::EvidenceEntity::from_subject_request( - "Person", - registry_notary_core::SubjectRequest { - id: "subject-1".to_string(), - id_type: None, - }, - )), - relationship: None, - on_behalf_of: None, - variables: Default::default(), - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect_err("purpose conflict fails before request"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::PurposeConflict) - )); -} - -#[tokio::test] -async fn raw_evaluate_preserves_body_only_purpose() { - let app = Router::new().route("/v1/evaluations", post(body_purpose_evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .evaluate_request( - registry_notary_core::EvaluateRequest { - requester: None, - target: Some(registry_notary_core::EvidenceEntity::from_subject_request( - "Person", - registry_notary_core::SubjectRequest { - id: "subject-1".to_string(), - id_type: None, - }, - )), - relationship: None, - on_behalf_of: None, - variables: Default::default(), - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect("evaluate succeeds"); - - assert!(response.body.results.is_empty()); -} - -#[tokio::test] -async fn raw_batch_preserves_body_only_purpose() { - let app = Router::new().route("/v1/batch-evaluations", post(body_purpose_batch_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .batch_evaluate_request( - registry_notary_core::BatchEvaluateRequest { - items: vec![registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - )], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect("batch evaluate succeeds"); - - assert_eq!(response.body.batch_id, "batch-1"); -} - -#[tokio::test] -async fn typed_batch_rejects_platform_ceiling_plus_one_before_transport() { - let calls = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route( - "/v1/batch-evaluations", - post(|State(calls): State>| async move { - calls.fetch_add(1, Ordering::SeqCst); - body_purpose_batch_handler( - HeaderMap::new(), - Bytes::from_static(br#"{"purpose":"body-purpose"}"#), - ) - .await - }), - ) - .with_state(Arc::clone(&calls)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - let item = registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - ); - let error = client - .batch_evaluate_request( - registry_notary_core::BatchEvaluateRequest { - items: vec![item; MAX_BATCH_EVALUATION_MEMBERS_V1 + 1], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: Some("body-purpose".to_string()), - }, - RequestOptions::default(), - ) - .await - .expect_err("the typed client rejects the hard ceiling plus one"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::BatchTooLarge { - actual, - maximum: MAX_BATCH_EVALUATION_MEMBERS_V1, - }) if actual == MAX_BATCH_EVALUATION_MEMBERS_V1 + 1 - )); - assert_eq!(calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn raw_credential_issue_preserves_body_only_purpose() { - let app = Router::new().route( - "/v1/credentials", - post(body_purpose_issue_credential_handler), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .issue_credential_request( - registry_notary_core::CredentialIssueRequest { - evaluation_id: "eval-1".to_string(), - credential_profile: None, - format: None, - claims: None, - disclosure: None, - purpose: Some("body-purpose".to_string()), - holder: None, - }, - RequestOptions::default(), - ) - .await - .expect("credential issue succeeds"); - - assert_eq!(response.body.credential_id, "cred-1"); -} - -#[tokio::test] -async fn idempotency_is_rejected_on_routes_that_ignore_it() { - let app = Router::new().route( - "/v1/evaluations/{evaluation_id}/render", - post(|| async { Json(json!({})) }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .render_request( - registry_notary_core::RenderRequest { - evaluation_id: "eval-1".to_string(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - disclosure: None, - claims: None, - purpose: None, - }, - RequestOptions::builder() - .idempotency_key("ignored-key") - .build(), - ) - .await - .expect_err("unsupported idempotency is rejected"); - - assert!(matches!( - error, - NotaryClientError::Build(NotaryClientBuildError::UnsupportedIdempotencyKey) - )); -} - -#[tokio::test] -async fn batch_retry_requires_idempotency_key() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/batch-evaluations", post(flaky_batch_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let request = registry_notary_core::BatchEvaluateRequest { - items: vec![registry_notary_core::BatchEvaluateItemRequest::from( - registry_notary_core::BatchSubjectRequest { - id: "subject-1".to_string(), - id_type: None, - purpose: None, - }, - )], - claims: vec![registry_notary_core::ClaimRef::new("claim-a")], - disclosure: None, - format: None, - purpose: None, - }; - - let without_key = client - .batch_evaluate_request(request.clone(), RequestOptions::default()) - .await - .expect_err("without key no retry occurs"); - assert!(matches!(without_key, NotaryClientError::Problem { .. })); - assert_eq!(state.load(Ordering::SeqCst), 1); - state.store(0, Ordering::SeqCst); - - let with_key = client - .batch_evaluate_request( - request, - RequestOptions::builder() - .idempotency_key("batch-key") - .build(), - ) - .await - .expect("with idempotency key retry succeeds"); - assert_eq!(with_key.body.batch_id, "batch-1"); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn retry_after_delta_on_problem_controls_retry_delay() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/claims", get(retry_after_then_claims_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - base_delay: Duration::from_secs(5), - max_delay: Duration::from_secs(5), - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let started = Instant::now(); - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("retry-after zero allows immediate retry"); - - assert!(started.elapsed() < Duration::from_millis(500)); - assert!(response.body.data.is_empty()); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn retry_after_http_date_uses_server_date_for_retry_delay() { - let state = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route("/v1/claims", get(retry_after_http_date_then_claims_handler)) - .with_state(Arc::clone(&state)); - let base = spawn(app).await; - let retry_policy = RetryPolicy { - max_attempts: 2, - base_delay: Duration::from_secs(5), - max_delay: Duration::from_secs(5), - retry_unavailable: true, - ..RetryPolicy::default() - }; - let client = RegistryNotaryClient::builder(base) - .retry_policy(retry_policy) - .build() - .expect("client builds"); - - let started = Instant::now(); - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("retry-after HTTP-date equal to server date allows immediate retry"); - - assert!(started.elapsed() < Duration::from_millis(500)); - assert!(response.body.data.is_empty()); - assert_eq!(state.load(Ordering::SeqCst), 2); -} - -#[tokio::test] -async fn accepted_status_decode_error_keeps_response_status() { - let app = Router::new().route( - "/ready", - get(|| async { - ( - StatusCode::SERVICE_UNAVAILABLE, - [("x-request-id", "req-ready-decode")], - "not-json", - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.ready().await.expect_err("invalid ready JSON fails"); - - match error { - NotaryClientError::Decode { status, request_id } => { - assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(request_id.as_deref(), Some("req-ready-decode")); - } - other => panic!("expected decode error, got {other:?}"), - } -} - -#[tokio::test] -async fn decode_error_display_is_opaque() { - let app = Router::new().route( - "/v1/claims", - get(|| async { - ( - StatusCode::OK, - [("x-request-id", "req-decode")], - "not-json-with-secret-token", - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .list_claims(RequestOptions::default()) - .await - .expect_err("invalid JSON fails"); - assert_eq!(error.to_string(), "failed to decode response body"); - assert!(!format!("{error:?}").contains("not-json-with-secret-token")); - assert_eq!(error.request_id(), Some("req-decode")); -} - -#[tokio::test] -async fn problem_debug_redacts_detail() { - let app = Router::new().route("/v1/claims", get(problem_with_sensitive_detail)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let error = client - .list_claims(RequestOptions::default()) - .await - .expect_err("problem maps"); - assert!(!format!("{error:?}").contains("subj-sensitive")); - assert!(!error.to_string().contains("subj-sensitive")); -} - -#[tokio::test] -async fn body_too_large_error_is_opaque_and_carries_request_id() { - let body = format!("credential-secret-{}", "x".repeat(80 * 1024)); - let app = Router::new().route( - "/healthz", - get(move || { - let body = body.clone(); - async move { (StatusCode::OK, [("x-request-id", "req-large")], body) } - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client.health().await.expect_err("body cap triggers"); - assert!(matches!(error, NotaryClientError::BodyTooLarge { .. })); - assert_eq!(error.request_id(), Some("req-large")); - assert_eq!( - error.to_string(), - "response body exceeded configured size limit" - ); - assert!(!format!("{error:?}").contains("credential-secret")); -} - -#[tokio::test] -async fn content_encoding_header_is_not_auto_decompressed() { - let app = Router::new().route( - "/v1/claims", - get(|| async { - ( - StatusCode::OK, - [("content-encoding", "gzip")], - Json(json!({ "data": [] })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .list_claims(RequestOptions::default()) - .await - .expect("plain body is not decompressed despite header"); - assert!(response.body.data.is_empty()); -} - -#[cfg(feature = "federation")] -#[tokio::test] -async fn federation_posts_already_signed_jws_without_minting() { - let app = Router::new().route("/federation/v1/evaluations", post(federation_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - - let response = client - .federation_evaluate_jws("header.payload.signature", RequestOptions::default()) - .await - .expect("federation succeeds"); - - assert_eq!(response.body, "signed-response-jws"); -} - -#[cfg(feature = "oid4vci")] -#[tokio::test] -async fn oid4vci_errors_use_oid4vci_envelope() { - let app = Router::new().route( - "/oid4vci/credential", - post(|| async { - ( - StatusCode::BAD_REQUEST, - [("content-type", "application/json")], - Json(json!({ - "error": "invalid_request", - "error_description": "nonce request contained sensitive value subj-1" - })), - ) - }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let error = client - .oid4vci_credential( - registry_platform_oid4vci::CredentialRequest { - format: registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: registry_platform_oid4vci::PROOF_TYPE_JWT.to_string(), - jwt: "proof-jwt".to_string(), - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }, - RequestOptions::default(), - ) - .await - .expect_err("oid4vci error maps"); - - assert_eq!(error.to_string(), "openid4vci error: invalid_request"); - assert!(!format!("{error:?}").contains("subj-1")); - - match error { - NotaryClientError::Oid4vci { error, .. } => { - assert_eq!(error.error, "invalid_request"); - let portable = NotaryClientError::Oid4vci { - status: StatusCode::BAD_REQUEST, - error, - request_id: None, - retry_after: None, - } - .portable(); - let rendered = serde_json::to_value(portable).expect("portable serializes"); - assert!(rendered.get("detail").is_none()); - assert!(!rendered.to_string().contains("subj-1")); - } - other => panic!("expected oid4vci error, got {other:?}"), - } -} - -#[cfg(feature = "oid4vci")] -#[tokio::test] -async fn oid4vci_success_routes_parse_typed_responses() { - let app = Router::new() - .route( - "/.well-known/openid-credential-issuer", - get(oid4vci_metadata_handler), - ) - .route("/oid4vci/credential", post(oid4vci_credential_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - - let metadata = client - .oid4vci_issuer_metadata(RequestOptions::default()) - .await - .expect("metadata"); - let credential = client - .oid4vci_credential( - registry_platform_oid4vci::CredentialRequest { - format: registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string(), - credential_identifier: Some("person_is_alive_sd_jwt".to_string()), - credential_configuration_id: None, - vct: None, - proof: registry_platform_oid4vci::CredentialRequestProof { - proof_type: registry_platform_oid4vci::PROOF_TYPE_JWT.to_string(), - jwt: "proof-jwt".to_string(), - }, - proofs: registry_platform_oid4vci::CredentialRequestProofs::default(), - }, - RequestOptions::default(), - ) - .await - .expect("credential"); - - assert_eq!(metadata.body.credential_issuer, "https://issuer.example"); - assert_eq!( - credential.body.credential, - registry_platform_oid4vci::CredentialValue::from("sd-jwt-credential") - ); - - let metadata_debug = format!("{metadata:?}"); - assert!(metadata_debug.contains("https://issuer.example")); - - let credential_debug = format!("{credential:?}"); - assert!(credential_debug.contains("")); - assert!(!credential_debug.contains("sd-jwt-credential")); - assert!(!credential_debug.contains("proof-jwt")); -} - -#[cfg(all(feature = "oid4vci", feature = "verifier"))] -#[tokio::test] -async fn oid4vci_verifier_rejects_object_credential_shape_before_jwks_fetch() { - let client = RegistryNotaryClient::builder("http://127.0.0.1:9") - .build() - .expect("client builds"); - let response = registry_platform_oid4vci::CredentialResponse { - credential: registry_platform_oid4vci::CredentialValue::Object(json!({ - "credential": "not-a-compact-sd-jwt" - })), - credentials: Vec::new(), - format: Some(registry_platform_oid4vci::SD_JWT_VC_FORMAT.to_string()), - c_nonce: None, - c_nonce_expires_in: None, - }; - - let error = client - .verify_oid4vci_credential( - &response, - registry_notary_client::VerifyOptions::new("https://issuer.example"), - ) - .await - .expect_err("object credential shape is not a compact SD-JWT VC"); - - assert_eq!(error.code(), "credential.unsupported_shape"); -} - -async fn evaluate_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some(FORMAT_CLAIM_RESULT_JSON) - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("benefits") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - assert_eq!(parsed["target"]["type"], json!("Person")); - assert_eq!( - parsed["target"]["identifiers"], - json!([{ - "scheme": "NATIONAL_ID", - "value": "subject-1", - "issuer": "civil_registry" - }]) - ); - assert_eq!(parsed["relationship"]["type"], json!("self")); - assert_eq!(parsed["variables"]["as_of_date"], json!("2026-01-01")); - ( - StatusCode::OK, - [("x-request-id", "req-123")], - Json(json!({ "results": [] })), - ) - .into_response() -} - -async fn body_purpose_evaluate_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - Json(json!({ "results": [] })).into_response() -} - -async fn body_purpose_batch_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - assert_eq!(parsed["format"], json!(FORMAT_CLAIM_RESULT_JSON)); - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn body_purpose_issue_credential_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer bearer-secret") - ); - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("body-purpose") - ); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("issue body parses"); - assert_eq!(parsed["purpose"], json!("body-purpose")); - issue_credential_json() -} - -async fn health_handler() -> Response { - Json(json!({ "status": "ok", "checks": {} })).into_response() -} - -async fn base_path_health_handler(uri: Uri) -> Response { - assert_eq!(uri.path(), "/tenant%20one/healthz"); - health_handler().await -} - -async fn admin_reload_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("x-api-key") - .and_then(|value| value.to_str().ok()), - Some("api-secret") - ); - Json(json!({ "reloaded": false, "status": "noop", "detail": "unchanged" })).into_response() -} - -async fn jwks_static_handler() -> Response { - Json(json!({ "keys": [{ "kty": "OKP", "kid": "kid-static", "crv": "Ed25519", "x": "abc" }] })) - .into_response() -} - -async fn claim_handler(Path(claim_id): Path, uri: Uri) -> Response { - assert_eq!(uri.path(), "/v1/claims/claim%20one"); - Json(json!({ "id": claim_id, "title": "Claim One" })).into_response() -} - -async fn render_handler(Path(evaluation_id): Path, body: Bytes) -> Response { - assert_eq!(evaluation_id, "eval-1"); - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("render body parses"); - assert!(parsed.get("evaluation_id").is_none()); - Json(json!({ "rendered": true })).into_response() -} - -async fn issue_credential_handler(body: Bytes) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("issue body parses"); - assert_eq!(parsed["evaluation_id"], "eval-1"); - issue_credential_json() -} - -fn issue_credential_json() -> Response { - Json(json!({ - "credential_id": "cred-1", - "credential_profile": "profile-1", - "format": "application/dc+sd-jwt", - "issuer": "did:web:notary.example", - "expires_at": "2026-05-29T00:00:00Z", - "credential": "issuer.jwt~disclosure~", - "issuer_signed_jwt": "issuer.jwt", - "disclosures": ["disclosure"] - })) - .into_response() -} - -async fn credential_status_handler(Path(credential_id): Path, uri: Uri) -> Response { - assert_eq!(uri.path(), "/v1/credentials/cred%201/status"); - Json(credential_status_json(&credential_id, "valid")).into_response() -} - -async fn update_credential_status_handler( - Path(credential_id): Path, - body: Bytes, -) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("status body parses"); - assert_eq!(parsed["status"], "revoked"); - Json(credential_status_json(&credential_id, "revoked")).into_response() -} - -fn credential_status_json(credential_id: &str, status: &str) -> serde_json::Value { - json!({ - "credential_id": credential_id, - "issuer": "did:web:notary.example", - "credential_profile": "profile-1", - "status": status, - "issued_at": "2026-05-29T00:00:00Z", - "expires_at": "2026-05-30T00:00:00Z", - "updated_at": "2026-05-29T01:00:00Z" - }) -} - -async fn flaky_batch_handler( - State(counter): State>, - headers: HeaderMap, -) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return problem(StatusCode::SERVICE_UNAVAILABLE); - } - if headers - .get("idempotency-key") - .and_then(|value| value.to_str().ok()) - != Some("batch-key") - { - return problem(StatusCode::SERVICE_UNAVAILABLE); - } - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn retry_after_then_claims_handler(State(counter): State>) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return ( - StatusCode::SERVICE_UNAVAILABLE, - [("retry-after", "0")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": 503, - "detail": "evidence not available", - "code": "evidence.not_available" - })), - ) - .into_response(); - } - Json(json!({ "data": [] })).into_response() -} - -async fn retry_after_http_date_then_claims_handler( - State(counter): State>, -) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - if call == 1 { - return ( - StatusCode::SERVICE_UNAVAILABLE, - [ - ("retry-after", "Wed, 31 Dec 2099 00:00:00 GMT"), - ("date", "Wed, 31 Dec 2099 00:00:00 GMT"), - ], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": 503, - "detail": "evidence not available", - "code": "evidence.not_available" - })), - ) - .into_response(); - } - Json(json!({ "data": [] })).into_response() -} - -async fn jwks_handler(State(counter): State>) -> Response { - let call = counter.fetch_add(1, Ordering::SeqCst) + 1; - Json(json!({ - "keys": [ - { "kty": "OKP", "kid": format!("kid-{call}"), "crv": "Ed25519", "x": "abc" } - ] - })) - .into_response() -} - -#[cfg(feature = "federation")] -async fn federation_handler(headers: HeaderMap, body: Bytes) -> Response { - assert_eq!( - headers - .get("content-type") - .and_then(|value| value.to_str().ok()), - Some("application/jwt") - ); - assert_eq!(body.as_ref(), b"header.payload.signature"); - "signed-response-jws".into_response() -} - -#[cfg(feature = "oid4vci")] -async fn oid4vci_metadata_handler() -> Response { - Json(json!({ - "credential_issuer": "https://issuer.example", - "credential_endpoint": "https://issuer.example/oid4vci/credential", - "credential_configurations_supported": {} - })) - .into_response() -} - -#[cfg(feature = "oid4vci")] -async fn oid4vci_credential_handler(body: Bytes) -> Response { - let parsed: serde_json::Value = serde_json::from_slice(&body).expect("credential body parses"); - assert_eq!(parsed["proof"]["jwt"], "proof-jwt"); - Json(json!({ - "credential": "sd-jwt-credential", - "format": "dc+sd-jwt" - })) - .into_response() -} - -fn problem(status: StatusCode) -> Response { - ( - status, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/unavailable", - "title": "Evidence not available", - "status": status.as_u16(), - "detail": "the requested evidence is unavailable", - "code": "evidence.not_available" - })), - ) - .into_response() -} - -async fn problem_with_sensitive_detail() -> Response { - ( - StatusCode::NOT_FOUND, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/source/not-found", - "title": "Source missing", - "status": 404, - "detail": "subject subj-sensitive was not found", - "code": "source.not_found" - })), - ) - .into_response() -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test server binds"); - let addr: SocketAddr = listener.local_addr().expect("local addr available"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server serves"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-client/tests/facade_contract.rs b/crates/registry-notary-client/tests/facade_contract.rs deleted file mode 100644 index 18a75a6af..000000000 --- a/crates/registry-notary-client/tests/facade_contract.rs +++ /dev/null @@ -1,230 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "json-facade")] - -use std::net::SocketAddr; - -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use registry_notary_client::facade::NotaryClientHandle; -use registry_notary_client::{PortableErrorKind, RegistryNotaryClient}; -use registry_notary_core::FORMAT_CLAIM_RESULT_JSON; -use serde_json::json; -use tokio::net::TcpListener; - -#[tokio::test] -async fn facade_accepts_canonical_snake_case_json() { - let app = Router::new().route("/v1/evaluations", post(evaluate_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .default_purpose("benefits") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let response = handle - .evaluate_json( - json!({ - "target": { - "type": "Person", - "identifiers": [{ "scheme": "NATIONAL_ID", "value": "subject-1" }] - }, - "claims": ["claim-a"] - }), - json!({}), - ) - .await - .expect("facade evaluate succeeds"); - - assert_eq!(response, json!({ "results": [] })); -} - -#[tokio::test] -async fn facade_core_methods_share_typed_validation_and_wire_shape() { - let app = Router::new() - .route("/v1/batch-evaluations", post(batch_handler)) - .route( - "/v1/evaluations/{evaluation_id}/render", - post(render_handler), - ) - .route("/v1/credentials", post(issue_handler)) - .route( - "/v1/claims", - get(|| async { Json(json!({ "data": [{ "id": "claim-a" }] })) }), - ) - .route( - "/v1/claims/claim-a", - get(|| async { Json(json!({ "id": "claim-a" })) }), - ) - .route( - "/v1/credentials/cred-1/status", - get(|| async { Json(credential_status("valid")) }), - ); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .default_purpose("benefits") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let batch = handle - .batch_evaluate_json( - json!({ - "items": [{ - "target": { - "type": "Person", - "identifiers": [{ "scheme": "NATIONAL_ID", "value": "subject-1" }] - } - }], - "claims": ["claim-a"] - }), - json!({ "idempotency_key": "batch-key" }), - ) - .await - .expect("facade batch succeeds"); - let rendered = handle - .render_json( - json!({ "evaluation_id": "eval-1", "format": FORMAT_CLAIM_RESULT_JSON }), - json!({}), - ) - .await - .expect("facade render succeeds"); - let issued = handle - .issue_credential_json(json!({ "evaluation_id": "eval-1" }), json!({})) - .await - .expect("facade issue succeeds"); - let claims = handle - .list_claims_json(json!({})) - .await - .expect("facade list claims succeeds"); - let claim = handle - .get_claim_json("claim-a".to_string(), json!({})) - .await - .expect("facade get claim succeeds"); - let status = handle - .credential_status_json("cred-1".to_string(), json!({})) - .await - .expect("facade credential status succeeds"); - - assert_eq!(batch["batch_id"], "batch-1"); - assert_eq!(rendered["rendered"], true); - assert_eq!(issued["credential_id"], "cred-1"); - assert_eq!(claims["data"][0]["id"], "claim-a"); - assert_eq!(claim["id"], "claim-a"); - assert_eq!(status["status"], "valid"); -} - -#[tokio::test] -async fn facade_error_excludes_detail() { - let app = Router::new().route("/v1/claims", get(problem_handler)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .bearer_token("bearer-secret") - .build() - .expect("client builds"); - let handle = NotaryClientHandle::new(client); - - let error = handle - .list_claims_json(json!({})) - .await - .expect_err("problem maps to portable error"); - - assert_eq!(error.kind, PortableErrorKind::Problem); - assert_eq!(error.status, Some(404)); - assert_eq!(error.code.as_deref(), Some("target.not_found")); - assert_eq!(error.title, "Target not found"); - let rendered = serde_json::to_value(&error).expect("portable error serializes"); - assert!(rendered.get("detail").is_none()); -} - -async fn evaluate_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("data-purpose") - .and_then(|value| value.to_str().ok()), - Some("benefits") - ); - assert_eq!( - headers.get("accept").and_then(|value| value.to_str().ok()), - Some(FORMAT_CLAIM_RESULT_JSON) - ); - Json(json!({ "results": [] })).into_response() -} - -async fn batch_handler(headers: HeaderMap) -> Response { - assert_eq!( - headers - .get("idempotency-key") - .and_then(|value| value.to_str().ok()), - Some("batch-key") - ); - Json(json!({ - "batch_id": "batch-1", - "status": "completed", - "claims": ["claim-a"], - "items": [], - "summary": { "succeeded": 0, "failed": 0 } - })) - .into_response() -} - -async fn render_handler() -> Response { - Json(json!({ "rendered": true })).into_response() -} - -async fn issue_handler() -> Response { - Json(json!({ - "credential_id": "cred-1", - "credential_profile": "profile-1", - "format": "application/dc+sd-jwt", - "issuer": "did:web:notary.example", - "expires_at": "2026-05-29T00:00:00Z", - "credential": "issuer.jwt~disclosure~", - "issuer_signed_jwt": "issuer.jwt", - "disclosures": ["disclosure"] - })) - .into_response() -} - -fn credential_status(status: &str) -> serde_json::Value { - json!({ - "credential_id": "cred-1", - "issuer": "did:web:notary.example", - "credential_profile": "profile-1", - "status": status, - "issued_at": "2026-05-29T00:00:00Z", - "expires_at": "2026-05-30T00:00:00Z", - "updated_at": "2026-05-29T01:00:00Z" - }) -} - -async fn problem_handler() -> Response { - ( - StatusCode::NOT_FOUND, - [("content-type", "application/problem+json")], - Json(json!({ - "type": "https://id.registrystack.org/problems/registry-notary/target/not-found", - "title": "Target not found", - "status": 404, - "detail": "target identifier subj-sensitive was not found", - "code": "target.not_found" - })), - ) - .into_response() -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test server binds"); - let addr: SocketAddr = listener.local_addr().expect("local addr available"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server serves"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-client/tests/status_verifier_contract.rs b/crates/registry-notary-client/tests/status_verifier_contract.rs deleted file mode 100644 index 83c53e94a..000000000 --- a/crates/registry-notary-client/tests/status_verifier_contract.rs +++ /dev/null @@ -1,527 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(all(feature = "verifier", feature = "test-support"))] - -use std::collections::BTreeMap; -use std::io::Write; -use std::sync::{Arc, Mutex}; - -use axum::body::Body; -use axum::extract::State; -use axum::http::{header, HeaderValue, Response, StatusCode}; -use axum::routing::get; -use axum::{Json, Router}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use flate2::write::ZlibEncoder; -use flate2::Compression; -use registry_notary_client::verifier; -use registry_notary_client::{ - RegistryNotaryClient, StatusListPolicy, VerificationError, VerifyOptions, -}; -use registry_platform_crypto::PrivateJwk; -use registry_platform_sdjwt::{Disclosure, SdJwtIssuanceInput, SdJwtIssuer}; -use serde_json::{json, Value}; -use time::OffsetDateTime; -use tokio::net::TcpListener; - -const ISSUER: &str = "did:web:issuer.test"; -const VCT: &str = "https://vct.example/test"; -const NOW: i64 = 1_700_000_010; -const ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:issuer.test#key-1"}"#; - -#[derive(Clone)] -struct HarnessState { - jwks: Value, - status: Arc>, -} - -#[derive(Clone)] -struct StatusHttpResponse { - status: StatusCode, - content_type: Option<&'static str>, - content_encoding: Option<&'static str>, - location: Option<&'static str>, - body: String, -} - -impl Default for StatusHttpResponse { - fn default() -> Self { - Self { - status: StatusCode::SERVICE_UNAVAILABLE, - content_type: Some("application/statuslist+jwt"), - content_encoding: None, - location: None, - body: String::new(), - } - } -} - -struct StatusHarness { - base_url: String, - status_uri: String, - state: HarnessState, - client: RegistryNotaryClient, -} - -impl StatusHarness { - async fn start() -> Self { - let state = HarnessState { - jwks: jwks(), - status: Arc::new(Mutex::new(StatusHttpResponse::default())), - }; - let app = Router::new() - .route("/.well-known/evidence/jwks.json", get(jwks_handler)) - .route("/status", get(status_handler)) - .with_state(state.clone()); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("test listener binds"); - let address = listener.local_addr().expect("test listener has address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("test server remains available"); - }); - let base_url = format!("http://{address}"); - let status_uri = format!("{base_url}/status"); - let client = RegistryNotaryClient::builder(&base_url) - .build() - .expect("test client builds"); - Self { - base_url, - status_uri, - state, - client, - } - } - - fn options(&self) -> VerifyOptions { - VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::loopback_for_testing(ISSUER, &self.base_url) - .expect("loopback test policy is valid"), - ) - } - - async fn credential(&self, status_uri: &str, index: u64) -> String { - issuer() - .issue(SdJwtIssuanceInput { - iss: ISSUER.to_string(), - sub_ref: "subject-ref".to_string(), - credential_id: Some("urn:ulid:01HG0000000000000000000000".to_string()), - iat: NOW, - exp: NOW + 600, - vct: VCT.to_string(), - status: Some(json!({ - "status_list": { - "idx": index, - "uri": status_uri, - } - })), - public_claims: BTreeMap::new(), - cnf: None, - disclosures: vec![Disclosure { - name: "claim-a".to_string(), - value: json!({"satisfied": true}), - }], - }) - .await - .expect("status-bearing credential issues") - .jwt - } - - async fn signed_status(&self, payload: Value) -> String { - issuer() - .sign_compact_jwt("statuslist+jwt", payload) - .await - .expect("status token signs") - } - - fn valid_payload(&self, encoded_list: &str) -> Value { - json!({ - "iss": ISSUER, - "sub": self.status_uri, - "aud": self.status_uri, - "iat": NOW, - "exp": NOW + 100, - "ttl": 100, - "status_list": { - "bits": 8, - "lst": encoded_list, - } - }) - } - - fn respond(&self, response: StatusHttpResponse) { - *self.state.status.lock().expect("status response lock") = response; - } - - async fn respond_with_token(&self, token: String) { - self.respond(StatusHttpResponse { - status: StatusCode::OK, - body: token, - ..StatusHttpResponse::default() - }); - } -} - -#[tokio::test] -async fn async_verifier_accepts_signed_valid_status() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - harness.respond_with_token(token).await; - - let verified = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect("valid status-bearing credential verifies"); - - assert_eq!(verified.issuer, ISSUER); -} - -#[tokio::test] -async fn synchronous_verifier_never_skips_status() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - - let missing_policy = verifier::verify_sd_jwt_vc( - &credential, - &jwks(), - &VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")), - ) - .expect_err("status cannot be skipped without policy"); - assert_code(missing_policy, "status.policy_required"); - - let requires_fetch = verifier::verify_sd_jwt_vc(&credential, &jwks(), &harness.options()) - .expect_err("synchronous verification cannot skip the fetch"); - assert_code(requires_fetch, "status.fetch_required"); -} - -#[tokio::test] -async fn revoked_suspended_and_unknown_status_fail_closed() { - for (encoded, expected_code) in [ - ("eJxjBAAAAgAC", "status.revoked"), - ("eJxjAgAAAwAD", "status.suspended"), - (&encoded_status_list(&[3]), "status.unknown"), - ] { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness.signed_status(harness.valid_payload(encoded)).await; - harness.respond_with_token(token).await; - - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("non-valid status is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn invalid_status_claims_signature_index_and_compression_fail_closed() { - let harness = StatusHarness::start().await; - let mut cases = Vec::new(); - - let mut wrong_issuer = harness.valid_payload("eJxjAAAAAQAB"); - wrong_issuer["iss"] = json!("did:web:other.example"); - cases.push((wrong_issuer, 0, "status.claim.issuer_mismatch")); - - let mut wrong_uri = harness.valid_payload("eJxjAAAAAQAB"); - wrong_uri["sub"] = json!(format!("{}/other", harness.base_url)); - cases.push((wrong_uri, 0, "status.claim.uri_mismatch")); - - let mut wrong_audience = harness.valid_payload("eJxjAAAAAQAB"); - wrong_audience["aud"] = json!("https://verifier.example"); - cases.push((wrong_audience, 0, "status.claim.audience_mismatch")); - - let mut stale = harness.valid_payload("eJxjAAAAAQAB"); - stale["iat"] = json!(NOW - 400); - stale["exp"] = json!(NOW - 300); - cases.push((stale, 0, "status.claim.time_invalid")); - - let mut excessive_lifetime = harness.valid_payload("eJxjAAAAAQAB"); - excessive_lifetime["exp"] = json!(NOW + 301); - excessive_lifetime["ttl"] = json!(301); - cases.push((excessive_lifetime, 0, "status.claim.time_invalid")); - - cases.push(( - harness.valid_payload("eJxjAAAAAQAB"), - 1, - "status.index.invalid", - )); - - let decompression_bomb = encoded_status_list(&vec![0; 128 * 1024 + 1]); - cases.push(( - harness.valid_payload(&decompression_bomb), - 0, - "status.list.decompression_limit", - )); - - for (payload, index, expected_code) in cases { - let credential = harness.credential(&harness.status_uri, index).await; - let token = harness.signed_status(payload).await; - harness.respond_with_token(token).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("invalid status material is rejected"); - assert_code(error, expected_code); - } - - let credential = harness.credential(&harness.status_uri, 0).await; - let token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - harness.respond_with_token(tamper_signature(&token)).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("invalid status signature is rejected"); - assert_code(error, "status.signature.invalid"); - - let valid_token = harness - .signed_status(harness.valid_payload("eJxjAAAAAQAB")) - .await; - for (token, expected_code) in [ - ( - rewrite_status_header(&valid_token, |header| { - header["kid"] = json!("did:web:issuer.test#unknown") - }), - "status.key.unknown", - ), - ( - rewrite_status_header(&valid_token, |header| header["alg"] = json!("RS256")), - "status.algorithm.disallowed", - ), - ( - rewrite_status_header(&valid_token, |header| { - header["jku"] = json!("https://attacker.example/jwks.json") - }), - "status.header.untrusted_key_reference", - ), - ] { - harness.respond_with_token(token).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("untrusted status signing metadata is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn status_transport_rejects_redirect_media_type_encoding_and_size() { - let harness = StatusHarness::start().await; - let credential = harness.credential(&harness.status_uri, 0).await; - - let cases = [ - ( - StatusHttpResponse { - status: StatusCode::SERVICE_UNAVAILABLE, - ..StatusHttpResponse::default() - }, - "status.http_status_invalid", - ), - ( - StatusHttpResponse { - status: StatusCode::FOUND, - location: Some("https://other.example/status"), - ..StatusHttpResponse::default() - }, - "status.redirect_denied", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - content_type: Some("application/json"), - body: "not-a-token".to_string(), - ..StatusHttpResponse::default() - }, - "status.media_type_invalid", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - content_encoding: Some("gzip"), - body: "not-a-token".to_string(), - ..StatusHttpResponse::default() - }, - "status.content_encoding_denied", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - body: "not-a-compact-jwt".to_string(), - ..StatusHttpResponse::default() - }, - "status.token_malformed", - ), - ( - StatusHttpResponse { - status: StatusCode::OK, - body: "x".repeat(256 * 1024 + 1), - ..StatusHttpResponse::default() - }, - "status.response_too_large", - ), - ]; - - for (response, expected_code) in cases { - harness.respond(response); - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("unsafe status response is rejected"); - assert_code(error, expected_code); - } -} - -#[tokio::test] -async fn status_origin_and_destination_must_be_explicit_and_safe() { - let harness = StatusHarness::start().await; - let untrusted_uri = "https://status.other.example/status"; - let credential = harness.credential(untrusted_uri, 0).await; - let error = harness - .client - .verify_sd_jwt_vc(&credential, harness.options()) - .await - .expect_err("unlisted status origin is rejected before fetch"); - assert_code(error, "status.origin_untrusted"); - - let unsafe_uri = "https://127.0.0.1/status"; - let credential = harness.credential(unsafe_uri, 0).await; - let options = VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::new(ISSUER, "https://127.0.0.1") - .expect("structurally valid HTTPS origin"), - ); - let error = harness - .client - .verify_sd_jwt_vc(&credential, options) - .await - .expect_err("private destination is rejected"); - assert_code(error, "status.destination_unsafe"); - - let closed_listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("closed-port probe binds"); - let closed_address = closed_listener - .local_addr() - .expect("closed-port probe has address"); - drop(closed_listener); - let unreachable_origin = format!("http://{closed_address}"); - let unreachable_uri = format!("{unreachable_origin}/status"); - let credential = harness.credential(&unreachable_uri, 0).await; - let options = VerifyOptions::new(ISSUER) - .now(OffsetDateTime::from_unix_timestamp(NOW).expect("test time is valid")) - .status_list( - StatusListPolicy::loopback_for_testing(ISSUER, unreachable_origin) - .expect("loopback test policy is valid"), - ); - let error = harness - .client - .verify_sd_jwt_vc(&credential, options) - .await - .expect_err("unreachable status endpoint is rejected"); - assert_code(error, "status.unreachable"); -} - -#[test] -fn status_policy_requires_exact_https_origins() { - assert!(StatusListPolicy::new(ISSUER, "http://status.example").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://status.example/path").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://user@status.example").is_err()); - assert!(StatusListPolicy::new(ISSUER, "https://status.example") - .expect("primary origin is accepted") - .allow_origin("https://status-backup.example:8443") - .is_ok()); -} - -fn issuer() -> SdJwtIssuer { - SdJwtIssuer::from_jwk(PrivateJwk::parse(ISSUER_JWK).expect("issuer JWK parses")) - .expect("issuer builds") -} - -fn jwks() -> Value { - let public = PrivateJwk::parse(ISSUER_JWK) - .expect("issuer JWK parses") - .public(); - json!({"keys": [serde_json::to_value(public).expect("public JWK serializes")]}) -} - -fn encoded_status_list(bytes: &[u8]) -> String { - let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(bytes).expect("status list compresses"); - URL_SAFE_NO_PAD.encode(encoder.finish().expect("status compression finishes")) -} - -fn rewrite_status_header(token: &str, mutate: impl FnOnce(&mut Value)) -> String { - let mut parts = token.split('.'); - let encoded_header = parts.next().expect("status token has header"); - let payload = parts.next().expect("status token has payload"); - let signature = parts.next().expect("status token has signature"); - assert!(parts.next().is_none()); - let mut header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(encoded_header) - .expect("status header decodes"), - ) - .expect("status header is JSON"); - mutate(&mut header); - let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); - format!("{header}.{payload}.{signature}") -} - -fn tamper_signature(token: &str) -> String { - let mut parts = token.split('.'); - let header = parts.next().expect("status token has header"); - let payload = parts.next().expect("status token has payload"); - let encoded_signature = parts.next().expect("status token has signature"); - assert!(parts.next().is_none()); - let mut signature = URL_SAFE_NO_PAD - .decode(encoded_signature) - .expect("status signature decodes"); - signature[0] ^= 1; - format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - -fn assert_code(error: VerificationError, expected: &str) { - assert_eq!(error.code(), expected, "unexpected verifier error: {error}"); -} - -async fn jwks_handler(State(state): State) -> Json { - Json(state.jwks) -} - -async fn status_handler(State(state): State) -> Response { - let response = state.status.lock().expect("status response lock").clone(); - let mut builder = Response::builder().status(response.status); - if let Some(content_type) = response.content_type { - builder = builder.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - } - if let Some(content_encoding) = response.content_encoding { - builder = builder.header( - header::CONTENT_ENCODING, - HeaderValue::from_static(content_encoding), - ); - } - if let Some(location) = response.location { - builder = builder.header(header::LOCATION, HeaderValue::from_static(location)); - } - builder - .body(Body::from(response.body)) - .expect("test response builds") -} diff --git a/crates/registry-notary-client/tests/verifier_contract.rs b/crates/registry-notary-client/tests/verifier_contract.rs deleted file mode 100644 index c9b5ce009..000000000 --- a/crates/registry-notary-client/tests/verifier_contract.rs +++ /dev/null @@ -1,638 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "verifier")] - -use std::collections::BTreeMap; -use std::net::SocketAddr; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; - -use axum::extract::State; -use axum::routing::get; -use axum::{Json, Router}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use registry_notary_client::verifier; -use registry_notary_client::{ - HolderBindingPolicy, RegistryNotaryClient, VerificationError, VerifyOptions, -}; -use registry_notary_core::SD_JWT_VC_JWT_TYP; -use registry_platform_crypto::{did_jwk_from_public_jwk, sign, PrivateJwk}; -use registry_platform_sdjwt::{Disclosure, HolderConfirmation, SdJwtIssuanceInput, SdJwtIssuer}; -use serde_json::{json, Value}; -use sha2::{Digest, Sha256}; -use tokio::net::TcpListener; - -const ISSUER: &str = "did:web:issuer.test"; -const VCT: &str = "https://vct.example/test"; -const NOW: i64 = 1_700_000_010; -const KB_AUD: &str = "https://verifier.example/callback"; -const KB_NONCE: &str = "nonce-1700000010"; -const ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"did:web:issuer.test#key-1"}"#; -const ISSUER_P256_JWK: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"did:web:issuer.test#p256-key-1"}"#; -const ROTATED_ISSUER_JWK: &str = r#"{"crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","kty":"OKP","alg":"EdDSA","kid":"did:web:issuer.test#key-2"}"#; -const HOLDER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA","kid":"holder-key-1"}"#; - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_valid_holder_bound_credential() { - let holder = holder_did(); - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder)).await; - let verified = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::RequiredKid(holder.clone())), - ) - .expect("credential verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.vct, VCT); - assert_eq!(verified.key_id, "did:web:issuer.test#key-1"); - assert_eq!(verified.algorithm, "EdDSA"); - assert_eq!(verified.disclosure_count, 1); - assert_eq!(verified.holder_key_id.as_deref(), Some(holder.as_str())); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_credential_without_disclosures() { - let compact = issue_plain_jwt_vc(ISSUER_JWK, ISSUER, NOW, NOW + 50); - - let verified = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect("credential without disclosures verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.disclosure_count, 0); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_es256_credential() { - let compact = issue_sd_jwt(ISSUER_P256_JWK, ISSUER, NOW, NOW + 50, None).await; - - let verified = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_P256_JWK), - &options().accepted_algorithms(["ES256"]), - ) - .expect("ES256 credential verifies"); - - assert_eq!(verified.issuer, ISSUER); - assert_eq!(verified.vct, VCT); - assert_eq!(verified.key_id, "did:web:issuer.test#p256-key-1"); - assert_eq!(verified.algorithm, "ES256"); - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_selectively_disclosed_subset() { - let compact = issue_sd_jwt_with_claims( - ISSUER_JWK, - ISSUER, - NOW, - NOW + 50, - None, - &["claim-a", "claim-b"], - ) - .await; - let presentation = keep_first_disclosure(&compact); - - let verified = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect("selective disclosure verifies"); - - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_duplicate_presented_disclosure() { - let compact = issue_sd_jwt_with_claims( - ISSUER_JWK, - ISSUER, - NOW, - NOW + 50, - None, - &["claim-a", "claim-b"], - ) - .await; - let presentation = duplicate_first_disclosure(&compact); - - let error = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect_err("duplicate disclosure is rejected"); - - assert_code(error, "disclosure.digest_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_separates_key_binding_jwt_from_disclosures() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let verified = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect("key binding jwt is not treated as a disclosure"); - - assert_eq!(verified.disclosure_count, 1); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_key_binding_without_expected_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::Required), - ) - .expect_err("key binding challenge is required"); - - assert_code(error, "holder_binding.challenge_required"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_missing_key_binding_for_expected_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("missing key binding jwt is rejected for verifier challenge"); - - assert_code(error, "holder_binding.challenge_required"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_accepts_optional_key_binding_without_challenge() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let verified = verifier::verify_sd_jwt_vc(&presentation, &jwks(ISSUER_JWK), &options()) - .expect("optional key binding does not require a verifier challenge"); - - assert_eq!( - verified.holder_key_id.as_deref(), - Some(holder_did().as_str()) - ); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_audience() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge("https://other-verifier.example/callback", KB_NONCE), - ) - .expect_err("key binding audience must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_nonce() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", signed_key_binding_jwt(&compact)); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, "stale-nonce"), - ) - .expect_err("key binding nonce must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_key_binding_sd_hash() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": "wrong" - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("key binding sd_hash must match"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_key_binding_sd_hash_without_trailing_separator() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest( - compact.strip_suffix('~').unwrap_or(&compact).as_bytes() - )), - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("key binding sd_hash must cover the trailing separator"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_expired_key_binding_jwt() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!( - "{compact}{}", - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW - 300, - "exp": NOW - 200, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest( - compact.as_bytes() - )), - })) - ); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("expired key binding jwt must be rejected"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_bad_key_binding_jwt() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, Some(&holder_did())).await; - let presentation = format!("{compact}{}", unsigned_compact_jws()); - - let error = verifier::verify_sd_jwt_vc( - &presentation, - &jwks(ISSUER_JWK), - &options() - .holder_binding(HolderBindingPolicy::Required) - .key_binding_challenge(KB_AUD, KB_NONCE), - ) - .expect_err("bad key binding jwt is rejected"); - - assert_code(error, "holder_binding.proof_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_bad_signature() { - let compact = tamper_signature(&issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("bad signature is rejected"); - - assert_code(error, "signature.invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_unknown_kid() { - let compact = rewrite_header( - &issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await, - |header| header["kid"] = json!("did:web:issuer.test#missing"), - ); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("unknown kid is rejected"); - - assert_code(error, "key.unknown"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_disallowed_algorithm() { - let compact = rewrite_header( - &issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await, - |header| header["alg"] = json!("RS256"), - ); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("disallowed alg is rejected"); - - assert_code(error, "algorithm.disallowed"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_wrong_issuer() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &VerifyOptions::new("did:web:other.example").now(now()), - ) - .expect_err("wrong issuer is rejected"); - - assert_code(error, "claim.issuer_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_expired_credential() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW - 300, NOW - 200, None).await; - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("expired credential is rejected"); - - assert_code(error, "claim.time_invalid"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_invalid_disclosure_digest() { - let compact = tamper_disclosure(&issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await); - let error = verifier::verify_sd_jwt_vc(&compact, &jwks(ISSUER_JWK), &options()) - .expect_err("invalid disclosure digest is rejected"); - - assert_code(error, "disclosure.digest_mismatch"); -} - -#[tokio::test] -async fn verify_sd_jwt_vc_rejects_missing_required_holder_binding() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &options().holder_binding(HolderBindingPolicy::Required), - ) - .expect_err("missing holder binding is rejected"); - - assert_code(error, "holder_binding.required"); -} - -#[tokio::test] -async fn verifier_errors_do_not_render_credential_fragments() { - let compact = issue_sd_jwt(ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - let error = verifier::verify_sd_jwt_vc( - &compact, - &jwks(ISSUER_JWK), - &VerifyOptions::new("did:web:other.example").now(now()), - ) - .expect_err("wrong issuer is rejected"); - let rendered = format!("{error:?} {error}"); - - assert!(rendered.contains("claim.issuer_mismatch")); - assert!(!rendered.contains(&compact)); - for fragment in compact.split(['.', '~']) { - if fragment.len() > 12 { - assert!( - !rendered.contains(fragment), - "error rendered compact credential fragment" - ); - } - } -} - -#[tokio::test] -async fn client_verifier_refreshes_once_on_unknown_kid() { - let counter = Arc::new(AtomicUsize::new(0)); - let app = Router::new() - .route( - "/.well-known/evidence/jwks.json", - get(rotating_jwks_handler), - ) - .with_state(Arc::clone(&counter)); - let base = spawn(app).await; - let client = RegistryNotaryClient::builder(base) - .build() - .expect("client builds"); - let compact = issue_sd_jwt(ROTATED_ISSUER_JWK, ISSUER, NOW, NOW + 50, None).await; - - let verified = client - .verify_sd_jwt_vc(&compact, options()) - .await - .expect("refresh finds rotated key"); - - assert_eq!(verified.key_id, "did:web:issuer.test#key-2"); - assert_eq!(counter.load(Ordering::SeqCst), 2); -} - -async fn issue_sd_jwt( - private_jwk: &str, - issuer_id: &str, - iat: i64, - exp: i64, - holder_id: Option<&str>, -) -> String { - issue_sd_jwt_with_claims(private_jwk, issuer_id, iat, exp, holder_id, &["claim-a"]).await -} - -async fn issue_sd_jwt_with_claims( - private_jwk: &str, - issuer_id: &str, - iat: i64, - exp: i64, - holder_id: Option<&str>, - claim_names: &[&str], -) -> String { - let sd_jwt_issuer = - SdJwtIssuer::from_jwk(PrivateJwk::parse(private_jwk).expect("issuer jwk parses")) - .expect("issuer builds"); - let holder_confirmation = holder_id.map(|kid| { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - HolderConfirmation { - jwk: holder.public(), - kid: Some(kid.to_string()), - } - }); - sd_jwt_issuer - .issue(SdJwtIssuanceInput { - iss: issuer_id.to_string(), - sub_ref: holder_id.unwrap_or("subject-ref").to_string(), - credential_id: Some("urn:ulid:01HG0000000000000000000000".to_string()), - iat, - exp, - vct: VCT.to_string(), - status: None, - public_claims: BTreeMap::new(), - cnf: holder_confirmation, - disclosures: claim_names - .iter() - .map(|claim_name| Disclosure { - name: (*claim_name).to_string(), - value: json!({"satisfied": true}), - }) - .collect(), - }) - .await - .expect("sd-jwt issues") - .jwt -} - -fn issue_plain_jwt_vc(private_jwk: &str, issuer_id: &str, iat: i64, exp: i64) -> String { - let issuer = PrivateJwk::parse(private_jwk).expect("issuer jwk parses"); - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ - "alg": "EdDSA", - "kid": "did:web:issuer.test#key-1", - "typ": SD_JWT_VC_JWT_TYP, - })) - .expect("header serializes"), - ); - let payload = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ - "iss": issuer_id, - "sub": "subject-ref", - "jti": "urn:ulid:01HG0000000000000000000000", - "iat": iat, - "exp": exp, - "vct": VCT, - })) - .expect("payload serializes"), - ); - let signing_input = format!("{header}.{payload}"); - let signature = sign(signing_input.as_bytes(), &issuer).expect("issuer signs"); - format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature)) -} - -fn options() -> VerifyOptions { - VerifyOptions::new(ISSUER).expected_vct(VCT).now(now()) -} - -fn now() -> time::OffsetDateTime { - time::OffsetDateTime::from_unix_timestamp(NOW).expect("test timestamp") -} - -fn jwks(private_jwk: &str) -> Value { - let public = PrivateJwk::parse(private_jwk).expect("jwk parses").public(); - json!({ "keys": [serde_json::to_value(public).expect("public jwk serializes")] }) -} - -fn holder_did() -> String { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - did_jwk_from_public_jwk(&holder.public()).expect("holder did encodes") -} - -fn assert_code(error: VerificationError, code: &str) { - assert_eq!(error.code(), code); -} - -fn tamper_signature(compact: &str) -> String { - let (jwt, suffix) = compact.split_once('~').expect("sd-jwt has disclosure"); - let mut parts = jwt.split('.').collect::>(); - let mut signature = URL_SAFE_NO_PAD - .decode(parts[2]) - .expect("signature base64 decodes"); - signature[0] ^= 0x01; - let signature = URL_SAFE_NO_PAD.encode(signature); - parts[2] = &signature; - format!("{}~{suffix}", parts.join(".")) -} - -fn tamper_disclosure(compact: &str) -> String { - let mut parts = compact.split('~').collect::>(); - let mut disclosure = parts[1].to_string(); - let replacement = if disclosure.ends_with('A') { "B" } else { "A" }; - disclosure.pop(); - disclosure.push_str(replacement); - parts[1] = &disclosure; - parts.join("~") -} - -fn keep_first_disclosure(compact: &str) -> String { - let mut parts = compact.split('~'); - let jwt = parts.next().expect("issuer jwt"); - let first_disclosure = parts.next().expect("first disclosure"); - format!("{jwt}~{first_disclosure}~") -} - -fn duplicate_first_disclosure(compact: &str) -> String { - let mut parts = compact.split('~'); - let jwt = parts.next().expect("issuer jwt"); - let first_disclosure = parts.next().expect("first disclosure"); - format!("{jwt}~{first_disclosure}~{first_disclosure}~") -} - -fn unsigned_compact_jws() -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"kb+jwt"}"#); - let payload = URL_SAFE_NO_PAD.encode(br#"{"iat":1700000010}"#); - format!("{header}.{payload}.signature") -} - -fn signed_key_binding_jwt(sd_jwt: &str) -> String { - signed_key_binding_jwt_with_payload(json!({ - "iat": NOW, - "exp": NOW + 30, - "aud": KB_AUD, - "nonce": KB_NONCE, - "sd_hash": URL_SAFE_NO_PAD.encode(Sha256::digest(sd_jwt.as_bytes())), - })) -} - -fn signed_key_binding_jwt_with_payload(payload: Value) -> String { - let holder = PrivateJwk::parse(HOLDER_JWK).expect("holder jwk parses"); - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"kb+jwt","kid":"holder-key-1"}"#); - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload serializes")); - let signing_input = format!("{header}.{payload}"); - let signature = sign(signing_input.as_bytes(), &holder).expect("holder proof signs"); - format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature)) -} - -fn rewrite_header(compact: &str, mutate: impl FnOnce(&mut Value)) -> String { - let (jwt, suffix) = compact.split_once('~').expect("sd-jwt has disclosure"); - let mut parts = jwt.split('.').collect::>(); - let mut header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(parts[0]) - .expect("header base64 decodes"), - ) - .expect("header json decodes"); - mutate(&mut header); - let header_b64 = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")); - parts[0] = &header_b64; - format!("{}~{suffix}", parts.join(".")) -} - -async fn rotating_jwks_handler(State(counter): State>) -> Json { - let call = counter.fetch_add(1, Ordering::SeqCst); - if call == 0 { - Json(jwks(ISSUER_JWK)) - } else { - Json(jwks(ROTATED_ISSUER_JWK)) - } -} - -async fn spawn(app: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test server"); - let addr: SocketAddr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { - axum::serve(listener, app).await.expect("test server runs"); - }); - format!("http://{addr}") -} diff --git a/crates/registry-notary-core/Cargo.toml b/crates/registry-notary-core/Cargo.toml deleted file mode 100644 index c7b9cac39..000000000 --- a/crates/registry-notary-core/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "registry-notary-core" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Core Registry Notary domain, configuration, and credential primitives." -readme = "README.md" -repository.workspace = true -publish = false - -[lints] -workspace = true - -[dependencies] -base64.workspace = true -humantime-serde.workspace = true -ipnet = { workspace = true, features = ["serde"] } -registry-platform-authcommon.workspace = true -registry-platform-config.workspace = true -registry-platform-crypto.workspace = true -registry-platform-httputil.workspace = true -registry-platform-oid4vci.workspace = true -registry-platform-ops.workspace = true -registry-platform-sdjwt.workspace = true -schemars.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -thiserror.workspace = true -time.workspace = true -ulid.workspace = true -url.workspace = true -utoipa.workspace = true - -[dev-dependencies] -serde_norway.workspace = true -tempfile.workspace = true -tokio.workspace = true diff --git a/crates/registry-notary-core/README.md b/crates/registry-notary-core/README.md deleted file mode 100644 index 541d25d8c..000000000 --- a/crates/registry-notary-core/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# registry-notary-core - -Portable Registry Notary domain model, configuration, and credential -primitives. - -This crate owns the serializable contracts shared by the server, binary, tests, -and downstream tooling. - -## What It Provides - -- Standalone Registry Notary configuration types and validation. -- Claim, requester/target evidence, source binding, disclosure, and evaluation - models. -- Static-peer federation config models, validation constants, and audit fields - for delegated evaluation. -- Error types used across the workspace. -- SD-JWT VC issuance helpers for claim views. -- OpenAPI-compatible schema derives for public contract types. - -## Typical Use - -```rust -use registry_notary_core::StandaloneRegistryNotaryConfig; - -fn load(raw_yaml: &str) -> Result> { - let config: StandaloneRegistryNotaryConfig = serde_norway::from_str(raw_yaml)?; - config.validate()?; - Ok(config) -} -``` - -## Boundary - -This crate is runtime-neutral. It should not own Axum routes, outbound HTTP -clients, tracing setup, process startup, or storage for evaluated evidence. - -## Testing - -```sh -cargo test -p registry-notary-core -``` - -## License - -Apache-2.0. diff --git a/crates/registry-notary-core/config/documentation-intent.json b/crates/registry-notary-core/config/documentation-intent.json deleted file mode 100644 index 0e2aba9c5..000000000 --- a/crates/registry-notary-core/config/documentation-intent.json +++ /dev/null @@ -1,7804 +0,0 @@ -{ - "$schema": "https://id.registrystack.org/schemas/registryctl/project-documentation/registry.runtime.configuration_intent.v1.schema.json", - "format_version": "1.0", - "runtime_schema": "notary", - "schema_id": "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json", - "schema_source": "registry-notary.config.schema.json", - "profiles": [ - { - "id": "notary_audit_internal", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_audit_secret_reference", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_audit_sensitive", - "purpose": "Controls Notary audit delivery, retention, hashing, and failure behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_auth_internal", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_auth_oidc_scope_map_open_map", - "purpose": "Each reviewed key is an external token scope and each value is the bounded Notary scope mapping granted for that exact token scope.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key is an external token scope and each value is the bounded Notary scope mapping granted for that exact token scope." - }, - { - "id": "notary_auth_secret_reference", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_auth_sensitive", - "purpose": "Controls Notary caller authentication, authorization details, token verification, and access-token signing.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_cel_internal", - "purpose": "Controls bounded CEL evaluation limits used by reviewed Notary policy expressions.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_config_trust_internal", - "purpose": "Controls Notary verification of signed configuration bundles, trust anchors, and rollback protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_config_trust_sensitive", - "purpose": "Controls Notary verification of signed configuration bundles, trust anchors, and rollback protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_credential_status_sensitive", - "purpose": "Controls Notary credential-status publication and retention behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_deployment_internal", - "purpose": "Declares Notary deployment posture and reviewed waiver metadata used by operator checks.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_deployment_sensitive", - "purpose": "Declares Notary deployment posture and reviewed waiver metadata used by operator checks.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_inputs_open_map", - "purpose": "Each reviewed key names a consultation input and each value binds it to an approved claim or variable source.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a consultation input and each value binds it to an approved claim or variable source." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_open_map", - "purpose": "Each reviewed key names a Relay consultation and each value defines the bounded consultation contract used to support this evidence claim.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a Relay consultation and each value defines the bounded consultation contract used to support this evidence claim." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_output_fields_open_map", - "purpose": "Each reviewed key names a closed structured output field and each value defines its requiredness and bounded recursive schema.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.16.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a closed structured output field and each value defines its requiredness and bounded recursive schema." - }, - { - "id": "notary_evidence_claims_evidence_mode_consultations_outputs_open_map", - "purpose": "Each reviewed key names a consultation output and each value defines its bounded evidence-result interpretation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a consultation output and each value defines its bounded evidence-result interpretation." - }, - { - "id": "notary_evidence_credential_profiles_open_map", - "purpose": "Each reviewed key names a credential profile and each value defines its exact claims, format, and issuance contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a credential profile and each value defines its exact claims, format, and issuance contract." - }, - { - "id": "notary_evidence_internal", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_evidence_secret_reference", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_evidence_sensitive", - "purpose": "Controls evidence claims, Relay consultations, disclosure, signing keys, variables, and credential profiles.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_evidence_signing_keys_open_map", - "purpose": "Each reviewed key names a signing-key binding and each value references the operator-managed signing material and lifecycle metadata.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a signing-key binding and each value references the operator-managed signing material and lifecycle metadata." - }, - { - "id": "notary_evidence_variables_open_map", - "purpose": "Each reviewed key names a Notary evidence variable and each value defines its bounded source and evaluation contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names a Notary evidence variable and each value defines its bounded source and evaluation contract." - }, - { - "id": "notary_federation_internal", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_federation_secret_reference", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_federation_sensitive", - "purpose": "Controls Notary federation trust, request verification, pairwise identifiers, and bounded peer behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_instance_internal", - "purpose": "Identifies the Notary instance and its deployment environment for operational correlation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_instance_sensitive", - "purpose": "Identifies the Notary instance and its deployment environment for operational correlation.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_oid4vci_credential_configurations_open_map", - "purpose": "Each reviewed key names an OpenID4VCI credential configuration and each value defines the advertised and issued credential contract.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "arbitrary_map_keys_not_fixed_properties" - ], - "open_map_semantics": "Each reviewed key names an OpenID4VCI credential configuration and each value defines the advertised and issued credential contract." - }, - { - "id": "notary_oid4vci_internal", - "purpose": "Controls Notary OpenID4VCI issuer, client, grant, proof, nonce, and credential-configuration behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_oid4vci_sensitive", - "purpose": "Controls Notary OpenID4VCI issuer, client, grant, proof, nonce, and credential-configuration behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_root_structural", - "purpose": "Defines the complete Notary runtime configuration boundary consumed when a Notary instance starts.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "structural", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_server_internal", - "purpose": "Controls Notary listeners, transport, timeouts, request limits, and administrative endpoints.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_server_sensitive", - "purpose": "Controls Notary listeners, transport, timeouts, request limits, and administrative endpoints.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - }, - { - "id": "notary_state_internal", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "bound_by_environment", - "sensitivity": "internal", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values" - ] - }, - { - "id": "notary_state_secret_reference", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "secret_reference", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "secret_never_reportable" - ] - }, - { - "id": "notary_state_sensitive", - "purpose": "Controls Notary durable state storage, database trust, timeouts, connection limits, and sensitive-state protection.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata" - ] - }, - { - "id": "notary_subject_access_sensitive", - "purpose": "Controls Notary subject and assisted-access policy, authorization details, and relationship proof behavior.", - "semantic_owner": "notary_runtime", - "human_owner": "notary_maintainers", - "scope": "The complete product-owned Notary runtime configuration accepted by schema validation, Rust deserialization, and operator preflight.", - "environment_behavior": "narrows_reviewed_authority", - "sensitivity": "sensitive", - "state": "runtime", - "products": [ - "notary", - "docs" - ], - "availability": "published", - "stability": "experimental", - "validation_stages": [ - "json_schema", - "rust_deserialization", - "operator_preflight" - ], - "diagnostic": "Notary runtime configuration does not yet publish a closed general diagnostic code; doctor reports the generic failed status.", - "introduced_in": "0.13.0", - "migration": "coordinate_deployment", - "migration_note": "Coordinate Notary deployment, key or trust rotation, and rollback review before activating runtime configuration changes.", - "example_guidance": "Use synthetic identifiers and non-routable placeholders; never copy credentials, environment values, deployment paths, or country configuration.", - "consumers": [ - "registry_notary", - "docs_generator" - ], - "generated_artifacts": [ - "notary_config", - "field_reference" - ], - "review_classes": [ - "contract", - "notary", - "security", - "privacy", - "documentation" - ], - "semantic_rules": [ - "knowledge_only", - "generated_docs_never_load_country_values", - "sensitive_operational_metadata", - "array_items_share_element_contract" - ] - } - ], - "assignments": [ - { - "schema": "notary", - "pointer": "", - "key_path": "", - "path_kind": "root", - "profile": "notary_root_structural", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/audit", - "key_path": "audit", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/hash_secret_env", - "key_path": "audit.hash_secret_env", - "path_kind": "property", - "profile": "notary_audit_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/max_files", - "key_path": "audit.max_files", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/max_size_mb", - "key_path": "audit.max_size_mb", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/path", - "key_path": "audit.path", - "path_kind": "property", - "profile": "notary_audit_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/sink", - "key_path": "audit.sink", - "path_kind": "property", - "profile": "notary_audit_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuditConfig/properties/syslog_socket_path", - "key_path": "audit.syslog_socket_path", - "path_kind": "property", - "profile": "notary_audit_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/auth", - "key_path": "auth", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/access_token_signing", - "key_path": "auth.access_token_signing", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/access_token_ttl_seconds", - "key_path": "auth.access_token_signing.access_token_ttl_seconds", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/allowed_algorithms", - "key_path": "auth.access_token_signing.allowed_algorithms", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/allowed_algorithms/items", - "key_path": "auth.access_token_signing.allowed_algorithms[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/audiences", - "key_path": "auth.access_token_signing.audiences", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/audiences/items", - "key_path": "auth.access_token_signing.audiences[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/enabled", - "key_path": "auth.access_token_signing.enabled", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/issuer", - "key_path": "auth.access_token_signing.issuer", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/signing_key_id", - "key_path": "auth.access_token_signing.signing_key_id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/token_typ", - "key_path": "auth.access_token_signing.token_typ", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids", - "key_path": "auth.access_token_signing.verification_key_ids", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids/items", - "key_path": "auth.access_token_signing.verification_key_ids[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/api_keys", - "key_path": "auth.api_keys", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/api_keys/items", - "key_path": "auth.api_keys[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.api_keys[].authorization_details", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.api_keys[].authorization_details.access_mode", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.api_keys[].authorization_details.actions", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions/items", - "key_path": "auth.api_keys[].authorization_details.actions[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAssistedAccessContext/properties/channel", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context.channel", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.api_keys[].authorization_details.assurance_level", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.api_keys[].authorization_details.claims", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims/items", - "key_path": "auth.api_keys[].authorization_details.claims[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/id", - "key_path": "auth.api_keys[].authorization_details.claims[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/version", - "key_path": "auth.api_keys[].authorization_details.claims[].version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.api_keys[].authorization_details.consent_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.api_keys[].authorization_details.disclosure", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.api_keys[].authorization_details.format", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.api_keys[].authorization_details.jurisdiction", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.api_keys[].authorization_details.legal_basis_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.api_keys[].authorization_details.locations", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations/items", - "key_path": "auth.api_keys[].authorization_details.locations[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.api_keys[].authorization_details.purpose", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.api_keys[].authorization_details.relationship", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/proof_claim", - "key_path": "auth.api_keys[].authorization_details.relationship.proof_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/relationship_type", - "key_path": "auth.api_keys[].authorization_details.relationship.relationship_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/schema_version", - "key_path": "auth.api_keys[].authorization_details.schema_version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.api_keys[].authorization_details.subject", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/binding_claim", - "key_path": "auth.api_keys[].authorization_details.subject.binding_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/id_type", - "key_path": "auth.api_keys[].authorization_details.subject.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.api_keys[].authorization_details.target", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id", - "key_path": "auth.api_keys[].authorization_details.target.id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id_type", - "key_path": "auth.api_keys[].authorization_details.target.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/type", - "key_path": "auth.api_keys[].authorization_details.type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/fingerprint", - "key_path": "auth.api_keys[].fingerprint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.api_keys[].fingerprint.name", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.api_keys[].fingerprint.path", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/provider", - "key_path": "auth.api_keys[].fingerprint.provider", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/id", - "key_path": "auth.api_keys[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes", - "key_path": "auth.api_keys[].scopes", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes/items", - "key_path": "auth.api_keys[].scopes[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/bearer_tokens", - "key_path": "auth.bearer_tokens", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/bearer_tokens/items", - "key_path": "auth.bearer_tokens[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.bearer_tokens[].authorization_details", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.bearer_tokens[].authorization_details.access_mode", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.bearer_tokens[].authorization_details.actions", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions/items", - "key_path": "auth.bearer_tokens[].authorization_details.actions[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAssistedAccessContext/properties/channel", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context.channel", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.bearer_tokens[].authorization_details.assurance_level", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.bearer_tokens[].authorization_details.claims", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims/items", - "key_path": "auth.bearer_tokens[].authorization_details.claims[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/id", - "key_path": "auth.bearer_tokens[].authorization_details.claims[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimRefObject/properties/version", - "key_path": "auth.bearer_tokens[].authorization_details.claims[].version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.bearer_tokens[].authorization_details.consent_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.bearer_tokens[].authorization_details.disclosure", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.bearer_tokens[].authorization_details.format", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.bearer_tokens[].authorization_details.jurisdiction", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.bearer_tokens[].authorization_details.legal_basis_ref", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.bearer_tokens[].authorization_details.locations", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations/items", - "key_path": "auth.bearer_tokens[].authorization_details.locations[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.bearer_tokens[].authorization_details.purpose", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.bearer_tokens[].authorization_details.relationship", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/proof_claim", - "key_path": "auth.bearer_tokens[].authorization_details.relationship.proof_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationRelationship/properties/relationship_type", - "key_path": "auth.bearer_tokens[].authorization_details.relationship.relationship_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/schema_version", - "key_path": "auth.bearer_tokens[].authorization_details.schema_version", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.bearer_tokens[].authorization_details.subject", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/binding_claim", - "key_path": "auth.bearer_tokens[].authorization_details.subject.binding_claim", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationSubject/properties/id_type", - "key_path": "auth.bearer_tokens[].authorization_details.subject.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.bearer_tokens[].authorization_details.target", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id", - "key_path": "auth.bearer_tokens[].authorization_details.target.id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationTarget/properties/id_type", - "key_path": "auth.bearer_tokens[].authorization_details.target.id_type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/type", - "key_path": "auth.bearer_tokens[].authorization_details.type", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/fingerprint", - "key_path": "auth.bearer_tokens[].fingerprint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.bearer_tokens[].fingerprint.name", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.bearer_tokens[].fingerprint.path", - "path_kind": "property", - "profile": "notary_auth_secret_reference", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/provider", - "key_path": "auth.bearer_tokens[].fingerprint.provider", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/id", - "key_path": "auth.bearer_tokens[].id", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes", - "key_path": "auth.bearer_tokens[].scopes", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/scopes/items", - "key_path": "auth.bearer_tokens[].scopes[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthConfig/properties/oidc", - "key_path": "auth.oidc", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allow_insecure_localhost", - "key_path": "auth.oidc.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_algorithms", - "key_path": "auth.oidc.allowed_algorithms", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_algorithms/items", - "key_path": "auth.oidc.allowed_algorithms[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_clients", - "key_path": "auth.oidc.allowed_clients", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_clients/items", - "key_path": "auth.oidc.allowed_clients[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_token_types", - "key_path": "auth.oidc.allowed_token_types", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/allowed_token_types/items", - "key_path": "auth.oidc.allowed_token_types[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/audiences", - "key_path": "auth.oidc.audiences", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/audiences/items", - "key_path": "auth.oidc.audiences[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/issuer", - "key_path": "auth.oidc.issuer", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/jwks_url", - "key_path": "auth.oidc.jwks_url", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/leeway", - "key_path": "auth.oidc.leeway", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/principal_claim", - "key_path": "auth.oidc.principal_claim", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_claim", - "key_path": "auth.oidc.scope_claim", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map", - "key_path": "auth.oidc.scope_map", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map/additionalProperties", - "key_path": "auth.oidc.scope_map.*", - "path_kind": "map_value", - "profile": "notary_auth_oidc_scope_map_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_map/additionalProperties/items", - "key_path": "auth.oidc.scope_map.*[]", - "path_kind": "array_item", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/scope_separator", - "key_path": "auth.oidc.scope_separator", - "path_kind": "property", - "profile": "notary_auth_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_endpoint", - "key_path": "auth.oidc.userinfo_endpoint", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_issuers", - "key_path": "auth.oidc.userinfo_issuers", - "path_kind": "property", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceOidcAuthConfig/properties/userinfo_issuers/items", - "key_path": "auth.oidc.userinfo_issuers[]", - "path_kind": "array_item", - "profile": "notary_auth_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/cel", - "key_path": "cel", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/allow_regex", - "key_path": "cel.allow_regex", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/eval_timeout_ms", - "key_path": "cel.eval_timeout_ms", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_binding_json_bytes", - "key_path": "cel.max_binding_json_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_expression_bytes", - "key_path": "cel.max_expression_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_list_items", - "key_path": "cel.max_list_items", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_object_depth", - "key_path": "cel.max_object_depth", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_object_keys", - "key_path": "cel.max_object_keys", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_result_json_bytes", - "key_path": "cel.max_result_json_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/max_string_bytes", - "key_path": "cel.max_string_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/mode", - "key_path": "cel.mode", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_count", - "key_path": "cel.worker_count", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_memory_bytes", - "key_path": "cel.worker_memory_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCelConfig/properties/worker_stderr_bytes", - "key_path": "cel.worker_stderr_bytes", - "path_kind": "property", - "profile": "notary_cel_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/config_trust", - "key_path": "config_trust", - "path_kind": "property", - "profile": "notary_config_trust_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/antirollback_state_path", - "key_path": "config_trust.antirollback_state_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/break_glass_override_path", - "key_path": "config_trust.break_glass_override_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/bundle_path", - "key_path": "config_trust.bundle_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/trust_anchor_path", - "key_path": "config_trust.trust_anchor_path", - "path_kind": "property", - "profile": "notary_config_trust_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/credential_status", - "key_path": "credential_status", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/base_url", - "key_path": "credential_status.base_url", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/enabled", - "key_path": "credential_status.enabled", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialStatusConfig/properties/retention_seconds", - "key_path": "credential_status.retention_seconds", - "path_kind": "property", - "profile": "notary_credential_status_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/deployment", - "key_path": "deployment", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/evidence", - "key_path": "deployment.evidence", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_cursor_path", - "key_path": "deployment.evidence.audit_ack_cursor_path", - "path_kind": "property", - "profile": "notary_deployment_sensitive", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_max_age_secs", - "key_path": "deployment.evidence.audit_ack_max_age_secs", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_offhost_shipping", - "key_path": "deployment.evidence.audit_offhost_shipping", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/signer_custody_approved", - "key_path": "deployment.evidence.signer_custody_approved", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/multi_instance", - "key_path": "deployment.multi_instance", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/profile", - "key_path": "deployment.profile", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers", - "key_path": "deployment.waivers", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers/items", - "key_path": "deployment.waivers[]", - "path_kind": "array_item", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/expires", - "key_path": "deployment.waivers[].expires", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/finding", - "key_path": "deployment.waivers[].finding", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/reference", - "key_path": "deployment.waivers[].reference", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/summary", - "key_path": "deployment.waivers[].summary", - "path_kind": "property", - "profile": "notary_deployment_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/evidence", - "key_path": "evidence", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/allowed_purposes", - "key_path": "evidence.allowed_purposes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/allowed_purposes/items", - "key_path": "evidence.allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/api_base_url", - "key_path": "evidence.api_base_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/api_version", - "key_path": "evidence.api_version", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims", - "key_path": "evidence.claims", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims_url", - "key_path": "evidence.claims_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/claims/items", - "key_path": "evidence.claims[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/cccev", - "key_path": "evidence.claims[].cccev", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type", - "key_path": "evidence.claims[].cccev.evidence_type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type_iri", - "key_path": "evidence.claims[].cccev.evidence_type_iri", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/requirement_type", - "key_path": "evidence.claims[].cccev.requirement_type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/credential_profiles", - "key_path": "evidence.claims[].credential_profiles", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/credential_profiles/items", - "key_path": "evidence.claims[].credential_profiles[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/depends_on", - "key_path": "evidence.claims[].depends_on", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/depends_on/items", - "key_path": "evidence.claims[].depends_on[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/disclosure", - "key_path": "evidence.claims[].disclosure", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/allowed", - "key_path": "evidence.claims[].disclosure.allowed", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/allowed/items", - "key_path": "evidence.claims[].disclosure.allowed[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/default", - "key_path": "evidence.claims[].disclosure.default", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/DisclosureConfig/properties/downgrade", - "key_path": "evidence.claims[].disclosure.downgrade", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/evidence_mode", - "key_path": "evidence.claims[].evidence_mode", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/consultations", - "key_path": "evidence.claims[].evidence_mode.consultations", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/consultations/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/inputs", - "key_path": "evidence.claims[].evidence_mode.consultations.*.inputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/inputs/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.inputs.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_inputs_open_map", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/outputs", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/outputs/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_outputs_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/4/properties/fields", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/4/properties/fields/additionalProperties", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*", - "path_kind": "map_value", - "profile": "notary_evidence_claims_evidence_mode_consultations_output_fields_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputObjectFieldContract/properties/required", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*.required", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputObjectFieldContract/properties/schema", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.fields.*.schema", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/5/properties/items", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.items", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/2/properties/max_bytes", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.max_bytes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/5/properties/max_items", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.max_items", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/1/properties/maximum", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.maximum", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/1/properties/minimum", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.minimum", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/0/properties/nullable", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.nullable", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayOutputContract/oneOf/0/properties/type", - "key_path": "evidence.claims[].evidence_mode.consultations.*.outputs.*.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationConfig/properties/profile", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationProfileRef/properties/contract_hash", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile.contract_hash", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConsultationProfileRef/properties/id", - "key_path": "evidence.claims[].evidence_mode.consultations.*.profile.id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SealedClaimEvidenceMode/oneOf/0/properties/type", - "key_path": "evidence.claims[].evidence_mode.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/formats", - "key_path": "evidence.claims[].formats", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/formats/items", - "key_path": "evidence.claims[].formats[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/id", - "key_path": "evidence.claims[].id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/inputs", - "key_path": "evidence.claims[].inputs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/inputs/items", - "key_path": "evidence.claims[].inputs[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimInputConfig/properties/name", - "key_path": "evidence.claims[].inputs[].name", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimInputConfig/properties/type", - "key_path": "evidence.claims[].inputs[].type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/oots", - "key_path": "evidence.claims[].oots", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/authentication_level_of_assurance", - "key_path": "evidence.claims[].oots.authentication_level_of_assurance", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/enabled", - "key_path": "evidence.claims[].oots.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/evidence_type_classification", - "key_path": "evidence.claims[].oots.evidence_type_classification", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/evidence_type_list", - "key_path": "evidence.claims[].oots.evidence_type_list", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/languages", - "key_path": "evidence.claims[].oots.languages", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/languages/items", - "key_path": "evidence.claims[].oots.languages[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/reference_framework", - "key_path": "evidence.claims[].oots.reference_framework", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OotsConfig/properties/requirement", - "key_path": "evidence.claims[].oots.requirement", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/operations", - "key_path": "evidence.claims[].operations", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimOperationsConfig/properties/batch_evaluate", - "key_path": "evidence.claims[].operations.batch_evaluate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/BatchOperationConfig/properties/enabled", - "key_path": "evidence.claims[].operations.batch_evaluate.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/BatchOperationConfig/properties/max_subjects", - "key_path": "evidence.claims[].operations.batch_evaluate.max_subjects", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimOperationsConfig/properties/evaluate", - "key_path": "evidence.claims[].operations.evaluate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/OperationConfig/properties/enabled", - "key_path": "evidence.claims[].operations.evaluate.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/purpose", - "key_path": "evidence.claims[].purpose", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/required_scopes", - "key_path": "evidence.claims[].required_scopes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/required_scopes/items", - "key_path": "evidence.claims[].required_scopes[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/rule", - "key_path": "evidence.claims[].rule", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/consultation", - "key_path": "evidence.claims[].rule.consultation", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/2/properties/expression", - "key_path": "evidence.claims[].rule.expression", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/output", - "key_path": "evidence.claims[].rule.output", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RuleConfig/oneOf/0/properties/type", - "key_path": "evidence.claims[].rule.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/semantics", - "key_path": "evidence.claims[].semantics", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/concept", - "key_path": "evidence.claims[].semantics.concept", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from", - "key_path": "evidence.claims[].semantics.derived_from", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from/items", - "key_path": "evidence.claims[].semantics.derived_from[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/predicate", - "key_path": "evidence.claims[].semantics.predicate", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/property", - "key_path": "evidence.claims[].semantics.property", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/value_mapping", - "key_path": "evidence.claims[].semantics.value_mapping", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/vocabulary", - "key_path": "evidence.claims[].semantics.vocabulary", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/subject_type", - "key_path": "evidence.claims[].subject_type", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/title", - "key_path": "evidence.claims[].title", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/value", - "key_path": "evidence.claims[].value", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/max_bytes", - "key_path": "evidence.claims[].value.max_bytes", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/nullable", - "key_path": "evidence.claims[].value.nullable", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/type", - "key_path": "evidence.claims[].value.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/unit", - "key_path": "evidence.claims[].value.unit", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/version", - "key_path": "evidence.claims[].version", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/concurrency", - "key_path": "evidence.concurrency", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/ConcurrencyConfig/properties/subjects", - "key_path": "evidence.concurrency.subjects", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/credential_profiles", - "key_path": "evidence.credential_profiles", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/credential_profiles/additionalProperties", - "key_path": "evidence.credential_profiles.*", - "path_kind": "map_value", - "profile": "notary_evidence_credential_profiles_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/allowed_claims", - "key_path": "evidence.credential_profiles.*.allowed_claims", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/allowed_claims/items", - "key_path": "evidence.credential_profiles.*.allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/disclosure", - "key_path": "evidence.credential_profiles.*.disclosure", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialDisclosureConfig/properties/allowed", - "key_path": "evidence.credential_profiles.*.disclosure.allowed", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialDisclosureConfig/properties/allowed/items", - "key_path": "evidence.credential_profiles.*.disclosure.allowed[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/format", - "key_path": "evidence.credential_profiles.*.format", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/holder_binding", - "key_path": "evidence.credential_profiles.*.holder_binding", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/allowed_did_methods", - "key_path": "evidence.credential_profiles.*.holder_binding.allowed_did_methods", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/allowed_did_methods/items", - "key_path": "evidence.credential_profiles.*.holder_binding.allowed_did_methods[]", - "path_kind": "array_item", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/mode", - "key_path": "evidence.credential_profiles.*.holder_binding.mode", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/HolderBindingConfig/properties/proof_of_possession", - "key_path": "evidence.credential_profiles.*.holder_binding.proof_of_possession", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/issuer", - "key_path": "evidence.credential_profiles.*.issuer", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/signing_key", - "key_path": "evidence.credential_profiles.*.signing_key", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/validity_seconds", - "key_path": "evidence.credential_profiles.*.validity_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialProfileConfig/properties/vct", - "key_path": "evidence.credential_profiles.*.vct", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/enabled", - "key_path": "evidence.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/formats_url", - "key_path": "evidence.formats_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/inline_batch_limit", - "key_path": "evidence.inline_batch_limit", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/machine_quota", - "key_path": "evidence.machine_quota", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/MachineQuotaConfig/properties/enabled", - "key_path": "evidence.machine_quota.enabled", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/MachineQuotaConfig/properties/subjects_per_minute", - "key_path": "evidence.machine_quota.subjects_per_minute", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/max_credential_validity_seconds", - "key_path": "evidence.max_credential_validity_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/relay", - "key_path": "evidence.relay", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allow_insecure_localhost", - "key_path": "evidence.relay.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allow_insecure_private_network", - "key_path": "evidence.relay.allow_insecure_private_network", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allowed_private_cidrs", - "key_path": "evidence.relay.allowed_private_cidrs", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/allowed_private_cidrs/items", - "key_path": "evidence.relay.allowed_private_cidrs[]", - "path_kind": "array_item", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/base_url", - "key_path": "evidence.relay.base_url", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/max_in_flight", - "key_path": "evidence.relay.max_in_flight", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/root_certificate_path", - "key_path": "evidence.relay.root_certificate_path", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/token_file", - "key_path": "evidence.relay.token_file", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RelayConnectionConfig/properties/workload_client_id", - "key_path": "evidence.relay.workload_client_id", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/service_id", - "key_path": "evidence.service_id", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/signing_keys", - "key_path": "evidence.signing_keys", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/signing_keys/additionalProperties", - "key_path": "evidence.signing_keys.*", - "path_kind": "map_value", - "profile": "notary_evidence_signing_keys_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/alg", - "key_path": "evidence.signing_keys.*.alg", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/key_id_hex", - "key_path": "evidence.signing_keys.*.key_id_hex", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/key_label", - "key_path": "evidence.signing_keys.*.key_label", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/kid", - "key_path": "evidence.signing_keys.*.kid", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/module_path", - "key_path": "evidence.signing_keys.*.module_path", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/password_env", - "key_path": "evidence.signing_keys.*.password_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/path", - "key_path": "evidence.signing_keys.*.path", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/pin_env", - "key_path": "evidence.signing_keys.*.pin_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/private_jwk_env", - "key_path": "evidence.signing_keys.*.private_jwk_env", - "path_kind": "property", - "profile": "notary_evidence_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/provider", - "key_path": "evidence.signing_keys.*.provider", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/public_jwk_env", - "key_path": "evidence.signing_keys.*.public_jwk_env", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/publish_until_unix_seconds", - "key_path": "evidence.signing_keys.*.publish_until_unix_seconds", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/status", - "key_path": "evidence.signing_keys.*.status", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/token_label", - "key_path": "evidence.signing_keys.*.token_label", - "path_kind": "property", - "profile": "notary_evidence_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/variables", - "key_path": "evidence.variables", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/variables/additionalProperties", - "key_path": "evidence.variables.*", - "path_kind": "map_value", - "profile": "notary_evidence_variables_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RequestVariableConfig/properties/from", - "key_path": "evidence.variables.*.from", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RequestVariableConfig/properties/type", - "key_path": "evidence.variables.*.type", - "path_kind": "property", - "profile": "notary_evidence_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/federation", - "key_path": "federation", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/clock_leeway_seconds", - "key_path": "federation.clock_leeway_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/emergency_denylist", - "key_path": "federation.emergency_denylist", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/kids", - "key_path": "federation.emergency_denylist.kids", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/kids/items", - "key_path": "federation.emergency_denylist.kids[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/node_ids", - "key_path": "federation.emergency_denylist.node_ids", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEmergencyDenylistConfig/properties/node_ids/items", - "key_path": "federation.emergency_denylist.node_ids[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/enabled", - "key_path": "federation.enabled", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/evaluation_profiles", - "key_path": "federation.evaluation_profiles", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/evaluation_profiles/items", - "key_path": "federation.evaluation_profiles[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/assurance_level", - "key_path": "federation.evaluation_profiles[].assurance_level", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/claim_id", - "key_path": "federation.evaluation_profiles[].claim_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/consent_ref", - "key_path": "federation.evaluation_profiles[].consent_ref", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/disclosure", - "key_path": "federation.evaluation_profiles[].disclosure", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/id", - "key_path": "federation.evaluation_profiles[].id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/jurisdiction", - "key_path": "federation.evaluation_profiles[].jurisdiction", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/legal_basis_ref", - "key_path": "federation.evaluation_profiles[].legal_basis_ref", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/max_claim_result_age_seconds", - "key_path": "federation.evaluation_profiles[].max_claim_result_age_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/ruleset", - "key_path": "federation.evaluation_profiles[].ruleset", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/subject_id_type", - "key_path": "federation.evaluation_profiles[].subject_id_type", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/federation_api", - "key_path": "federation.federation_api", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/inbound_body_limit_bytes", - "key_path": "federation.inbound_body_limit_bytes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/issuer", - "key_path": "federation.issuer", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/jwks_uri", - "key_path": "federation.jwks_uri", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/max_request_lifetime_seconds", - "key_path": "federation.max_request_lifetime_seconds", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/node_id", - "key_path": "federation.node_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/pairwise_subject_hash", - "key_path": "federation.pairwise_subject_hash", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPairwiseSubjectHashConfig/properties/secret_env", - "key_path": "federation.pairwise_subject_hash.secret_env", - "path_kind": "property", - "profile": "notary_federation_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/peers", - "key_path": "federation.peers", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/peers/items", - "key_path": "federation.peers[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allow_insecure_localhost", - "key_path": "federation.peers[].allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allow_insecure_private_network", - "key_path": "federation.peers[].allow_insecure_private_network", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_profiles", - "key_path": "federation.peers[].allowed_profiles", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_profiles/items", - "key_path": "federation.peers[].allowed_profiles[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_protocol_versions", - "key_path": "federation.peers[].allowed_protocol_versions", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_protocol_versions/items", - "key_path": "federation.peers[].allowed_protocol_versions[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_purposes", - "key_path": "federation.peers[].allowed_purposes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/allowed_purposes/items", - "key_path": "federation.peers[].allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/evaluation_scopes", - "key_path": "federation.peers[].evaluation_scopes", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/evaluation_scopes/items", - "key_path": "federation.peers[].evaluation_scopes[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/issuer", - "key_path": "federation.peers[].issuer", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/jwks_uri", - "key_path": "federation.peers[].jwks_uri", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationPeerConfig/properties/node_id", - "key_path": "federation.peers[].node_id", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/response_shaping", - "key_path": "federation.response_shaping", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationResponseShapingConfig/properties/minimum_denial_latency_ms", - "key_path": "federation.response_shaping.minimum_denial_latency_ms", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/signing", - "key_path": "federation.signing", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationSigningConfig/properties/signing_key", - "key_path": "federation.signing.signing_key", - "path_kind": "property", - "profile": "notary_federation_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/supported_protocol_versions", - "key_path": "federation.supported_protocol_versions", - "path_kind": "property", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/FederationConfig/properties/supported_protocol_versions/items", - "key_path": "federation.supported_protocol_versions[]", - "path_kind": "array_item", - "profile": "notary_federation_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/instance", - "key_path": "instance", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/environment", - "key_path": "instance.environment", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/id", - "key_path": "instance.id", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/jurisdiction", - "key_path": "instance.jurisdiction", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/owner", - "key_path": "instance.owner", - "path_kind": "property", - "profile": "notary_instance_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/public_base_url", - "key_path": "instance.public_base_url", - "path_kind": "property", - "profile": "notary_instance_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/oid4vci", - "key_path": "oid4vci", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/accepted_token_audiences", - "key_path": "oid4vci.accepted_token_audiences", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/accepted_token_audiences/items", - "key_path": "oid4vci.accepted_token_audiences[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization", - "key_path": "oid4vci.authorization", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization_servers", - "key_path": "oid4vci.authorization_servers", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/authorization_servers/items", - "key_path": "oid4vci.authorization_servers[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciAuthorizationConfig/properties/require_pkce_method", - "key_path": "oid4vci.authorization.require_pkce_method", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_configurations", - "key_path": "oid4vci.credential_configurations", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_configurations/additionalProperties", - "key_path": "oid4vci.credential_configurations.*", - "path_kind": "map_value", - "profile": "notary_oid4vci_credential_configurations_open_map", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claim_id", - "key_path": "oid4vci.credential_configurations.*.claim_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims", - "key_path": "oid4vci.credential_configurations.*.claims", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims/items", - "key_path": "oid4vci.credential_configurations.*.claims[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/display_name", - "key_path": "oid4vci.credential_configurations.*.claims[].display_name", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/id", - "key_path": "oid4vci.credential_configurations.*.claims[].id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/output_path", - "key_path": "oid4vci.credential_configurations.*.claims[].output_path", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/output_path/items", - "key_path": "oid4vci.credential_configurations.*.claims[].output_path[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialClaimConfig/properties/sd", - "key_path": "oid4vci.credential_configurations.*.claims[].sd", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/credential_profile", - "key_path": "oid4vci.credential_configurations.*.credential_profile", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/cryptographic_binding_methods_supported", - "key_path": "oid4vci.credential_configurations.*.cryptographic_binding_methods_supported", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/cryptographic_binding_methods_supported/items", - "key_path": "oid4vci.credential_configurations.*.cryptographic_binding_methods_supported[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/display", - "key_path": "oid4vci.credential_configurations.*.display", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/display_name", - "key_path": "oid4vci.credential_configurations.*.display_name", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_color", - "key_path": "oid4vci.credential_configurations.*.display.background_color", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_image", - "key_path": "oid4vci.credential_configurations.*.display.background_image", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.background_image.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.background_image.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.background_image.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/description", - "key_path": "oid4vci.credential_configurations.*.display.description", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/locale", - "key_path": "oid4vci.credential_configurations.*.display.locale", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/logo", - "key_path": "oid4vci.credential_configurations.*.display.logo", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.logo.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.logo.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.logo.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/secondary_image", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.uri", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/text_color", - "key_path": "oid4vci.credential_configurations.*.display.text_color", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/format", - "key_path": "oid4vci.credential_configurations.*.format", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/proof_signing_alg_values_supported", - "key_path": "oid4vci.credential_configurations.*.proof_signing_alg_values_supported", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/proof_signing_alg_values_supported/items", - "key_path": "oid4vci.credential_configurations.*.proof_signing_alg_values_supported[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/representative_issuance", - "key_path": "oid4vci.credential_configurations.*.representative_issuance", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciRepresentativeIssuanceConfig/properties/ceremony", - "key_path": "oid4vci.credential_configurations.*.representative_issuance.ceremony", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciRepresentativeIssuanceConfig/properties/relationship", - "key_path": "oid4vci.credential_configurations.*.representative_issuance.relationship", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/scope", - "key_path": "oid4vci.credential_configurations.*.scope", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/vct", - "key_path": "oid4vci.credential_configurations.*.vct", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_endpoint", - "key_path": "oid4vci.credential_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/credential_issuer", - "key_path": "oid4vci.credential_issuer", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/display", - "key_path": "oid4vci.display", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/display/items", - "key_path": "oid4vci.display[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/locale", - "key_path": "oid4vci.display[].locale", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/logo", - "key_path": "oid4vci.display[].logo", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.display[].logo.alt_text", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.display[].logo.uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.display[].logo.url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/name", - "key_path": "oid4vci.display[].name", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/enabled", - "key_path": "oid4vci.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/nonce", - "key_path": "oid4vci.nonce", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/nonce_endpoint", - "key_path": "oid4vci.nonce_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciNonceConfig/properties/enabled", - "key_path": "oid4vci.nonce.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciNonceConfig/properties/ttl_seconds", - "key_path": "oid4vci.nonce.ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/offer_endpoint", - "key_path": "oid4vci.offer_endpoint", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/pre_authorized_code", - "key_path": "oid4vci.pre_authorized_code", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/enabled", - "key_path": "oid4vci.pre_authorized_code.enabled", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/esignet", - "key_path": "oid4vci.pre_authorized_code.esignet", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/allow_insecure_localhost", - "key_path": "oid4vci.pre_authorized_code.esignet.allow_insecure_localhost", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/authorize_url", - "key_path": "oid4vci.pre_authorized_code.esignet.authorize_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/client_id", - "key_path": "oid4vci.pre_authorized_code.esignet.client_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/client_signing_key_id", - "key_path": "oid4vci.pre_authorized_code.esignet.client_signing_key_id", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/issuer", - "key_path": "oid4vci.pre_authorized_code.esignet.issuer", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/jwks_uri", - "key_path": "oid4vci.pre_authorized_code.esignet.jwks_uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/login_state_ttl_seconds", - "key_path": "oid4vci.pre_authorized_code.esignet.login_state_ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/redirect_uri", - "key_path": "oid4vci.pre_authorized_code.esignet.redirect_uri", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/scopes", - "key_path": "oid4vci.pre_authorized_code.esignet.scopes", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/scopes/items", - "key_path": "oid4vci.pre_authorized_code.esignet.scopes[]", - "path_kind": "array_item", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/token_url", - "key_path": "oid4vci.pre_authorized_code.esignet.token_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciEsignetRpConfig/properties/userinfo_url", - "key_path": "oid4vci.pre_authorized_code.esignet.userinfo_url", - "path_kind": "property", - "profile": "notary_oid4vci_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/pre_authorized_code_ttl_seconds", - "key_path": "oid4vci.pre_authorized_code.pre_authorized_code_ttl_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciPreAuthorizedCodeConfig/properties/tx_code", - "key_path": "oid4vci.pre_authorized_code.tx_code", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/input_mode", - "key_path": "oid4vci.pre_authorized_code.tx_code.input_mode", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/length", - "key_path": "oid4vci.pre_authorized_code.tx_code.length", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciTxCodeConfig/properties/required", - "key_path": "oid4vci.pre_authorized_code.tx_code.required", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciConfig/properties/proof", - "key_path": "oid4vci.proof", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciProofConfig/properties/max_age_seconds", - "key_path": "oid4vci.proof.max_age_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciProofConfig/properties/max_clock_skew_seconds", - "key_path": "oid4vci.proof.max_clock_skew_seconds", - "path_kind": "property", - "profile": "notary_oid4vci_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/server", - "key_path": "server", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/admin_listener", - "key_path": "server.admin_listener", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/bind", - "key_path": "server.admin_listener.bind", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/mode", - "key_path": "server.admin_listener.mode", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/bind", - "key_path": "server.bind", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/cors", - "key_path": "server.cors", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCorsConfig/properties/allowed_origins", - "key_path": "server.cors.allowed_origins", - "path_kind": "property", - "profile": "notary_server_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryCorsConfig/properties/allowed_origins/items", - "key_path": "server.cors.allowed_origins[]", - "path_kind": "array_item", - "profile": "notary_server_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/http1_header_read_timeout", - "key_path": "server.http1_header_read_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/max_connections", - "key_path": "server.max_connections", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/openapi_requires_auth", - "key_path": "server.openapi_requires_auth", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/request_body_timeout", - "key_path": "server.request_body_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/request_timeout", - "key_path": "server.request_timeout", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips", - "key_path": "server.trusted_proxy_ips", - "path_kind": "property", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips/items", - "key_path": "server.trusted_proxy_ips[]", - "path_kind": "array_item", - "profile": "notary_server_internal", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/state", - "key_path": "state", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/postgresql", - "key_path": "state.postgresql", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/connect_timeout_ms", - "key_path": "state.postgresql.connect_timeout_ms", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/max_connections", - "key_path": "state.postgresql.max_connections", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/operation_timeout_ms", - "key_path": "state.postgresql.operation_timeout_ms", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/root_certificate_path", - "key_path": "state.postgresql.root_certificate_path", - "path_kind": "property", - "profile": "notary_state_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/sensitive_state_key_env", - "key_path": "state.postgresql.sensitive_state_key_env", - "path_kind": "property", - "profile": "notary_state_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/url_env", - "key_path": "state.postgresql.url_env", - "path_kind": "property", - "profile": "notary_state_secret_reference", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/storage", - "key_path": "state.storage", - "path_kind": "property", - "profile": "notary_state_internal", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/properties/subject_access", - "key_path": "subject_access", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "reviewed_runtime_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_claims", - "key_path": "subject_access.allowed_claims", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_claims/items", - "key_path": "subject_access.allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_disclosures", - "key_path": "subject_access.allowed_disclosures", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_disclosures/items", - "key_path": "subject_access.allowed_disclosures[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_formats", - "key_path": "subject_access.allowed_formats", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_formats/items", - "key_path": "subject_access.allowed_formats[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_operations", - "key_path": "subject_access.allowed_operations", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/batch_evaluate", - "key_path": "subject_access.allowed_operations.batch_evaluate", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/evaluate", - "key_path": "subject_access.allowed_operations.evaluate", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/issue_credential", - "key_path": "subject_access.allowed_operations.issue_credential", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessOperationsConfig/properties/render", - "key_path": "subject_access.allowed_operations.render", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_purposes", - "key_path": "subject_access.allowed_purposes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_purposes/items", - "key_path": "subject_access.allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_wallet_origins", - "key_path": "subject_access.allowed_wallet_origins", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/allowed_wallet_origins/items", - "key_path": "subject_access.allowed_wallet_origins[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/citizen_clients", - "key_path": "subject_access.citizen_clients", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_audiences", - "key_path": "subject_access.citizen_clients.allowed_audiences", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_audiences/items", - "key_path": "subject_access.citizen_clients.allowed_audiences[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_client_ids", - "key_path": "subject_access.citizen_clients.allowed_client_ids", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessCitizenClientsConfig/properties/allowed_client_ids/items", - "key_path": "subject_access.citizen_clients.allowed_client_ids[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/credential_profiles", - "key_path": "subject_access.credential_profiles", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/credential_profiles/items", - "key_path": "subject_access.credential_profiles[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/delegation", - "key_path": "subject_access.delegation", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/allowed_relationships", - "key_path": "subject_access.delegation.allowed_relationships", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/allowed_relationships/items", - "key_path": "subject_access.delegation.allowed_relationships[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_claims", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_claims", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_claims/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_claims[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_disclosures", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_disclosures", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_disclosures/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_disclosures[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_formats", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_formats", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_formats/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_formats[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_purposes", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_purposes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/allowed_purposes/items", - "key_path": "subject_access.delegation.allowed_relationships[].allowed_purposes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/max_proof_age_seconds", - "key_path": "subject_access.delegation.allowed_relationships[].max_proof_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/proof_claim", - "key_path": "subject_access.delegation.allowed_relationships[].proof_claim", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/relationship_type", - "key_path": "subject_access.delegation.allowed_relationships[].relationship_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "no_schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegatedRelationshipConfig/properties/target_id_type", - "key_path": "subject_access.delegation.allowed_relationships[].target_id_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessDelegationConfig/properties/enabled", - "key_path": "subject_access.delegation.enabled", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/enabled", - "key_path": "subject_access.enabled", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/rate_limits", - "key_path": "subject_access.rate_limits", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/credential_issuance_per_principal_per_hour", - "key_path": "subject_access.rate_limits.credential_issuance_per_principal_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/invalid_token_per_client_address_per_minute", - "key_path": "subject_access.rate_limits.invalid_token_per_client_address_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/per_holder_per_hour", - "key_path": "subject_access.rate_limits.per_holder_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/per_principal_per_minute", - "key_path": "subject_access.rate_limits.per_principal_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/subject_mismatch_per_principal_per_hour", - "key_path": "subject_access.rate_limits.subject_mismatch_per_principal_per_hour", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessRateLimitsConfig/properties/tx_code_attempts_per_code_per_minute", - "key_path": "subject_access.rate_limits.tx_code_attempts_per_code_per_minute", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "schema_description", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/required_scopes", - "key_path": "subject_access.required_scopes", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/required_scopes/items", - "key_path": "subject_access.required_scopes[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/scope_policy", - "key_path": "subject_access.scope_policy", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/subject_binding", - "key_path": "subject_access.subject_binding", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/allow_sub_as_civil_id", - "key_path": "subject_access.subject_binding.allow_sub_as_civil_id", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/claim_source", - "key_path": "subject_access.subject_binding.claim_source", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/id_type", - "key_path": "subject_access.subject_binding.id_type", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/normalize", - "key_path": "subject_access.subject_binding.normalize", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/request_field", - "key_path": "subject_access.subject_binding.request_field", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessSubjectBindingConfig/properties/token_claim", - "key_path": "subject_access.subject_binding.token_claim", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessConfig/properties/token_policy", - "key_path": "subject_access.token_policy", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/assurance_claim_source", - "key_path": "subject_access.token_policy.assurance_claim_source", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_access_token_lifetime_seconds", - "key_path": "subject_access.token_policy.max_access_token_lifetime_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_auth_age_seconds", - "key_path": "subject_access.token_policy.max_auth_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_clock_leeway_seconds", - "key_path": "subject_access.token_policy.max_clock_leeway_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_credential_validity_seconds", - "key_path": "subject_access.token_policy.max_credential_validity_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/max_evaluation_age_seconds", - "key_path": "subject_access.token_policy.max_evaluation_age_seconds", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/required_acr_values", - "key_path": "subject_access.token_policy.required_acr_values", - "path_kind": "property", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "schema_default", - "schema_facts_reviewed": true - }, - { - "schema": "notary", - "pointer": "/$defs/SubjectAccessTokenPolicyConfig/properties/required_acr_values/items", - "key_path": "subject_access.token_policy.required_acr_values[]", - "path_kind": "array_item", - "profile": "notary_subject_access_sensitive", - "purpose_source": "profile", - "default_source": "not_applicable", - "schema_facts_reviewed": true - } - ], - "overrides": [ - { - "schema": "notary", - "pointer": "/$defs/AccessTokenSigningConfig/properties/verification_key_ids", - "key_path": "auth.access_token_signing.verification_key_ids", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.api_keys[].authorization_details", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.api_keys[].authorization_details.access_mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.api_keys[].authorization_details.actions", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.api_keys[].authorization_details.assisted_access_context", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.api_keys[].authorization_details.assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.api_keys[].authorization_details.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.api_keys[].authorization_details.consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.api_keys[].authorization_details.disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.api_keys[].authorization_details.format", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.api_keys[].authorization_details.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.api_keys[].authorization_details.legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.api_keys[].authorization_details.locations", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.api_keys[].authorization_details.purpose", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.api_keys[].authorization_details.relationship", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.api_keys[].authorization_details.subject", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.api_keys[].authorization_details.target", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.api_keys[].fingerprint.name", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.api_keys[].fingerprint.path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceCredentialConfig/properties/authorization_details", - "key_path": "auth.bearer_tokens[].authorization_details", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/access_mode", - "key_path": "auth.bearer_tokens[].authorization_details.access_mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/actions", - "key_path": "auth.bearer_tokens[].authorization_details.actions", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assisted_access_context", - "key_path": "auth.bearer_tokens[].authorization_details.assisted_access_context", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/assurance_level", - "key_path": "auth.bearer_tokens[].authorization_details.assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/claims", - "key_path": "auth.bearer_tokens[].authorization_details.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/consent_ref", - "key_path": "auth.bearer_tokens[].authorization_details.consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/disclosure", - "key_path": "auth.bearer_tokens[].authorization_details.disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/format", - "key_path": "auth.bearer_tokens[].authorization_details.format", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/jurisdiction", - "key_path": "auth.bearer_tokens[].authorization_details.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/legal_basis_ref", - "key_path": "auth.bearer_tokens[].authorization_details.legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/locations", - "key_path": "auth.bearer_tokens[].authorization_details.locations", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/purpose", - "key_path": "auth.bearer_tokens[].authorization_details.purpose", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/relationship", - "key_path": "auth.bearer_tokens[].authorization_details.relationship", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/subject", - "key_path": "auth.bearer_tokens[].authorization_details.subject", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceAuthorizationDetails/properties/target", - "key_path": "auth.bearer_tokens[].authorization_details.target", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/name", - "key_path": "auth.bearer_tokens[].fingerprint.name", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CredentialFingerprintRef/properties/path", - "key_path": "auth.bearer_tokens[].fingerprint.path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/cel", - "key_path": "cel", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/properties/config_trust", - "key_path": "config_trust", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ConfigTrustConfig/properties/break_glass_override_path", - "key_path": "config_trust.break_glass_override_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/credential_status", - "key_path": "credential_status", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/properties/deployment", - "key_path": "deployment", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_cursor_path", - "key_path": "deployment.evidence.audit_ack_cursor_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentEvidenceConfig/properties/audit_ack_max_age_secs", - "key_path": "deployment.evidence.audit_ack_max_age_secs", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/profile", - "key_path": "deployment.profile", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentConfig/properties/waivers", - "key_path": "deployment.waivers", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/DeploymentWaiverConfig/properties/summary", - "key_path": "deployment.waivers[].summary", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type", - "key_path": "evidence.claims[].cccev.evidence_type", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/CccevConfig/properties/evidence_type_iri", - "key_path": "evidence.claims[].cccev.evidence_type_iri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimDefinition/properties/semantics", - "key_path": "evidence.claims[].semantics", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/concept", - "key_path": "evidence.claims[].semantics.concept", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/derived_from", - "key_path": "evidence.claims[].semantics.derived_from", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/predicate", - "key_path": "evidence.claims[].semantics.predicate", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/property", - "key_path": "evidence.claims[].semantics.property", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/value_mapping", - "key_path": "evidence.claims[].semantics.value_mapping", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimSemanticConfig/properties/vocabulary", - "key_path": "evidence.claims[].semantics.vocabulary", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/max_bytes", - "key_path": "evidence.claims[].value.max_bytes", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a per-claim string byte ceiling." - }, - { - "schema": "notary", - "pointer": "/$defs/ClaimValueConfig/properties/nullable", - "key_path": "evidence.claims[].value.nullable", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/EvidenceConfig/properties/relay", - "key_path": "evidence.relay", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/SigningKeyConfig/properties/publish_until_unix_seconds", - "key_path": "evidence.signing_keys.*.publish_until_unix_seconds", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/federation", - "key_path": "federation", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/assurance_level", - "key_path": "federation.evaluation_profiles[].assurance_level", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/consent_ref", - "key_path": "federation.evaluation_profiles[].consent_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/disclosure", - "key_path": "federation.evaluation_profiles[].disclosure", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/jurisdiction", - "key_path": "federation.evaluation_profiles[].jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/legal_basis_ref", - "key_path": "federation.evaluation_profiles[].legal_basis_ref", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/FederationEvaluationProfileConfig/properties/max_claim_result_age_seconds", - "key_path": "federation.evaluation_profiles[].max_claim_result_age_seconds", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/instance", - "key_path": "instance", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/jurisdiction", - "key_path": "instance.jurisdiction", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/owner", - "key_path": "instance.owner", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/NotaryInstanceConfig/properties/public_base_url", - "key_path": "instance.public_base_url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/oid4vci", - "key_path": "oid4vci", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claim_id", - "key_path": "oid4vci.credential_configurations.*.claim_id", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/claims", - "key_path": "oid4vci.credential_configurations.*.claims", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialConfigurationConfig/properties/representative_issuance", - "key_path": "oid4vci.credential_configurations.*.representative_issuance", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_color", - "key_path": "oid4vci.credential_configurations.*.display.background_color", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/background_image", - "key_path": "oid4vci.credential_configurations.*.display.background_image", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.background_image.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.background_image.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.background_image.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/description", - "key_path": "oid4vci.credential_configurations.*.display.description", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/locale", - "key_path": "oid4vci.credential_configurations.*.display.locale", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/logo", - "key_path": "oid4vci.credential_configurations.*.display.logo", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.logo.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.logo.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.logo.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/secondary_image", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.credential_configurations.*.display.secondary_image.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciCredentialDisplayConfig/properties/text_color", - "key_path": "oid4vci.credential_configurations.*.display.text_color", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/locale", - "key_path": "oid4vci.display[].locale", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciIssuerDisplayConfig/properties/logo", - "key_path": "oid4vci.display[].logo", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/alt_text", - "key_path": "oid4vci.display[].logo.alt_text", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/uri", - "key_path": "oid4vci.display[].logo.uri", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/Oid4vciDisplayImageConfig/properties/url", - "key_path": "oid4vci.display[].logo.url", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/admin_listener", - "key_path": "server.admin_listener", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryAdminListenerConfig/properties/mode", - "key_path": "server.admin_listener.mode", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/openapi_requires_auth", - "key_path": "server.openapi_requires_auth", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the reviewed product-owned scalar default; its value is omitted from this value-free reference." - }, - { - "schema": "notary", - "pointer": "/$defs/RegistryNotaryHttpConfig/properties/trusted_proxy_ips", - "key_path": "server.trusted_proxy_ips", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned empty collection required by this runtime contract." - }, - { - "schema": "notary", - "pointer": "/properties/state", - "key_path": "state", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/StateConfig/properties/postgresql", - "key_path": "state.postgresql", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - }, - { - "schema": "notary", - "pointer": "/$defs/StatePostgresqlConfig/properties/root_certificate_path", - "key_path": "state.postgresql.root_certificate_path", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization preserves the product-owned absence state without materializing a configuration value." - }, - { - "schema": "notary", - "pointer": "/properties/subject_access", - "key_path": "subject_access", - "path_kind": "property", - "runtime_default_note": "When omitted, Rust deserialization supplies the product-owned nested default contract; no deployment value is copied into this reference." - } - ] -} diff --git a/crates/registry-notary-core/src/config.rs b/crates/registry-notary-core/src/config.rs deleted file mode 100644 index 9e001e5e2..000000000 --- a/crates/registry-notary-core/src/config.rs +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary configuration model. - -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::collections::HashSet; -use std::net::{IpAddr, SocketAddr}; -use std::path::PathBuf; -use std::time::{Duration, SystemTime}; - -use registry_platform_authcommon::CredentialFingerprintRef; -use registry_platform_config::DeprecatedConfigField; -use registry_platform_crypto::validate_did_web_https_issuer_binding; -use registry_platform_crypto::PublicJwk; -pub use registry_platform_crypto::{ - KeyProviderKind as SigningKeyProviderConfig, KeyStatus as SigningKeyStatus, -}; -use registry_platform_oid4vci::{ - CREDENTIAL_SIGNING_ALG_EDDSA, CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK, - SD_JWT_VC_FORMAT as OID4VCI_SD_JWT_VC_FORMAT, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::deployment::DeploymentConfig; -use crate::model::{ - is_request_variable_name, DisclosureProfile, EvidenceAuthorizationDetails, FORMAT_CCCEV_JSONLD, - FORMAT_CLAIM_RESULT_JSON, FORMAT_SD_JWT_VC, MAX_REQUEST_VARIABLES_V1, - SD_JWT_VC_HOLDER_BINDING_METHOD, SD_JWT_VC_SIGNING_ALG, -}; - -mod audit; -mod auth; -mod cel; -mod credential_status; -mod errors; -mod evidence; -mod federation; -mod http; -mod oid4vci; -mod root; -pub mod schema; -mod state; -mod subject_access; - -pub use audit::*; -pub use auth::*; -pub use cel::*; -pub use credential_status::*; -pub use errors::*; -pub use evidence::*; -pub use federation::*; -pub use http::*; -pub use oid4vci::*; -pub use root::*; -pub use state::*; -pub use subject_access::*; - -#[cfg(test)] -mod tests; diff --git a/crates/registry-notary-core/src/config/audit.rs b/crates/registry-notary-core/src/config/audit.rs deleted file mode 100644 index d898404b9..000000000 --- a/crates/registry-notary-core/src/config/audit.rs +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evidence audit sink configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAuditConfig { - #[serde(default = "default_audit_sink")] - pub sink: String, - #[serde(default)] - pub path: Option, - #[serde(default)] - pub hash_secret_env: Option, - #[serde(default)] - pub max_size_mb: Option, - #[serde(default)] - pub max_files: Option, - #[serde(default)] - pub syslog_socket_path: Option, -} - -impl Default for EvidenceAuditConfig { - fn default() -> Self { - Self { - sink: default_audit_sink(), - path: None, - hash_secret_env: None, - max_size_mb: None, - max_files: None, - syslog_socket_path: None, - } - } -} - -impl EvidenceAuditConfig { - pub const DEFAULT_MAX_SIZE_MB: u64 = 100; - pub const DEFAULT_MAX_FILES: u32 = 14; - - pub fn max_size_bytes(&self) -> u64 { - self.max_size_mb.unwrap_or(Self::DEFAULT_MAX_SIZE_MB) * 1024 * 1024 - } - - pub fn max_files(&self) -> u32 { - self.max_files.unwrap_or(Self::DEFAULT_MAX_FILES) - } -} - -pub(super) fn default_audit_sink() -> String { - "stdout".to_string() -} - -/// A durable audit sink retains the evidence trail beyond process stdout. -/// -/// `stdout` and `none` are not durable, retained sinks for a production-shaped -/// deployment; `file`, `jsonl`, and `syslog` write to a retained destination. -pub(super) fn audit_sink_is_durable(config: &EvidenceAuditConfig) -> bool { - matches!(config.sink.as_str(), "file" | "jsonl" | "syslog") -} diff --git a/crates/registry-notary-core/src/config/auth.rs b/crates/registry-notary-core/src/config/auth.rs deleted file mode 100644 index 0a55962d8..000000000 --- a/crates/registry-notary-core/src/config/auth.rs +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Caller authentication and token-signing configuration. - -use super::*; - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryCorsConfig { - #[serde(default)] - pub allowed_origins: Vec, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAuthConfig { - #[serde(default)] - pub api_keys: Vec, - #[serde(default)] - pub bearer_tokens: Vec, - #[serde(default)] - pub oidc: Option, - /// Trust anchor for Notary-minted access tokens (the pre-authorized-code - /// flow's second verifier). Disabled by default so existing configs load - /// unchanged. - #[serde(default)] - pub access_token_signing: AccessTokenSigningConfig, -} - -/// Self-issued access-token signing configuration. -/// -/// When `enabled`, the Notary mints its own access tokens (for the -/// pre-authorized-code flow) signed with a dedicated `signing_keys` entry that -/// MUST be distinct from any credential-signing key. The minted token's -/// `iss`/`aud`/`typ`/alg pin the second verifier's trust anchor. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct AccessTokenSigningConfig { - #[serde(default)] - pub enabled: bool, - /// Issuer (`iss`) the Notary stamps into its own access tokens. - #[serde(default)] - pub issuer: String, - /// Audiences (`aud`) accepted for Notary-minted access tokens. - #[serde(default)] - pub audiences: Vec, - /// Allowed signing algorithms. Only EdDSA is supported. - #[serde(default = "default_access_token_signing_algorithms")] - pub allowed_algorithms: Vec, - /// Header `typ` stamped into Notary access tokens, distinct from the - /// credential `typ` so a token cannot be replayed as another class. - #[serde(default = "default_access_token_typ")] - pub token_typ: String, - /// `evidence.signing_keys` entry used to sign access tokens. Must be a - /// dedicated key, never a credential-signing key. - #[serde(default)] - pub signing_key_id: String, - /// Additional publish-only `evidence.signing_keys` entries accepted for - /// verifying previously minted Notary access tokens and pre-authorized - /// codes during a governed key rotation. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub verification_key_ids: Vec, - /// Access-token lifetime in seconds. - #[serde(default = "default_access_token_ttl_seconds")] - pub access_token_ttl_seconds: u64, -} - -impl Default for AccessTokenSigningConfig { - fn default() -> Self { - Self { - enabled: false, - issuer: String::new(), - audiences: Vec::new(), - allowed_algorithms: default_access_token_signing_algorithms(), - token_typ: default_access_token_typ(), - signing_key_id: String::new(), - verification_key_ids: Vec::new(), - access_token_ttl_seconds: default_access_token_ttl_seconds(), - } - } -} - -pub(super) fn default_access_token_signing_algorithms() -> Vec { - vec![CREDENTIAL_SIGNING_ALG_EDDSA.to_string()] -} - -pub(super) fn default_access_token_typ() -> String { - "registry-notary-access+jwt".to_string() -} - -pub(super) const fn default_access_token_ttl_seconds() -> u64 { - 300 -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct EvidenceCredentialConfig { - pub id: String, - #[schemars(with = "schema::CredentialFingerprintSchema")] - pub fingerprint: CredentialFingerprintRef, - #[serde(default)] - pub scopes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub authorization_details: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceOidcAuthConfig { - pub issuer: String, - pub jwks_url: String, - #[serde(default)] - pub userinfo_endpoint: Option, - #[serde(default)] - pub userinfo_issuers: Vec, - #[serde(default)] - pub audiences: Vec, - #[serde(default)] - pub allowed_clients: Vec, - #[serde(default = "default_oidc_allowed_algorithms")] - pub allowed_algorithms: Vec, - #[serde(default = "default_oidc_allowed_token_types")] - pub allowed_token_types: Vec, - #[serde(default = "default_oidc_scope_claim")] - pub scope_claim: String, - #[serde(default = "default_oidc_scope_separator")] - pub scope_separator: String, - #[serde(default)] - pub scope_map: BTreeMap>, - #[serde(default = "default_oidc_principal_claim")] - pub principal_claim: String, - #[serde(default = "default_oidc_leeway", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub leeway: Duration, - #[serde(default)] - pub allow_insecure_localhost: bool, -} - -pub(super) fn default_oidc_allowed_algorithms() -> Vec { - vec![SD_JWT_VC_SIGNING_ALG.to_string()] -} - -pub(super) fn default_oidc_allowed_token_types() -> Vec { - vec!["JWT".to_string()] -} - -pub(super) fn default_oidc_scope_claim() -> String { - "scope".to_string() -} - -pub(super) fn default_oidc_scope_separator() -> String { - " ".to_string() -} - -pub(super) fn default_oidc_principal_claim() -> String { - "sub".to_string() -} - -pub(super) fn default_oidc_leeway() -> Duration { - Duration::from_secs(60) -} - -impl EvidenceOidcAuthConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.issuer.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "issuer must not be empty".to_string(), - }); - } - if self.jwks_url.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "jwks_url must not be empty".to_string(), - }); - } - validate_jwks_url_transport(&self.jwks_url, self.allow_insecure_localhost)?; - if let Some(userinfo_endpoint) = self.userinfo_endpoint.as_deref() { - if userinfo_endpoint.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "userinfo_endpoint must not be empty when configured".to_string(), - }); - } - validate_jwks_url_transport(userinfo_endpoint, self.allow_insecure_localhost)?; - } - validate_entries("auth.oidc.userinfo_issuers", &self.userinfo_issuers)?; - if self.audiences.is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "audiences must list at least one accepted audience".to_string(), - }); - } - if self.scope_separator.chars().count() != 1 { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "scope_separator must be exactly one character".to_string(), - }); - } - if self.principal_claim.trim().is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "principal_claim must not be empty".to_string(), - }); - } - Ok(()) - } -} - -pub(super) fn validate_jwks_url_transport( - jwks_url: &str, - allow_insecure_localhost: bool, -) -> Result<(), EvidenceConfigError> { - let jwks_url = jwks_url.trim(); - if jwks_url.starts_with("https://") - || (allow_insecure_localhost && is_insecure_localhost_url(jwks_url)) - { - return Ok(()); - } - Err(EvidenceConfigError::InvalidOidcConfig { - reason: - "jwks_url must use https unless allow_insecure_localhost permits an http localhost URL" - .to_string(), - }) -} - -pub(super) fn is_insecure_localhost_url(url: &str) -> bool { - let Some(rest) = url.strip_prefix("http://") else { - return false; - }; - let authority = rest - .split(['/', '?', '#']) - .next() - .unwrap_or_default() - .rsplit('@') - .next() - .unwrap_or_default(); - let host = if let Some(after_bracket) = authority.strip_prefix('[') { - after_bracket.split(']').next().unwrap_or_default() - } else { - authority.split(':').next().unwrap_or_default() - }; - matches!(host, "localhost" | "127.0.0.1" | "::1") -} diff --git a/crates/registry-notary-core/src/config/cel.rs b/crates/registry-notary-core/src/config/cel.rs deleted file mode 100644 index 991ebb3aa..000000000 --- a/crates/registry-notary-core/src/config/cel.rs +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! CEL worker configuration. - -use super::*; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryCelConfig { - #[serde(default = "default_cel_mode")] - pub mode: String, - #[serde(default = "default_cel_worker_count")] - pub worker_count: usize, - #[serde(default = "default_cel_eval_timeout_ms")] - pub eval_timeout_ms: u64, - #[serde(default)] - pub allow_regex: bool, - #[serde(default = "default_cel_max_expression_bytes")] - pub max_expression_bytes: usize, - #[serde(default = "default_cel_max_binding_json_bytes")] - pub max_binding_json_bytes: usize, - #[serde(default = "default_cel_max_result_json_bytes")] - pub max_result_json_bytes: usize, - #[serde(default = "default_cel_max_string_bytes")] - pub max_string_bytes: usize, - #[serde(default = "default_cel_max_list_items")] - pub max_list_items: usize, - #[serde(default = "default_cel_max_object_depth")] - pub max_object_depth: usize, - #[serde(default = "default_cel_max_object_keys")] - pub max_object_keys: usize, - #[serde(default = "default_cel_worker_memory_bytes")] - pub worker_memory_bytes: u64, - #[serde(default = "default_cel_worker_stderr_bytes")] - pub worker_stderr_bytes: usize, -} - -impl Default for RegistryNotaryCelConfig { - fn default() -> Self { - Self { - mode: default_cel_mode(), - worker_count: default_cel_worker_count(), - eval_timeout_ms: default_cel_eval_timeout_ms(), - allow_regex: false, - max_expression_bytes: default_cel_max_expression_bytes(), - max_binding_json_bytes: default_cel_max_binding_json_bytes(), - max_result_json_bytes: default_cel_max_result_json_bytes(), - max_string_bytes: default_cel_max_string_bytes(), - max_list_items: default_cel_max_list_items(), - max_object_depth: default_cel_max_object_depth(), - max_object_keys: default_cel_max_object_keys(), - worker_memory_bytes: default_cel_worker_memory_bytes(), - worker_stderr_bytes: default_cel_worker_stderr_bytes(), - } - } -} - -impl RegistryNotaryCelConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.mode != "worker" && self.mode != "disabled" { - return invalid_cel("cel.mode must be worker or disabled"); - } - if self.worker_count == 0 || self.worker_count > 16 { - return invalid_cel("cel.worker_count must be between 1 and 16"); - } - if self.eval_timeout_ms == 0 || self.eval_timeout_ms > 30_000 { - return invalid_cel("cel.eval_timeout_ms must be between 1 and 30000"); - } - if self.max_expression_bytes == 0 || self.max_expression_bytes > 256 * 1024 { - return invalid_cel("cel.max_expression_bytes must be between 1 and 262144"); - } - if self.max_binding_json_bytes == 0 || self.max_binding_json_bytes > 1024 * 1024 { - return invalid_cel("cel.max_binding_json_bytes must be between 1 and 1048576"); - } - if self.max_result_json_bytes == 0 || self.max_result_json_bytes > 1024 * 1024 { - return invalid_cel("cel.max_result_json_bytes must be between 1 and 1048576"); - } - if self.max_string_bytes == 0 || self.max_string_bytes > 256 * 1024 { - return invalid_cel("cel.max_string_bytes must be between 1 and 262144"); - } - if self.max_list_items == 0 || self.max_list_items > 100_000 { - return invalid_cel("cel.max_list_items must be between 1 and 100000"); - } - if self.max_object_depth == 0 || self.max_object_depth > 64 { - return invalid_cel("cel.max_object_depth must be between 1 and 64"); - } - if self.max_object_keys == 0 || self.max_object_keys > 2048 { - return invalid_cel("cel.max_object_keys must be between 1 and 2048"); - } - if self.worker_memory_bytes < 32 * 1024 * 1024 - || self.worker_memory_bytes > 1024 * 1024 * 1024 - { - return invalid_cel("cel.worker_memory_bytes must be between 33554432 and 1073741824"); - } - if self.worker_stderr_bytes == 0 || self.worker_stderr_bytes > 64 * 1024 { - return invalid_cel("cel.worker_stderr_bytes must be between 1 and 65536"); - } - Ok(()) - } -} - -pub(super) fn registry_notary_cel_config_is_default(config: &RegistryNotaryCelConfig) -> bool { - config == &RegistryNotaryCelConfig::default() -} - -pub(super) fn invalid_cel(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidCelConfig { - reason: reason.into(), - }) -} - -pub(super) fn default_cel_mode() -> String { - "worker".to_string() -} - -pub(super) const fn default_cel_worker_count() -> usize { - 2 -} - -pub(super) const fn default_cel_eval_timeout_ms() -> u64 { - 2_000 -} - -pub(super) const fn default_cel_max_expression_bytes() -> usize { - 8 * 1024 -} - -pub(super) const fn default_cel_max_binding_json_bytes() -> usize { - 64 * 1024 -} - -pub(super) const fn default_cel_max_result_json_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_cel_max_string_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_cel_max_list_items() -> usize { - 1024 -} - -pub(super) const fn default_cel_max_object_depth() -> usize { - 16 -} - -pub(super) const fn default_cel_max_object_keys() -> usize { - 256 -} - -pub(super) const fn default_cel_worker_memory_bytes() -> u64 { - 128 * 1024 * 1024 -} - -pub(super) const fn default_cel_worker_stderr_bytes() -> usize { - 1024 -} diff --git a/crates/registry-notary-core/src/config/credential_status.rs b/crates/registry-notary-core/src/config/credential_status.rs deleted file mode 100644 index fab4a0de1..000000000 --- a/crates/registry-notary-core/src/config/credential_status.rs +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Credential status store configuration. - -use super::*; - -pub const CREDENTIAL_STATUS_VALID: &str = "valid"; -pub const CREDENTIAL_STATUS_SUSPENDED: &str = "suspended"; -pub const CREDENTIAL_STATUS_REVOKED: &str = "revoked"; -pub const CREDENTIAL_STATUS_EXPIRED: &str = "expired"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialStatusConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub base_url: String, - #[serde(default = "default_credential_status_retention_seconds")] - pub retention_seconds: u64, -} - -impl Default for CredentialStatusConfig { - fn default() -> Self { - Self { - enabled: false, - base_url: String::new(), - retention_seconds: default_credential_status_retention_seconds(), - } - } -} - -impl CredentialStatusConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - validate_credential_status_http_url("credential_status.base_url", &self.base_url)?; - if self.retention_seconds == 0 { - return invalid_credential_status( - "credential_status.retention_seconds must be greater than zero", - ); - } - Ok(()) - } -} - -pub(super) fn credential_status_config_is_default(config: &CredentialStatusConfig) -> bool { - config == &CredentialStatusConfig::default() -} - -pub(super) const fn default_credential_status_retention_seconds() -> u64 { - 86_400 -} - -pub(super) fn validate_credential_status_non_empty( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_credential_status(format!("{field} must not be empty")); - } - Ok(()) -} - -pub(super) fn validate_credential_status_http_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_credential_status_non_empty(field, value)?; - let Some(rest) = value - .strip_prefix("https://") - .or_else(|| value.strip_prefix("http://")) - else { - return invalid_credential_status(format!("{field} must be an HTTP or HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_credential_status(format!("{field} must include a valid host")); - } - Ok(()) -} - -pub(super) fn invalid_credential_status( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidCredentialStatusConfig { - reason: reason.into(), - }) -} diff --git a/crates/registry-notary-core/src/config/errors.rs b/crates/registry-notary-core/src/config/errors.rs deleted file mode 100644 index 5d6e8a4a5..000000000 --- a/crates/registry-notary-core/src/config/errors.rs +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Configuration validation errors. - -#[derive(Debug, thiserror::Error)] -pub enum EvidenceConfigError { - #[error("evidence.enabled must be true for the standalone Registry Notary")] - EvidenceDisabled, - #[error("at least one API key, bearer token, or OIDC authenticator must be configured")] - NoCredentialsConfigured, - #[error("invalid auth config: {reason}")] - InvalidAuthConfig { reason: String }, - #[error("invalid auth.oidc config: {reason}")] - InvalidOidcConfig { reason: String }, - #[error("invalid subject_access config: {reason}")] - InvalidSubjectAccessConfig { reason: String }, - #[error("invalid oid4vci config: {reason}")] - InvalidOid4vciConfig { reason: String }, - #[error("invalid auth.access_token_signing config: {reason}")] - InvalidAccessTokenSigningConfig { reason: String }, - #[error("invalid state config: {reason}")] - InvalidStateConfig { reason: String }, - #[error("invalid credential status config: {reason}")] - InvalidCredentialStatusConfig { reason: String }, - #[error("invalid cel config: {reason}")] - InvalidCelConfig { reason: String }, - #[error("invalid federation config: {reason}")] - InvalidFederationConfig { reason: String }, - #[error("invalid server config: {reason}")] - InvalidServerConfig { reason: String }, - #[error("invalid config_trust config: {reason}")] - InvalidConfigTrustConfig { reason: String }, - #[error("invalid deployment config: {reason}")] - InvalidDeploymentConfig { reason: String }, - #[error( - "deployment.evidence.audit_ack_max_age_secs is set but deployment.evidence.audit_ack_cursor_path is not; a freshness window is meaningless with no cursor to read. Set audit_ack_cursor_path to the registry.audit.ack_cursor.v1 file the audit shipper maintains, or remove audit_ack_max_age_secs" - )] - AuditAckMaxAgeWithoutCursor, - #[error( - "deployment.evidence.audit_ack_cursor_path is set with a local file audit sink but deployment.evidence.audit_offhost_shipping is false; an ack cursor asserts observed off-host shipping that has not been declared. Set audit_offhost_shipping: true once shipping is in place, or remove audit_ack_cursor_path" - )] - AuditAckCursorWithoutShippingDeclared, - #[error("invalid evidence.relay config: {reason}")] - InvalidRelayConfig { reason: String }, - #[error("invalid evidence.variables config: {reason}")] - InvalidRequestVariableConfig { reason: String }, - #[error("claim id must not be empty")] - InvalidClaim, - /// REQ-DM-CLAIM-001 requires a claim's `id` to be unique across the - /// configuration; RS-DM-CLAIM Section 10 previously documented this as an - /// operator responsibility the loader did not enforce. - #[error("claim id '{claim}' is used by more than one claim; claim ids must be unique")] - DuplicateClaimId { claim: String }, - #[error("claim '{claim}' has invalid semantics config: {reason}")] - InvalidClaimSemantics { claim: String, reason: String }, - #[error("claim '{claim}' has invalid value config: {reason}")] - InvalidClaimValueConfig { claim: String, reason: String }, - #[error("claim '{claim}' has invalid evidence_mode: {reason}")] - InvalidClaimEvidenceMode { claim: String, reason: String }, - #[error("claim '{claim}' dependency closure exceeds v1 bounds ({nodes} nodes, {edges} edges)")] - ClaimDependencyGraphTooLarge { - claim: String, - nodes: usize, - edges: usize, - }, - /// REQ-DM-CLAIM-008 requires a claim's `disclosure.default` to be a - /// member of `disclosure.allowed`; RS-DM-CLAIM Section 10 previously - /// documented this as unchecked at load, surfacing only when a result - /// was rendered. - #[error( - "claim '{claim}' disclosure.default '{default}' is not a member of \ - disclosure.allowed ({allowed}); a claim's default disclosure mode must be one \ - it is permitted to render", - allowed = allowed.join(", ") - )] - ClaimDisclosureDefaultNotAllowed { - claim: String, - default: String, - allowed: Vec, - }, - #[error( - "claim '{claim}' formats must not be empty; omit formats to use the default \ - application/vnd.registry-notary.claim-result+json representation, or list one or more response formats" - )] - EmptyClaimFormats { claim: String }, - #[error( - "claim '{claim}' formats must include the canonical evaluation response format \ - application/vnd.registry-notary.claim-result+json; add it alongside any supported additional evaluation renderers" - )] - MissingCanonicalClaimFormat { claim: String }, - #[error( - "claim '{claim}' has unsupported evaluation response format '{format}' in formats; \ - supported formats are application/vnd.registry-notary.claim-result+json and \ - application/ld+json; profile=\"cccev\". SD-JWT VC belongs in credential_profiles, not claim formats" - )] - UnsupportedClaimFormat { claim: String, format: String }, - #[error("allowed purpose must not be empty")] - InvalidPurpose, - #[error("concurrency.subjects must be >= 1")] - InvalidConcurrency, - #[error("invalid evidence.machine_quota config: {reason}")] - InvalidMachineQuotaConfig { reason: String }, - #[error("invalid evidence batch config: {reason}")] - InvalidBatchConfig { reason: String }, - /// Credential holder binding only works with did:jwk because holder_jwk() - /// only implements did:jwk resolution. Restrict allowed_did_methods to - /// ["did:jwk"] or leave it empty when holder binding is disabled. - #[error( - "credential profile '{profile}': holder binding is only supported with did:jwk, \ - but allowed_did_methods contains unsupported method(s): {methods}; \ - restrict allowed_did_methods to [\"did:jwk\"]", - methods = methods.join(", ") - )] - UnsupportedCredentialProfileDidMethods { - profile: String, - methods: Vec, - }, - #[error("claim '{claim}' depends_on unknown claim '{unknown}'")] - DependsOnUnknownClaim { claim: String, unknown: String }, - #[error( - "depends_on cycle detected: {cycle}", - cycle = cycle.join(" -> ") - )] - DependsOnCycle { cycle: Vec }, - /// A credential profile with an empty `allowed_claims` would short-circuit - /// the issuance-time claim filter (api.rs treats empty as "all claims - /// allowed"). Reject at load time so operators must explicitly enumerate - /// the claims a profile may bind to. - #[error( - "credential profile '{profile}': allowed_claims must list at least one \ - claim; an empty list would permit any claim at issuance" - )] - EmptyAllowedClaims { profile: String }, - #[error("invalid credential claim binding: {reason}")] - InvalidCredentialClaimBinding { reason: String }, - /// Registry Notary currently issues only SD-JWT VC credentials using the - /// current `application/dc+sd-jwt` media type. Reject aliases and profile - /// labels so operator config cannot drift from the wire contract. - #[error( - "credential profile '{profile}': unsupported format '{format}'; \ - supported credential format is application/dc+sd-jwt" - )] - UnsupportedCredentialProfileFormat { profile: String, format: String }, - #[error("signing key '{key}' is invalid: {reason}")] - InvalidSigningKeyConfig { key: String, reason: String }, - #[error("credential profile '{profile}' references unknown signing key '{key}'")] - UnknownCredentialProfileSigningKey { profile: String, key: String }, - #[error("credential profile '{profile}' references non-active signing key '{key}'")] - CredentialProfileSigningKeyNotActive { profile: String, key: String }, - #[error( - "credential profile '{profile}' validity_seconds {validity_seconds} must be between 1 and {max_validity_seconds}" - )] - InvalidCredentialProfileValidity { - profile: String, - validity_seconds: i64, - max_validity_seconds: u64, - }, - #[error("credential profile '{profile}' issuer does not match signing key '{key}': {reason}")] - CredentialProfileSigningKeyIssuerMismatch { - profile: String, - key: String, - reason: String, - }, -} diff --git a/crates/registry-notary-core/src/config/evidence/claims.rs b/crates/registry-notary-core/src/config/evidence/claims.rs deleted file mode 100644 index 978e26432..000000000 --- a/crates/registry-notary-core/src/config/evidence/claims.rs +++ /dev/null @@ -1,1117 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Claim definitions, rules, and operation configuration. - -use std::borrow::Cow; - -use schemars::{Schema, SchemaGenerator}; - -use super::*; - -pub const MAX_CLAIM_DEPENDENCY_NODES_V1: usize = 64; -pub const MAX_CLAIM_DEPENDENCY_EDGES_V1: usize = 256; -pub const MAX_CLAIM_VALUE_STRING_BYTES_V1: u32 = 64 * 1024; -pub const MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1: usize = 8; -pub const MAX_RELAY_OUTPUT_SCHEMA_NODES_V1: usize = 256; -pub const MAX_RELAY_OUTPUT_EXPANDED_NODES_V1: usize = 4_096; -pub const MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1: usize = 32; -pub const MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1: u16 = 256; -pub const MAX_RELAY_OUTPUT_NAME_BYTES_V1: usize = 128; -pub const MAX_RELAY_OUTPUT_VALUE_BYTES_V1: u32 = 64 * 1024; - -// The platform decoder wraps consultation outputs in the Relay result root and -// its closed `outputs` object. The remaining fixed result fields consume 18 -// more nodes. Reserving all 20 fixed nodes here ensures an accepted authored -// contract cannot fail later when the complete result decoder is compiled. -const RELAY_OUTPUT_ROOT_DEPTH_V1: usize = 3; -const RELAY_RESULT_ENVELOPE_NODES_V1: usize = 20; - -fn default_claim_formats() -> Vec { - vec![FORMAT_CLAIM_RESULT_JSON.to_string()] -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimDefinition { - pub id: String, - pub title: String, - pub version: String, - pub subject_type: String, - /// Sealed registry provenance configuration. This field is intentionally - /// required so every claim names its compiler-pinned Relay consultation. - pub evidence_mode: ClaimEvidenceMode, - #[serde(default)] - pub value: ClaimValueConfig, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub semantics: Option, - #[serde(default)] - pub inputs: Vec, - #[serde(default)] - pub depends_on: Vec, - #[serde(default)] - pub purpose: Option, - /// Caller scopes checked before any registry consultation is dispatched. - #[serde(default)] - pub required_scopes: Vec, - pub rule: RuleConfig, - #[serde(default)] - pub operations: ClaimOperationsConfig, - #[serde(default)] - pub disclosure: DisclosureConfig, - /// Omitting this field keeps existing authored claims renderable using the - /// canonical claim-result representation. An explicitly empty list is - /// rejected during configuration validation. - #[serde(default = "default_claim_formats")] - pub formats: Vec, - #[serde(default)] - pub credential_profiles: Vec, - #[serde(default)] - pub cccev: Option, - #[serde(default)] - pub oots: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum ClaimEvidenceMode { - RegistryBacked { - consultations: BTreeMap, - }, - /// Uninhabited internal variant that keeps downstream destructuring - /// refutable without adding another wire-level evidence mode. - #[doc(hidden)] - #[serde(skip)] - Impossible { - impossible: std::convert::Infallible, - }, -} - -impl<'de> Deserialize<'de> for ClaimEvidenceMode { - fn deserialize(deserializer: Deserializer) -> Result - where - Deserializer: serde::Deserializer<'de>, - { - match SealedClaimEvidenceMode::deserialize(deserializer)? { - SealedClaimEvidenceMode::RegistryBacked { consultations } => { - Ok(Self::RegistryBacked { consultations }) - } - } - } -} - -/// The tagged configuration wire contract shared by deserialization and schema generation. -#[derive(Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum SealedClaimEvidenceMode { - RegistryBacked { - consultations: BTreeMap, - }, -} - -impl JsonSchema for ClaimEvidenceMode { - fn schema_name() -> Cow<'static, str> { - "ClaimEvidenceMode".into() - } - - fn json_schema(generator: &mut SchemaGenerator) -> Schema { - generator.subschema_for::() - } -} - -impl ClaimEvidenceMode { - #[must_use] - pub const fn name(&self) -> &'static str { - match self { - Self::RegistryBacked { .. } => "registry_backed", - Self::Impossible { impossible } => match *impossible {}, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConsultationConfig { - pub profile: RelayConsultationProfileRef, - #[schemars(with = "BTreeMap")] - pub inputs: BTreeMap, - /// Complete closed public output schema expected from the pinned profile. - #[serde(default)] - pub outputs: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConsultationProfileRef { - pub id: String, - pub contract_hash: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum RelayOutputContract { - Boolean { - #[serde(default)] - nullable: bool, - }, - Integer { - #[serde(default)] - nullable: bool, - minimum: i64, - maximum: i64, - }, - String { - #[serde(default)] - nullable: bool, - max_bytes: u32, - }, - Date { - #[serde(default)] - nullable: bool, - }, - Object { - #[serde(default)] - nullable: bool, - max_bytes: u32, - fields: BTreeMap, - }, - Array { - #[serde(default)] - nullable: bool, - max_bytes: u32, - max_items: u16, - items: Box, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayOutputObjectFieldContract { - pub required: bool, - pub schema: Box, -} - -impl RelayOutputContract { - #[must_use] - pub const fn nullable(&self) -> bool { - match self { - Self::Boolean { nullable } - | Self::Integer { nullable, .. } - | Self::String { nullable, .. } - | Self::Date { nullable } - | Self::Object { nullable, .. } - | Self::Array { nullable, .. } => *nullable, - } - } - - #[must_use] - pub const fn value_type(&self) -> &'static str { - match self { - Self::Boolean { .. } => "boolean", - Self::Integer { .. } => "integer", - Self::String { .. } => "string", - Self::Date { .. } => "date", - Self::Object { .. } => "object", - Self::Array { .. } => "array", - } - } - - #[must_use] - pub const fn is_scalar(&self) -> bool { - matches!( - self, - Self::Boolean { .. } | Self::Integer { .. } | Self::String { .. } | Self::Date { .. } - ) - } - - /// Validate one exact public Relay value without retaining a serialized - /// copy or exposing value-bearing error details. - #[must_use] - pub fn validates_value(&self, value: &serde_json::Value) -> bool { - if value.is_null() { - return self.nullable(); - } - match (self, value) { - (Self::Boolean { .. }, serde_json::Value::Bool(_)) => true, - ( - Self::Integer { - minimum, maximum, .. - }, - serde_json::Value::Number(value), - ) => exact_json_i64(value).is_some_and(|value| value >= *minimum && value <= *maximum), - (Self::String { max_bytes, .. }, serde_json::Value::String(value)) => { - value.len() <= *max_bytes as usize - } - (Self::Date { .. }, serde_json::Value::String(value)) => { - crate::is_rfc3339_full_date(value) - } - ( - Self::Object { - max_bytes, fields, .. - }, - serde_json::Value::Object(value), - ) => { - serialized_value_fits(value, *max_bytes) - && value.keys().all(|name| fields.contains_key(name)) - && fields.iter().all(|(name, field)| { - value - .get(name) - .is_some_and(|value| field.schema.validates_value(value)) - || !field.required && !value.contains_key(name) - }) - } - ( - Self::Array { - max_bytes, - max_items, - items, - .. - }, - serde_json::Value::Array(value), - ) => { - value.len() <= usize::from(*max_items) - && serialized_value_fits(value, *max_bytes) - && value.iter().all(|value| items.validates_value(value)) - } - _ => false, - } - } -} - -fn exact_json_i64(value: &serde_json::Number) -> Option { - value - .as_i64() - .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok())) -} - -fn serialized_value_fits(value: &T, max_bytes: u32) -> bool { - struct ByteLimitWriter { - remaining: usize, - } - - impl std::io::Write for ByteLimitWriter { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - if bytes.len() > self.remaining { - return Err(std::io::Error::other("serialized value exceeds its bound")); - } - self.remaining -= bytes.len(); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - serde_json::to_writer( - ByteLimitWriter { - remaining: max_bytes as usize, - }, - value, - ) - .is_ok() -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RequestVariableConfig { - pub from: String, - #[serde(rename = "type")] - pub value_type: RequestVariableType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RequestVariableType { - Date, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RelayConsultationInput { - TargetId, - TargetIdentifier(String), - TargetAttribute(String), - RequesterId, - RequesterIdentifier(String), -} - -impl RelayConsultationInput { - #[must_use] - pub fn request_path(&self) -> &str { - match self { - Self::TargetId => "target.id", - Self::TargetIdentifier(path) => path, - Self::TargetAttribute(path) => path, - Self::RequesterId => "request.requester.id", - Self::RequesterIdentifier(path) => path, - } - } - - #[must_use] - pub fn request_context_path(&self) -> &str { - self.request_path() - .strip_prefix("request.") - .unwrap_or(self.request_path()) - } - - #[must_use] - pub const fn is_requester_derived(&self) -> bool { - matches!(self, Self::RequesterId | Self::RequesterIdentifier(_)) - } - - #[must_use] - pub const fn is_target_derived(&self) -> bool { - matches!( - self, - Self::TargetId | Self::TargetIdentifier(_) | Self::TargetAttribute(_) - ) - } - - #[must_use] - pub const fn is_authenticated_target_identifier(&self) -> bool { - matches!(self, Self::TargetId | Self::TargetIdentifier(_)) - } -} - -impl Serialize for RelayConsultationInput { - fn serialize( - &self, - serializer: Serializer, - ) -> Result - where - Serializer: serde::Serializer, - { - serializer.serialize_str(self.request_path()) - } -} - -impl<'de> Deserialize<'de> for RelayConsultationInput { - fn deserialize(deserializer: Deserializer) -> Result - where - Deserializer: serde::Deserializer<'de>, - { - let mapping = String::deserialize(deserializer)?; - match mapping.as_str() { - "target.id" => Ok(Self::TargetId), - "request.requester.id" => Ok(Self::RequesterId), - _ if mapping - .strip_prefix("request.target.identifiers.") - .is_some_and(is_request_identifier_name) => - { - Ok(Self::TargetIdentifier(mapping)) - } - _ if mapping - .strip_prefix("request.target.attributes.") - .is_some_and(is_target_attribute_name) => - { - Ok(Self::TargetAttribute(mapping)) - } - _ if mapping - .strip_prefix("request.requester.identifiers.") - .is_some_and(is_request_identifier_name) => - { - Ok(Self::RequesterIdentifier(mapping)) - } - _ => Err(serde::de::Error::custom( - "unsupported consultation input mapping; v1 permits target.id, request.requester.id, request.target.identifiers., request.target.attributes., or request.requester.identifiers.", - )), - } - } -} - -pub(in crate::config) fn validate_claim_evidence_mode( - claim: &ClaimDefinition, - relay_configured: bool, -) -> Result<(), EvidenceConfigError> { - validate_claim_required_scopes(claim)?; - match &claim.evidence_mode { - ClaimEvidenceMode::RegistryBacked { consultations } => { - if !relay_configured { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires evidence.relay", - ); - } - if claim.purpose.as_deref().is_none_or(|purpose| { - purpose.is_empty() - || purpose.len() > 256 - || purpose.contains(',') - || purpose - .chars() - .any(|character| character.is_control() || character.is_whitespace()) - }) { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires one explicit bounded purpose token", - ); - } - if claim.required_scopes.is_empty() { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires required_scopes to contain at least one entry", - ); - } - if consultations.len() != 1 { - return invalid_claim_evidence_mode( - claim, - "registry_backed requires exactly one named consultation in v1", - ); - } - let (consultation_name, consultation) = consultations - .first_key_value() - .expect("exactly one consultation was checked above"); - validate_consultation(claim, consultation_name, consultation)?; - match &claim.rule { - RuleConfig::ConsultationOutput { - consultation: rule_consultation, - output, - } => { - if rule_consultation != consultation_name { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.consultation must match its consultation name", - ); - } - if !is_input_name(output) { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.output must be one top-level Relay output name", - ); - } - if let Some(output_config) = consultation.outputs.get(output) { - if claim.value.value_type != output_config.value_type() - || !claim.value.nullable - { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output claim value type must match its declared output and remain nullable for no_match", - ); - } - } else if consultation.outputs.is_empty() { - if claim.value.value_type != "string" { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output claim value.type must be string in v1 unless typed outputs are declared", - ); - } - } else { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_output rule.output must name a declared consultation output", - ); - } - } - RuleConfig::ConsultationMatched { - consultation: rule_consultation, - } => { - if rule_consultation != consultation_name { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_matched rule.consultation must match its consultation name", - ); - } - if claim.value.value_type != "boolean" { - return invalid_claim_evidence_mode( - claim, - "registry_backed consultation_matched claim value.type must be boolean", - ); - } - } - RuleConfig::Cel { .. } => { - if consultation.outputs.is_empty() { - return invalid_claim_evidence_mode( - claim, - "registry_backed supports only consultation_matched and consultation_output rules in v1 unless a complete typed consultation output schema is declared", - ); - } - if !matches!( - claim.value.value_type.as_str(), - "boolean" | "integer" | "string" | "date" - ) { - return invalid_claim_evidence_mode( - claim, - "registry_backed CEL result type must be boolean, integer, string, or date; generic Number is not supported", - ); - } - } - } - } - ClaimEvidenceMode::Impossible { impossible } => match *impossible {}, - } - Ok(()) -} - -pub(in crate::config) fn validate_claim_value_config( - claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let Some(max_bytes) = claim.value.max_bytes else { - return Ok(()); - }; - if claim.value.value_type != "string" { - return Err(EvidenceConfigError::InvalidClaimValueConfig { - claim: claim.id.clone(), - reason: "value.max_bytes is allowed only when value.type is string".to_string(), - }); - } - if !(1..=MAX_CLAIM_VALUE_STRING_BYTES_V1).contains(&max_bytes) { - return Err(EvidenceConfigError::InvalidClaimValueConfig { - claim: claim.id.clone(), - reason: format!( - "value.max_bytes must be between 1 and {MAX_CLAIM_VALUE_STRING_BYTES_V1}" - ), - }); - } - Ok(()) -} - -pub(in crate::config) fn validate_relay_activation_shape( - claims: &[ClaimDefinition], -) -> Result<(), EvidenceConfigError> { - let mut outputs_by_client = BTreeMap::new(); - for claim in claims { - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - unreachable!("registry_backed is the only configured evidence mode"); - }; - let (_, consultation) = consultations - .first_key_value() - .expect("individual mode validation requires one consultation"); - let input_name = consultation - .inputs - .first_key_value() - .expect("individual mode validation requires one input") - .0; - let client_key = ( - consultation.profile.clone(), - claim - .purpose - .clone() - .expect("individual mode validation requires one purpose"), - input_name.clone(), - ); - let legacy_output = consultation - .outputs - .is_empty() - .then(|| match &claim.rule { - RuleConfig::ConsultationOutput { output, .. } => Some(output.clone()), - RuleConfig::ConsultationMatched { .. } => None, - RuleConfig::Cel { .. } => None, - }) - .flatten(); - match outputs_by_client.get_mut(&client_key) { - Some((expected_outputs, expected_legacy_output)) - if expected_outputs != &consultation.outputs => - { - return invalid_claim_evidence_mode( - claim, - "claims sharing one Relay profile, purpose, and input name must declare one identical result contract", - ); - } - Some((_, Some(expected))) - if legacy_output - .as_ref() - .is_some_and(|actual| actual != expected) => - { - return invalid_claim_evidence_mode( - claim, - "legacy claims sharing one Relay profile, purpose, and input name must select one shared string output", - ); - } - Some((_, expected @ None)) if legacy_output.is_some() => { - *expected = legacy_output; - } - None => { - outputs_by_client.insert(client_key, (consultation.outputs.clone(), legacy_output)); - } - Some(_) => {} - } - } - Ok(()) -} - -pub(in crate::config) fn validate_claim_dependency_bounds( - claims: &[ClaimDefinition], -) -> Result<(), EvidenceConfigError> { - if claims.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 { - return Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { - claim: "*".to_string(), - nodes: claims.len(), - edges: 0, - }); - } - for root in claims { - let mut pending = vec![root.id.as_str()]; - let mut visited = HashSet::new(); - let mut edges = 0usize; - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let Some(claim) = claims.iter().find(|candidate| candidate.id == claim_id) else { - continue; - }; - edges = edges.saturating_add(claim.depends_on.len()); - if visited.len() > MAX_CLAIM_DEPENDENCY_NODES_V1 - || edges > MAX_CLAIM_DEPENDENCY_EDGES_V1 - { - return Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { - claim: root.id.clone(), - nodes: visited.len(), - edges, - }); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - } - Ok(()) -} - -fn validate_claim_required_scopes(claim: &ClaimDefinition) -> Result<(), EvidenceConfigError> { - if claim.required_scopes.len() > 16 { - return invalid_claim_evidence_mode( - claim, - "required_scopes cannot contain more than 16 entries", - ); - } - let mut seen = HashSet::new(); - for scope in &claim.required_scopes { - if scope.is_empty() - || scope.len() > 128 - || !scope - .bytes() - .all(|byte| matches!(byte, b'!' | b'#'..=b'[' | b']'..=b'~')) - { - return invalid_claim_evidence_mode( - claim, - "required_scopes entries must be bounded OAuth scope tokens", - ); - } - if !seen.insert(scope.as_str()) { - return invalid_claim_evidence_mode( - claim, - "required_scopes must not contain duplicate entries", - ); - } - } - Ok(()) -} - -fn validate_sha256_uri(value: &str) -> Result<(), &'static str> { - let Some(hex) = value.strip_prefix("sha256:") else { - return Err("must start with sha256:"); - }; - if hex.len() != 64 { - return Err("must contain 64 lowercase hex characters"); - } - if !hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err("must contain only lowercase hex characters"); - } - Ok(()) -} - -fn validate_consultation( - claim: &ClaimDefinition, - name: &str, - consultation: &RelayConsultationConfig, -) -> Result<(), EvidenceConfigError> { - if !is_stable_id(name) { - return invalid_claim_evidence_mode( - claim, - "consultation names must use the bounded Relay stable-id grammar", - ); - } - if !is_stable_id(&consultation.profile.id) { - return invalid_claim_evidence_mode( - claim, - "consultation profile.id must match [a-z][a-z0-9._-]{0,95}", - ); - } - validate_sha256_uri(&consultation.profile.contract_hash).map_err(|reason| { - EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: format!("consultation profile.contract_hash {reason}"), - } - })?; - if !(1..=16).contains(&consultation.inputs.len()) { - return invalid_claim_evidence_mode( - claim, - "consultation inputs must contain one to sixteen typed request mappings in v1", - ); - } - let mut request_paths = BTreeSet::new(); - for (input_name, input) in &consultation.inputs { - if !is_input_name(input_name) { - return invalid_claim_evidence_mode( - claim, - "consultation input names must match [a-z][a-z0-9_]{0,95}", - ); - } - if !request_paths.insert(input.request_path()) { - return invalid_claim_evidence_mode( - claim, - "consultation inputs must map injectively to request context paths", - ); - } - } - if !(1..=MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1).contains(&consultation.outputs.len()) { - return invalid_claim_evidence_mode( - claim, - "consultation outputs must contain one to 32 entries", - ); - } - let mut schema_nodes = RELAY_RESULT_ENVELOPE_NODES_V1; - let mut expanded_nodes = RELAY_RESULT_ENVELOPE_NODES_V1; - for (output_name, output) in &consultation.outputs { - if !is_input_name(output_name) || matches!(output_name.as_str(), "matched" | "outcome") { - return invalid_claim_evidence_mode( - claim, - "consultation output names must match [a-z][a-z0-9_]{0,95} and cannot be matched or outcome", - ); - } - let expanded = validate_relay_output_schema( - output, - RELAY_OUTPUT_ROOT_DEPTH_V1, - &mut schema_nodes, - ) - .ok_or_else(|| EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: "consultation output schema must be closed and remain within platform depth, field, item, node, name, numeric, and byte bounds".to_string(), - })?; - expanded_nodes = expanded_nodes.checked_add(expanded).ok_or_else(|| { - EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: "consultation output schema exceeds the platform expanded-node bound" - .to_string(), - } - })?; - if expanded_nodes > MAX_RELAY_OUTPUT_EXPANDED_NODES_V1 { - return invalid_claim_evidence_mode( - claim, - "consultation output schema exceeds the platform expanded-node bound", - ); - } - } - Ok(()) -} - -fn validate_relay_output_schema( - schema: &RelayOutputContract, - depth: usize, - nodes: &mut usize, -) -> Option { - *nodes = nodes.checked_add(1)?; - if depth > MAX_RELAY_OUTPUT_SCHEMA_DEPTH_V1 || *nodes > MAX_RELAY_OUTPUT_SCHEMA_NODES_V1 { - return None; - } - match schema { - RelayOutputContract::Boolean { .. } | RelayOutputContract::Date { .. } => Some(1), - RelayOutputContract::Integer { - minimum, maximum, .. - } if valid_json_integer_bounds(*minimum, *maximum) => Some(1), - RelayOutputContract::String { max_bytes, .. } - if (1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) => - { - Some(1) - } - RelayOutputContract::Object { - max_bytes, fields, .. - } => { - if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) - || !(1..=MAX_RELAY_OUTPUT_OBJECT_FIELDS_V1).contains(&fields.len()) - { - return None; - } - let mut expanded = 1_usize; - for (name, field) in fields { - if !valid_relay_output_name(name) { - return None; - } - expanded = expanded.checked_add(validate_relay_output_schema( - &field.schema, - depth + 1, - nodes, - )?)?; - } - Some(expanded) - } - RelayOutputContract::Array { - max_bytes, - max_items, - items, - .. - } => { - if !(1..=MAX_RELAY_OUTPUT_VALUE_BYTES_V1).contains(max_bytes) - || !(1..=MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1).contains(max_items) - { - return None; - } - validate_relay_output_schema(items, depth + 1, nodes)? - .checked_mul(usize::from(*max_items)) - .and_then(|expanded| expanded.checked_add(1)) - } - RelayOutputContract::Integer { .. } | RelayOutputContract::String { .. } => None, - } -} - -fn valid_json_integer_bounds(minimum: i64, maximum: i64) -> bool { - const MAX_SAFE_INTEGER: i64 = (1_i64 << 53) - 1; - minimum <= maximum && minimum >= -MAX_SAFE_INTEGER && maximum <= MAX_SAFE_INTEGER -} - -fn valid_relay_output_name(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_RELAY_OUTPUT_NAME_BYTES_V1 - && !value.chars().any(char::is_control) -} - -fn is_request_identifier_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| { - matches!( - byte, - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_' - ) - }) -} - -fn is_target_attribute_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 64 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -fn is_stable_id(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'-' | b'_')) -} - -fn is_input_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -fn invalid_claim_evidence_mode( - claim: &ClaimDefinition, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidClaimEvidenceMode { - claim: claim.id.clone(), - reason: reason.into(), - }) -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimSemanticConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub concept: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub property: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub vocabulary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub predicate: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub derived_from: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub value_mapping: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimValueConfig { - #[serde(rename = "type", default)] - pub value_type: String, - #[serde(default, skip_serializing_if = "is_false")] - pub nullable: bool, - /// Optional evaluated UTF-8 string byte ceiling. Absence preserves the - /// direct-configuration behavior that predates per-claim byte bounds. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1, max = 65536))] - pub max_bytes: Option, - #[serde(default)] - pub unit: Option, -} - -fn is_false(value: &bool) -> bool { - !*value -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimInputConfig { - pub name: String, - #[serde(rename = "type")] - pub input_type: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -pub enum RuleConfig { - ConsultationOutput { - consultation: String, - output: String, - }, - ConsultationMatched { - consultation: String, - }, - Cel { - expression: String, - }, -} - -pub(in crate::config) fn validate_claim_semantics( - claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let Some(semantics) = &claim.semantics else { - return Ok(()); - }; - let mut has_term = false; - for (field, value) in [ - ("concept", semantics.concept.as_deref()), - ("property", semantics.property.as_deref()), - ("vocabulary", semantics.vocabulary.as_deref()), - ("predicate", semantics.predicate.as_deref()), - ] { - let Some(value) = value else { - continue; - }; - has_term = true; - validate_semantic_reference(&claim.id, field, value)?; - } - for value in &semantics.derived_from { - has_term = true; - validate_semantic_reference(&claim.id, "derived_from", value)?; - } - if let Some(value_mapping) = semantics.value_mapping.as_deref() { - if value_mapping.trim().is_empty() { - return invalid_claim_semantics(&claim.id, "value_mapping must not be empty"); - } - } - if !has_term { - return invalid_claim_semantics( - &claim.id, - "at least one of concept, property, vocabulary, predicate, or derived_from must be set", - ); - } - if semantics.property.is_some() && semantics.predicate.is_some() { - return invalid_claim_semantics( - &claim.id, - "property and predicate are mutually exclusive; use derived_from for predicate inputs", - ); - } - Ok(()) -} - -pub(in crate::config) fn validate_semantic_reference( - claim_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - let value = value.trim(); - if value.is_empty() { - return invalid_claim_semantics(claim_id, format!("{field} must not be empty")); - } - if value.starts_with("https://") || value.starts_with("http://") || value.starts_with("urn:") { - return Ok(()); - } - invalid_claim_semantics( - claim_id, - format!("{field} must be an absolute http(s) URI or urn"), - ) -} - -pub(in crate::config) fn invalid_claim_semantics( - claim: &str, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidClaimSemantics { - claim: claim.to_string(), - reason: reason.into(), - }) -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimOperationsConfig { - #[serde(default = "default_enabled_operation")] - pub evaluate: OperationConfig, - #[serde(default)] - pub batch_evaluate: BatchOperationConfig, -} - -impl Default for ClaimOperationsConfig { - fn default() -> Self { - Self { - evaluate: OperationConfig { enabled: true }, - batch_evaluate: BatchOperationConfig::default(), - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct OperationConfig { - #[serde(default)] - pub enabled: bool, -} - -pub(in crate::config) fn default_enabled_operation() -> OperationConfig { - OperationConfig { enabled: true } -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchOperationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_inline_batch_limit")] - #[schemars(range(min = 1, max = 100))] - pub max_subjects: usize, -} - -impl Default for BatchOperationConfig { - fn default() -> Self { - Self { - enabled: false, - max_subjects: default_inline_batch_limit(), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CccevConfig { - #[serde(default)] - pub requirement_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evidence_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evidence_type_iri: Option, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct OotsConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub requirement: Option, - #[serde(default)] - pub reference_framework: Option, - #[serde(default)] - pub evidence_type_classification: Option, - #[serde(default)] - pub evidence_type_list: Option, - #[serde(default)] - pub languages: Vec, - #[serde(default)] - pub authentication_level_of_assurance: Option, -} diff --git a/crates/registry-notary-core/src/config/evidence/disclosure.rs b/crates/registry-notary-core/src/config/evidence/disclosure.rs deleted file mode 100644 index f2ffd5ae2..000000000 --- a/crates/registry-notary-core/src/config/evidence/disclosure.rs +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Claim and credential disclosure configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DisclosureConfig { - #[serde(default = "default_disclosure_profile")] - pub default: String, - #[serde(default = "default_disclosure_allowed")] - pub allowed: Vec, - #[serde(default = "default_disclosure_downgrade")] - pub downgrade: String, -} - -impl Default for DisclosureConfig { - fn default() -> Self { - Self { - default: default_disclosure_profile(), - allowed: default_disclosure_allowed(), - downgrade: default_disclosure_downgrade(), - } - } -} - -pub(in crate::config) fn default_disclosure_profile() -> String { - "redacted".to_string() -} - -pub(in crate::config) fn default_disclosure_allowed() -> Vec { - vec!["redacted".to_string()] -} - -pub(in crate::config) fn default_disclosure_downgrade() -> String { - "deny".to_string() -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialDisclosureConfig { - #[serde(default)] - pub allowed: Vec, -} diff --git a/crates/registry-notary-core/src/config/evidence/limits.rs b/crates/registry-notary-core/src/config/evidence/limits.rs deleted file mode 100644 index 4632f83df..000000000 --- a/crates/registry-notary-core/src/config/evidence/limits.rs +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evaluation concurrency and machine quota configuration. - -use super::*; - -/// Per-request cap on concurrently evaluated subjects. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ConcurrencyConfig { - #[serde(default = "default_concurrency_subjects")] - pub subjects: usize, -} - -impl Default for ConcurrencyConfig { - fn default() -> Self { - Self { - subjects: default_concurrency_subjects(), - } - } -} - -impl ConcurrencyConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.subjects < 1 { - return Err(EvidenceConfigError::InvalidConcurrency); - } - Ok(()) - } -} - -const fn default_concurrency_subjects() -> usize { - 16 -} - -/// Per-principal quota for machine `evaluate`/`batch_evaluate` traffic. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct MachineQuotaConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_machine_quota_subjects_per_minute")] - pub subjects_per_minute: u32, -} - -impl Default for MachineQuotaConfig { - fn default() -> Self { - Self { - enabled: false, - subjects_per_minute: default_machine_quota_subjects_per_minute(), - } - } -} - -impl MachineQuotaConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.enabled && self.subjects_per_minute == 0 { - return Err(EvidenceConfigError::InvalidMachineQuotaConfig { - reason: "subjects_per_minute must be greater than zero when enabled".to_string(), - }); - } - Ok(()) - } -} - -const fn default_machine_quota_subjects_per_minute() -> u32 { - 6000 -} diff --git a/crates/registry-notary-core/src/config/evidence/mod.rs b/crates/registry-notary-core/src/config/evidence/mod.rs deleted file mode 100644 index bda2e3d08..000000000 --- a/crates/registry-notary-core/src/config/evidence/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Evidence, source, claim, disclosure, and signing configuration. - -use super::*; - -mod claims; -mod disclosure; -mod limits; -mod relay; -mod signing; - -pub use claims::*; -pub use disclosure::*; -pub use limits::*; -pub use relay::*; -pub use signing::*; - -/// Hard 1.0 platform ceiling for synchronous batch evaluation members. -/// -/// Operator settings may reduce this value, but never raise it. -pub const MAX_BATCH_EVALUATION_MEMBERS_V1: usize = 100; - -/// Registry Notary configuration. Disabled by default so existing -/// Registry Relay deployments load unchanged. -#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_service_id")] - pub service_id: String, - #[serde(default = "default_api_version")] - pub api_version: String, - #[serde(default = "default_api_base_url")] - pub api_base_url: String, - #[serde(default = "default_claims_url")] - pub claims_url: String, - #[serde(default = "default_formats_url")] - pub formats_url: String, - #[serde(default = "default_inline_batch_limit")] - #[schemars(range(min = 1, max = 100))] - pub inline_batch_limit: usize, - #[serde(default = "default_max_credential_validity_seconds")] - pub max_credential_validity_seconds: u64, - #[serde(default)] - pub allowed_purposes: Vec, - /// Closed union of request variables declared by authored services. - #[serde(default)] - pub variables: BTreeMap, - #[serde(default)] - pub claims: Vec, - #[serde(default)] - pub signing_keys: BTreeMap, - #[serde(default)] - pub credential_profiles: BTreeMap, - /// The one Registry Relay connection available to registry-backed claims. - /// Authentication remains a reloadable local file reference; core never - /// loads the bearer token value. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay: Option, - /// Per-request cap for concurrently evaluated subjects. - #[serde(default)] - pub concurrency: ConcurrencyConfig, - /// Per-principal budget for machine `evaluate`/`batch_evaluate` traffic, - /// counted in subjects (a single evaluate consumes 1; a batch consumes - /// `items.len()`) over a fixed one-minute window. Disabled by default. - #[serde(default)] - pub machine_quota: MachineQuotaConfig, -} - -pub(in crate::config) const fn default_max_credential_validity_seconds() -> u64 { - 600 -} - -impl EvidenceConfig { - pub(in crate::config) fn validate_batch_limits(&self) -> Result<(), EvidenceConfigError> { - if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&self.inline_batch_limit) { - return Err(EvidenceConfigError::InvalidBatchConfig { - reason: format!( - "evidence.inline_batch_limit must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}" - ), - }); - } - for claim in &self.claims { - let max_subjects = claim.operations.batch_evaluate.max_subjects; - if !(1..=MAX_BATCH_EVALUATION_MEMBERS_V1).contains(&max_subjects) { - return Err(EvidenceConfigError::InvalidBatchConfig { - reason: format!( - "claim '{}' operations.batch_evaluate.max_subjects must be between 1 and {MAX_BATCH_EVALUATION_MEMBERS_V1}", - claim.id - ), - }); - } - } - Ok(()) - } - - pub(in crate::config) fn validate_signing_keys(&self) -> Result<(), EvidenceConfigError> { - let mut published_kids = HashSet::new(); - for (key_id, key) in &self.signing_keys { - validate_signing_key_id(key_id)?; - key.validate(key_id)?; - if key.status.may_publish() && !published_kids.insert(key.kid.as_str()) { - return Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.clone(), - reason: format!("duplicate published kid '{}'", key.kid), - }); - } - } - Ok(()) - } - - /// Validate resolved signing-capable key material after runtime providers - /// have loaded their public JWKs. Static config can compare ids and kids, - /// but only the resolved JWKs reveal whether different active entries reuse - /// the same key material under different ids or kids. - /// - /// The reuse comparison is confined to the separated EdDSA signing roles - /// issue #173 names: the access-token signing key and every credential - /// profile signing key, plus the federation signing key (the documented - /// separation boundary in `validate_signing_key_alg_usage` treats all three - /// as distinct EdDSA roles). `reuse_scoped_key_ids` carries exactly those - /// role keys. The eSignet pre-authorized-code RP client key is a separate, - /// relaxed role that is deliberately allowed to share material with the - /// credential issuer key, so callers must leave it out of - /// `reuse_scoped_key_ids`; resolved JWKs for keys outside the set are not - /// compared. - pub fn validate_resolved_signing_key_material<'a, I>( - &self, - resolved_public_jwks: I, - reuse_scoped_key_ids: &HashSet<&str>, - ) -> Result<(), EvidenceConfigError> - where - I: IntoIterator, - { - let mut thumbprints_by_key_id = BTreeMap::new(); - for (key_id, public_jwk) in resolved_public_jwks { - let Some(key) = self.signing_keys.get(key_id) else { - return invalid_signing_key( - key_id, - "resolved public JWK does not match a configured signing key", - ); - }; - if !key.status.may_sign() { - continue; - } - // Only the separated signing roles (#173) are compared against one - // another; keys outside that set (notably the eSignet RP client - // key) are allowed to reuse credential material by design. - if !reuse_scoped_key_ids.contains(key_id) { - continue; - } - let thumbprint = - public_jwk - .jkt() - .map_err(|_| EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: "resolved public JWK could not be thumbprinted".to_string(), - })?; - if let Some(previous_key_id) = - thumbprints_by_key_id.insert(thumbprint, key_id.to_string()) - { - return invalid_signing_key( - key_id, - format!("reuses public key material with signing key '{previous_key_id}'"), - ); - } - } - Ok(()) - } -} - -pub(in crate::config) fn default_service_id() -> String { - "registry-notary".to_string() -} - -pub(in crate::config) fn default_api_version() -> String { - "2026-05".to_string() -} - -pub(in crate::config) fn default_api_base_url() -> String { - "/".to_string() -} - -pub(in crate::config) fn default_claims_url() -> String { - "/v1/claims".to_string() -} - -pub(in crate::config) fn default_formats_url() -> String { - "/v1/formats".to_string() -} - -pub(in crate::config) const fn default_inline_batch_limit() -> usize { - MAX_BATCH_EVALUATION_MEMBERS_V1 -} diff --git a/crates/registry-notary-core/src/config/evidence/relay.rs b/crates/registry-notary-core/src/config/evidence/relay.rs deleted file mode 100644 index 859130269..000000000 --- a/crates/registry-notary-core/src/config/evidence/relay.rs +++ /dev/null @@ -1,296 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Sealed Registry Relay connection configuration. - -use std::collections::BTreeSet; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::path::Path; - -use ipnet::IpNet; - -use super::*; - -const MAX_RELAY_BASE_URL_BYTES: usize = 2_048; -const MAX_RELAY_TOKEN_FILE_BYTES: usize = 4_096; -const MAX_RELAY_PRIVATE_CIDRS: usize = 16; -const MAX_RELAY_IN_FLIGHT: usize = 64; - -const fn default_relay_max_in_flight() -> usize { - 8 -} - -#[derive(Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RelayConnectionConfig { - pub base_url: String, - pub workload_client_id: String, - pub token_file: PathBuf, - /// Optional private root bundle for the exact HTTPS Relay destination. - /// - /// The deployment must provide a bounded regular file owned by root or - /// the Notary effective user, readable by its owner, and inaccessible to - /// group and other users (0400 or 0600). - #[serde(default)] - pub root_certificate_path: Option, - #[serde(default)] - #[schemars(with = "Vec")] - pub allowed_private_cidrs: Vec, - #[serde(default)] - pub allow_insecure_localhost: bool, - /// Permit this signed Notary-to-Relay service hop to use application HTTP. - /// Runtime resolution remains confined to eligible private addresses. - #[serde(default)] - pub allow_insecure_private_network: bool, - #[serde(default = "default_relay_max_in_flight")] - pub max_in_flight: usize, -} - -impl std::fmt::Debug for RelayConnectionConfig { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("RelayConnectionConfig") - .field("base_url", &"") - .field("token_file", &"") - .field( - "custom_root_certificate", - &self.root_certificate_path.is_some(), - ) - .field( - "allowed_private_cidr_count", - &self.allowed_private_cidrs.len(), - ) - .field("allow_insecure_localhost", &self.allow_insecure_localhost) - .field( - "allow_insecure_private_network", - &self.allow_insecure_private_network, - ) - .field("max_in_flight", &self.max_in_flight) - .finish() - } -} - -impl RelayConnectionConfig { - pub(in crate::config) fn validate( - &self, - deployment_profile: Option, - ) -> Result<(), EvidenceConfigError> { - if self.base_url.is_empty() - || self.base_url.len() > MAX_RELAY_BASE_URL_BYTES - || self.base_url.trim() != self.base_url - { - return invalid_relay("base_url must be a non-empty URL of bounded length"); - } - if !stable_workload_id(&self.workload_client_id) { - return invalid_relay( - "workload_client_id must be a stable lowercase workload identifier", - ); - } - let Some(origin) = parse_relay_origin(&self.base_url) else { - return invalid_relay( - "base_url must be an absolute HTTP(S) origin with path exactly / and no credentials, query, or fragment", - ); - }; - validate_private_cidrs(&self.allowed_private_cidrs)?; - if self.allow_insecure_localhost && self.allow_insecure_private_network { - return invalid_relay( - "allow_insecure_localhost and allow_insecure_private_network are mutually exclusive", - ); - } - if self.allow_insecure_private_network && origin.scheme() != "http" { - return invalid_relay("allow_insecure_private_network requires an HTTP base_url"); - } - match origin.scheme() { - "https" => {} - "http" if self.allow_insecure_localhost && is_loopback_origin(&origin) => {} - "http" - if self.allow_insecure_private_network - && matches!(origin.host(), Some(url::Host::Domain(_))) => {} - "http" - if deployment_profile == Some(crate::deployment::DeploymentProfile::Local) - && private_origin_has_exact_allowlist_entry( - &origin, - &self.allowed_private_cidrs, - ) => {} - "http" => { - return invalid_relay( - "base_url must use https unless an explicit loopback, private service, or local exact-private-IP HTTP profile applies", - ); - } - _ => return invalid_relay("base_url must use the http or https scheme"), - } - if !valid_token_file(&self.token_file) { - return invalid_relay("token_file must be a bounded absolute canonical file path"); - } - if self - .root_certificate_path - .as_ref() - .is_some_and(|path| !valid_token_file(path)) - { - return invalid_relay( - "root_certificate_path must be a bounded absolute canonical file path", - ); - } - if self.root_certificate_path.is_some() && origin.scheme() != "https" { - return invalid_relay("root_certificate_path requires an https base_url"); - } - if !(1..=MAX_RELAY_IN_FLIGHT).contains(&self.max_in_flight) { - return invalid_relay("max_in_flight must be between 1 and 64"); - } - Ok(()) - } - - #[must_use] - pub fn uses_insecure_url(&self) -> bool { - self.base_url.starts_with("http://") - } - - #[must_use] - pub fn uses_insecure_loopback_url(&self) -> bool { - parse_relay_origin(&self.base_url) - .is_some_and(|origin| origin.scheme() == "http" && is_loopback_origin(&origin)) - } - - #[must_use] - pub fn uses_insecure_private_network_url(&self) -> bool { - self.allow_insecure_private_network - && parse_relay_origin(&self.base_url).is_some_and(|origin| { - origin.scheme() == "http" && matches!(origin.host(), Some(url::Host::Domain(_))) - }) - } -} - -fn private_origin_has_exact_allowlist_entry(origin: &url::Url, cidrs: &[IpNet]) -> bool { - match origin.host() { - Some(url::Host::Ipv4(address)) => cidrs.iter().any(|cidr| { - matches!(cidr, IpNet::V4(cidr) if cidr.prefix_len() == 32 && cidr.network() == address) - }), - Some(url::Host::Ipv6(address)) => cidrs.iter().any(|cidr| { - matches!(cidr, IpNet::V6(cidr) if cidr.prefix_len() == 128 && cidr.network() == address) - }), - Some(url::Host::Domain(_)) | None => false, - } -} - -fn stable_workload_id(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= 96 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-')) -} - -fn is_loopback_origin(origin: &url::Url) -> bool { - match origin.host() { - Some(url::Host::Ipv4(address)) => address.is_loopback(), - Some(url::Host::Ipv6(address)) => address.is_loopback(), - Some(url::Host::Domain(_)) | None => false, - } -} - -fn parse_relay_origin(value: &str) -> Option { - let (scheme, rest) = value.split_once("://")?; - if !matches!(scheme, "http" | "https") || rest.is_empty() || rest.contains(['?', '#']) { - return None; - } - // Check the raw shape before URL normalization so `/private/..` cannot - // normalize into the accepted root origin. - let authority = rest.strip_suffix('/').unwrap_or(rest); - if authority.is_empty() || authority.contains('/') { - return None; - } - let origin = url::Url::parse(value).ok()?; - (!origin.cannot_be_a_base() - && origin.host().is_some() - && origin.port() != Some(0) - && origin.username().is_empty() - && origin.password().is_none() - && origin.path() == "/" - && origin.query().is_none() - && origin.fragment().is_none()) - .then_some(origin) -} - -fn valid_token_file(path: &Path) -> bool { - let Some(text) = path.to_str() else { - return false; - }; - !text.is_empty() - && text.len() <= MAX_RELAY_TOKEN_FILE_BYTES - && text.starts_with('/') - && text != "/" - && !text.starts_with("//") - && !text.ends_with('/') - && !text.contains('\\') - && !text.bytes().any(|byte| byte.is_ascii_control()) - && !text - .split('/') - .skip(1) - .any(|component| component.is_empty() || matches!(component, "." | "..")) -} - -fn validate_private_cidrs(cidrs: &[IpNet]) -> Result<(), EvidenceConfigError> { - if cidrs.len() > MAX_RELAY_PRIVATE_CIDRS { - return invalid_relay("allowed_private_cidrs cannot contain more than 16 entries"); - } - let mut seen = BTreeSet::new(); - for cidr in cidrs { - if cidr.trunc() != *cidr - || !eligible_private_cidr(*cidr) - || metadata_singleton(*cidr) - || !seen.insert(*cidr) - { - return invalid_relay( - "allowed_private_cidrs must contain unique canonical RFC 1918, RFC 6598, or IPv6 ULA networks", - ); - } - } - Ok(()) -} - -fn eligible_private_cidr(cidr: IpNet) -> bool { - match cidr { - IpNet::V4(cidr) => { - let address = cidr.network(); - let prefix = cidr.prefix_len(); - (prefix >= 8 && ipv4_in_prefix(address, Ipv4Addr::new(10, 0, 0, 0), 8)) - || (prefix >= 12 && ipv4_in_prefix(address, Ipv4Addr::new(172, 16, 0, 0), 12)) - || (prefix >= 16 && ipv4_in_prefix(address, Ipv4Addr::new(192, 168, 0, 0), 16)) - || (prefix >= 10 && ipv4_in_prefix(address, Ipv4Addr::new(100, 64, 0, 0), 10)) - } - IpNet::V6(cidr) => { - cidr.prefix_len() >= 7 - && ipv6_in_prefix( - cidr.network(), - Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), - 7, - ) - } - } -} - -fn ipv4_in_prefix(address: Ipv4Addr, network: Ipv4Addr, prefix: u8) -> bool { - let mask = u32::MAX << (32 - prefix); - u32::from(address) & mask == u32::from(network) & mask -} - -fn ipv6_in_prefix(address: Ipv6Addr, network: Ipv6Addr, prefix: u8) -> bool { - let mask = u128::MAX << (128 - prefix); - u128::from(address) & mask == u128::from(network) & mask -} - -fn metadata_singleton(cidr: IpNet) -> bool { - match cidr { - IpNet::V4(cidr) if cidr.prefix_len() == 32 => { - IpAddr::V4(cidr.network()) == IpAddr::V4(Ipv4Addr::new(100, 100, 100, 200)) - } - IpNet::V6(cidr) if cidr.prefix_len() == 128 => { - IpAddr::V6(cidr.network()) - == IpAddr::V6(Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254)) - } - _ => false, - } -} - -fn invalid_relay(reason: &str) -> Result { - Err(EvidenceConfigError::InvalidRelayConfig { - reason: reason.to_string(), - }) -} diff --git a/crates/registry-notary-core/src/config/evidence/signing.rs b/crates/registry-notary-core/src/config/evidence/signing.rs deleted file mode 100644 index f0768d97c..000000000 --- a/crates/registry-notary-core/src/config/evidence/signing.rs +++ /dev/null @@ -1,323 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Credential profile and signing-key configuration. - -use super::*; - -pub(in crate::config) fn validate_credential_profile_validity( - profile_id: &str, - profile: &CredentialProfileConfig, - max_validity_seconds: u64, -) -> Result<(), EvidenceConfigError> { - if profile.validity_seconds <= 0 { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - }); - } - let validity_seconds = u64::try_from(profile.validity_seconds).map_err(|_| { - EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - } - })?; - if validity_seconds > max_validity_seconds { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: profile_id.to_string(), - validity_seconds: profile.validity_seconds, - max_validity_seconds, - }); - } - Ok(()) -} - -pub fn signing_provider_uses_local_software_custody(provider: SigningKeyProviderConfig) -> bool { - matches!( - provider, - SigningKeyProviderConfig::LocalJwkEnv - | SigningKeyProviderConfig::FileWatch - | SigningKeyProviderConfig::LocalPkcs12File - ) -} - -pub fn signing_key_uses_local_software_custody(key: &SigningKeyConfig) -> bool { - key.status.may_sign() && signing_provider_uses_local_software_custody(key.provider) -} - -pub(in crate::config) fn validate_signing_key_id(key_id: &str) -> Result<(), EvidenceConfigError> { - if key_id.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: "signing key id must not be empty".to_string(), - }); - } - Ok(()) -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialProfileConfig { - pub format: String, - pub issuer: String, - pub signing_key: String, - pub vct: String, - #[serde(default = "default_credential_validity_seconds")] - pub validity_seconds: i64, - #[serde(default)] - pub holder_binding: HolderBindingConfig, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub disclosure: CredentialDisclosureConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SigningKeyConfig { - #[schemars(with = "schema::SigningKeyProviderSchema")] - pub provider: SigningKeyProviderConfig, - pub alg: String, - pub kid: String, - #[schemars(with = "schema::SigningKeyStatusSchema")] - pub status: SigningKeyStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub publish_until_unix_seconds: Option, - #[serde(default)] - pub private_jwk_env: String, - #[serde(default)] - pub public_jwk_env: String, - #[serde(default)] - pub module_path: String, - #[serde(default)] - pub token_label: String, - #[serde(default)] - pub pin_env: String, - #[serde(default)] - pub key_label: String, - #[serde(default)] - pub key_id_hex: String, - #[serde(default)] - pub path: String, - #[serde(default)] - pub password_env: String, -} - -impl SigningKeyConfig { - pub(super) fn validate(&self, key_id: &str) -> Result<(), EvidenceConfigError> { - validate_signing_key_non_empty(key_id, "alg", &self.alg)?; - if self.alg != CREDENTIAL_SIGNING_ALG_EDDSA - && self.alg != CREDENTIAL_SIGNING_ALG_ES256 - && self.alg != CLIENT_ASSERTION_SIGNING_ALG_RS256 - { - return invalid_signing_key( - key_id, - format!( - "alg must be {CREDENTIAL_SIGNING_ALG_EDDSA}, {CREDENTIAL_SIGNING_ALG_ES256}, or {CLIENT_ASSERTION_SIGNING_ALG_RS256}" - ), - ); - } - validate_signing_key_non_empty(key_id, "kid", &self.kid)?; - if self.publish_until_unix_seconds.is_some() - && !matches!(self.status, SigningKeyStatus::PublishOnly) - { - return invalid_signing_key( - key_id, - "publish_until_unix_seconds is valid only for publish_only signing keys", - ); - } - match self.provider { - SigningKeyProviderConfig::LocalJwkEnv => { - if self.status.may_sign() { - validate_signing_key_non_empty( - key_id, - "private_jwk_env", - &self.private_jwk_env, - )?; - } - if matches!(self.status, SigningKeyStatus::PublishOnly) { - validate_signing_key_non_empty(key_id, "public_jwk_env", &self.public_jwk_env)?; - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - } - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - validate_signing_key_absent(key_id, "path", &self.path)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::Pkcs11 => { - if self.alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_signing_key(key_id, "pkcs11 provider supports only EdDSA"); - } - if self.status.may_publish() { - validate_signing_key_non_empty(key_id, "public_jwk_env", &self.public_jwk_env)?; - } - if self.status.may_sign() { - validate_signing_key_non_empty(key_id, "module_path", &self.module_path)?; - if !std::path::Path::new(&self.module_path).is_absolute() { - return invalid_signing_key(key_id, "module_path must be absolute"); - } - validate_signing_key_non_empty(key_id, "token_label", &self.token_label)?; - validate_signing_key_non_empty(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_non_empty(key_id, "key_label", &self.key_label)?; - validate_signing_key_non_empty(key_id, "key_id_hex", &self.key_id_hex)?; - if !self.key_id_hex.len().is_multiple_of(2) - || !self.key_id_hex.chars().all(|ch| ch.is_ascii_hexdigit()) - { - return invalid_signing_key(key_id, "key_id_hex must be even-length hex"); - } - } - if matches!(self.status, SigningKeyStatus::PublishOnly) { - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - } - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - validate_signing_key_absent(key_id, "path", &self.path)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::FileWatch => { - if matches!(self.status, SigningKeyStatus::PublishOnly) { - return invalid_signing_key( - key_id, - "file_watch provider supports only active or disabled signing keys", - ); - } - if self.status.may_sign() { - validate_signing_key_non_empty(key_id, "path", &self.path)?; - } - validate_signing_key_absent(key_id, "private_jwk_env", &self.private_jwk_env)?; - validate_signing_key_absent(key_id, "public_jwk_env", &self.public_jwk_env)?; - validate_signing_key_absent(key_id, "module_path", &self.module_path)?; - validate_signing_key_absent(key_id, "token_label", &self.token_label)?; - validate_signing_key_absent(key_id, "pin_env", &self.pin_env)?; - validate_signing_key_absent(key_id, "key_label", &self.key_label)?; - validate_signing_key_absent(key_id, "key_id_hex", &self.key_id_hex)?; - validate_signing_key_absent(key_id, "password_env", &self.password_env)?; - } - SigningKeyProviderConfig::LocalPkcs12File => { - invalid_signing_key( - key_id, - "local_pkcs12_file provider is intentionally not implemented yet", - )?; - } - _ => { - invalid_signing_key(key_id, "signing key provider is unsupported by this Notary")?; - } - } - Ok(()) - } - - pub fn may_publish_at(&self, now_unix_seconds: u64) -> bool { - if !self.status.may_publish() { - return false; - } - self.publish_until_unix_seconds - .is_none_or(|publish_until| now_unix_seconds <= publish_until) - } -} - -pub(in crate::config) fn validate_signing_key_non_empty( - key_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_signing_key(key_id, format!("{field} must not be empty")); - } - Ok(()) -} - -pub(in crate::config) fn validate_signing_key_absent( - key_id: &str, - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if !value.trim().is_empty() { - return invalid_signing_key( - key_id, - format!("{field} is not valid for this signing key provider"), - ); - } - Ok(()) -} - -pub(in crate::config) fn invalid_signing_key( - key_id: &str, - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidSigningKeyConfig { - key: key_id.to_string(), - reason: reason.into(), - }) -} - -pub(in crate::config) fn validate_profile_signing_key_issuer_binding( - profile_id: &str, - profile: &CredentialProfileConfig, - key: &SigningKeyConfig, -) -> Result<(), EvidenceConfigError> { - if let Some(kid_did) = key - .kid - .split('#') - .next() - .filter(|did| did.starts_with("did:web:")) - { - if profile.issuer.starts_with("did:web:") && profile.issuer != kid_did { - return Err( - EvidenceConfigError::CredentialProfileSigningKeyIssuerMismatch { - profile: profile_id.to_string(), - key: profile.signing_key.clone(), - reason: "did:web issuer must match signing key kid DID".to_string(), - }, - ); - } - if profile.issuer.starts_with("https://") { - validate_did_web_https_issuer_binding(kid_did, &profile.issuer).map_err(|error| { - EvidenceConfigError::CredentialProfileSigningKeyIssuerMismatch { - profile: profile_id.to_string(), - key: profile.signing_key.clone(), - reason: error.to_string(), - } - })?; - } - } - Ok(()) -} - -pub(in crate::config) const fn default_credential_validity_seconds() -> i64 { - 600 -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct HolderBindingConfig { - #[serde(default = "default_holder_binding_mode")] - pub mode: String, - #[serde(default)] - pub proof_of_possession: Option, - #[serde(default = "default_holder_binding_allowed_did_methods")] - pub allowed_did_methods: Vec, -} - -impl Default for HolderBindingConfig { - fn default() -> Self { - Self { - mode: default_holder_binding_mode(), - proof_of_possession: None, - allowed_did_methods: default_holder_binding_allowed_did_methods(), - } - } -} - -pub(in crate::config) fn default_holder_binding_mode() -> String { - "did".to_string() -} - -pub(in crate::config) fn default_holder_binding_allowed_did_methods() -> Vec { - vec![SD_JWT_VC_HOLDER_BINDING_METHOD.to_string()] -} diff --git a/crates/registry-notary-core/src/config/federation.rs b/crates/registry-notary-core/src/config/federation.rs deleted file mode 100644 index 70375a07c..000000000 --- a/crates/registry-notary-core/src/config/federation.rs +++ /dev/null @@ -1,469 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Federated evaluation configuration. - -use super::*; - -pub const FEDERATION_PROTOCOL_V0_1: &str = "registry-notary-federation/v0.1"; -pub const FEDERATION_REQUEST_JWT_TYP: &str = "registry-notary-request+jwt"; -pub const FEDERATION_RESPONSE_JWT_TYP: &str = "registry-notary-response+jwt"; -pub const FEDERATION_SIGNING_ALG_EDDSA: &str = "EdDSA"; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub node_id: String, - #[serde(default)] - pub issuer: String, - #[serde(default)] - pub jwks_uri: String, - #[serde(default)] - pub federation_api: String, - #[serde(default)] - pub supported_protocol_versions: Vec, - #[serde(default = "default_federation_inbound_body_limit_bytes")] - pub inbound_body_limit_bytes: usize, - #[serde(default = "default_federation_max_request_lifetime_seconds")] - pub max_request_lifetime_seconds: u64, - #[serde(default = "default_federation_clock_leeway_seconds")] - pub clock_leeway_seconds: u64, - #[serde(default)] - pub signing: FederationSigningConfig, - #[serde(default)] - pub pairwise_subject_hash: FederationPairwiseSubjectHashConfig, - #[serde(default)] - pub response_shaping: FederationResponseShapingConfig, - #[serde(default)] - pub emergency_denylist: FederationEmergencyDenylistConfig, - #[serde(default)] - pub peers: Vec, - #[serde(default)] - pub evaluation_profiles: Vec, -} - -impl Default for FederationConfig { - fn default() -> Self { - Self { - enabled: false, - node_id: String::new(), - issuer: String::new(), - jwks_uri: String::new(), - federation_api: String::new(), - supported_protocol_versions: Vec::new(), - inbound_body_limit_bytes: default_federation_inbound_body_limit_bytes(), - max_request_lifetime_seconds: default_federation_max_request_lifetime_seconds(), - clock_leeway_seconds: default_federation_clock_leeway_seconds(), - signing: FederationSigningConfig::default(), - pairwise_subject_hash: FederationPairwiseSubjectHashConfig::default(), - response_shaping: FederationResponseShapingConfig::default(), - emergency_denylist: FederationEmergencyDenylistConfig::default(), - peers: Vec::new(), - evaluation_profiles: Vec::new(), - } - } -} - -pub(super) fn federation_config_is_default(config: &FederationConfig) -> bool { - config == &FederationConfig::default() -} - -pub(super) const fn default_federation_inbound_body_limit_bytes() -> usize { - 16 * 1024 -} - -pub(super) const fn default_federation_max_request_lifetime_seconds() -> u64 { - 300 -} - -pub(super) const fn default_federation_clock_leeway_seconds() -> u64 { - 60 -} - -impl FederationConfig { - pub(super) fn validate(&self, evidence: &EvidenceConfig) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - validate_federation_non_empty("federation.node_id", &self.node_id)?; - validate_federation_non_empty("federation.issuer", &self.issuer)?; - validate_federation_https_url("federation.issuer", &self.issuer)?; - validate_federation_https_url("federation.jwks_uri", &self.jwks_uri)?; - validate_federation_https_url("federation.federation_api", &self.federation_api)?; - validate_did_web_https_issuer_binding(&self.node_id, &self.issuer).map_err(|error| { - EvidenceConfigError::InvalidFederationConfig { - reason: format!("federation.node_id must bind to federation.issuer: {error}"), - } - })?; - if !self - .supported_protocol_versions - .iter() - .any(|version| version == FEDERATION_PROTOCOL_V0_1) - { - return invalid_federation("federation.supported_protocol_versions must include registry-notary-federation/v0.1"); - } - if self.inbound_body_limit_bytes == 0 { - return invalid_federation( - "federation.inbound_body_limit_bytes must be greater than zero", - ); - } - if self.max_request_lifetime_seconds == 0 { - return invalid_federation( - "federation.max_request_lifetime_seconds must be greater than zero", - ); - } - validate_federation_non_empty("federation.signing.signing_key", &self.signing.signing_key)?; - let signing_key = evidence - .signing_keys - .get(self.signing.signing_key.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation.signing.signing_key references unknown signing key '{}'", - self.signing.signing_key - ), - })?; - if !signing_key.status.may_sign() { - return invalid_federation( - "federation.signing.signing_key must reference an active signing key", - ); - } - validate_federation_non_empty( - "federation.pairwise_subject_hash.secret_env", - &self.pairwise_subject_hash.secret_env, - )?; - if self.peers.is_empty() { - return invalid_federation("federation.peers must list at least one peer"); - } - if self.evaluation_profiles.is_empty() { - return invalid_federation( - "federation.evaluation_profiles must list at least one profile", - ); - } - let mut profile_ids = HashSet::new(); - for profile in &self.evaluation_profiles { - validate_federation_non_empty("federation.evaluation_profiles[].id", &profile.id)?; - if !profile_ids.insert(profile.id.as_str()) { - return invalid_federation("federation.evaluation_profiles contains duplicate id"); - } - validate_federation_non_empty( - "federation.evaluation_profiles[].ruleset", - &profile.ruleset, - )?; - validate_federation_non_empty( - "federation.evaluation_profiles[].claim_id", - &profile.claim_id, - )?; - validate_federation_non_empty( - "federation.evaluation_profiles[].subject_id_type", - &profile.subject_id_type, - )?; - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == profile.claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: - "federation.evaluation_profiles[].claim_id must reference an evidence claim" - .to_string(), - })?; - validate_federation_claim_inputs(evidence, profile, claim)?; - if let Some(disclosure) = profile.disclosure.as_deref() { - if DisclosureProfile::parse(disclosure).is_none() { - return invalid_federation( - "federation.evaluation_profiles[].disclosure must be value, predicate, or redacted", - ); - } - } - } - let mut peer_nodes = HashSet::new(); - for peer in &self.peers { - validate_federation_non_empty("federation.peers[].node_id", &peer.node_id)?; - validate_federation_non_empty("federation.peers[].issuer", &peer.issuer)?; - validate_federation_https_url("federation.peers[].issuer", &peer.issuer)?; - if peer.allow_insecure_private_network { - validate_federation_http_or_https_url( - "federation.peers[].jwks_uri", - &peer.jwks_uri, - )?; - } else if peer.allow_insecure_localhost { - validate_federation_localhost_or_https_url( - "federation.peers[].jwks_uri", - &peer.jwks_uri, - )?; - } else { - validate_federation_https_url("federation.peers[].jwks_uri", &peer.jwks_uri)?; - } - validate_did_web_https_issuer_binding(&peer.node_id, &peer.issuer).map_err( - |error| EvidenceConfigError::InvalidFederationConfig { - reason: format!("federation.peers[].node_id must bind to issuer: {error}"), - }, - )?; - if !peer_nodes.insert(peer.node_id.as_str()) { - return invalid_federation("federation.peers contains duplicate node_id"); - } - if !peer - .allowed_protocol_versions - .iter() - .any(|version| version == FEDERATION_PROTOCOL_V0_1) - { - return invalid_federation( - "federation.peers[].allowed_protocol_versions must include registry-notary-federation/v0.1", - ); - } - for purpose in &peer.allowed_purposes { - validate_federation_https_url("federation.peers[].allowed_purposes[]", purpose)?; - } - for profile in &peer.allowed_profiles { - let profile_config = self - .evaluation_profiles - .iter() - .find(|candidate| candidate.id == profile.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: "federation.peers[].allowed_profiles must reference an evaluation profile" - .to_string(), - })?; - validate_federation_peer_profile_scopes(evidence, peer, profile_config)?; - } - } - Ok(()) - } -} - -fn validate_federation_claim_inputs( - evidence: &EvidenceConfig, - profile: &FederationEvaluationProfileConfig, - root: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - let expected_path = format!("request.target.identifiers.{}", profile.subject_id_type); - let mut pending = vec![root.id.as_str()]; - let mut visited = HashSet::new(); - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation evaluation profile '{}' references an incomplete claim dependency closure", - profile.id - ), - })?; - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return invalid_federation(format!( - "federation evaluation profile '{}' must select only registry_backed claims", - profile.id - )); - }; - if consultations - .values() - .flat_map(|consultation| consultation.inputs.values()) - .any(|input| { - !matches!( - input, - RelayConsultationInput::TargetIdentifier(path) if path == &expected_path - ) - }) - { - return invalid_federation(format!( - "federation evaluation profile '{}' claim '{}' Relay inputs must derive from {}", - profile.id, claim.id, expected_path - )); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - Ok(()) -} - -fn validate_federation_peer_profile_scopes( - evidence: &EvidenceConfig, - peer: &FederationPeerConfig, - profile: &FederationEvaluationProfileConfig, -) -> Result<(), EvidenceConfigError> { - let available_scopes: HashSet<&str> = - peer.evaluation_scopes.iter().map(String::as_str).collect(); - let mut pending = vec![profile.claim_id.as_str()]; - let mut visited = HashSet::new(); - while let Some(claim_id) = pending.pop() { - if !visited.insert(claim_id) { - continue; - } - let claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidFederationConfig { - reason: format!( - "federation evaluation profile '{}' references an incomplete claim dependency closure", - profile.id - ), - })?; - if let Some(scope) = claim - .required_scopes - .iter() - .find(|scope| !available_scopes.contains(scope.as_str())) - { - return invalid_federation(format!( - "federation peer '{}' evaluation_scopes must include required scope '{}' for profile '{}' claim '{}'", - peer.node_id, scope, profile.id, claim.id - )); - } - pending.extend(claim.depends_on.iter().map(String::as_str)); - } - Ok(()) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationSigningConfig { - pub signing_key: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationPairwiseSubjectHashConfig { - #[serde(default)] - pub secret_env: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationResponseShapingConfig { - #[serde(default = "default_minimum_denial_latency_ms")] - pub minimum_denial_latency_ms: u64, -} - -impl Default for FederationResponseShapingConfig { - fn default() -> Self { - Self { - minimum_denial_latency_ms: default_minimum_denial_latency_ms(), - } - } -} - -pub(super) const fn default_minimum_denial_latency_ms() -> u64 { - 250 -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationEmergencyDenylistConfig { - #[serde(default)] - pub node_ids: Vec, - #[serde(default)] - pub kids: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationPeerConfig { - pub node_id: String, - pub issuer: String, - pub jwks_uri: String, - #[serde(default)] - pub allow_insecure_localhost: bool, - #[serde(default)] - pub allow_insecure_private_network: bool, - #[serde(default)] - pub allowed_protocol_versions: Vec, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_profiles: Vec, - #[serde(default)] - pub evaluation_scopes: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct FederationEvaluationProfileConfig { - pub id: String, - pub ruleset: String, - pub claim_id: String, - pub subject_id_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disclosure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_claim_result_age_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub legal_basis_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consent_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance_level: Option, -} - -pub(super) fn invalid_federation(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidFederationConfig { - reason: reason.into(), - }) -} - -pub(super) fn validate_federation_non_empty( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_federation(format!("{field} must not be empty")); - } - Ok(()) -} - -pub(super) fn validate_federation_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_federation_non_empty(field, value)?; - let Some(rest) = value.strip_prefix("https://") else { - return invalid_federation(format!("{field} must be an HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_federation(format!("{field} must include a valid host")); - } - Ok(()) -} - -pub(super) fn validate_federation_localhost_or_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.starts_with("https://") { - return validate_federation_https_url(field, value); - } - let Some(rest) = value.strip_prefix("http://") else { - return invalid_federation(format!("{field} must be HTTPS or localhost HTTP")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.starts_with("127.0.0.1:") - || host == "127.0.0.1" - || host.starts_with("localhost:") - || host == "localhost" - { - Ok(()) - } else { - invalid_federation(format!("{field} permits HTTP only for localhost")) - } -} - -pub(super) fn validate_federation_http_or_https_url( - field: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - validate_federation_non_empty(field, value)?; - let Some(rest) = value - .strip_prefix("https://") - .or_else(|| value.strip_prefix("http://")) - else { - return invalid_federation(format!("{field} must be an HTTP or HTTPS URL")); - }; - let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); - if host.is_empty() || host.contains('@') { - return invalid_federation(format!("{field} must include a valid host")); - } - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/http.rs b/crates/registry-notary-core/src/config/http.rs deleted file mode 100644 index 557a47ada..000000000 --- a/crates/registry-notary-core/src/config/http.rs +++ /dev/null @@ -1,175 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! HTTP listener configuration. - -use super::*; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryHttpConfig { - #[serde(default = "default_bind_addr")] - #[schemars(with = "schema::SocketAddrSchema")] - pub bind: SocketAddr, - #[serde( - default = "default_openapi_requires_auth", - skip_serializing_if = "openapi_requires_auth_is_default" - )] - pub openapi_requires_auth: bool, - #[serde(default, skip_serializing_if = "admin_listener_config_is_default")] - pub admin_listener: RegistryNotaryAdminListenerConfig, - #[serde(default)] - pub cors: RegistryNotaryCorsConfig, - #[serde(default = "default_request_timeout", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub request_timeout: Duration, - #[serde(default = "default_request_body_timeout", with = "humantime_serde")] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub request_body_timeout: Duration, - #[serde( - default = "default_http1_header_read_timeout", - with = "humantime_serde" - )] - #[schemars(with = "schema::HumantimeDurationSchema")] - pub http1_header_read_timeout: Duration, - #[serde(default = "default_max_connections")] - pub max_connections: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub trusted_proxy_ips: Vec, -} - -impl Default for RegistryNotaryHttpConfig { - fn default() -> Self { - Self { - bind: default_bind_addr(), - openapi_requires_auth: default_openapi_requires_auth(), - admin_listener: RegistryNotaryAdminListenerConfig::default(), - cors: RegistryNotaryCorsConfig::default(), - request_timeout: default_request_timeout(), - request_body_timeout: default_request_body_timeout(), - http1_header_read_timeout: default_http1_header_read_timeout(), - max_connections: default_max_connections(), - trusted_proxy_ips: Vec::new(), - } - } -} - -impl RegistryNotaryHttpConfig { - pub(super) fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.request_timeout.is_zero() - || self.request_body_timeout.is_zero() - || self.http1_header_read_timeout.is_zero() - || self.max_connections == 0 - { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: - "server timeouts must be non-zero and max_connections must be greater than zero" - .to_string(), - }); - } - Ok(()) - } -} - -pub(super) fn default_bind_addr() -> SocketAddr { - // SAFETY: the literal is a valid loopback socket address. - "127.0.0.1:8081" - .parse() - .expect("default bind address is valid") -} - -pub(super) fn default_openapi_requires_auth() -> bool { - true -} - -pub(super) fn openapi_requires_auth_is_default(value: &bool) -> bool { - *value == default_openapi_requires_auth() -} - -pub(super) fn default_request_timeout() -> Duration { - Duration::from_secs(30) -} - -pub(super) fn default_request_body_timeout() -> Duration { - Duration::from_secs(10) -} - -pub(super) fn default_http1_header_read_timeout() -> Duration { - Duration::from_secs(10) -} - -pub(super) fn default_max_connections() -> usize { - 1024 -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RegistryNotaryAdminListenerMode { - SharedWithPublic, - Dedicated, - #[default] - Disabled, -} - -impl RegistryNotaryAdminListenerMode { - pub fn as_str(self) -> &'static str { - match self { - Self::SharedWithPublic => "shared_with_public", - Self::Dedicated => "dedicated", - Self::Disabled => "disabled", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct RegistryNotaryAdminListenerConfig { - #[serde(default, skip_serializing_if = "admin_listener_mode_is_default")] - pub mode: RegistryNotaryAdminListenerMode, - #[serde(default = "default_admin_bind_addr")] - #[schemars(with = "schema::SocketAddrSchema")] - pub bind: SocketAddr, -} - -impl RegistryNotaryAdminListenerConfig { - pub(super) fn validate( - &self, - public_bind: SocketAddr, - governed_config_enabled: bool, - ) -> Result<(), EvidenceConfigError> { - if governed_config_enabled && self.mode != RegistryNotaryAdminListenerMode::Dedicated { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: "config_trust requires server.admin_listener.mode = dedicated".to_string(), - }); - } - if self.mode == RegistryNotaryAdminListenerMode::Dedicated && self.bind == public_bind { - return Err(EvidenceConfigError::InvalidServerConfig { - reason: "server.admin_listener.bind must differ from server.bind in dedicated mode" - .to_string(), - }); - } - Ok(()) - } -} - -impl Default for RegistryNotaryAdminListenerConfig { - fn default() -> Self { - Self { - mode: RegistryNotaryAdminListenerMode::Disabled, - bind: default_admin_bind_addr(), - } - } -} - -pub(super) fn default_admin_bind_addr() -> SocketAddr { - // SAFETY: the literal is a valid loopback socket address. - "127.0.0.1:8082" - .parse() - .expect("default admin bind address is valid") -} - -pub(super) fn admin_listener_config_is_default(config: &RegistryNotaryAdminListenerConfig) -> bool { - config == &RegistryNotaryAdminListenerConfig::default() -} - -pub(super) fn admin_listener_mode_is_default(mode: &RegistryNotaryAdminListenerMode) -> bool { - mode == &RegistryNotaryAdminListenerMode::default() -} diff --git a/crates/registry-notary-core/src/config/oid4vci.rs b/crates/registry-notary-core/src/config/oid4vci.rs deleted file mode 100644 index f569744fb..000000000 --- a/crates/registry-notary-core/src/config/oid4vci.rs +++ /dev/null @@ -1,1340 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! OpenID4VCI issuer configuration. - -use super::*; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub credential_issuer: String, - #[serde(default)] - pub authorization_servers: Vec, - #[serde(default)] - pub accepted_token_audiences: Vec, - #[serde(default)] - pub credential_endpoint: String, - #[serde(default)] - pub offer_endpoint: String, - #[serde(default)] - pub nonce_endpoint: Option, - #[serde(default)] - pub nonce: Oid4vciNonceConfig, - #[serde(default)] - pub authorization: Oid4vciAuthorizationConfig, - #[serde(default)] - pub proof: Oid4vciProofConfig, - /// Issuer-initiated pre-authorized-code flow settings. This is the only - /// wallet-facing issuance grant in the 1.0 profile. - #[serde(default)] - pub pre_authorized_code: Oid4vciPreAuthorizedCodeConfig, - #[serde(default)] - pub display: Vec, - #[serde(default)] - pub credential_configurations: BTreeMap, -} - -pub(super) fn oid4vci_config_is_default(config: &Oid4vciConfig) -> bool { - config == &Oid4vciConfig::default() -} - -impl Oid4vciConfig { - pub(super) fn validate( - &self, - subject_access: &SubjectAccessConfig, - evidence: &EvidenceConfig, - credential_status: &CredentialStatusConfig, - ) -> Result<(), EvidenceConfigError> { - // The pre-authorized-code block is validated regardless of the oid4vci - // enable toggle so a partially-configured flow is rejected, but the - // flow is an OID4VCI grant and so requires oid4vci itself enabled. - if self.pre_authorized_code.enabled && !self.enabled { - return invalid_oid4vci( - "pre_authorized_code.enabled = true requires oid4vci.enabled = true", - ); - } - self.pre_authorized_code.validate()?; - // The eSignet RP client assertion is signed with this key, so it must - // resolve to an active signing key. Surface that at config time rather - // than as a startup failure when the pre-auth flow is first built. - if self.pre_authorized_code.enabled { - let key_id = self - .pre_authorized_code - .esignet - .client_signing_key_id - .as_str(); - let key = evidence.signing_keys.get(key_id).ok_or_else(|| { - EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "pre_authorized_code.esignet.client_signing_key_id '{key_id}' must reference an evidence.signing_keys entry" - ), - } - })?; - if !key.status.may_sign() { - return invalid_oid4vci(format!( - "pre_authorized_code.esignet.client_signing_key_id '{key_id}' must reference an active signing key" - )); - } - } - // The pre-auth callback resolves the subject-binding claim from the - // eSignet userinfo endpoint when the claim is userinfo-sourced, so the - // endpoint must be configured for that path to work. - if self.pre_authorized_code.enabled - && subject_access.subject_binding.claim_source == SubjectAccessClaimSource::Userinfo - && self - .pre_authorized_code - .esignet - .userinfo_url - .trim() - .is_empty() - { - return invalid_oid4vci( - "pre_authorized_code.esignet.userinfo_url must be set when subject_access.subject_binding.claim_source = userinfo", - ); - } - if self.pre_authorized_code.enabled - && self.pre_authorized_code.tx_code.required - && subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute - == 0 - { - return invalid_oid4vci( - "subject_access.rate_limits.tx_code_attempts_per_code_per_minute must be greater than zero when pre_authorized_code.enabled = true and tx_code.required = true", - ); - } - if !self.enabled { - return Ok(()); - } - if !self.pre_authorized_code.enabled { - return invalid_oid4vci( - "enabled oid4vci requires pre_authorized_code.enabled = true; wallet-facing authorization_code issuance is not supported in 1.0", - ); - } - if !subject_access.enabled { - return invalid_oid4vci("enabled oid4vci requires subject_access.enabled = true"); - } - validate_oid4vci_public_url("oid4vci.credential_issuer", &self.credential_issuer)?; - validate_oid4vci_endpoint_url( - "oid4vci.credential_endpoint", - &self.credential_endpoint, - &self.credential_issuer, - )?; - if !self.offer_endpoint.trim().is_empty() { - validate_oid4vci_endpoint_url( - "oid4vci.offer_endpoint", - &self.offer_endpoint, - &self.credential_issuer, - )?; - } - validate_oid4vci_non_empty_entries( - "oid4vci.authorization_servers", - &self.authorization_servers, - )?; - for server in &self.authorization_servers { - validate_oid4vci_public_url("oid4vci.authorization_servers", server)?; - } - validate_oid4vci_non_empty_entries( - "oid4vci.accepted_token_audiences", - &self.accepted_token_audiences, - )?; - if self.credential_configurations.is_empty() { - return invalid_oid4vci("credential_configurations must not be empty"); - } - if !self.nonce.enabled { - return invalid_oid4vci( - "oid4vci.nonce.enabled must be true for the transaction-scoped token nonce", - ); - } - if self.nonce_endpoint.is_some() { - return invalid_oid4vci( - "oid4vci.nonce_endpoint must be omitted; 1.0 has no public unbound nonce endpoint", - ); - } - self.nonce.validate()?; - self.authorization.validate()?; - self.proof.validate()?; - for display in &self.display { - display.validate("oid4vci.display")?; - } - - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let allowed_claim_ids: HashSet<&str> = subject_access - .allowed_claims - .iter() - .map(String::as_str) - .collect(); - let allowed_profiles: HashSet<&str> = subject_access - .credential_profiles - .iter() - .map(String::as_str) - .collect(); - - let mut configured_vcts = HashSet::new(); - let validation = Oid4vciCredentialValidationContext { - credential_issuer: &self.credential_issuer, - subject_access, - evidence, - credential_status, - claim_ids: &claim_ids, - allowed_claim_ids: &allowed_claim_ids, - allowed_profiles: &allowed_profiles, - }; - for (configuration_id, configuration) in &self.credential_configurations { - configuration.validate(configuration_id, &validation)?; - if !configured_vcts.insert(configuration.vct.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct must be unique" - )); - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciNonceConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_oid4vci_nonce_ttl_seconds")] - pub ttl_seconds: u64, -} - -impl Default for Oid4vciNonceConfig { - fn default() -> Self { - Self { - enabled: false, - ttl_seconds: default_oid4vci_nonce_ttl_seconds(), - } - } -} - -impl Oid4vciNonceConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.ttl_seconds == 0 || self.ttl_seconds > 600 { - return invalid_oid4vci("nonce.ttl_seconds must be between 1 and 600"); - } - Ok(()) - } -} - -pub(super) const fn default_oid4vci_nonce_ttl_seconds() -> u64 { - 300 -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciAuthorizationConfig { - #[serde(default = "default_oid4vci_pkce_method")] - pub require_pkce_method: String, -} - -impl Default for Oid4vciAuthorizationConfig { - fn default() -> Self { - Self { - require_pkce_method: default_oid4vci_pkce_method(), - } - } -} - -impl Oid4vciAuthorizationConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.require_pkce_method != PKCE_METHOD_S256 { - return invalid_oid4vci("authorization.require_pkce_method must be S256"); - } - Ok(()) - } -} - -pub(super) fn default_oid4vci_pkce_method() -> String { - PKCE_METHOD_S256.to_string() -} - -/// Pre-authorized-code flow configuration. -/// -/// All fields default so existing configs that omit this block load unchanged -/// with the flow disabled. When `enabled`, the eSignet RP login settings, the -/// callback redirect, and the TTLs become required (validated cross-block). -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciPreAuthorizedCodeConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub tx_code: Oid4vciTxCodeConfig, - /// eSignet RP settings for the citizen login leg. - #[serde(default)] - pub esignet: Oid4vciEsignetRpConfig, - /// Pre-authorized-code lifetime in seconds. - #[serde(default = "default_pre_authorized_code_ttl_seconds")] - pub pre_authorized_code_ttl_seconds: u64, -} - -impl Default for Oid4vciPreAuthorizedCodeConfig { - fn default() -> Self { - Self { - enabled: false, - tx_code: Oid4vciTxCodeConfig::default(), - esignet: Oid4vciEsignetRpConfig::default(), - pre_authorized_code_ttl_seconds: default_pre_authorized_code_ttl_seconds(), - } - } -} - -impl Oid4vciPreAuthorizedCodeConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - self.tx_code.validate()?; - self.esignet.validate()?; - if self.pre_authorized_code_ttl_seconds == 0 || self.pre_authorized_code_ttl_seconds > 600 { - return invalid_oid4vci( - "pre_authorized_code.pre_authorized_code_ttl_seconds must be between 1 and 600", - ); - } - if !self.tx_code.required - && self.pre_authorized_code_ttl_seconds > MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS - { - return invalid_oid4vci( - "pre_authorized_code.pre_authorized_code_ttl_seconds must be between 1 and 300 when pre_authorized_code.tx_code.required = false", - ); - } - Ok(()) - } -} - -pub const MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS: u64 = 300; - -pub(super) const fn default_pre_authorized_code_ttl_seconds() -> u64 { - 300 -} - -/// `tx_code` (PIN) policy for the pre-authorized-code grant. A `tx_code` is -/// required by default because a code without a PIN is a bearer credential. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciTxCodeConfig { - #[serde(default = "default_tx_code_required")] - pub required: bool, - #[serde(default = "default_tx_code_input_mode")] - pub input_mode: String, - #[serde(default = "default_tx_code_length")] - pub length: u64, -} - -impl Default for Oid4vciTxCodeConfig { - fn default() -> Self { - Self { - required: default_tx_code_required(), - input_mode: default_tx_code_input_mode(), - length: default_tx_code_length(), - } - } -} - -impl Oid4vciTxCodeConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.required { - return Ok(()); - } - if self.input_mode != TX_CODE_INPUT_MODE_NUMERIC { - return invalid_oid4vci("pre_authorized_code.tx_code.input_mode must be numeric"); - } - if !(4..=12).contains(&self.length) { - return invalid_oid4vci("pre_authorized_code.tx_code.length must be between 4 and 12"); - } - Ok(()) - } -} - -pub(super) const fn default_tx_code_required() -> bool { - true -} - -pub(super) fn default_tx_code_input_mode() -> String { - TX_CODE_INPUT_MODE_NUMERIC.to_string() -} - -pub(super) const fn default_tx_code_length() -> u64 { - 6 -} - -/// eSignet relying-party settings for the citizen login leg of the -/// pre-authorized-code flow. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciEsignetRpConfig { - /// Confidential client id the Notary presents to eSignet. - #[serde(default)] - pub client_id: String, - /// `evidence.signing_keys` entry used to sign the eSignet - /// `private_key_jwt` client assertion. - #[serde(default)] - pub client_signing_key_id: String, - /// Notary callback the citizen browser is redirected back to. - #[serde(default)] - pub redirect_uri: String, - /// eSignet authorize endpoint. - #[serde(default)] - pub authorize_url: String, - /// eSignet token endpoint. - #[serde(default)] - pub token_url: String, - /// eSignet OIDC issuer, pinned when validating the returned `id_token`. - #[serde(default)] - pub issuer: String, - /// eSignet JWKS URI, used to resolve the `id_token` signing key by `kid`. - #[serde(default)] - pub jwks_uri: String, - /// eSignet userinfo endpoint. Required when the subject-binding claim is - /// sourced from userinfo rather than the `id_token`; the callback fetches - /// the userinfo JWS with the eSignet access token and reads the binding - /// claim from it. - #[serde(default)] - pub userinfo_url: String, - /// OAuth scopes requested at eSignet. - #[serde(default)] - pub scopes: Vec, - /// Lifetime of the short-lived login state (PKCE verifier + nonce + - /// selection) reserved between `offer/start` and `offer/callback`. - #[serde(default = "default_login_state_ttl_seconds")] - pub login_state_ttl_seconds: u64, - /// Allow `http` loopback URLs for the eSignet endpoints and JWKS transport. - /// For local development and tests only. - #[serde(default)] - pub allow_insecure_localhost: bool, -} - -impl Oid4vciEsignetRpConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.client_id.trim().is_empty() { - return invalid_oid4vci("pre_authorized_code.esignet.client_id must not be empty"); - } - if self.client_signing_key_id.trim().is_empty() { - return invalid_oid4vci( - "pre_authorized_code.esignet.client_signing_key_id must not be empty", - ); - } - validate_oid4vci_public_url( - "pre_authorized_code.esignet.redirect_uri", - &self.redirect_uri, - )?; - validate_oid4vci_public_url( - "pre_authorized_code.esignet.authorize_url", - &self.authorize_url, - )?; - validate_oid4vci_public_url("pre_authorized_code.esignet.token_url", &self.token_url)?; - validate_oid4vci_public_url("pre_authorized_code.esignet.issuer", &self.issuer)?; - validate_oid4vci_public_url("pre_authorized_code.esignet.jwks_uri", &self.jwks_uri)?; - if !self.userinfo_url.trim().is_empty() { - validate_oid4vci_public_url( - "pre_authorized_code.esignet.userinfo_url", - &self.userinfo_url, - )?; - } - validate_oid4vci_non_empty_entries("pre_authorized_code.esignet.scopes", &self.scopes)?; - if self.login_state_ttl_seconds == 0 || self.login_state_ttl_seconds > 600 { - return invalid_oid4vci( - "pre_authorized_code.esignet.login_state_ttl_seconds must be between 1 and 600", - ); - } - Ok(()) - } -} - -pub(super) const fn default_login_state_ttl_seconds() -> u64 { - 300 -} - -const TX_CODE_INPUT_MODE_NUMERIC: &str = "numeric"; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciProofConfig { - #[serde(default = "default_oid4vci_proof_max_age_seconds")] - pub max_age_seconds: u64, - #[serde(default = "default_oid4vci_proof_max_clock_skew_seconds")] - pub max_clock_skew_seconds: u64, -} - -impl Default for Oid4vciProofConfig { - fn default() -> Self { - Self { - max_age_seconds: default_oid4vci_proof_max_age_seconds(), - max_clock_skew_seconds: default_oid4vci_proof_max_clock_skew_seconds(), - } - } -} - -impl Oid4vciProofConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.max_age_seconds == 0 || self.max_age_seconds > 600 { - return invalid_oid4vci("proof.max_age_seconds must be between 1 and 600"); - } - if self.max_clock_skew_seconds > 60 { - return invalid_oid4vci("proof.max_clock_skew_seconds must be at most 60"); - } - Ok(()) - } -} - -pub(super) const fn default_oid4vci_proof_max_age_seconds() -> u64 { - 300 -} - -pub(super) const fn default_oid4vci_proof_max_clock_skew_seconds() -> u64 { - 60 -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialConfigurationConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claim_id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claims: Vec, - pub credential_profile: String, - pub format: String, - pub scope: String, - pub vct: String, - pub display_name: String, - #[serde(default)] - pub display: Oid4vciCredentialDisplayConfig, - #[serde(default = "default_oid4vci_proof_signing_alg_values_supported")] - pub proof_signing_alg_values_supported: Vec, - #[serde(default = "default_oid4vci_cryptographic_binding_methods_supported")] - pub cryptographic_binding_methods_supported: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub representative_issuance: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciRepresentativeIssuanceConfig { - pub ceremony: Oid4vciRepresentativeIssuanceCeremony, - pub relationship: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Oid4vciRepresentativeIssuanceCeremony { - DigitallyAuthenticatedRepresentative, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialClaimConfig { - pub id: String, - pub output_path: Vec, - pub display_name: String, - pub sd: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Oid4vciCredentialClaimMode<'a> { - LegacyClaimWrapper { - claim_id: &'a str, - }, - FieldProjection { - entries: &'a [Oid4vciCredentialClaimConfig], - }, -} - -impl Oid4vciCredentialClaimMode<'_> { - #[must_use] - pub fn is_field_projection(&self) -> bool { - matches!(self, Self::FieldProjection { .. }) - } -} - -impl Oid4vciCredentialConfigurationConfig { - #[must_use] - pub fn credential_claim_mode(&self) -> Oid4vciCredentialClaimMode<'_> { - if let Some(claim_id) = self.claim_id.as_deref() { - Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id } - } else { - Oid4vciCredentialClaimMode::FieldProjection { - entries: &self.claims, - } - } - } - - #[must_use] - pub fn credential_claim_ids(&self) -> Vec { - match self.credential_claim_mode() { - Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id } => { - vec![claim_id.to_string()] - } - Oid4vciCredentialClaimMode::FieldProjection { entries } => { - entries.iter().map(|entry| entry.id.clone()).collect() - } - } - } - - fn validate( - &self, - configuration_id: &str, - context: &Oid4vciCredentialValidationContext<'_>, - ) -> Result<(), EvidenceConfigError> { - let Oid4vciCredentialValidationContext { - credential_issuer, - subject_access, - evidence, - credential_status, - claim_ids, - allowed_claim_ids, - allowed_profiles, - } = context; - if configuration_id.trim().is_empty() { - return invalid_oid4vci("credential_configurations must not contain a blank id"); - } - let claim_mode = self.validate_claim_mode(configuration_id)?; - validate_oid4vci_non_empty_value( - "credential_configurations.credential_profile", - &self.credential_profile, - )?; - validate_oid4vci_non_empty_value("credential_configurations.scope", &self.scope)?; - validate_oid4vci_non_empty_value( - "credential_configurations.display_name", - &self.display_name, - )?; - self.display.validate("credential_configurations.display")?; - let profile = evidence - .credential_profiles - .get(&self.credential_profile) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' references unknown credential profile '{}'", - self.credential_profile - ), - })?; - if !allowed_profiles.contains(self.credential_profile.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references credential profile '{}' outside subject_access.credential_profiles", - self.credential_profile - )); - } - if self.format != OID4VCI_SD_JWT_VC_FORMAT { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' format must be dc+sd-jwt" - )); - } - if profile.format != FORMAT_SD_JWT_VC { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references credential profile '{}' with unsupported format '{}'", - self.credential_profile, profile.format - )); - } - let mut projection_purpose = None; - for claim_id in self.credential_claim_ids() { - let claim = validate_oid4vci_credential_claim_reference( - configuration_id, - &claim_id, - &self.credential_profile, - evidence, - profile, - claim_ids, - self.representative_issuance - .is_none() - .then_some(*allowed_claim_ids), - )?; - if claim_mode.is_field_projection() { - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' field projection claim '{claim_id}' must define purpose" - ), - } - })?; - if let Some(previous) = projection_purpose { - if previous != purpose { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' field projection claims must share one purpose" - )); - } - } else { - projection_purpose = Some(purpose); - } - if claim.disclosure.default != "value" { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' field projection claim '{claim_id}' must use value as the default disclosure" - )); - } - } - } - if self.vct != profile.vct { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct must match credential profile '{}'", - self.credential_profile - )); - } - validate_oid4vci_public_url("credential_configurations.vct", &self.vct)?; - validate_oid4vci_endpoint_url( - "credential_configurations.vct", - &self.vct, - credential_issuer, - )?; - let Some((_, _, vct_path)) = split_absolute_url(&self.vct) else { - return invalid_oid4vci("credential_configurations.vct must be an absolute URL"); - }; - let Some(expected_vct_prefix) = oid4vci_credentials_path_prefix(credential_issuer) else { - return invalid_oid4vci("oid4vci.credential_issuer must be an absolute URL"); - }; - if !vct_path.starts_with(&expected_vct_prefix) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' vct path must start with {expected_vct_prefix}" - )); - } - validate_oid4vci_non_empty_entries( - "credential_configurations.proof_signing_alg_values_supported", - &self.proof_signing_alg_values_supported, - )?; - for alg in &self.proof_signing_alg_values_supported { - if alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' supports unsupported proof signing algorithm '{alg}'" - )); - } - } - validate_oid4vci_non_empty_entries( - "credential_configurations.cryptographic_binding_methods_supported", - &self.cryptographic_binding_methods_supported, - )?; - for method in &self.cryptographic_binding_methods_supported { - if method != CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' supports unsupported binding method '{method}'" - )); - } - } - self.validate_representative_issuance( - configuration_id, - subject_access, - evidence, - credential_status, - )?; - Ok(()) - } - - fn validate_representative_issuance( - &self, - configuration_id: &str, - subject_access: &SubjectAccessConfig, - evidence: &EvidenceConfig, - credential_status: &CredentialStatusConfig, - ) -> Result<(), EvidenceConfigError> { - let Some(representative) = self.representative_issuance.as_ref() else { - return Ok(()); - }; - if !subject_access.delegation.enabled { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.delegation.enabled = true" - )); - } - if !subject_access.allowed_operations.evaluate { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.allowed_operations.evaluate = true" - )); - } - if !subject_access.allowed_operations.issue_credential { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires subject_access.allowed_operations.issue_credential = true" - )); - } - if !credential_status.enabled { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires credential_status.enabled = true" - )); - } - if representative.relationship.trim().is_empty() { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance.relationship must not be empty" - )); - } - let relationship = subject_access - .delegation - .relationship(&representative.relationship) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown relationship '{}'", - representative.relationship - ), - })?; - let claim_ids = self.credential_claim_ids(); - let [claim_id] = claim_ids.as_slice() else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance requires exactly one credential claim root" - )); - }; - if subject_access - .allowed_claims - .iter() - .any(|allowed| allowed == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' must not appear in subject_access.allowed_claims; representative roots are delegated-only" - )); - } - if !relationship - .allowed_claims - .iter() - .any(|allowed| allowed == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' is not allowed by relationship '{}'", - relationship.relationship_type - )); - } - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown claim '{claim_id}'" - ), - })?; - if !claim - .depends_on - .iter() - .any(|dependency| dependency == &relationship.proof_claim) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{claim_id}' must depend_on proof claim '{}'", - relationship.proof_claim - )); - } - let requester_id_type = subject_access.subject_binding.id_type.as_str(); - let target_id_type = relationship - .target_id_type - .as_deref() - .unwrap_or(requester_id_type); - let requester_path = format!("requester.identifiers.{requester_id_type}"); - let target_path = format!("target.identifiers.{target_id_type}"); - validate_representative_credential_closure_inputs( - configuration_id, - claim, - &relationship.proof_claim, - evidence, - &requester_path, - &target_path, - )?; - let proof_claim = evidence - .claims - .iter() - .find(|candidate| candidate.id == relationship.proof_claim) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance references unknown proof claim '{}'", - relationship.proof_claim - ), - })?; - let ClaimEvidenceMode::RegistryBacked { consultations } = &proof_claim.evidence_mode else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' must be registry_backed", - relationship.proof_claim - )); - }; - let Some((_, consultation)) = consultations - .first_key_value() - .filter(|_| consultations.len() == 1) - else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' must declare exactly one Relay consultation", - relationship.proof_claim - )); - }; - let expected_inputs = BTreeSet::from([requester_path, target_path]); - let actual_inputs = consultation - .inputs - .values() - .map(|input| input.request_context_path().to_string()) - .collect::>(); - if actual_inputs != expected_inputs { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance proof claim '{}' consultation must map exactly the authenticated requester identifier and selected target identifier", - relationship.proof_claim - )); - } - Ok(()) - } - - fn validate_claim_mode( - &self, - configuration_id: &str, - ) -> Result, EvidenceConfigError> { - match (self.claim_id.as_deref(), self.claims.is_empty()) { - (Some(claim_id), true) => { - validate_oid4vci_non_empty_value("credential_configurations.claim_id", claim_id)?; - Ok(Oid4vciCredentialClaimMode::LegacyClaimWrapper { claim_id }) - } - (None, false) => { - validate_oid4vci_projection_claims(configuration_id, &self.claims)?; - Ok(Oid4vciCredentialClaimMode::FieldProjection { - entries: &self.claims, - }) - } - (Some(_), false) => invalid_oid4vci(format!( - "credential configuration '{configuration_id}' must set exactly one of claim_id or claims" - )), - (None, true) => invalid_oid4vci(format!( - "credential configuration '{configuration_id}' must set exactly one of claim_id or claims" - )), - } - } -} - -fn validate_representative_credential_closure_inputs( - configuration_id: &str, - root: &ClaimDefinition, - proof_claim_id: &str, - evidence: &EvidenceConfig, - requester_path: &str, - target_path: &str, -) -> Result<(), EvidenceConfigError> { - let mut pending = vec![root]; - let mut visited = BTreeSet::new(); - while let Some(claim) = pending.pop() { - if !visited.insert(claim.id.as_str()) || claim.id == proof_claim_id { - continue; - } - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' must be registry_backed", - claim.id - )); - }; - for (consultation_name, consultation) in consultations { - let mut consumes_target = false; - for (input_name, input) in &consultation.inputs { - let path = input.request_context_path(); - if path != requester_path && path != target_path { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' consultation '{consultation_name}' input '{input_name}' maps '{path}' outside the representative ceremony; expected '{requester_path}' or '{target_path}'", - claim.id - )); - } - consumes_target |= path == target_path; - } - if !consumes_target { - let actual_inputs = consultation - .inputs - .iter() - .map(|(input_name, input)| { - format!("'{input_name}' maps '{}'", input.request_context_path()) - }) - .collect::>() - .join(", "); - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' consultation '{consultation_name}' does not consume the selected target; {actual_inputs}; required target path is '{target_path}' and the only additional allowed canonical path is '{requester_path}'", - claim.id - )); - } - } - for dependency_id in &claim.depends_on { - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == *dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' representative_issuance claim '{}' dependency closure references unknown claim '{dependency_id}'", - root.id - ), - })?; - pending.push(dependency); - } - } - Ok(()) -} - -pub(super) fn validate_oid4vci_projection_claims( - configuration_id: &str, - claims: &[Oid4vciCredentialClaimConfig], -) -> Result<(), EvidenceConfigError> { - let mut ids = BTreeSet::new(); - let mut paths = BTreeSet::new(); - for claim in claims { - validate_oid4vci_non_empty_value("credential_configurations.claims[].id", &claim.id)?; - if !ids.insert(claim.id.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' contains duplicate claims[].id" - )); - } - validate_oid4vci_non_empty_value( - "credential_configurations.claims[].display_name", - &claim.display_name, - )?; - validate_oid4vci_non_empty_value("credential_configurations.claims[].sd", &claim.sd)?; - if claim.sd != "always" { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].sd must be always" - )); - } - if claim.output_path.is_empty() { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path must not be empty" - )); - } - if claim.output_path.len() != 1 { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path must be a single segment in V1" - )); - } - let segment = &claim.output_path[0]; - validate_oid4vci_non_empty_value( - "credential_configurations.claims[].output_path", - segment, - )?; - if is_reserved_oid4vci_projection_output_name(segment) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' claims[].output_path uses reserved claim name '{segment}'" - )); - } - if !paths.insert(segment.as_str()) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' contains duplicate claims[].output_path" - )); - } - } - Ok(()) -} - -struct Oid4vciCredentialValidationContext<'a> { - credential_issuer: &'a str, - subject_access: &'a SubjectAccessConfig, - evidence: &'a EvidenceConfig, - credential_status: &'a CredentialStatusConfig, - claim_ids: &'a HashSet<&'a str>, - allowed_claim_ids: &'a HashSet<&'a str>, - allowed_profiles: &'a HashSet<&'a str>, -} - -pub(super) fn validate_oid4vci_credential_claim_reference<'a>( - configuration_id: &str, - claim_id: &str, - credential_profile_id: &str, - evidence: &'a EvidenceConfig, - profile: &CredentialProfileConfig, - claim_ids: &HashSet<&str>, - allowed_claim_ids: Option<&HashSet<&str>>, -) -> Result<&'a ClaimDefinition, EvidenceConfigError> { - if !claim_ids.contains(claim_id) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references unknown claim '{claim_id}'" - )); - } - if allowed_claim_ids.is_some_and(|allowed| !allowed.contains(claim_id)) { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' references claim '{claim_id}' outside subject_access.allowed_claims" - )); - } - if !profile - .allowed_claims - .iter() - .any(|allowed_claim_id| allowed_claim_id == claim_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' maps claim '{claim_id}' to credential profile '{credential_profile_id}' but the profile does not allow that claim" - )); - } - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!( - "credential configuration '{configuration_id}' references unknown claim '{claim_id}'" - ), - })?; - if !claim - .credential_profiles - .iter() - .any(|profile_id| profile_id == credential_profile_id) - { - return invalid_oid4vci(format!( - "credential configuration '{configuration_id}' maps claim '{claim_id}' to credential profile '{credential_profile_id}' but the claim does not reference that profile" - )); - } - Ok(claim) -} - -pub(super) fn is_reserved_oid4vci_projection_output_name(value: &str) -> bool { - const RESERVED: [&str; 17] = [ - "iss", - "sub", - "aud", - "iat", - "nbf", - "exp", - "vct", - "vct#integrity", - "id", - "jti", - "_sd", - "_sd_alg", - "cnf", - "status", - "issuanceDate", - "expirationDate", - "credential_configuration_id", - ]; - RESERVED.contains(&value) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciIssuerDisplayConfig { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub locale: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, -} - -impl Oid4vciIssuerDisplayConfig { - fn validate(&self, name: &str) -> Result<(), EvidenceConfigError> { - validate_oid4vci_non_empty_value(&format!("{name}.name"), &self.name)?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.locale"), - self.locale.as_deref(), - )?; - validate_oid4vci_display_image(&format!("{name}.logo"), &self.logo) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciCredentialDisplayConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub locale: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logo: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub text_color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background_image: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub secondary_image: Option, -} - -impl Oid4vciCredentialDisplayConfig { - fn validate(&self, name: &str) -> Result<(), EvidenceConfigError> { - validate_optional_oid4vci_non_empty_value( - &format!("{name}.locale"), - self.locale.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.description"), - self.description.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.background_color"), - self.background_color.as_deref(), - )?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.text_color"), - self.text_color.as_deref(), - )?; - validate_oid4vci_display_image(&format!("{name}.logo"), &self.logo)?; - validate_oid4vci_display_image( - &format!("{name}.background_image"), - &self.background_image, - )?; - validate_oid4vci_display_image(&format!("{name}.secondary_image"), &self.secondary_image) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct Oid4vciDisplayImageConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub uri: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub alt_text: Option, -} - -pub(super) fn default_oid4vci_proof_signing_alg_values_supported() -> Vec { - vec![CREDENTIAL_SIGNING_ALG_EDDSA.to_string()] -} - -pub(super) fn default_oid4vci_cryptographic_binding_methods_supported() -> Vec { - vec![CRYPTOGRAPHIC_BINDING_METHOD_DID_JWK.to_string()] -} - -pub(super) fn validate_oid4vci_public_url( - name: &str, - url: &str, -) -> Result<(), EvidenceConfigError> { - let url = url.trim(); - let (scheme, authority, _) = - split_absolute_url(url).ok_or_else(|| EvidenceConfigError::InvalidOid4vciConfig { - reason: format!("{name} must be an absolute URL"), - })?; - if scheme != "https" && !(scheme == "http" && is_insecure_localhost_url(url)) { - return invalid_oid4vci(format!( - "{name} must use https unless it is an http loopback URL" - )); - } - if authority.is_empty() { - return invalid_oid4vci(format!("{name} must include a host")); - } - if url.contains('#') { - return invalid_oid4vci(format!("{name} must not include a fragment")); - } - Ok(()) -} - -pub(super) fn validate_oid4vci_endpoint_url( - name: &str, - url: &str, - credential_issuer: &str, -) -> Result<(), EvidenceConfigError> { - validate_oid4vci_public_url(name, url)?; - let Some((_, _, path)) = split_absolute_url(url) else { - return invalid_oid4vci(format!("{name} must be an absolute URL")); - }; - if path.is_empty() || path == "/" { - return invalid_oid4vci(format!("{name} must include an endpoint path")); - } - if url.contains('?') { - return invalid_oid4vci(format!("{name} must not include a query string")); - } - let issuer_prefix = credential_issuer.trim().trim_end_matches('/'); - if !url.trim().starts_with(&format!("{issuer_prefix}/")) { - return invalid_oid4vci(format!("{name} must be under oid4vci.credential_issuer")); - } - Ok(()) -} - -pub(super) fn oid4vci_credentials_path_prefix(credential_issuer: &str) -> Option { - let (_, _, issuer_path) = split_absolute_url(credential_issuer.trim())?; - let issuer_path = issuer_path.trim_end_matches('/'); - if issuer_path.is_empty() { - Some("/credentials/".to_string()) - } else { - Some(format!("{issuer_path}/credentials/")) - } -} - -pub(super) fn split_absolute_url(url: &str) -> Option<(&str, &str, &str)> { - let (scheme, rest) = url.split_once("://")?; - if scheme.is_empty() || rest.is_empty() { - return None; - } - let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - let authority = &rest[..authority_end]; - if authority.is_empty() { - return None; - } - let path = if rest[authority_end..].starts_with('/') { - rest[authority_end..] - .split(['?', '#']) - .next() - .unwrap_or_default() - } else { - "" - }; - Some((scheme, authority, path)) -} - -pub(super) fn validate_oid4vci_non_empty_entries( - name: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_oid4vci(format!("{name} must not be empty")); - } - for value in values { - validate_oid4vci_non_empty_value(name, value)?; - } - Ok(()) -} - -pub(super) fn validate_oid4vci_non_empty_value( - name: &str, - value: &str, -) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_oid4vci(format!("{name} must not contain blank entries")); - } - Ok(()) -} - -pub(super) fn validate_optional_oid4vci_non_empty_value( - name: &str, - value: Option<&str>, -) -> Result<(), EvidenceConfigError> { - if let Some(value) = value { - validate_oid4vci_non_empty_value(name, value)?; - } - Ok(()) -} - -pub(super) fn validate_oid4vci_display_image( - name: &str, - image: &Option, -) -> Result<(), EvidenceConfigError> { - let Some(image) = image else { - return Ok(()); - }; - validate_optional_oid4vci_non_empty_value(&format!("{name}.uri"), image.uri.as_deref())?; - validate_optional_oid4vci_non_empty_value(&format!("{name}.url"), image.url.as_deref())?; - validate_optional_oid4vci_non_empty_value( - &format!("{name}.alt_text"), - image.alt_text.as_deref(), - )?; - match (image.uri.as_deref(), image.url.as_deref()) { - (None, None) => invalid_oid4vci(format!("{name} must include uri or url")), - (uri, url) => { - if let Some(uri) = uri { - validate_oid4vci_public_url(&format!("{name}.uri"), uri)?; - } - if let Some(url) = url { - validate_oid4vci_public_url(&format!("{name}.url"), url)?; - } - Ok(()) - } - } -} - -pub(super) fn invalid_oid4vci(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidOid4vciConfig { - reason: reason.into(), - }) -} - -pub(super) fn invalid_access_token_signing( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: reason.into(), - }) -} - -pub(super) fn validate_access_token_signing_entries( - field: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_access_token_signing(format!("{field} must not be empty when enabled")); - } - if values.iter().any(|value| value.trim().is_empty()) { - return invalid_access_token_signing(format!("{field} must not contain blank entries")); - } - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/root.rs b/crates/registry-notary-core/src/config/root.rs deleted file mode 100644 index dec0d6c4a..000000000 --- a/crates/registry-notary-core/src/config/root.rs +++ /dev/null @@ -1,813 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Root Registry Notary configuration and cross-domain validation. - -use super::*; -use registry_platform_httputil::destination::MAX_SERVICE_HOP_OPERATION_TIMEOUT; - -pub(super) const PKCE_METHOD_S256: &str = "S256"; -const RELAY_SERVICE_HOP_REQUEST_RESERVE: Duration = Duration::from_secs(5); -const MIN_RELAY_OUTER_REQUEST_TIMEOUT: Duration = Duration::from_secs( - MAX_SERVICE_HOP_OPERATION_TIMEOUT.as_secs() + RELAY_SERVICE_HOP_REQUEST_RESERVE.as_secs(), -); - -/// Non-EdDSA signing algorithms accepted for credential-profile signing. -/// Access-token and federation signing stay EdDSA; `validate_signing_key_alg_usage` -/// enforces that separation. -pub const CREDENTIAL_SIGNING_ALG_ES256: &str = "ES256"; -pub const CLIENT_ASSERTION_SIGNING_ALG_RS256: &str = "RS256"; - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StandaloneRegistryNotaryConfig { - #[serde(default, skip_serializing_if = "instance_config_is_default")] - pub instance: NotaryInstanceConfig, - #[serde(default)] - pub server: RegistryNotaryHttpConfig, - pub evidence: EvidenceConfig, - pub auth: EvidenceAuthConfig, - #[serde(default)] - pub audit: EvidenceAuditConfig, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_trust: Option, - #[serde(default, skip_serializing_if = "state_config_is_default")] - pub state: StateConfig, - #[serde(default, skip_serializing_if = "credential_status_config_is_default")] - pub credential_status: CredentialStatusConfig, - #[serde(default, skip_serializing_if = "registry_notary_cel_config_is_default")] - pub cel: RegistryNotaryCelConfig, - #[serde(default, skip_serializing_if = "subject_access_config_is_default")] - pub subject_access: SubjectAccessConfig, - #[serde(default, skip_serializing_if = "oid4vci_config_is_default")] - pub oid4vci: Oid4vciConfig, - #[serde(default, skip_serializing_if = "federation_config_is_default")] - pub federation: FederationConfig, - #[serde(default, skip_serializing_if = "DeploymentConfig::is_default")] - pub deployment: DeploymentConfig, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct NotaryInstanceConfig { - #[serde(default = "default_instance_id")] - pub id: String, - #[serde(default = "default_instance_environment")] - pub environment: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub owner: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub public_base_url: Option, -} - -/// Optional governed-configuration local trust state. -/// -/// Simple local deployments omit this block. Signed/governed apply requires it -/// so anti-rollback state lives in an explicit durable location. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct ConfigTrustConfig { - pub trust_anchor_path: PathBuf, - pub bundle_path: PathBuf, - pub antirollback_state_path: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_override_path: Option, -} - -impl Default for NotaryInstanceConfig { - fn default() -> Self { - Self { - id: default_instance_id(), - environment: default_instance_environment(), - owner: None, - jurisdiction: None, - public_base_url: None, - } - } -} - -pub(super) fn instance_config_is_default(config: &NotaryInstanceConfig) -> bool { - config == &NotaryInstanceConfig::default() -} - -pub(super) fn default_instance_id() -> String { - "registry-notary-standalone".to_string() -} - -pub(super) fn default_instance_environment() -> String { - "development".to_string() -} - -impl StandaloneRegistryNotaryConfig { - pub fn validate(&self) -> Result<(), EvidenceConfigError> { - if !self.evidence.enabled { - return Err(EvidenceConfigError::EvidenceDisabled); - } - self.server.validate()?; - self.server - .admin_listener - .validate(self.server.bind, self.config_trust.is_some())?; - if let Some(config_trust) = &self.config_trust { - if config_trust.trust_anchor_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.trust_anchor_path must not be empty".to_string(), - }); - } - if config_trust.bundle_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.bundle_path must not be empty".to_string(), - }); - } - if config_trust.antirollback_state_path.as_os_str().is_empty() { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.antirollback_state_path must not be empty".to_string(), - }); - } - if config_trust - .break_glass_override_path - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return Err(EvidenceConfigError::InvalidConfigTrustConfig { - reason: "config_trust.break_glass_override_path must not be empty when set" - .to_string(), - }); - } - } - self.state - .validate(&self.deployment, self.oid4vci.pre_authorized_code.enabled)?; - validate_static_credential_ids(&self.auth.api_keys, &self.auth.bearer_tokens)?; - if self.auth.api_keys.is_empty() - && self.auth.bearer_tokens.is_empty() - && self.auth.oidc.is_none() - { - return Err(EvidenceConfigError::NoCredentialsConfigured); - } - if let Some(oidc) = &self.auth.oidc { - if !self.auth.bearer_tokens.is_empty() { - return Err(EvidenceConfigError::InvalidOidcConfig { - reason: "auth.bearer_tokens cannot be combined with auth.oidc because both use Authorization: Bearer" - .to_string(), - }); - } - oidc.validate()?; - } - self.evidence.concurrency.validate()?; - self.evidence.machine_quota.validate()?; - self.evidence.validate_batch_limits()?; - if let Some(relay) = &self.evidence.relay { - relay.validate(self.deployment.profile)?; - if self.evidence.claims.is_empty() { - return Err(EvidenceConfigError::InvalidRelayConfig { - reason: "evidence.relay requires at least one registry_backed claim" - .to_string(), - }); - } - if self.server.request_timeout < MIN_RELAY_OUTER_REQUEST_TIMEOUT { - return Err(EvidenceConfigError::InvalidRelayConfig { - reason: "server.request_timeout must be at least 30 seconds for registry_backed claims, reserving 5 seconds outside the fixed 25-second Relay service hop" - .to_string(), - }); - } - } - self.cel.validate()?; - if self.evidence.max_credential_validity_seconds == 0 { - return Err(EvidenceConfigError::InvalidCredentialProfileValidity { - profile: "*".to_string(), - validity_seconds: self.evidence.max_credential_validity_seconds as i64, - max_validity_seconds: self.evidence.max_credential_validity_seconds, - }); - } - if self - .evidence - .allowed_purposes - .iter() - .any(|purpose| purpose.trim().is_empty()) - { - return Err(EvidenceConfigError::InvalidPurpose); - } - if self.evidence.variables.len() > MAX_REQUEST_VARIABLES_V1 { - return Err(EvidenceConfigError::InvalidRequestVariableConfig { - reason: format!( - "at most {MAX_REQUEST_VARIABLES_V1} request variables may be declared" - ), - }); - } - for (name, variable) in &self.evidence.variables { - if !is_request_variable_name(name) - || variable.from != format!("request.variables.{name}") - || variable.value_type != RequestVariableType::Date - { - return Err(EvidenceConfigError::InvalidRequestVariableConfig { - reason: "v1 variables must use a stable name, the exact matching request.variables path, and type date" - .to_string(), - }); - } - } - self.credential_status.validate()?; - validate_claim_dependency_bounds(&self.evidence.claims)?; - let mut seen_claim_ids: HashSet<&str> = HashSet::new(); - for claim in &self.evidence.claims { - if claim.id.trim().is_empty() { - return Err(EvidenceConfigError::InvalidClaim); - } - // REQ-DM-CLAIM-001: reject a duplicate claim id at load rather - // than letting a later claim silently shadow an earlier one. - if !seen_claim_ids.insert(claim.id.as_str()) { - return Err(EvidenceConfigError::DuplicateClaimId { - claim: claim.id.clone(), - }); - } - validate_claim_semantics(claim)?; - validate_claim_value_config(claim)?; - validate_claim_evidence_mode(claim, self.evidence.relay.is_some())?; - // REQ-DM-CLAIM-008: reject a disclosure default outside the - // allowed set at load; this is the most consequential of the - // three RS-DM-CLAIM Section 10 gaps because a privacy-sensitive - // claim could otherwise load with an internally inconsistent - // disclosure policy that only fails on first render. - if !claim - .disclosure - .allowed - .iter() - .any(|mode| mode == &claim.disclosure.default) - { - return Err(EvidenceConfigError::ClaimDisclosureDefaultNotAllowed { - claim: claim.id.clone(), - default: claim.disclosure.default.clone(), - allowed: claim.disclosure.allowed.clone(), - }); - } - // REQ-DM-CLAIM-009: omitted formats deserialize to the canonical - // claim-result representation, but an authored empty list cannot - // render any response and must fail before startup. The response - // format set is closed: credential issuance formats do not render - // evaluation responses. - if claim.formats.is_empty() { - return Err(EvidenceConfigError::EmptyClaimFormats { - claim: claim.id.clone(), - }); - } - for format in &claim.formats { - if format != FORMAT_CLAIM_RESULT_JSON && format != FORMAT_CCCEV_JSONLD { - return Err(EvidenceConfigError::UnsupportedClaimFormat { - claim: claim.id.clone(), - format: format.clone(), - }); - } - } - if !claim - .formats - .iter() - .any(|format| format == FORMAT_CLAIM_RESULT_JSON) - { - return Err(EvidenceConfigError::MissingCanonicalClaimFormat { - claim: claim.id.clone(), - }); - } - } - // Registry Notary currently resolves holder material only from - // did:jwk. Reject any other configured method so discovery metadata - // cannot advertise support that issuance cannot satisfy. - self.evidence.validate_signing_keys()?; - for (profile_id, profile) in &self.evidence.credential_profiles { - validate_credential_profile_validity( - profile_id, - profile, - self.evidence.max_credential_validity_seconds, - )?; - if profile.format != FORMAT_SD_JWT_VC { - return Err(EvidenceConfigError::UnsupportedCredentialProfileFormat { - profile: profile_id.clone(), - format: profile.format.clone(), - }); - } - let unsupported: Vec = profile - .holder_binding - .allowed_did_methods - .iter() - .filter(|m| m.as_str() != SD_JWT_VC_HOLDER_BINDING_METHOD) - .cloned() - .collect(); - if !unsupported.is_empty() { - return Err( - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { - profile: profile_id.clone(), - methods: unsupported, - }, - ); - } - // An empty allowed_claims short-circuits the issuance-time filter - // in api.rs (`is_empty()` means "any claim allowed"). Require - // operators to enumerate the claims a profile may bind to. A list - // composed only of blank entries is treated the same as empty so - // operators cannot trip the short-circuit via `[""]`. - if profile - .allowed_claims - .iter() - .all(|claim| claim.trim().is_empty()) - { - return Err(EvidenceConfigError::EmptyAllowedClaims { - profile: profile_id.clone(), - }); - } - let key = self - .evidence - .signing_keys - .get(profile.signing_key.as_str()) - .ok_or_else(|| EvidenceConfigError::UnknownCredentialProfileSigningKey { - profile: profile_id.clone(), - key: profile.signing_key.clone(), - })?; - if !key.status.may_sign() { - return Err(EvidenceConfigError::CredentialProfileSigningKeyNotActive { - profile: profile_id.clone(), - key: profile.signing_key.clone(), - }); - } - validate_profile_signing_key_issuer_binding(profile_id, profile, key)?; - } - // Finding 8: detect cycles in the depends_on graph using DFS with - // grey (in-progress) and black (done) sets. - let claim_ids: HashSet<&str> = self.evidence.claims.iter().map(|c| c.id.as_str()).collect(); - for claim in &self.evidence.claims { - for dep in &claim.depends_on { - if !claim_ids.contains(dep.as_str()) { - return Err(EvidenceConfigError::DependsOnUnknownClaim { - claim: claim.id.clone(), - unknown: dep.clone(), - }); - } - } - } - let mut grey: HashSet = HashSet::new(); - let mut black: HashSet = HashSet::new(); - for claim in &self.evidence.claims { - if !black.contains(&claim.id) { - detect_depends_on_cycle( - &self.evidence.claims, - &claim.id, - &mut grey, - &mut black, - &mut Vec::new(), - )?; - } - } - validate_relay_activation_shape(&self.evidence.claims)?; - self.subject_access.validate(&self.auth, &self.evidence)?; - self.validate_oid4vci_cross_block()?; - validate_credential_claim_bindings(&self.evidence)?; - self.validate_access_token_signing_cross_block()?; - self.federation.validate(&self.evidence)?; - self.validate_signing_key_alg_usage()?; - self.deployment.validate().map_err(|error| { - EvidenceConfigError::InvalidDeploymentConfig { - reason: error.to_string(), - } - })?; - self.validate_audit_ack_cursor()?; - Ok(()) - } - - /// Validate the off-host ack cursor configuration against the audit sink. - /// - /// A freshness window with no cursor to read is meaningless, and pointing a - /// cursor at a local file sink that does not declare off-host shipping - /// asserts observed shipping that the operator never attested. Both are - /// config errors so the contradiction is caught at load, not papered over. - fn validate_audit_ack_cursor(&self) -> Result<(), EvidenceConfigError> { - let evidence = &self.deployment.evidence; - if evidence.audit_ack_max_age_secs.is_some() && evidence.audit_ack_cursor_path.is_none() { - return Err(EvidenceConfigError::AuditAckMaxAgeWithoutCursor); - } - if evidence.audit_ack_cursor_path.is_some() - && matches!(self.audit.sink.as_str(), "file" | "jsonl") - && !evidence.audit_offhost_shipping - { - return Err(EvidenceConfigError::AuditAckCursorWithoutShippingDeclared); - } - Ok(()) - } - - pub fn validate_governed_runtime(&self) -> Result<(), EvidenceConfigError> { - self.validate()?; - self.server.admin_listener.validate(self.server.bind, true) - } - - /// Snapshot the configuration facts the deployment gate engine reads. - /// - /// Boot-time projection is configuration-only. A configured cursor clears - /// the static shipping-unverified gate, while runtime readiness and posture - /// must sample and bind it before shipping-stale clears. Keeping filesystem - /// I/O out of this path prevents startup from blocking on a stalled mount. - pub fn gate_input(&self) -> crate::deployment::GateInput { - self.gate_input_with_ack_observation(®istry_platform_ops::AckObservation::unverified()) - } - - /// Read the current off-host shipping cursor once for callers that need to - /// project both deployment gates and posture from the same observation. - pub fn audit_ack_observation(&self) -> registry_platform_ops::AckObservation { - self.audit_ack_observation_at(SystemTime::now()) - } - - /// Deterministic form of [`Self::audit_ack_observation`] for tests. - pub fn audit_ack_observation_at( - &self, - now: SystemTime, - ) -> registry_platform_ops::AckObservation { - registry_platform_ops::evaluate_ack_health( - self.deployment.evidence.audit_ack_cursor_path(), - now, - self.deployment.evidence.audit_ack_max_age(), - ) - } - - /// Snapshot gate facts as of `now`, including a synchronous cursor read. - /// - /// `now` is threaded through so cursor contract tests and offline commands - /// can evaluate freshness deterministically. Public runtime handlers use a - /// bounded async worker instead of this synchronous path. - pub fn gate_input_at(&self, now: SystemTime) -> crate::deployment::GateInput { - let ack_observation = self.audit_ack_observation_at(now); - self.gate_input_with_ack_observation(&ack_observation) - } - - /// Project gate facts using an already sampled shipping observation. - /// Keeping filesystem I/O outside the pure projection lets one HTTP response - /// use a single cursor snapshot for its gate and posture fields. - pub fn gate_input_with_ack_observation( - &self, - ack_observation: ®istry_platform_ops::AckObservation, - ) -> crate::deployment::GateInput { - crate::deployment::GateInput { - state_in_memory: self.state.storage == STATE_STORAGE_IN_MEMORY, - federation_enabled: self.federation.enabled, - oid4vci_preauth_enabled: self.oid4vci.enabled - && self.oid4vci.pre_authorized_code.enabled, - holder_proof_required: self.evidence.credential_profiles.values().any(|profile| { - profile.holder_binding.proof_of_possession.as_deref() == Some("required") - }), - wallet_facing: self.subject_access.enabled, - multi_instance: self.deployment.multi_instance, - audit_sink_class_durable: audit_sink_is_durable(&self.audit), - // A local file sink caps retention to whatever the host disk - // holds; an attacker with host access can destroy it. stdout and - // syslog are exempt: their retention is owned by the orchestrator - // log pipeline or the syslog daemon's own forwarding surface. - audit_retention_local_only: matches!(self.audit.sink.as_str(), "file" | "jsonl") - && !self.deployment.evidence.audit_offhost_shipping, - audit_shipping_target_configured: matches!( - self.audit.sink.as_str(), - "stdout" | "syslog" - ) || (matches!( - self.audit.sink.as_str(), - "file" | "jsonl" - ) && self - .deployment - .evidence - .audit_offhost_shipping), - audit_ack_cursor_configured: self.deployment.evidence.audit_ack_cursor_path().is_some(), - audit_ack_health_ok: ack_observation.health == registry_platform_ops::AckHealth::Ok, - admin_shared_exposure: self.server.admin_listener.mode - == RegistryNotaryAdminListenerMode::SharedWithPublic, - openapi_public: !self.server.openapi_requires_auth, - config_unsigned: self.config_trust.is_none(), - subject_access_enabled: self.subject_access.enabled, - transaction_token_anchor_configured: self.auth.access_token_signing.enabled, - // DPoP/mTLS proof validation for transaction tokens is not yet - // implemented. Keep this explicit so production/evidence profiles - // surface the missing sender-constraint assurance. - transaction_token_sender_constrained: false, - signer_without_custody_approval: !self.deployment.evidence.signer_custody_approved - && self.custody_scoped_signing_key_ids().iter().any(|key_id| { - self.evidence - .signing_keys - .get(*key_id) - .is_some_and(|key| key.status.may_sign()) - }), - } - } - - /// Signing-key ids used to issue credentials or access tokens, or to sign - /// federation responses. These are the custody-relevant Notary roles. The - /// eSignet RP client key is intentionally excluded because it signs an - /// outbound client assertion rather than a Notary-issued artifact. - pub fn custody_scoped_signing_key_ids(&self) -> HashSet<&str> { - let mut scoped: HashSet<&str> = self - .evidence - .credential_profiles - .values() - .map(|profile| profile.signing_key.as_str()) - .collect(); - if self.auth.access_token_signing.enabled { - let access_token_key = self.auth.access_token_signing.signing_key_id.as_str(); - if !access_token_key.is_empty() { - scoped.insert(access_token_key); - } - } - if self.federation.enabled { - let federation_key = self.federation.signing.signing_key.as_str(); - if !federation_key.is_empty() { - scoped.insert(federation_key); - } - } - scoped - } - - /// Signing-key ids whose resolved public material must not be shared, per - /// issue #173. These are the separated signing roles: every credential - /// profile signing key, the access-token signing key (when enabled), and the - /// federation signing key (when enabled). The eSignet pre-authorized-code RP - /// client key is intentionally excluded: it is a separate role that is - /// allowed to reuse the credential issuer's key material. - pub fn reuse_scoped_signing_key_ids(&self) -> HashSet<&str> { - self.custody_scoped_signing_key_ids() - } - - /// Confine ES256 signing keys to credential profiles and confine RS256 to - /// the eSignet pre-authorized-code RP client assertion. Access-token - /// signing and federation signing must reference EdDSA keys. - fn validate_signing_key_alg_usage(&self) -> Result<(), EvidenceConfigError> { - for (key_id, key) in &self.evidence.signing_keys { - if key.alg == CREDENTIAL_SIGNING_ALG_EDDSA { - continue; - } - if self.auth.access_token_signing.signing_key_id == *key_id { - return invalid_signing_key( - key_id, - "non-EdDSA signing key is used as the access-token signing key \ - (auth.access_token_signing.signing_key_id); non-EdDSA signing keys may only \ - be used by credential profiles or as the eSignet pre-authorized-code RP \ - client assertion key (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - if self.federation.signing.signing_key == *key_id { - return invalid_signing_key( - key_id, - "non-EdDSA signing key is used as the federation signing key \ - (federation.signing.signing_key); non-EdDSA signing keys may only be used by \ - credential profiles or as the eSignet pre-authorized-code RP client assertion \ - key (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - if key.alg == CLIENT_ASSERTION_SIGNING_ALG_RS256 - && self - .evidence - .credential_profiles - .values() - .any(|profile| profile.signing_key == *key_id) - { - return invalid_signing_key( - key_id, - "RS256 signing key is used by a credential profile; credential profile \ - signing keys must use EdDSA or ES256, and RS256 is reserved for the eSignet \ - pre-authorized-code RP client assertion key \ - (oid4vci.pre_authorized_code.esignet.client_signing_key_id)", - ); - } - } - Ok(()) - } - - fn validate_oid4vci_cross_block(&self) -> Result<(), EvidenceConfigError> { - self.oid4vci.validate( - &self.subject_access, - &self.evidence, - &self.credential_status, - ) - } - - fn validate_access_token_signing_cross_block(&self) -> Result<(), EvidenceConfigError> { - let signing = &self.auth.access_token_signing; - if !signing.enabled { - return Ok(()); - } - if signing.issuer.trim().is_empty() { - return invalid_access_token_signing("issuer must not be empty when enabled"); - } - validate_access_token_signing_entries("audiences", &signing.audiences)?; - if signing.allowed_algorithms.is_empty() - || signing - .allowed_algorithms - .iter() - .any(|alg| alg != CREDENTIAL_SIGNING_ALG_EDDSA) - { - return invalid_access_token_signing(format!( - "allowed_algorithms must list only {CREDENTIAL_SIGNING_ALG_EDDSA}" - )); - } - if signing.token_typ.trim().is_empty() { - return invalid_access_token_signing("token_typ must not be empty when enabled"); - } - // The access-token `typ` must differ from the pre-authorized-code `typ`, - // or a pre-authorized code would also verify as an access token (the two - // are distinguished only by header `typ`). - if signing.token_typ == crate::tokens::PRE_AUTHORIZED_CODE_JWT_TYP { - return invalid_access_token_signing(format!( - "token_typ must not equal the pre-authorized-code typ '{}'", - crate::tokens::PRE_AUTHORIZED_CODE_JWT_TYP - )); - } - if signing.access_token_ttl_seconds == 0 || signing.access_token_ttl_seconds > 600 { - return invalid_access_token_signing( - "access_token_ttl_seconds must be between 1 and 600", - ); - } - if signing.signing_key_id.trim().is_empty() { - return invalid_access_token_signing("signing_key_id must not be empty when enabled"); - } - let key = self - .evidence - .signing_keys - .get(signing.signing_key_id.as_str()) - .ok_or_else(|| EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: format!( - "signing_key_id '{}' must reference an evidence.signing_keys entry", - signing.signing_key_id - ), - })?; - if !key.status.may_sign() { - return invalid_access_token_signing(format!( - "signing_key_id '{}' must be an active signing key", - signing.signing_key_id - )); - } - // The access-token key MUST be distinct from every credential-signing - // key so a confusion or compromise of one is not the other. - for (profile_id, profile) in &self.evidence.credential_profiles { - if profile.signing_key == signing.signing_key_id { - return invalid_access_token_signing(format!( - "signing_key_id '{}' must be distinct from credential profile '{profile_id}' signing key", - signing.signing_key_id - )); - } - } - let mut verification_keys = std::collections::BTreeSet::new(); - for key_id in &signing.verification_key_ids { - if key_id.trim().is_empty() { - return invalid_access_token_signing( - "verification_key_ids must not contain blank entries", - ); - } - if key_id == &signing.signing_key_id { - return invalid_access_token_signing(format!( - "verification_key_ids must not repeat active signing_key_id '{}'", - signing.signing_key_id - )); - } - if !verification_keys.insert(key_id.as_str()) { - return invalid_access_token_signing(format!( - "verification_key_ids contains duplicate key '{key_id}'" - )); - } - let key = self.evidence.signing_keys.get(key_id).ok_or_else(|| { - EvidenceConfigError::InvalidAccessTokenSigningConfig { - reason: format!( - "verification_key_ids entry '{key_id}' must reference an evidence.signing_keys entry" - ), - } - })?; - if !key.status.may_publish() || key.status.may_sign() { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must be a publish_only signing key" - )); - } - if key.alg != CREDENTIAL_SIGNING_ALG_EDDSA { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must use {CREDENTIAL_SIGNING_ALG_EDDSA}" - )); - } - for (profile_id, profile) in &self.evidence.credential_profiles { - if profile.signing_key == *key_id { - return invalid_access_token_signing(format!( - "verification_key_ids entry '{key_id}' must be distinct from credential profile '{profile_id}' signing key" - )); - } - } - } - Ok(()) - } -} - -/// Close both sides of every credential claim/profile binding at load time. -/// -/// A credential profile is a signing capability. Keeping this validation at -/// the shared root prevents direct issuance, subject-access issuance, and -/// OID4VCI from interpreting a one-sided binding differently. -fn validate_credential_claim_bindings( - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - for (profile_id, profile) in &evidence.credential_profiles { - for claim_id in &profile.allowed_claims { - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' allowed_claims references unknown claim '{claim_id}'" - ), - })?; - if !claim - .credential_profiles - .iter() - .any(|candidate| candidate == profile_id) - { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' allows claim '{claim_id}', but the claim does not reference that profile" - ), - }); - } - let mut pending = claim - .depends_on - .iter() - .map(String::as_str) - .collect::>(); - let mut visited = HashSet::new(); - while let Some(dependency_id) = pending.pop() { - if !visited.insert(dependency_id) { - continue; - } - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' claim '{claim_id}' dependency closure references unknown claim '{dependency_id}'" - ), - })?; - if dependency.purpose != claim.purpose { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "credential profile '{profile_id}' claim '{claim_id}' dependency '{dependency_id}' must declare the same canonical purpose" - ), - }); - } - pending.extend(dependency.depends_on.iter().map(String::as_str)); - } - } - } - - for claim in &evidence.claims { - for profile_id in &claim.credential_profiles { - let profile = evidence - .credential_profiles - .get(profile_id) - .ok_or_else(|| EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "claim '{}' references unknown credential profile '{profile_id}'", - claim.id - ), - })?; - if !profile - .allowed_claims - .iter() - .any(|candidate| candidate == &claim.id) - { - return Err(EvidenceConfigError::InvalidCredentialClaimBinding { - reason: format!( - "claim '{}' references credential profile '{profile_id}', but the profile allowed_claims does not include that claim", - claim.id - ), - }); - } - } - } - Ok(()) -} - -pub(super) fn validate_static_credential_ids( - api_keys: &[EvidenceCredentialConfig], - bearer_tokens: &[EvidenceCredentialConfig], -) -> Result<(), EvidenceConfigError> { - let mut ids = HashSet::with_capacity(api_keys.len() + bearer_tokens.len()); - for (field, credentials) in [ - ("auth.api_keys", api_keys), - ("auth.bearer_tokens", bearer_tokens), - ] { - for credential in credentials { - if ids.insert(credential.id.as_str()) { - continue; - } - return Err(EvidenceConfigError::InvalidAuthConfig { - reason: format!("{field} contains duplicate id '{}'", credential.id), - }); - } - } - Ok(()) -} - -pub fn deprecated_config_fields() -> Vec { - vec![ - DeprecatedConfigField::renamed("auth.oidc.jwks_uri", "auth.oidc.jwks_url"), - DeprecatedConfigField::renamed("auth.oidc.leeway_seconds", "auth.oidc.leeway"), - DeprecatedConfigField::renamed("auth.oidc.allowed_typ", "auth.oidc.allowed_token_types"), - DeprecatedConfigField::renamed("audit.max_size_bytes", "audit.max_size_mb"), - DeprecatedConfigField::removed( - "server.cors.allow_credentials", - "Notary now always disables credentialed CORS; remove the field", - ), - ] -} diff --git a/crates/registry-notary-core/src/config/schema.rs b/crates/registry-notary-core/src/config/schema.rs deleted file mode 100644 index 16fa870f2..000000000 --- a/crates/registry-notary-core/src/config/schema.rs +++ /dev/null @@ -1,204 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Reproducible Draft 2020-12 schema for the complete Notary runtime config. - -#![allow( - dead_code, - reason = "schema adapter types are compile-time descriptions and are never constructed" -)] - -use std::borrow::Cow; - -use registry_platform_authcommon::CredentialFingerprintProvider; -use schemars::{generate::SchemaSettings, json_schema, JsonSchema, Schema, SchemaGenerator}; -use serde_json::{json, Value}; - -use super::{SigningKeyProviderConfig, SigningKeyStatus, StandaloneRegistryNotaryConfig}; - -/// Stable identifier for the product-owned Notary runtime configuration schema. -pub const CONFIG_SCHEMA_ID: &str = - "https://id.registrystack.org/schemas/registry-notary/registry-notary.config.schema.json"; - -/// Schema-only deployment-waiver reference contract shared with posture. -pub(crate) struct DeploymentWaiverReferenceSchema; - -impl JsonSchema for DeploymentWaiverReferenceSchema { - fn schema_name() -> Cow<'static, str> { - "DeploymentWaiverReference".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - registry_platform_ops::deployment_waiver_reference_schema_fragment() - .try_into() - .expect("the shared waiver-reference fragment is a valid JSON Schema") - } -} - -/// Schema-only structural deployment-waiver summary contract shared with posture. -pub(crate) struct DeploymentWaiverSummarySchema; - -impl JsonSchema for DeploymentWaiverSummarySchema { - fn schema_name() -> Cow<'static, str> { - "DeploymentWaiverSummary".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - registry_platform_ops::deployment_waiver_summary_schema_fragment() - .try_into() - .expect("the shared waiver-summary fragment is a valid JSON Schema") - } -} - -/// Schema-only contract for values parsed by `humantime_serde`. -pub(crate) struct HumantimeDurationSchema; - -impl JsonSchema for HumantimeDurationSchema { - fn schema_name() -> Cow<'static, str> { - "HumantimeDuration".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A humantime duration string. The runtime parser remains authoritative for its complete grammar.", - "type": "string" - }) - } -} - -/// Schema-only contract for YAML socket addresses parsed by `SocketAddr`. -pub(crate) struct SocketAddrSchema; - -impl JsonSchema for SocketAddrSchema { - fn schema_name() -> Cow<'static, str> { - "SocketAddr".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A Rust SocketAddr string. The runtime parser remains authoritative for address and port validity.", - "type": "string" - }) - } -} - -/// Schema-only contract for `IpNet` CIDR values. -pub(crate) struct IpNetSchema; - -impl JsonSchema for IpNetSchema { - fn schema_name() -> Cow<'static, str> { - "IpNet".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "An IP network CIDR string. The runtime parser remains authoritative for address and prefix validity.", - "type": "string" - }) - } -} - -pub(crate) struct CredentialFingerprintSchema; - -impl JsonSchema for CredentialFingerprintSchema { - fn schema_name() -> Cow<'static, str> { - "CredentialFingerprintRef".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - // `CredentialFingerprintRef` has a custom deserializer. It accepts - // either provider together with zero, one, or both optional references; - // doctor/runtime validation decides whether the selected provider has a - // usable, non-ambiguous reference. Keep that division of responsibility - // instead of making the schema stricter than deserialization. - json_schema!({ - "type": "object", - "additionalProperties": false, - "required": ["provider"], - "properties": { - "provider": string_enum(CredentialFingerprintProvider::ALL.iter().map(|provider| provider.as_str())), - "name": { "type": ["string", "null"] }, - "path": { "type": ["string", "null"] } - } - }) - } -} - -pub(crate) struct SigningKeyProviderSchema; - -impl JsonSchema for SigningKeyProviderSchema { - fn schema_name() -> Cow<'static, str> { - "SigningKeyProviderSchema".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - string_enum( - SigningKeyProviderConfig::ALL - .iter() - .map(|provider| provider.as_str()), - ) - } -} - -pub(crate) struct SigningKeyStatusSchema; - -impl JsonSchema for SigningKeyStatusSchema { - fn schema_name() -> Cow<'static, str> { - "SigningKeyStatusSchema".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - string_enum(SigningKeyStatus::ALL.iter().map(|status| status.as_str())) - } -} - -fn string_enum(labels: impl Iterator) -> Schema { - json!({ - "type": "string", - "enum": labels.collect::>() - }) - .try_into() - .expect("a JSON object is always a valid JSON Schema") -} - -/// Schema-only representation of the string-only consultation-input deserializer. -pub(crate) struct RelayConsultationInputSchema; - -impl JsonSchema for RelayConsultationInputSchema { - fn schema_name() -> Cow<'static, str> { - "RelayConsultationInput".into() - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "description": "A supported consultation input path. The runtime parser remains authoritative for exact stable-name bounds.", - "type": "string", - "minLength": 1 - }) - } -} - -/// Generate the deserialization contract for [`StandaloneRegistryNotaryConfig`]. -#[must_use] -pub fn document() -> Value { - let schema = SchemaSettings::draft2020_12() - .into_generator() - .into_root_schema_for::(); - let mut value = serde_json::to_value(schema).expect("JSON Schema serializes to JSON"); - let root = value.as_object_mut().expect("root schema is an object"); - root.insert( - "$id".to_string(), - Value::String(CONFIG_SCHEMA_ID.to_string()), - ); - root.insert( - "title".to_string(), - Value::String("Registry Notary config".to_string()), - ); - value -} - -/// Serialize the generated schema deterministically with exactly one trailing LF. -#[must_use] -pub fn document_json() -> String { - let mut output = serde_json::to_string_pretty(&document()).expect("JSON Schema serializes"); - output.push('\n'); - output -} diff --git a/crates/registry-notary-core/src/config/state.rs b/crates/registry-notary-core/src/config/state.rs deleted file mode 100644 index b08e5dcbe..000000000 --- a/crates/registry-notary-core/src/config/state.rs +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary correctness-state storage configuration. - -use super::*; - -pub const STATE_STORAGE_IN_MEMORY: &str = "in_memory"; -pub const STATE_STORAGE_POSTGRESQL: &str = "postgresql"; -pub const STATE_POSTGRESQL_MAX_CONNECTIONS: usize = 256; - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StateConfig { - #[serde(default = "default_state_storage")] - pub storage: String, - #[serde(default, skip_serializing_if = "state_postgresql_config_is_default")] - pub postgresql: StatePostgresqlConfig, -} - -impl Default for StateConfig { - fn default() -> Self { - Self { - storage: default_state_storage(), - postgresql: StatePostgresqlConfig::default(), - } - } -} - -impl StateConfig { - pub(super) fn validate( - &self, - deployment: &DeploymentConfig, - preauthorization_enabled: bool, - ) -> Result<(), EvidenceConfigError> { - match self.storage.as_str() { - STATE_STORAGE_POSTGRESQL => { - validate_state_non_empty("state.postgresql.url_env", &self.postgresql.url_env)?; - if self.postgresql.connect_timeout_ms == 0 { - return invalid_state( - "state.postgresql.connect_timeout_ms must be greater than zero", - ); - } - if self.postgresql.operation_timeout_ms == 0 { - return invalid_state( - "state.postgresql.operation_timeout_ms must be greater than zero", - ); - } - if !(1..=STATE_POSTGRESQL_MAX_CONNECTIONS) - .contains(&self.postgresql.max_connections) - { - return invalid_state( - "state.postgresql.max_connections must be between 1 and 256", - ); - } - if self - .postgresql - .root_certificate_path - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return invalid_state( - "state.postgresql.root_certificate_path must not be empty when set", - ); - } - if preauthorization_enabled { - validate_state_non_empty( - "state.postgresql.sensitive_state_key_env", - &self.postgresql.sensitive_state_key_env, - )?; - } - Ok(()) - } - STATE_STORAGE_IN_MEMORY => { - if deployment.profile != Some(crate::deployment::DeploymentProfile::Local) { - return invalid_state( - "state.storage = in_memory requires deployment.profile = local", - ); - } - if deployment.multi_instance { - return invalid_state( - "state.storage = in_memory requires deployment.multi_instance = false", - ); - } - Ok(()) - } - _ => invalid_state("state.storage must be postgresql or in_memory"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct StatePostgresqlConfig { - #[serde(default = "default_state_postgresql_url_env")] - pub url_env: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub root_certificate_path: Option, - #[serde(default = "default_state_postgresql_connect_timeout_ms")] - pub connect_timeout_ms: u64, - #[serde(default = "default_state_postgresql_operation_timeout_ms")] - pub operation_timeout_ms: u64, - #[serde(default = "default_state_postgresql_max_connections")] - pub max_connections: usize, - #[serde(default = "default_sensitive_state_key_env")] - pub sensitive_state_key_env: String, -} - -impl Default for StatePostgresqlConfig { - fn default() -> Self { - Self { - url_env: default_state_postgresql_url_env(), - root_certificate_path: None, - connect_timeout_ms: default_state_postgresql_connect_timeout_ms(), - operation_timeout_ms: default_state_postgresql_operation_timeout_ms(), - max_connections: default_state_postgresql_max_connections(), - sensitive_state_key_env: default_sensitive_state_key_env(), - } - } -} - -pub(super) fn state_config_is_default(config: &StateConfig) -> bool { - config == &StateConfig::default() -} - -pub(super) fn state_postgresql_config_is_default(config: &StatePostgresqlConfig) -> bool { - config == &StatePostgresqlConfig::default() -} - -pub(super) fn default_state_storage() -> String { - STATE_STORAGE_POSTGRESQL.to_string() -} - -pub(super) fn default_state_postgresql_url_env() -> String { - "REGISTRY_NOTARY_POSTGRES_URL".to_string() -} - -pub(super) const fn default_state_postgresql_connect_timeout_ms() -> u64 { - 5_000 -} - -pub(super) const fn default_state_postgresql_operation_timeout_ms() -> u64 { - 2_000 -} - -pub(super) const fn default_state_postgresql_max_connections() -> usize { - 16 -} - -pub(super) fn default_sensitive_state_key_env() -> String { - "REGISTRY_NOTARY_SENSITIVE_STATE_KEY".to_string() -} - -fn validate_state_non_empty(field: &str, value: &str) -> Result<(), EvidenceConfigError> { - if value.trim().is_empty() { - return invalid_state(format!("{field} must not be empty")); - } - Ok(()) -} - -fn invalid_state(reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidStateConfig { - reason: reason.into(), - }) -} diff --git a/crates/registry-notary-core/src/config/subject_access.rs b/crates/registry-notary-core/src/config/subject_access.rs deleted file mode 100644 index be9bc802a..000000000 --- a/crates/registry-notary-core/src/config/subject_access.rs +++ /dev/null @@ -1,1204 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Subject-bound and delegated subject-access configuration. - -use super::*; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub subject_binding: SubjectAccessSubjectBindingConfig, - #[serde(default)] - pub citizen_clients: SubjectAccessCitizenClientsConfig, - #[serde(default)] - pub token_policy: SubjectAccessTokenPolicyConfig, - #[serde(default)] - pub allowed_operations: SubjectAccessOperationsConfig, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub allowed_formats: Vec, - #[serde(default)] - pub allowed_disclosures: Vec, - #[serde(default)] - pub scope_policy: SubjectAccessScopePolicy, - #[serde(default)] - pub required_scopes: Vec, - #[serde(default)] - pub allowed_wallet_origins: Vec, - #[serde(default)] - pub credential_profiles: Vec, - #[serde(default)] - pub delegation: SubjectAccessDelegationConfig, - #[serde(default)] - pub rate_limits: SubjectAccessRateLimitsConfig, -} - -pub(super) fn subject_access_config_is_default(config: &SubjectAccessConfig) -> bool { - config == &SubjectAccessConfig::default() -} - -impl SubjectAccessConfig { - pub(super) fn validate( - &self, - auth: &EvidenceAuthConfig, - evidence: &EvidenceConfig, - ) -> Result<(), EvidenceConfigError> { - if !self.enabled { - return Ok(()); - } - let oidc = - auth.oidc - .as_ref() - .ok_or_else(|| EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "enabled subject_access requires auth.oidc".to_string(), - })?; - - self.subject_binding.validate()?; - self.citizen_clients.validate(oidc)?; - self.token_policy.validate(oidc)?; - if self.subject_binding.claim_source == SubjectAccessClaimSource::Userinfo - && oidc - .userinfo_endpoint - .as_deref() - .unwrap_or_default() - .is_empty() - { - return self.invalid( - "subject_binding.claim_source = userinfo requires auth.oidc.userinfo_endpoint", - ); - } - self.allowed_operations.validate()?; - self.delegation - .validate(evidence, &self.subject_binding.id_type)?; - if self.allowed_claims.is_empty() { - if !self.delegation.enabled { - return self.invalid( - "subject_access.allowed_claims must not be empty unless delegation is enabled", - ); - } - if !self.allowed_purposes.is_empty() - || !self.allowed_formats.is_empty() - || !self.allowed_disclosures.is_empty() - { - return self.invalid( - "delegation-only subject_access requires allowed_purposes, allowed_formats, and allowed_disclosures to be empty; configure them on each delegated relationship", - ); - } - } else { - validate_non_empty_entries("subject_access.allowed_purposes", &self.allowed_purposes)?; - validate_non_empty_entries("subject_access.allowed_formats", &self.allowed_formats)?; - validate_non_empty_entries( - "subject_access.allowed_disclosures", - &self.allowed_disclosures, - )?; - } - validate_entries("subject_access.allowed_claims", &self.allowed_claims)?; - if self.scope_policy != SubjectAccessScopePolicy::Disabled - && self.required_scopes.is_empty() - { - return self.invalid("scope_policy requires required_scopes unless it is disabled"); - } - if self.scope_policy == SubjectAccessScopePolicy::Disabled - && !self.required_scopes.is_empty() - { - return self.invalid("scope_policy = disabled requires required_scopes to be empty"); - } - if self.scope_policy != SubjectAccessScopePolicy::Disabled { - validate_non_empty_entries("subject_access.required_scopes", &self.required_scopes)?; - } else { - validate_entries("subject_access.required_scopes", &self.required_scopes)?; - } - validate_non_empty_entries( - "subject_access.credential_profiles", - &self.credential_profiles, - )?; - for relationship in &self.delegation.allowed_relationships { - if relationship.max_proof_age_seconds > self.token_policy.max_evaluation_age_seconds { - return self.invalid(format!( - "subject_access.delegation relationship '{}' max_proof_age_seconds must not exceed token_policy.max_evaluation_age_seconds", - relationship.relationship_type - )); - } - } - self.rate_limits.validate()?; - validate_exact_wallet_origins(&self.allowed_wallet_origins)?; - - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let allowed_claim_ids: HashSet<&str> = - self.allowed_claims.iter().map(String::as_str).collect(); - let subject_access_claim_ids: HashSet<&str> = allowed_claim_ids - .iter() - .copied() - .chain( - self.delegation - .allowed_relationships - .iter() - .flat_map(|relationship| { - relationship.allowed_claims.iter().map(String::as_str) - }), - ) - .collect(); - let allowed_purposes: HashSet<&str> = - self.allowed_purposes.iter().map(String::as_str).collect(); - let allowed_formats: HashSet<&str> = - self.allowed_formats.iter().map(String::as_str).collect(); - let allowed_disclosures: HashSet<&str> = self - .allowed_disclosures - .iter() - .map(String::as_str) - .collect(); - let allowed_profiles: HashSet<&str> = self - .credential_profiles - .iter() - .map(String::as_str) - .collect(); - - for claim_id in &self.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("allowed_claims references unknown claim '{claim_id}'"), - }); - } - } - - for profile_id in &self.credential_profiles { - if !evidence.credential_profiles.contains_key(profile_id) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "credential_profiles references unknown profile '{profile_id}'" - ), - }); - } - } - - for profile_id in &self.credential_profiles { - let profile = evidence - .credential_profiles - .get(profile_id) - // SAFETY: the preceding credential_profiles loop rejects - // every profile id missing from evidence.credential_profiles. - .expect("profile id was checked above"); - validate_subject_access_profile( - profile_id, - profile, - &claim_ids, - &subject_access_claim_ids, - self.token_policy.max_credential_validity_seconds, - )?; - } - - for claim_id in &self.allowed_claims { - let claim = evidence - .claims - .iter() - .find(|claim| claim.id == *claim_id) - // SAFETY: the preceding allowed_claims loop rejects every - // claim id missing from evidence.claims. - .expect("claim id was checked above"); - validate_subject_access_claim( - claim, - &allowed_purposes, - &allowed_formats, - &allowed_disclosures, - &allowed_profiles, - self.allowed_operations.issue_credential, - )?; - validate_subject_bound_registry_inputs(claim, &self.subject_binding.id_type)?; - } - - validate_subject_access_allow_lists_are_supported(self, evidence)?; - if self.scope_policy != SubjectAccessScopePolicy::Disabled { - validate_required_scope_mappings(self, oidc)?; - } - Ok(()) - } - - fn invalid(&self, reason: impl Into) -> Result { - Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: reason.into(), - }) - } -} - -fn validate_subject_bound_registry_inputs( - claim: &ClaimDefinition, - subject_id_type: &str, -) -> Result<(), EvidenceConfigError> { - let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode else { - return Ok(()); - }; - let target_path = format!("target.identifiers.{subject_id_type}"); - let requester_path = format!("requester.identifiers.{subject_id_type}"); - for consultation in consultations.values() { - for input in consultation.inputs.values() { - let path = input.request_context_path(); - if path != target_path && path != requester_path { - return invalid_subject_access(format!( - "allowed registry_backed claim '{}' maps Relay input '{}' outside the authenticated subject binding; expected '{}' or '{}'", - claim.id, path, target_path, requester_path - )); - } - } - } - Ok(()) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessDelegationConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub allowed_relationships: Vec, -} - -impl SubjectAccessDelegationConfig { - fn validate( - &self, - evidence: &EvidenceConfig, - requester_id_type: &str, - ) -> Result<(), EvidenceConfigError> { - if !self.enabled { - if !self.allowed_relationships.is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: - "subject_access.delegation.enabled=false requires allowed_relationships to be empty" - .to_string(), - }); - } - return Ok(()); - } - if self.allowed_relationships.is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "subject_access.delegation.enabled requires allowed_relationships" - .to_string(), - }); - } - let claim_ids: HashSet<&str> = evidence - .claims - .iter() - .map(|claim| claim.id.as_str()) - .collect(); - let mut relationship_types = HashSet::new(); - for relationship in &self.allowed_relationships { - relationship.validate(evidence, &claim_ids, requester_id_type)?; - if !relationship_types.insert(relationship.relationship_type.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation.allowed_relationships contains duplicate relationship_type '{}'", - relationship.relationship_type - ), - }); - } - } - Ok(()) - } - - #[must_use] - pub fn relationship( - &self, - relationship_type: &str, - ) -> Option<&SubjectAccessDelegatedRelationshipConfig> { - self.allowed_relationships - .iter() - .find(|relationship| relationship.relationship_type == relationship_type) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessDelegatedRelationshipConfig { - pub relationship_type: String, - pub proof_claim: String, - #[serde(default)] - pub target_id_type: Option, - #[serde(default = "default_delegated_max_proof_age_seconds")] - #[schemars(range(min = 1, max = 600))] - pub max_proof_age_seconds: u64, - #[serde(default)] - pub allowed_claims: Vec, - #[serde(default)] - pub allowed_purposes: Vec, - #[serde(default)] - pub allowed_formats: Vec, - #[serde(default)] - pub allowed_disclosures: Vec, -} - -const fn default_delegated_max_proof_age_seconds() -> u64 { - 300 -} - -impl SubjectAccessDelegatedRelationshipConfig { - fn validate( - &self, - evidence: &EvidenceConfig, - claim_ids: &HashSet<&str>, - requester_id_type: &str, - ) -> Result<(), EvidenceConfigError> { - if self.relationship_type.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: - "subject_access.delegation.allowed_relationships.relationship_type is required" - .to_string(), - }); - } - if self.proof_claim.trim().is_empty() || !claim_ids.contains(self.proof_claim.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation proof_claim references unknown claim '{}'", - self.proof_claim - ), - }); - } - let Some(proof_claim) = evidence - .claims - .iter() - .find(|claim| claim.id == self.proof_claim) - else { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation proof_claim references unknown claim '{}'", - self.proof_claim - ), - }); - }; - validate_delegated_proof_claim_binding(self, proof_claim)?; - if let Some(target_id_type) = self.target_id_type.as_deref() { - if target_id_type.trim().is_empty() { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: "subject_access.delegation target_id_type must not be blank" - .to_string(), - }); - } - } - if self.max_proof_age_seconds == 0 || self.max_proof_age_seconds > 600 { - return invalid_subject_access( - "subject_access.delegation.allowed_relationships.max_proof_age_seconds must be between 1 and 600", - ); - } - validate_non_empty_entries( - "subject_access.delegation.allowed_claims", - &self.allowed_claims, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_purposes", - &self.allowed_purposes, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_formats", - &self.allowed_formats, - )?; - validate_non_empty_entries( - "subject_access.delegation.allowed_disclosures", - &self.allowed_disclosures, - )?; - let allowed_purposes: HashSet<&str> = - self.allowed_purposes.iter().map(String::as_str).collect(); - let allowed_formats: HashSet<&str> = - self.allowed_formats.iter().map(String::as_str).collect(); - let allowed_disclosures: HashSet<&str> = self - .allowed_disclosures - .iter() - .map(String::as_str) - .collect(); - for claim_id in &self.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation allowed_claims references unknown claim '{claim_id}'" - ), - }); - } - let Some(claim) = evidence.claims.iter().find(|claim| claim.id == *claim_id) else { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "subject_access.delegation allowed_claims references unknown claim '{claim_id}'" - ), - }); - }; - if !claim.depends_on.iter().any(|dep| dep == &self.proof_claim) { - return Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "delegated claim '{claim_id}' must depend_on proof_claim '{}'", - self.proof_claim - ), - }); - } - if claim.purpose != proof_claim.purpose { - return invalid_subject_access(format!( - "delegated claim '{claim_id}' and proof_claim '{}' must declare the same purpose", - self.proof_claim - )); - } - validate_delegated_subject_access_claim( - claim, - &allowed_purposes, - &allowed_formats, - &allowed_disclosures, - )?; - validate_delegated_registry_closure_inputs( - &self.relationship_type, - claim, - &self.proof_claim, - evidence, - requester_id_type, - self.target_id_type.as_deref().unwrap_or(requester_id_type), - )?; - } - validate_delegated_subject_access_allow_lists_are_supported(self, evidence)?; - Ok(()) - } -} - -fn validate_delegated_registry_closure_inputs( - relationship_type: &str, - root: &ClaimDefinition, - proof_claim_id: &str, - evidence: &EvidenceConfig, - requester_id_type: &str, - target_id_type: &str, -) -> Result<(), EvidenceConfigError> { - let requester_path = format!("requester.identifiers.{requester_id_type}"); - let target_path = format!("target.identifiers.{target_id_type}"); - let mut pending = vec![root]; - let mut visited = BTreeSet::new(); - - while let Some(claim) = pending.pop() { - if !visited.insert(claim.id.as_str()) || claim.id == proof_claim_id { - continue; - } - if let ClaimEvidenceMode::RegistryBacked { consultations } = &claim.evidence_mode { - for (consultation_name, consultation) in consultations { - let mut consumes_target = false; - for (input_name, input) in &consultation.inputs { - let path = input.request_context_path(); - if path != requester_path && path != target_path { - return invalid_subject_access(format!( - "delegated relationship '{relationship_type}' allowed claim '{}' closure claim '{}' consultation '{consultation_name}' input '{input_name}' maps non-canonical path '{path}'; allowed canonical paths are '{requester_path}' and '{target_path}'", - root.id, claim.id - )); - } - consumes_target |= path == target_path; - } - if !consumes_target { - let actual_inputs = consultation - .inputs - .iter() - .map(|(input_name, input)| { - format!("'{input_name}' maps '{}'", input.request_context_path()) - }) - .collect::>() - .join(", "); - return invalid_subject_access(format!( - "delegated relationship '{relationship_type}' allowed claim '{}' closure claim '{}' consultation '{consultation_name}' does not consume the selected target; {actual_inputs}; required target path is '{target_path}' and the only additional allowed canonical path is '{requester_path}'", - root.id, claim.id - )); - } - } - } - for dependency_id in &claim.depends_on { - let dependency = evidence - .claims - .iter() - .find(|candidate| candidate.id == *dependency_id) - .ok_or_else(|| EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "delegated relationship '{relationship_type}' allowed claim '{}' dependency closure references unknown claim '{dependency_id}'", - root.id - ), - })?; - pending.push(dependency); - } - } - Ok(()) -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessScopePolicy { - #[default] - Required, - Optional, - Disabled, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessSubjectBindingConfig { - #[serde(default)] - pub token_claim: String, - #[serde(default)] - pub claim_source: SubjectAccessClaimSource, - #[serde(default)] - pub request_field: SubjectId, - #[serde(default)] - pub id_type: String, - #[serde(default)] - pub normalize: SubjectBindingNormalize, - #[serde(default)] - pub allow_sub_as_civil_id: bool, -} - -impl SubjectAccessSubjectBindingConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.token_claim.is_empty() { - return invalid_subject_access("subject_binding.token_claim must not be empty"); - } - if !self - .token_claim - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | ':' | '/' | '.' | '-')) - { - return invalid_subject_access( - "subject_binding.token_claim must match [A-Za-z0-9_:/\\.\\-]+", - ); - } - if self.token_claim == "sub" && !self.allow_sub_as_civil_id { - return invalid_subject_access( - "subject_binding.token_claim = sub requires allow_sub_as_civil_id = true", - ); - } - if self.id_type.trim().is_empty() { - return invalid_subject_access("subject_binding.id_type must not be empty"); - } - if self.normalize != SubjectBindingNormalize::Exact { - return invalid_subject_access("subject_binding.normalize must be exact"); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessClaimSource { - #[default] - AccessToken, - Userinfo, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub enum SubjectId { - #[default] - SubjectId, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectBindingNormalize { - #[default] - Exact, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessCitizenClientsConfig { - #[serde(default)] - pub allowed_client_ids: Vec, - #[serde(default)] - pub allowed_audiences: Vec, -} - -impl SubjectAccessCitizenClientsConfig { - fn validate(&self, oidc: &EvidenceOidcAuthConfig) -> Result<(), EvidenceConfigError> { - if self.allowed_client_ids.is_empty() && self.allowed_audiences.is_empty() { - return invalid_subject_access( - "citizen_clients must list at least one allowed client id or audience", - ); - } - validate_entries( - "subject_access.citizen_clients.allowed_client_ids", - &self.allowed_client_ids, - )?; - validate_entries( - "subject_access.citizen_clients.allowed_audiences", - &self.allowed_audiences, - )?; - for audience in &self.allowed_audiences { - if !oidc.audiences.iter().any(|accepted| accepted == audience) { - return invalid_subject_access(format!( - "citizen audience '{audience}' is not listed in auth.oidc.audiences" - )); - } - } - if !oidc.allowed_clients.is_empty() { - for client_id in &self.allowed_client_ids { - if !oidc - .allowed_clients - .iter() - .any(|accepted| accepted == client_id) - { - return invalid_subject_access(format!( - "citizen client '{client_id}' is not listed in auth.oidc.allowed_clients" - )); - } - } - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessTokenPolicyConfig { - #[serde(default)] - pub required_acr_values: Vec, - #[serde(default)] - pub assurance_claim_source: SubjectAccessAssuranceClaimSource, - #[serde(default)] - pub max_auth_age_seconds: u64, - #[serde(default)] - pub max_access_token_lifetime_seconds: u64, - #[serde(default)] - pub max_evaluation_age_seconds: u64, - #[serde(default)] - pub max_credential_validity_seconds: u64, - #[serde(default)] - pub max_clock_leeway_seconds: u64, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum SubjectAccessAssuranceClaimSource { - #[default] - AccessToken, - IdToken, -} - -impl SubjectAccessTokenPolicyConfig { - fn validate(&self, oidc: &EvidenceOidcAuthConfig) -> Result<(), EvidenceConfigError> { - validate_entries( - "subject_access.token_policy.required_acr_values", - &self.required_acr_values, - )?; - if self.max_auth_age_seconds == 0 { - return invalid_subject_access( - "token_policy.max_auth_age_seconds must be greater than zero", - ); - } - if self.max_access_token_lifetime_seconds == 0 { - return invalid_subject_access( - "token_policy.max_access_token_lifetime_seconds must be greater than zero", - ); - } - if self.max_evaluation_age_seconds == 0 || self.max_evaluation_age_seconds > 600 { - return invalid_subject_access( - "token_policy.max_evaluation_age_seconds must be between 1 and 600", - ); - } - if self.max_credential_validity_seconds == 0 { - return invalid_subject_access( - "token_policy.max_credential_validity_seconds must be greater than zero", - ); - } - if self.max_clock_leeway_seconds == 0 || self.max_clock_leeway_seconds > 60 { - return invalid_subject_access( - "token_policy.max_clock_leeway_seconds must be between 1 and 60", - ); - } - if oidc.leeway > Duration::from_secs(self.max_clock_leeway_seconds) { - return invalid_subject_access( - "auth.oidc.leeway must not exceed token_policy.max_clock_leeway_seconds", - ); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessOperationsConfig { - #[serde(default)] - pub evaluate: bool, - #[serde(default)] - pub render: bool, - #[serde(default)] - pub issue_credential: bool, - #[serde(default)] - pub batch_evaluate: bool, -} - -impl SubjectAccessOperationsConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.batch_evaluate { - return invalid_subject_access("allowed_operations.batch_evaluate must be false in v1"); - } - if !self.evaluate && !self.render && !self.issue_credential { - return invalid_subject_access( - "allowed_operations must enable at least one subject-access operation", - ); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectAccessRateLimitsConfig { - #[serde(default)] - pub invalid_token_per_client_address_per_minute: u32, - #[serde(default)] - pub per_principal_per_minute: u32, - #[serde(default)] - pub subject_mismatch_per_principal_per_hour: u32, - #[serde(default)] - pub per_holder_per_hour: u32, - #[serde(default)] - pub credential_issuance_per_principal_per_hour: u32, - /// Per-minute cap on `tx_code` attempts against a single - /// `pre-authorized_code`. Bounds brute force of the numeric PIN at the - /// pre-authorized-code token endpoint. Defaults to zero so existing - /// configs that do not enable pre-auth still validate; it must be greater - /// than zero only when the pre-authorized-code flow is enabled. - #[serde(default)] - pub tx_code_attempts_per_code_per_minute: u32, -} - -impl SubjectAccessRateLimitsConfig { - fn validate(&self) -> Result<(), EvidenceConfigError> { - if self.invalid_token_per_client_address_per_minute == 0 - || self.per_principal_per_minute == 0 - || self.subject_mismatch_per_principal_per_hour == 0 - || self.per_holder_per_hour == 0 - || self.credential_issuance_per_principal_per_hour == 0 - { - return invalid_subject_access("rate_limits values must all be greater than zero"); - } - Ok(()) - } -} - -pub(super) fn validate_non_empty_entries( - name: &str, - values: &[String], -) -> Result<(), EvidenceConfigError> { - if values.is_empty() { - return invalid_subject_access(format!("{name} must not be empty")); - } - validate_entries(name, values) -} - -pub(super) fn validate_entries(name: &str, values: &[String]) -> Result<(), EvidenceConfigError> { - if values.iter().any(|value| value.trim().is_empty()) { - return invalid_subject_access(format!("{name} must not contain blank entries")); - } - Ok(()) -} - -pub(super) fn validate_exact_wallet_origins(origins: &[String]) -> Result<(), EvidenceConfigError> { - for origin in origins { - if origin == "*" || origin.contains('*') { - return invalid_subject_access( - "allowed_wallet_origins must contain exact origins, not wildcards", - ); - } - if !origin.starts_with("https://") { - return invalid_subject_access("allowed_wallet_origins must use https origins"); - } - } - Ok(()) -} - -pub(super) fn validate_subject_access_claim( - claim: &ClaimDefinition, - allowed_purposes: &HashSet<&str>, - allowed_formats: &HashSet<&str>, - allowed_disclosures: &HashSet<&str>, - allowed_profiles: &HashSet<&str>, - issue_credential: bool, -) -> Result<(), EvidenceConfigError> { - if !claim.operations.evaluate.enabled { - return invalid_subject_access(format!( - "allowed claim '{}' must enable evaluate", - claim.id - )); - } - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("allowed claim '{}' must declare purpose", claim.id), - } - })?; - if !allowed_purposes.contains(purpose) { - return invalid_subject_access(format!( - "allowed claim '{}' declares unallowed purpose '{}'", - claim.id, purpose - )); - } - if !claim - .formats - .iter() - .any(|format| allowed_formats.contains(format.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must support at least one allowed format", - claim.id - )); - } - if !claim - .disclosure - .allowed - .iter() - .any(|disclosure| allowed_disclosures.contains(disclosure.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must support at least one allowed disclosure", - claim.id - )); - } - if issue_credential - && !claim - .credential_profiles - .iter() - .any(|profile| allowed_profiles.contains(profile.as_str())) - { - return invalid_subject_access(format!( - "allowed claim '{}' must reference an allowed credential profile", - claim.id - )); - } - Ok(()) -} - -pub(super) fn validate_subject_access_profile( - profile_id: &str, - profile: &CredentialProfileConfig, - claim_ids: &HashSet<&str>, - allowed_claim_ids: &HashSet<&str>, - max_credential_validity_seconds: u64, -) -> Result<(), EvidenceConfigError> { - if profile.validity_seconds <= 0 { - return invalid_subject_access(format!( - "credential profile '{profile_id}' validity_seconds must be greater than zero" - )); - } - let validity_seconds = u64::try_from(profile.validity_seconds).map_err(|_| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!( - "credential profile '{profile_id}' validity_seconds must be greater than zero" - ), - } - })?; - if validity_seconds > max_credential_validity_seconds { - return invalid_subject_access(format!( - "credential profile '{profile_id}' validity_seconds must not exceed the subject-access ceiling" - )); - } - if profile.holder_binding.mode != "did" { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.mode must be did" - )); - } - if profile.holder_binding.proof_of_possession.as_deref() != Some("required") { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.proof_of_possession must be required" - )); - } - if profile.holder_binding.allowed_did_methods.is_empty() - || profile - .holder_binding - .allowed_did_methods - .iter() - .any(|method| method != SD_JWT_VC_HOLDER_BINDING_METHOD) - { - return invalid_subject_access(format!( - "credential profile '{profile_id}' holder_binding.allowed_did_methods must only contain did:jwk" - )); - } - for claim_id in &profile.allowed_claims { - if !claim_ids.contains(claim_id.as_str()) { - return invalid_subject_access(format!( - "credential profile '{profile_id}' references unknown claim '{claim_id}'" - )); - } - } - if !profile - .allowed_claims - .iter() - .any(|claim_id| allowed_claim_ids.contains(claim_id.as_str())) - { - return invalid_subject_access(format!( - "credential profile '{profile_id}' must allow at least one subject-access claim" - )); - } - Ok(()) -} - -pub(super) fn validate_subject_access_allow_lists_are_supported( - config: &SubjectAccessConfig, - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - let allowed_claims: Vec<&ClaimDefinition> = config - .allowed_claims - .iter() - .filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id)) - .collect(); - let allowed_profiles: Vec<&CredentialProfileConfig> = config - .credential_profiles - .iter() - .filter_map(|profile_id| evidence.credential_profiles.get(profile_id)) - .collect(); - - for purpose in &config.allowed_purposes { - if !allowed_claims - .iter() - .any(|claim| claim.purpose.as_deref() == Some(purpose.as_str())) - { - return invalid_subject_access(format!( - "allowed_purposes entry '{purpose}' is not used by any allowed claim" - )); - } - } - - for format in &config.allowed_formats { - let supported_by_claim = allowed_claims - .iter() - .any(|claim| claim.formats.iter().any(|candidate| candidate == format)); - if !supported_by_claim { - return invalid_subject_access(format!( - "allowed_formats entry '{format}' is not supported by any allowed claim" - )); - } - } - - for disclosure in &config.allowed_disclosures { - let supported_by_claim = allowed_claims.iter().any(|claim| { - claim - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - let supported_by_profile = allowed_profiles.iter().any(|profile| { - profile - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - if !supported_by_claim && !supported_by_profile { - return invalid_subject_access(format!( - "allowed_disclosures entry '{disclosure}' is not supported by any allowed claim or profile" - )); - } - } - - Ok(()) -} - -pub(super) fn validate_delegated_proof_claim_binding( - relationship: &SubjectAccessDelegatedRelationshipConfig, - proof_claim: &ClaimDefinition, -) -> Result<(), EvidenceConfigError> { - if !proof_claim.depends_on.is_empty() { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must not depend_on other claims so the relationship is proven before the delegated claim closure runs", - relationship.proof_claim - )); - } - let ClaimEvidenceMode::RegistryBacked { consultations } = &proof_claim.evidence_mode else { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must be registry_backed", - relationship.proof_claim - )); - }; - let Some((_, consultation)) = consultations - .first_key_value() - .filter(|_| consultations.len() == 1) - else { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must declare exactly one Relay consultation", - relationship.proof_claim - )); - }; - let has_requester = consultation - .inputs - .values() - .any(RelayConsultationInput::is_requester_derived); - let has_target = consultation - .inputs - .values() - .any(RelayConsultationInput::is_authenticated_target_identifier); - if !has_requester || !has_target { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must map both a requester-derived input and an authenticated target identifier", - relationship.proof_claim - )); - } - if proof_claim.value.value_type != "boolean" { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must produce a boolean result", - relationship.proof_claim - )); - } - if proof_claim.purpose.as_deref().is_none() { - return invalid_subject_access(format!( - "delegated proof_claim '{}' must declare purpose", - relationship.proof_claim - )); - } - Ok(()) -} - -pub(super) fn validate_delegated_subject_access_claim( - claim: &ClaimDefinition, - allowed_purposes: &HashSet<&str>, - allowed_formats: &HashSet<&str>, - allowed_disclosures: &HashSet<&str>, -) -> Result<(), EvidenceConfigError> { - if !claim.operations.evaluate.enabled { - return invalid_subject_access(format!( - "delegated claim '{}' must enable evaluate", - claim.id - )); - } - let purpose = claim.purpose.as_deref().ok_or_else(|| { - EvidenceConfigError::InvalidSubjectAccessConfig { - reason: format!("delegated claim '{}' must declare purpose", claim.id), - } - })?; - if !allowed_purposes.contains(purpose) { - return invalid_subject_access(format!( - "delegated claim '{}' declares unallowed purpose '{}'", - claim.id, purpose - )); - } - if !claim - .formats - .iter() - .any(|format| allowed_formats.contains(format.as_str())) - { - return invalid_subject_access(format!( - "delegated claim '{}' must support at least one allowed format", - claim.id - )); - } - if !claim - .disclosure - .allowed - .iter() - .any(|disclosure| allowed_disclosures.contains(disclosure.as_str())) - { - return invalid_subject_access(format!( - "delegated claim '{}' must support at least one allowed disclosure", - claim.id - )); - } - Ok(()) -} - -pub(super) fn validate_delegated_subject_access_allow_lists_are_supported( - relationship: &SubjectAccessDelegatedRelationshipConfig, - evidence: &EvidenceConfig, -) -> Result<(), EvidenceConfigError> { - let allowed_claims: Vec<&ClaimDefinition> = relationship - .allowed_claims - .iter() - .filter_map(|claim_id| evidence.claims.iter().find(|claim| claim.id == *claim_id)) - .collect(); - for purpose in &relationship.allowed_purposes { - if !allowed_claims - .iter() - .any(|claim| claim.purpose.as_deref() == Some(purpose.as_str())) - { - return invalid_subject_access(format!( - "subject_access.delegation allowed_purposes entry '{purpose}' is not used by any allowed claim" - )); - } - } - - for format in &relationship.allowed_formats { - let supported_by_claim = allowed_claims - .iter() - .any(|claim| claim.formats.iter().any(|candidate| candidate == format)); - if !supported_by_claim { - return invalid_subject_access(format!( - "subject_access.delegation allowed_formats entry '{format}' is not supported by any allowed claim" - )); - } - } - - for disclosure in &relationship.allowed_disclosures { - let supported_by_claim = allowed_claims.iter().any(|claim| { - claim - .disclosure - .allowed - .iter() - .any(|candidate| candidate == disclosure) - }); - if !supported_by_claim { - return invalid_subject_access(format!( - "subject_access.delegation allowed_disclosures entry '{disclosure}' is not supported by any allowed claim" - )); - } - } - - Ok(()) -} - -pub(super) fn validate_required_scope_mappings( - config: &SubjectAccessConfig, - oidc: &EvidenceOidcAuthConfig, -) -> Result<(), EvidenceConfigError> { - let required_scopes: HashSet<&str> = - config.required_scopes.iter().map(String::as_str).collect(); - for scope in &required_scopes { - if !oidc - .scope_map - .values() - .any(|mapped_scopes| mapped_scopes.iter().any(|mapped| mapped == scope)) - { - return invalid_subject_access(format!( - "required scope '{scope}' must be present in auth.oidc.scope_map" - )); - } - } - - Ok(()) -} - -pub(super) fn invalid_subject_access( - reason: impl Into, -) -> Result { - Err(EvidenceConfigError::InvalidSubjectAccessConfig { - reason: reason.into(), - }) -} - -pub(super) fn detect_depends_on_cycle( - claims: &[ClaimDefinition], - claim_id: &str, - grey: &mut HashSet, - black: &mut HashSet, - path: &mut Vec, -) -> Result<(), EvidenceConfigError> { - grey.insert(claim_id.to_string()); - path.push(claim_id.to_string()); - let claim = claims.iter().find(|c| c.id == claim_id); - if let Some(claim) = claim { - for dep in &claim.depends_on { - if grey.contains(dep.as_str()) { - // Back edge found: build the cycle path from where dep appears. - let cycle_start = path.iter().position(|id| id == dep).unwrap_or(0); - let mut cycle = path[cycle_start..].to_vec(); - cycle.push(dep.clone()); - return Err(EvidenceConfigError::DependsOnCycle { cycle }); - } - if !black.contains(dep.as_str()) { - detect_depends_on_cycle(claims, dep, grey, black, path)?; - } - } - } - path.pop(); - grey.remove(claim_id); - black.insert(claim_id.to_string()); - Ok(()) -} diff --git a/crates/registry-notary-core/src/config/tests.rs b/crates/registry-notary-core/src/config/tests.rs deleted file mode 100644 index 409c050b9..000000000 --- a/crates/registry-notary-core/src/config/tests.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub(super) use super::*; -mod auth; -mod credentials; -mod infrastructure; -mod issuance; -mod preauth; -mod relay; -mod root; -mod support; diff --git a/crates/registry-notary-core/src/config/tests/auth.rs b/crates/registry-notary-core/src/config/tests/auth.rs deleted file mode 100644 index 1ca032028..000000000 --- a/crates/registry-notary-core/src/config/tests/auth.rs +++ /dev/null @@ -1,319 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{credentials::*, infrastructure::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn at_least_one_authenticator_is_required() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.bearer_tokens.clear(); - config.auth.oidc = None; - - let err = config - .validate() - .expect_err("an empty auth configuration must fail"); - - assert!(matches!(err, EvidenceConfigError::NoCredentialsConfigured)); -} - -#[test] -pub(super) fn oidc_auth_validates_required_settings() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "https://issuer.example/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn duplicate_static_credential_api_key_id_rejected() { - let mut config = minimal_config(); - let duplicate = config.auth.api_keys[0].clone(); - config.auth.api_keys.push(duplicate); - - let reason = match config - .validate() - .expect_err("duplicate API key id must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.api_keys") && reason.contains("test-key"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn duplicate_static_credential_bearer_token_id_rejected() { - let mut config = minimal_config(); - let mut token = config.auth.api_keys[0].clone(); - token.id = "shared-bearer-token".to_string(); - config.auth.bearer_tokens.push(token.clone()); - config.auth.bearer_tokens.push(token); - - let reason = match config - .validate() - .expect_err("duplicate bearer token id must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.bearer_tokens") && reason.contains("shared-bearer-token"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn duplicate_static_credential_id_across_api_key_and_bearer_token_rejected() { - let mut config = minimal_config(); - config - .auth - .bearer_tokens - .push(config.auth.api_keys[0].clone()); - - let reason = match config - .validate() - .expect_err("duplicate static credential id across types must fail validation") - { - EvidenceConfigError::InvalidAuthConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - - assert!( - reason.contains("auth.bearer_tokens") && reason.contains("test-key"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn oidc_jwks_url_must_use_https() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "http://issuer.example/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - let err = config - .validate() - .expect_err("remote http jwks_url must fail validation"); - match err { - EvidenceConfigError::InvalidOidcConfig { reason } => { - assert!( - reason.contains("jwks_url must use https"), - "unexpected: {reason}" - ); - } - other => panic!("unexpected error variant: {other}"), - } - - config - .auth - .oidc - .as_mut() - .expect("oidc config exists") - .allow_insecure_localhost = true; - let err = config - .validate() - .expect_err("allow_insecure_localhost must not permit remote http"); - assert!(matches!(err, EvidenceConfigError::InvalidOidcConfig { .. })); -} - -#[test] -pub(super) fn oidc_jwks_url_allows_insecure_localhost_only_when_enabled() { - let mut config = minimal_config(); - config.auth.api_keys.clear(); - config.auth.oidc = Some(EvidenceOidcAuthConfig { - issuer: "https://issuer.example".to_string(), - jwks_url: "http://127.0.0.1:8080/jwks.json".to_string(), - userinfo_endpoint: None, - userinfo_issuers: Vec::new(), - audiences: vec!["registry-notary".to_string()], - allowed_clients: vec!["registry-client".to_string()], - allowed_algorithms: vec!["EdDSA".to_string()], - allowed_token_types: vec!["JWT".to_string()], - scope_claim: "scope".to_string(), - scope_separator: " ".to_string(), - scope_map: BTreeMap::new(), - principal_claim: "sub".to_string(), - leeway: Duration::from_secs(60), - allow_insecure_localhost: false, - }); - - let err = config - .validate() - .expect_err("localhost http jwks_url needs explicit opt-in"); - assert!(matches!(err, EvidenceConfigError::InvalidOidcConfig { .. })); - - config - .auth - .oidc - .as_mut() - .expect("oidc config exists") - .allow_insecure_localhost = true; - config - .validate() - .expect("localhost http jwks_url is allowed only with the opt-in"); -} - -#[test] -pub(super) fn api_key_plaintext_is_never_loaded_only_fingerprint() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - api_keys: - - id: test-key - token_env: TEST_TOKEN -"#, - ) - .expect_err("plaintext token_env is not part of the credential schema"); - - assert!( - err.to_string().contains("unknown field `token_env`"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn legacy_api_key_fingerprint_commitment_rejected() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH - commitment: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -"#, - ) - .expect_err("legacy fingerprint commitment must fail deserialization"); - - assert!( - err.to_string() - .contains("fingerprint.commitment was removed"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn legacy_bearer_token_fingerprint_commitment_rejected() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - bearer_tokens: - - id: test-bearer - fingerprint: - provider: env - name: TEST_BEARER_HASH - commitment: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -"#, - ) - .expect_err("legacy bearer token fingerprint commitment must fail deserialization"); - - assert!( - err.to_string() - .contains("fingerprint.commitment was removed"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn removed_auth_mode_is_rejected_at_parse_time() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - mode: oauth2 -"#, - ) - .expect_err("the removed auth mode field must fail deserialization"); - - let message = err.to_string(); - assert!( - message.contains("mode") || message.contains("unknown field"), - "unexpected error: {message}" - ); -} - -#[test] -pub(super) fn oidc_auth_allows_api_key_credentials() { - let mut config = valid_subject_access_config(); - config.auth.api_keys.push(EvidenceCredentialConfig { - id: "legacy-api-key".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("LEGACY_API_KEY_HASH".to_string()), - path: None, - }, - scopes: vec!["subject_access".to_string()], - authorization_details: None, - }); - - config - .validate() - .expect("OIDC and API-key authenticators use distinct carriers"); -} - -#[test] -pub(super) fn oidc_auth_rejects_static_bearer_credentials() { - let mut config = valid_subject_access_config(); - config.auth.bearer_tokens.push(EvidenceCredentialConfig { - id: "ambiguous-bearer".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("AMBIGUOUS_BEARER_HASH".to_string()), - path: None, - }, - scopes: vec!["subject_access".to_string()], - authorization_details: None, - }); - - let reason = match config - .validate() - .expect_err("OIDC and static bearer tokens share one carrier") - { - EvidenceConfigError::InvalidOidcConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("Authorization: Bearer")); -} diff --git a/crates/registry-notary-core/src/config/tests/credentials.rs b/crates/registry-notary-core/src/config/tests/credentials.rs deleted file mode 100644 index 29dc25eed..000000000 --- a/crates/registry-notary-core/src/config/tests/credentials.rs +++ /dev/null @@ -1,1111 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, infrastructure::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn proof_of_possession_required_with_only_did_jwk_is_valid() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - assert!( - config.validate().is_ok(), - "did:jwk only should pass validation" - ); -} - -#[test] -pub(super) fn credential_profile_format_must_use_current_sd_jwt_vc_media_type() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: sd_jwt_vc -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("legacy-alias".to_string(), profile); - - let err = config - .validate() - .expect_err("legacy profile format alias must fail validation"); - match err { - EvidenceConfigError::UnsupportedCredentialProfileFormat { profile, format } => { - assert_eq!(profile, "legacy-alias"); - assert_eq!(format, "sd_jwt_vc"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn credential_profile_default_validity_is_short_lived() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.validity_seconds, 600); -} - -#[test] -pub(super) fn credential_profile_default_holder_binding_is_did_jwk() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.holder_binding.mode, "did"); - assert_eq!( - profile.holder_binding.allowed_did_methods, - vec!["did:jwk".to_string()] - ); - assert!(profile.holder_binding.proof_of_possession.is_none()); -} - -#[test] -pub(super) fn credential_profile_can_explicitly_opt_out_of_holder_binding() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: none -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.holder_binding.mode, "none"); - assert_eq!( - profile.holder_binding.allowed_did_methods, - vec!["did:jwk".to_string()] - ); -} - -#[test] -pub(super) fn credential_profile_explicit_validity_is_honored() { - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -validity_seconds: 300 -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - - assert_eq!(profile.validity_seconds, 300); -} - -#[test] -pub(super) fn credential_profile_validity_above_general_ceiling_is_rejected() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://vct.example/test -validity_seconds: 601 -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("long-lived".to_string(), profile); - - let err = config - .validate() - .expect_err("over-ceiling credential validity must fail"); - assert!(matches!( - err, - EvidenceConfigError::InvalidCredentialProfileValidity { - profile, - validity_seconds: 601, - max_validity_seconds: 600 - } if profile == "long-lived" - )); -} - -#[test] -pub(super) fn credential_profile_non_positive_validity_is_rejected() { - for invalid in [0, -1] { - let mut config = minimal_config(); - let mut profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - profile.validity_seconds = invalid; - config - .evidence - .credential_profiles - .insert("invalid-validity".to_string(), profile); - - let err = config - .validate() - .expect_err("non-positive credential validity must fail"); - assert!(matches!( - err, - EvidenceConfigError::InvalidCredentialProfileValidity { .. } - )); - } -} - -#[test] -pub(super) fn signing_keys_are_configured_separately_from_credential_profiles() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2026: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2026 - status: active -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-2026 -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config - .validate() - .expect("profile may reference an active signing key"); -} - -#[test] -pub(super) fn credential_profiles_must_reference_active_signing_keys() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-2025 -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - - let err = config - .validate() - .expect_err("publish-only keys must not be used for new issuance"); - match err { - EvidenceConfigError::CredentialProfileSigningKeyNotActive { profile, key } => { - assert_eq!(profile, "test-profile"); - assert_eq!(key, "issuer-2025"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn publish_only_local_jwk_uses_public_jwk_env_only() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - private_jwk_env: OLD_ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("publish-only local keys must not require private material"); - assert!( - err.to_string().contains("public_jwk_env must not be empty"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn publish_only_signing_key_accepts_bounded_publication_window() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-2025: - provider: local_jwk_env - public_jwk_env: OLD_ISSUER_PUBLIC_KEY - alg: EdDSA - kid: did:web:issuer.example#issuer-2025 - status: publish_only - publish_until_unix_seconds: 1893456000 -"#, - ) - .expect("signing key YAML is valid"); - - let key = config - .evidence - .signing_keys - .get("issuer-2025") - .expect("publish-only key exists"); - assert_eq!(key.publish_until_unix_seconds, Some(1_893_456_000)); - assert!(key.may_publish_at(1_893_456_000)); - assert!(!key.may_publish_at(1_893_456_001)); - config - .validate() - .expect("publish-only key may carry a publication deadline"); -} - -#[test] -pub(super) fn active_signing_key_rejects_publication_window() { - let mut config = minimal_config(); - let active = config - .evidence - .signing_keys - .values_mut() - .find(|key| key.status == SigningKeyStatus::Active) - .expect("minimal config has an active key"); - active.publish_until_unix_seconds = Some(1_893_456_000); - - let err = config - .validate() - .expect_err("active signing keys cannot carry a publication deadline"); - assert!( - err.to_string() - .contains("publish_until_unix_seconds is valid only for publish_only signing keys"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_shape_validates_without_loading_module() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-hsm -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config.validate().expect("PKCS#11 key shape validates"); -} - -#[test] -pub(super) fn file_watch_signing_key_shape_validates_without_secret_material_in_config() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - path: /run/secrets/issuer.jwk - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-file -vct: https://vct.example/test -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - add_registry_credential_claim(&mut config, "some-claim", "test-profile"); - - config.validate().expect("file-watch key shape validates"); -} - -#[test] -pub(super) fn file_watch_signing_key_rejects_secret_fields_and_missing_path() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - private_jwk_env: REGISTRY_NOTARY_ISSUER_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("file-watch key must use a local path"); - assert!( - err.to_string().contains("path must not be empty"), - "unexpected error: {err}" - ); - - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-file: - provider: file_watch - path: /run/secrets/issuer.jwk - private_jwk_env: REGISTRY_NOTARY_ISSUER_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-file - status: active -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("file-watch key must not carry env-backed private material"); - assert!( - err.to_string() - .contains("private_jwk_env is not valid for this signing key provider"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_requires_absolute_module_path() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("relative module path must fail validation"); - assert!( - err.to_string().contains("module_path must be absolute"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn pkcs11_signing_key_rejects_rs256_algorithm() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - token_label: registry-notary - pin_env: REGISTRY_NOTARY_PKCS11_PIN - key_label: issuer-signing-key - key_id_hex: 01ab23cd - public_jwk_env: REGISTRY_NOTARY_ISSUER_PUBLIC_JWK - alg: RS256 - kid: did:web:issuer.example#issuer-hsm - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("PKCS#11 signing only supports EdDSA"); - assert!( - err.to_string() - .contains("pkcs11 provider supports only EdDSA"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn publish_only_pkcs11_key_uses_public_jwk_env_only() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm-old: - provider: pkcs11 - public_jwk_env: REGISTRY_NOTARY_OLD_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm-old - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - - config - .validate() - .expect("publish-only PKCS#11 key needs only public metadata"); - - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-hsm-old: - provider: pkcs11 - module_path: /usr/lib/softhsm/libsofthsm2.so - public_jwk_env: REGISTRY_NOTARY_OLD_ISSUER_PUBLIC_JWK - alg: EdDSA - kid: did:web:issuer.example#issuer-hsm-old - status: publish_only -"#, - ) - .expect("signing key YAML is valid"); - let err = config - .validate() - .expect_err("publish-only PKCS#11 key must not require HSM access"); - assert!( - err.to_string() - .contains("module_path is not valid for this signing key provider"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn local_pkcs12_file_provider_is_deferred_without_partial_support() { - let mut config = minimal_config(); - config.evidence.signing_keys = serde_norway::from_str( - r#" -issuer-p12: - provider: local_pkcs12_file - path: /run/secrets/issuer.p12 - password_env: REGISTRY_NOTARY_P12_PASSWORD - alg: EdDSA - kid: did:web:issuer.example#issuer-p12 - status: active -"#, - ) - .expect("signing key YAML is valid"); - - let err = config - .validate() - .expect_err("PKCS#12 support must fail closed until it is implemented"); - assert!( - err.to_string() - .contains("local_pkcs12_file provider is intentionally not implemented yet"), - "unexpected error: {err}" - ); -} - -#[test] -pub(super) fn proof_of_possession_required_with_non_jwk_method_is_rejected() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk - - did:key -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - - let err = config - .validate() - .expect_err("did:key with proof_of_possession required must fail"); - match &err { - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { profile, methods } => { - assert_eq!(profile, "test-profile"); - assert!( - methods.contains(&"did:key".to_string()), - "error must name did:key, got: {methods:?}" - ); - assert!( - !methods.contains(&"did:jwk".to_string()), - "did:jwk must not appear in the unsupported list" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn non_jwk_methods_are_rejected_even_without_proof_of_possession() { - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -holder_binding: - mode: did - allowed_did_methods: - - did:jwk - - did:key - - did:web -allowed_claims: - - some-claim -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("test-profile".to_string(), profile); - let err = config - .validate() - .expect_err("non-did:jwk holder methods must fail validation"); - match &err { - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { profile, methods } => { - assert_eq!(profile, "test-profile"); - assert_eq!(methods, &vec!["did:key".to_string(), "did:web".to_string()]); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// Finding 8: depends_on cycle detection -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn valid_dag_passes_cycle_detection() { - // A -> B -> C (no cycle) - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-b".to_string()]; - let mut claim_b = minimal_claim("claim-b"); - claim_b.depends_on = vec!["claim-c".to_string()]; - let claim_c = minimal_claim("claim-c"); - config.evidence.claims = vec![claim_a, claim_b, claim_c]; - assert!(config.validate().is_ok(), "A->B->C DAG should pass"); -} - -#[test] -pub(super) fn two_node_cycle_is_detected() { - // A -> B -> A - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-b".to_string()]; - let mut claim_b = minimal_claim("claim-b"); - claim_b.depends_on = vec!["claim-a".to_string()]; - config.evidence.claims = vec![claim_a, claim_b]; - - let err = config - .validate() - .expect_err("A->B->A cycle must fail validation"); - match &err { - EvidenceConfigError::DependsOnCycle { cycle } => { - assert!( - cycle.contains(&"claim-a".to_string()), - "cycle must mention claim-a, got: {cycle:?}" - ); - assert!( - cycle.contains(&"claim-b".to_string()), - "cycle must mention claim-b, got: {cycle:?}" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn self_loop_is_detected() { - // A -> A - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-a".to_string()]; - config.evidence.claims = vec![claim_a]; - - let err = config - .validate() - .expect_err("self-loop must fail validation"); - match &err { - EvidenceConfigError::DependsOnCycle { cycle } => { - assert!( - cycle.contains(&"claim-a".to_string()), - "cycle must mention claim-a, got: {cycle:?}" - ); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn unknown_depends_on_is_rejected() { - let mut config = minimal_config(); - let mut claim_a = minimal_claim("claim-a"); - claim_a.depends_on = vec!["claim-nonexistent".to_string()]; - config.evidence.claims = vec![claim_a]; - - let err = config - .validate() - .expect_err("depends_on unknown claim must fail validation"); - match &err { - EvidenceConfigError::DependsOnUnknownClaim { claim, unknown } => { - assert_eq!(claim, "claim-a"); - assert_eq!(unknown, "claim-nonexistent"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// GH#170 / RS-DM-CLAIM Section 10: load-time validation for invariants -// the loader previously deferred to request/evaluation time. -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn duplicate_claim_id_is_rejected() { - // REQ-DM-CLAIM-001: two claims sharing an id previously loaded - // cleanly; the loader must now reject it. - let mut config = minimal_config(); - let claim_a = minimal_claim("repeated-id"); - let claim_b = minimal_claim("repeated-id"); - config.evidence.claims = vec![claim_a, claim_b]; - - let err = config - .validate() - .expect_err("duplicate claim id must fail validation"); - match &err { - EvidenceConfigError::DuplicateClaimId { claim } => { - assert_eq!(claim, "repeated-id"); - } - other => panic!("unexpected error variant: {other}"), - } - assert!( - err.to_string().contains("repeated-id"), - "error must name the offending claim id: {err}" - ); -} - -#[test] -pub(super) fn disclosure_default_outside_allowed_is_rejected() { - // REQ-DM-CLAIM-008: a disclosure default outside the allowed set - // previously surfaced only when a result was rendered. This is the - // most consequential of the three Section 10 gaps: a - // privacy-sensitive claim could otherwise ship an internally - // inconsistent disclosure policy that only fails on first render. - let mut config = minimal_config(); - let mut claim = minimal_claim("residency-status"); - claim.disclosure = DisclosureConfig { - default: "value".to_string(), - allowed: vec!["redacted".to_string()], - downgrade: "deny".to_string(), - }; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("disclosure default outside allowed must fail validation"); - match &err { - EvidenceConfigError::ClaimDisclosureDefaultNotAllowed { - claim, - default, - allowed, - } => { - assert_eq!(claim, "residency-status"); - assert_eq!(default, "value"); - assert_eq!(allowed, &vec!["redacted".to_string()]); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("residency-status") && message.contains("disclosure"), - "error must name the offending claim id and field: {message}" - ); -} - -#[test] -pub(super) fn omitted_claim_formats_default_to_claim_result_json() { - let claim = minimal_claim("default-format"); - - assert_eq!( - claim.formats, - vec![FORMAT_CLAIM_RESULT_JSON.to_string()], - "omitted formats must retain the canonical evaluation representation" - ); -} - -#[test] -pub(super) fn explicit_empty_claim_formats_are_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("empty-format"); - claim.formats.clear(); - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("an explicitly empty formats list must fail validation"); - match &err { - EvidenceConfigError::EmptyClaimFormats { claim } => { - assert_eq!(claim, "empty-format"); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("empty-format") && message.contains("omit formats"), - "error must name the claim and explain how to use the default: {message}" - ); -} - -#[test] -pub(super) fn canonical_claim_format_is_valid() { - let mut config = minimal_config(); - let mut claim = minimal_claim("canonical-format"); - claim.formats = vec![FORMAT_CLAIM_RESULT_JSON.to_string()]; - config.evidence.claims = vec![claim]; - - config - .validate() - .expect("the canonical evaluation format must validate"); -} - -#[test] -pub(super) fn canonical_and_cccev_claim_formats_are_valid() { - let mut config = minimal_config(); - let mut claim = minimal_claim("canonical-and-cccev"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - FORMAT_CCCEV_JSONLD.to_string(), - ]; - config.evidence.claims = vec![claim]; - - config - .validate() - .expect("the canonical and CCCEV evaluation formats must validate"); -} - -#[test] -pub(super) fn cccev_without_canonical_claim_format_is_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("cccev-only"); - claim.formats = vec![FORMAT_CCCEV_JSONLD.to_string()]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("CCCEV-only formats must fail validation"); - match &err { - EvidenceConfigError::MissingCanonicalClaimFormat { claim } => { - assert_eq!(claim, "cccev-only"); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("cccev-only") && message.contains(FORMAT_CLAIM_RESULT_JSON), - "error must name the claim and canonical format: {message}" - ); -} - -#[test] -pub(super) fn sd_jwt_vc_claim_format_is_rejected_before_canonical_omission() { - let mut config = minimal_config(); - let mut claim = minimal_claim("sd-jwt-only"); - claim.formats = vec![FORMAT_SD_JWT_VC.to_string()]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("SD-JWT VC is not an evaluation response format"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "sd-jwt-only"); - assert_eq!(format, FORMAT_SD_JWT_VC); - } - other => panic!("unexpected error variant: {other}"), - } - let message = err.to_string(); - assert!( - message.contains("credential_profiles") && message.contains(FORMAT_SD_JWT_VC), - "error must identify SD-JWT VC and its configuration home: {message}" - ); -} - -#[test] -pub(super) fn mixed_claim_formats_reject_the_first_unsupported_format() { - let mut config = minimal_config(); - let mut claim = minimal_claim("mixed-formats"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - FORMAT_SD_JWT_VC.to_string(), - "application/example+json".to_string(), - ]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("mixed formats must reject the unsupported SD-JWT VC entry"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "mixed-formats"); - assert_eq!(format, FORMAT_SD_JWT_VC); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn unknown_claim_format_is_rejected() { - let mut config = minimal_config(); - let mut claim = minimal_claim("unknown-format"); - claim.formats = vec![ - FORMAT_CLAIM_RESULT_JSON.to_string(), - "application/example+json".to_string(), - ]; - config.evidence.claims = vec![claim]; - - let err = config - .validate() - .expect_err("unknown evaluation formats must fail validation"); - match &err { - EvidenceConfigError::UnsupportedClaimFormat { claim, format } => { - assert_eq!(claim, "unknown-format"); - assert_eq!(format, "application/example+json"); - } - other => panic!("unexpected error variant: {other}"), - } - assert!( - err.to_string().contains("application/example+json"), - "error must name the offending format: {err}" - ); -} - -#[test] -pub(super) fn empty_allowed_claims_is_rejected() { - // A credential profile with an empty allowed_claims would silently - // accept every claim at issue time (see api.rs `is_empty()` short - // circuit). Reject at config-load time so the operator must opt in. - let mut config = minimal_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: https://issuer.example -signing_key: issuer-key -vct: https://vct.example/test -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("the_profile_id".to_string(), profile); - - let err = config - .validate() - .expect_err("empty allowed_claims must fail validation"); - match &err { - EvidenceConfigError::EmptyAllowedClaims { profile } => { - assert_eq!(profile, "the_profile_id"); - } - other => panic!("unexpected error variant: {other}"), - } -} - -// ----------------------------------------------------------------------- -// Stage 1: concurrency config and the kill-switch -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn default_concurrency_has_documented_defaults() { - let cfg = ConcurrencyConfig::default(); - assert_eq!(cfg.subjects, 16); - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn concurrency_zero_subjects_is_rejected() { - let mut config = minimal_config(); - config.evidence.concurrency = ConcurrencyConfig { subjects: 0 }; - let err = config - .validate() - .expect_err("subjects=0 must fail validation"); - assert!(matches!(err, EvidenceConfigError::InvalidConcurrency)); -} - -#[test] -pub(super) fn concurrency_subjects_one_validates() { - let mut config = minimal_config(); - config.evidence.concurrency = ConcurrencyConfig { subjects: 1 }; - assert!(config.validate().is_ok()); -} - -// ----------------------------------------------------------------------- -// Machine quota config -// ----------------------------------------------------------------------- - -#[test] -pub(super) fn machine_quota_defaults_to_disabled_with_documented_limit() { - let cfg = MachineQuotaConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.subjects_per_minute, 6000); - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn machine_quota_disabled_zero_limit_still_validates() { - // A zero subjects_per_minute is only invalid once the quota is - // enabled; an operator-provided but unused value must not block - // deployments that leave the quota off. - let cfg = MachineQuotaConfig { - enabled: false, - subjects_per_minute: 0, - }; - assert!(cfg.validate().is_ok()); -} - -#[test] -pub(super) fn machine_quota_enabled_zero_limit_is_rejected() { - let mut config = minimal_config(); - config.evidence.machine_quota = MachineQuotaConfig { - enabled: true, - subjects_per_minute: 0, - }; - let err = config - .validate() - .expect_err("enabled machine_quota with subjects_per_minute=0 must fail validation"); - match &err { - EvidenceConfigError::InvalidMachineQuotaConfig { reason } => { - assert!(reason.contains("subjects_per_minute")); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn machine_quota_enabled_with_positive_limit_validates() { - let mut config = minimal_config(); - config.evidence.machine_quota = MachineQuotaConfig { - enabled: true, - subjects_per_minute: 1, - }; - assert!(config.validate().is_ok()); -} diff --git a/crates/registry-notary-core/src/config/tests/infrastructure.rs b/crates/registry-notary-core/src/config/tests/infrastructure.rs deleted file mode 100644 index 8c705242b..000000000 --- a/crates/registry-notary-core/src/config/tests/infrastructure.rs +++ /dev/null @@ -1,508 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, issuance::*, preauth::*, root::*}; - -#[test] -pub(super) fn state_postgresql_config_parses_and_validates() { - let mut config = minimal_config(); - config.state = serde_norway::from_str( - r#" -storage: postgresql -postgresql: - url_env: REGISTRY_NOTARY_POSTGRES_URL - root_certificate_path: /run/secrets/notary-postgres-ca.pem - connect_timeout_ms: 5000 - operation_timeout_ms: 2000 - max_connections: 12 - sensitive_state_key_env: REGISTRY_NOTARY_SENSITIVE_STATE_KEY -"#, - ) - .expect("PostgreSQL state config parses"); - - config - .validate() - .expect("PostgreSQL state config validates"); - assert_eq!(config.state.storage, STATE_STORAGE_POSTGRESQL); - assert_eq!( - config.state.postgresql.root_certificate_path.as_deref(), - Some(std::path::Path::new("/run/secrets/notary-postgres-ca.pem")) - ); -} - -#[test] -pub(super) fn state_defaults_to_postgresql_contract() { - let config = minimal_config(); - - assert_eq!(config.state.storage, STATE_STORAGE_POSTGRESQL); - assert_eq!( - config.state.postgresql.url_env, - "REGISTRY_NOTARY_POSTGRES_URL" - ); - assert_eq!(config.state.postgresql.connect_timeout_ms, 5_000); - assert_eq!(config.state.postgresql.operation_timeout_ms, 2_000); - assert_eq!(config.state.postgresql.max_connections, 16); - assert_eq!( - config.state.postgresql.sensitive_state_key_env, - "REGISTRY_NOTARY_SENSITIVE_STATE_KEY" - ); - config.validate().expect("default state config validates"); -} - -#[test] -pub(super) fn state_postgresql_config_rejects_invalid_connection_shape() { - let mut config = minimal_config(); - config.state.postgresql.url_env.clear(); - let reason = expect_state_error(&config); - assert!( - reason.contains("state.postgresql.url_env"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.connect_timeout_ms = 0; - let reason = expect_state_error(&config); - assert!( - reason.contains("connect_timeout_ms"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.operation_timeout_ms = 0; - let reason = expect_state_error(&config); - assert!( - reason.contains("operation_timeout_ms"), - "unexpected: {reason}" - ); - - config = minimal_config(); - config.state.postgresql.max_connections = 0; - let reason = expect_state_error(&config); - assert!(reason.contains("max_connections"), "unexpected: {reason}"); - - config = minimal_config(); - config.state.postgresql.max_connections = STATE_POSTGRESQL_MAX_CONNECTIONS + 1; - let reason = expect_state_error(&config); - assert!(reason.contains("max_connections"), "unexpected: {reason}"); - - config = minimal_config(); - config.state.postgresql.root_certificate_path = Some(std::path::PathBuf::new()); - let reason = expect_state_error(&config); - assert!( - reason.contains("root_certificate_path"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn state_in_memory_is_limited_to_local_single_instance() { - let mut config = minimal_config(); - config.state.storage = STATE_STORAGE_IN_MEMORY.to_string(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.profile = local"), - "unexpected: {reason}" - ); - - config.deployment.profile = Some(crate::deployment::DeploymentProfile::HostedLab); - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.profile = local"), - "unexpected: {reason}" - ); - - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config.deployment.multi_instance = true; - let reason = expect_state_error(&config); - assert!( - reason.contains("deployment.multi_instance = false"), - "unexpected: {reason}" - ); - - config.deployment.multi_instance = false; - config - .validate() - .expect("local single-instance in-memory state validates"); -} - -#[test] -pub(super) fn state_rejects_unknown_storage() { - let mut config = minimal_config(); - config.state.storage = "redis".to_string(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("postgresql or in_memory"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn state_postgresql_requires_sensitive_key_env_for_preauthorization() { - let mut config = valid_pre_auth_config(); - config.state.postgresql.sensitive_state_key_env.clear(); - - let reason = expect_state_error(&config); - assert!( - reason.contains("sensitive_state_key_env"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn removed_per_domain_storage_selectors_are_rejected() { - let replay = serde_norway::from_str::( - r#" -evidence: - enabled: true - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -replay: - storage: redis -"#, - ) - .expect_err("top-level replay config was removed"); - assert!(replay.to_string().contains("replay")); - - let credential_status = serde_norway::from_str::( - r#" -enabled: true -base_url: https://issuer.example -storage: redis -redis: - url_env: REGISTRY_NOTARY_STATUS_REDIS_URL -"#, - ) - .expect_err("credential-status storage selectors were removed"); - let error = credential_status.to_string(); - assert!(error.contains("storage") || error.contains("redis")); -} - -#[test] -pub(super) fn credential_status_config_requires_base_url_when_enabled() { - let mut config = minimal_config(); - config.credential_status = serde_norway::from_str( - r#" -enabled: true -base_url: "" -"#, - ) - .expect("credential status config parses"); - - let reason = expect_credential_status_error(&config); - assert!( - reason.contains("credential_status.base_url"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn audit_config_deserializes_rotation_and_syslog_fields() { - let file: EvidenceAuditConfig = serde_norway::from_str( - r#" -sink: file -path: /var/log/registry-notary/audit.jsonl -hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -max_size_mb: 4 -max_files: 3 -"#, - ) - .expect("file audit config is valid YAML"); - - assert_eq!(file.sink, "file"); - assert_eq!( - file.path.as_deref(), - Some("/var/log/registry-notary/audit.jsonl") - ); - assert_eq!(file.max_size_bytes(), 4 * 1024 * 1024); - assert_eq!(file.max_files(), 3); - assert_eq!(file.syslog_socket_path, None); - - let syslog: EvidenceAuditConfig = serde_norway::from_str( - r#" -sink: syslog -hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -syslog_socket_path: /dev/log -"#, - ) - .expect("syslog audit config is valid YAML"); - - assert_eq!(syslog.sink, "syslog"); - assert_eq!(syslog.path, None); - assert_eq!(syslog.max_size_bytes(), 100 * 1024 * 1024); - assert_eq!(syslog.max_files(), 14); - assert_eq!(syslog.syslog_socket_path.as_deref(), Some("/dev/log")); -} - -pub(super) fn valid_federation_config() -> StandaloneRegistryNotaryConfig { - let mut config = minimal_config(); - let mut claim = minimal_claim("disability-status"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("minimal claim is registry backed") - }; - consultations - .get_mut("test_source") - .expect("minimal consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.national_id".to_string(), - ), - ); - config.evidence.claims = vec![claim]; - config.federation = FederationConfig { - enabled: true, - node_id: "did:web:agency-a.example.gov".to_string(), - issuer: "https://agency-a.example.gov".to_string(), - jwks_uri: "https://agency-a.example.gov/federation/jwks.json".to_string(), - federation_api: "https://agency-a.example.gov/federation/v1".to_string(), - supported_protocol_versions: vec![FEDERATION_PROTOCOL_V0_1.to_string()], - signing: FederationSigningConfig { - signing_key: "federation-key".to_string(), - }, - pairwise_subject_hash: FederationPairwiseSubjectHashConfig { - secret_env: "FEDERATION_PAIRWISE_SECRET".to_string(), - }, - peers: vec![FederationPeerConfig { - node_id: "did:web:agency-b.example.gov".to_string(), - issuer: "https://agency-b.example.gov".to_string(), - jwks_uri: "https://agency-b.example.gov/federation/jwks.json".to_string(), - allowed_protocol_versions: vec![FEDERATION_PROTOCOL_V0_1.to_string()], - allowed_purposes: vec![ - "https://purpose.example.gov/social-protection/service-delivery".to_string(), - ], - allowed_profiles: vec!["disability_status_predicate".to_string()], - evaluation_scopes: vec!["registry:consult:test-source".to_string()], - ..FederationPeerConfig::default() - }], - evaluation_profiles: vec![FederationEvaluationProfileConfig { - id: "disability_status_predicate".to_string(), - ruleset: "disability-status-v1".to_string(), - claim_id: "disability-status".to_string(), - subject_id_type: "national_id".to_string(), - disclosure: Some("predicate".to_string()), - max_claim_result_age_seconds: Some(300), - ..FederationEvaluationProfileConfig::default() - }], - ..FederationConfig::default() - }; - config.evidence.signing_keys.insert( - "federation-key".to_string(), - SigningKeyConfig { - provider: SigningKeyProviderConfig::LocalJwkEnv, - alg: FEDERATION_SIGNING_ALG_EDDSA.to_string(), - kid: "agency-a-fed-1".to_string(), - status: SigningKeyStatus::Active, - publish_until_unix_seconds: None, - private_jwk_env: "FEDERATION_SIGNING_KEY".to_string(), - public_jwk_env: String::new(), - module_path: String::new(), - token_label: String::new(), - pin_env: String::new(), - key_label: String::new(), - key_id_hex: String::new(), - path: String::new(), - password_env: String::new(), - }, - ); - config -} - -#[test] -pub(super) fn federation_config_validates_enabled_mvp_shape() { - valid_federation_config() - .validate() - .expect("federation config validates"); -} - -#[test] -pub(super) fn federation_profile_rejects_relay_inputs_unavailable_from_subject() { - let mut config = valid_federation_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("federation claim is registry backed") - }; - consultations - .get_mut("test_source") - .expect("federation consultation exists") - .inputs - .insert("subject_id".to_string(), RelayConsultationInput::TargetId); - - let reason = expect_federation_error(&config); - - assert!( - reason.contains("Relay inputs must derive from request.target.identifiers.national_id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_peer_rejects_profile_with_missing_dependency_scope() { - let mut config = valid_federation_config(); - let mut dependency = config.evidence.claims[0].clone(); - dependency.id = "disability-status-dependency".to_string(); - dependency.required_scopes = vec!["registry:consult:dependency".to_string()]; - config.evidence.claims[0].depends_on = vec![dependency.id.clone()]; - config.evidence.claims.push(dependency); - - let reason = expect_federation_error(&config); - - assert!( - reason.contains( - "evaluation_scopes must include required scope 'registry:consult:dependency'" - ), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_signing_key_must_reference_active_named_signing_key() { - let mut config = valid_federation_config(); - config.federation.signing.signing_key = "missing-key".to_string(); - let reason = expect_federation_error(&config); - assert!( - reason.contains("unknown signing key 'missing-key'"), - "unexpected: {reason}" - ); - - config = valid_federation_config(); - let federation_key = config - .evidence - .signing_keys - .get_mut("federation-key") - .expect("federation signing key exists"); - federation_key.status = SigningKeyStatus::PublishOnly; - federation_key.private_jwk_env = String::new(); - federation_key.public_jwk_env = "FEDERATION_SIGNING_PUBLIC_KEY".to_string(); - let reason = expect_federation_error(&config); - assert!( - reason.contains("must reference an active signing key"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn federation_replay_storage_selector_is_rejected() { - let error = serde_norway::from_str::( - r#" -replay: - storage: redis -"#, - ) - .expect_err("federation replay storage selection was removed"); - assert!(error.to_string().contains("replay")); -} - -#[test] -pub(super) fn federation_peer_private_network_jwks_escape_hatch_deserializes_and_validates() { - let mut config = valid_federation_config(); - let peer: FederationPeerConfig = serde_norway::from_str( - r#" -node_id: did:web:agency-b.example.gov -issuer: https://agency-b.example.gov -jwks_uri: http://federation-peer-jwks:8080/jwks.json -allow_insecure_private_network: true -allowed_protocol_versions: - - registry-notary-federation/v0.1 -allowed_purposes: - - https://purpose.example.gov/social-protection/service-delivery -allowed_profiles: - - disability_status_predicate -evaluation_scopes: - - registry:consult:test-source -"#, - ) - .expect("private-network peer YAML parses"); - assert!(peer.allow_insecure_private_network); - config.federation.peers = vec![peer]; - config - .validate() - .expect("private-network peer JWKS is accepted only with explicit opt-in"); -} - -#[test] -pub(super) fn federation_rejects_removed_source_named_fields() { - let peer_error = serde_norway::from_str::( - r#" -node_id: did:web:agency-b.example.gov -issuer: https://agency-b.example.gov -jwks_uri: https://agency-b.example.gov/.well-known/jwks.json -source_scopes: - - civil_registry:evidence_verification -"#, - ) - .expect_err("the unreleased source_scopes field must not remain an alias"); - assert!(peer_error.to_string().contains("source_scopes")); - - let profile_error = serde_norway::from_str::( - r#" -id: disability_status_predicate -ruleset: disability-status-v1 -claim_id: disability-status -subject_id_type: national_id -max_source_observed_age_seconds: 300 -"#, - ) - .expect_err("the unreleased source-age field must not remain an alias"); - assert!(profile_error - .to_string() - .contains("max_source_observed_age_seconds")); -} - -#[test] -pub(super) fn federation_peer_http_private_network_jwks_requires_escape_hatch() { - let mut config = valid_federation_config(); - config.federation.peers[0].jwks_uri = "http://federation-peer-jwks:8080/jwks.json".to_string(); - let reason = expect_federation_error(&config); - assert!(reason.contains("jwks_uri must be an HTTPS URL")); -} - -#[test] -pub(super) fn federation_config_rejects_bad_did_issuer_binding() { - let mut config = valid_federation_config(); - config.federation.issuer = "https://other-agency.example.gov".to_string(); - let reason = expect_federation_error(&config); - assert!(reason.contains("node_id must bind")); -} - -#[test] -pub(super) fn federation_config_rejects_missing_protocol_and_bad_profile_reference() { - let mut missing_protocol = valid_federation_config(); - missing_protocol - .federation - .supported_protocol_versions - .clear(); - let reason = expect_federation_error(&missing_protocol); - assert!(reason.contains("supported_protocol_versions")); - - let mut bad_profile = valid_federation_config(); - bad_profile.federation.evaluation_profiles[0].claim_id = "unknown".to_string(); - let reason = expect_federation_error(&bad_profile); - assert!(reason.contains("claim_id must reference")); -} - -#[test] -pub(super) fn federation_profile_disclosure_must_be_known_profile() { - let mut config = valid_federation_config(); - config.federation.evaluation_profiles[0].disclosure = Some("raw".to_string()); - let reason = expect_federation_error(&config); - assert!(reason.contains("disclosure must be value, predicate, or redacted")); -} - -// ----------------------------------------------------------------------- -// Finding 3: holder binding / did-method mismatch -// ----------------------------------------------------------------------- diff --git a/crates/registry-notary-core/src/config/tests/issuance.rs b/crates/registry-notary-core/src/config/tests/issuance.rs deleted file mode 100644 index b69661571..000000000 --- a/crates/registry-notary-core/src/config/tests/issuance.rs +++ /dev/null @@ -1,1532 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, preauth::*, root::*}; - -#[test] -pub(super) fn subject_access_is_disabled_by_default() { - let config = minimal_config(); - assert!(!config.subject_access.enabled); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn oid4vci_is_disabled_by_default() { - let config = minimal_config(); - assert!(!config.oid4vci.enabled); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn disabled_default_subject_access_is_omitted_from_serialized_config() { - let config = minimal_config(); - let serialized = serde_json::to_value(&config).expect("config serializes as JSON"); - - assert!( - serialized.get("subject_access").is_none(), - "disabled default subject_access must stay compact when serialized: {serialized}", - ); -} - -#[test] -pub(super) fn disabled_default_oid4vci_is_omitted_from_serialized_config() { - let config = minimal_config(); - let serialized = serde_json::to_value(&config).expect("config serializes as JSON"); - - assert!( - serialized.get("oid4vci").is_none(), - "disabled default oid4vci must stay compact when serialized: {serialized}", - ); -} - -#[test] -pub(super) fn valid_subject_access_config_passes_validation() { - let config = valid_subject_access_config(); - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn delegated_subject_access_accepts_compiler_pinned_relay_proofs() { - let config = valid_delegated_subject_access_config(); - config - .validate() - .expect("Relay-backed delegated proof config validates"); -} - -#[test] -pub(super) fn valid_oid4vci_config_passes_validation() { - let config = valid_oid4vci_config(); - assert!(config.validate().is_ok()); -} - -fn valid_representative_oid4vci_config() -> StandaloneRegistryNotaryConfig { - let oid4vci_base = valid_oid4vci_config(); - let mut config = valid_delegated_subject_access_config(); - config.oid4vci = oid4vci_base.oid4vci; - config.auth.access_token_signing = oid4vci_base.auth.access_token_signing; - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config.evidence.signing_keys.insert( - "access-token-key".to_string(), - oid4vci_base - .evidence - .signing_keys - .get("access-token-key") - .expect("access token key exists") - .clone(), - ); - let profile = config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("credential profile exists"); - profile.vct = "http://127.0.0.1:4325/credentials/civil-status".to_string(); - profile - .allowed_claims - .push("dependent-date-of-birth".to_string()); - - let mut dependent = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "date-of-birth") - .expect("registry-backed source claim exists") - .clone(); - dependent.id = "dependent-date-of-birth".to_string(); - dependent.title = "Dependent date of birth".to_string(); - dependent.purpose = Some("dependent_attestation".to_string()); - dependent.depends_on = vec!["guardian-link".to_string()]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependent.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.remove("national_id"); - inputs.insert( - "civil_registration_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - *config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") = dependent; - config.subject_access.allowed_claims.clear(); - config.subject_access.allowed_purposes.clear(); - config.subject_access.allowed_formats.clear(); - config.subject_access.allowed_disclosures.clear(); - - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists"); - credential.claim_id = Some("dependent-date-of-birth".to_string()); - credential.representative_issuance = Some(Oid4vciRepresentativeIssuanceConfig { - ceremony: Oid4vciRepresentativeIssuanceCeremony::DigitallyAuthenticatedRepresentative, - relationship: "guardian".to_string(), - }); - config.credential_status = CredentialStatusConfig { - enabled: true, - base_url: "http://127.0.0.1:4325".to_string(), - ..CredentialStatusConfig::default() - }; - config -} - -#[test] -fn representative_oid4vci_accepts_a_delegation_only_target_contract() { - valid_representative_oid4vci_config() - .validate() - .expect("representative issuance configuration validates"); -} - -#[test] -fn representative_oid4vci_rejects_a_requester_only_credential_root() { - let mut config = valid_representative_oid4vci_config(); - let root = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut root.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.clear(); - inputs.insert( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("does not consume the selected target") - && reason.contains("'requester_id' maps 'requester.identifiers.national_id'") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_a_requester_only_credential_dependency() { - let mut config = valid_representative_oid4vci_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("representative dependency is registry-backed"); - }; - let inputs = &mut consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs; - inputs.clear(); - inputs.insert( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("does not consume the selected target") - && reason.contains("'requester_id' maps 'requester.identifiers.national_id'") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_delegated_evaluation() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_operations.evaluate = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("allowed_operations.evaluate = true"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_credential_issuance() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_operations.issue_credential = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("allowed_operations.issue_credential = true"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_a_subject_bound_root() { - let mut config = valid_representative_oid4vci_config(); - config.subject_access.allowed_claims = vec!["dependent-date-of-birth".to_string()]; - config.subject_access.allowed_purposes = vec!["dependent_attestation".to_string()]; - config.subject_access.allowed_formats = - vec!["application/vnd.registry-notary.claim-result+json".to_string()]; - config.subject_access.allowed_disclosures = vec!["value".to_string()]; - config.subject_access.delegation.allowed_relationships[0].target_id_type = - Some("national_id".to_string()); - for claim_id in ["dependent-date-of-birth", "guardian-link"] { - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == claim_id) - .expect("representative claim exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("representative claim is registry-backed"); - }; - for input in consultations - .values_mut() - .flat_map(|consultation| consultation.inputs.values_mut()) - { - if let RelayConsultationInput::TargetIdentifier(path) = input { - *path = "request.target.identifiers.national_id".to_string(); - } - } - } - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains( - "representative_issuance claim 'dependent-date-of-birth' must not appear in subject_access.allowed_claims" - ), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_requires_status_and_an_exact_relationship() { - let mut without_status = valid_representative_oid4vci_config(); - without_status.credential_status.enabled = false; - let reason = expect_oid4vci_error(&without_status); - assert!(reason.contains("credential_status.enabled = true")); - - let mut unknown_relationship = valid_representative_oid4vci_config(); - unknown_relationship - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists") - .representative_issuance - .as_mut() - .expect("representative issuance exists") - .relationship = "unknown".to_string(); - let reason = expect_oid4vci_error(&unknown_relationship); - assert!(reason.contains("unknown relationship 'unknown'")); - - let mut proof_outlives_evaluation = valid_representative_oid4vci_config(); - let proof_age = proof_outlives_evaluation - .subject_access - .delegation - .allowed_relationships[0] - .max_proof_age_seconds; - proof_outlives_evaluation - .subject_access - .token_policy - .max_evaluation_age_seconds = proof_age - 1; - let error = proof_outlives_evaluation - .validate() - .expect_err("relationship proof freshness cannot outlive stored evaluation authority"); - assert!( - error - .to_string() - .contains("must not exceed token_policy.max_evaluation_age_seconds"), - "unexpected error: {error}" - ); -} - -#[test] -fn representative_oid4vci_requires_an_atomic_relationship_proof() { - let mut config = valid_representative_oid4vci_config(); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("relationship proof exists") - .depends_on = vec!["date-of-birth".to_string()]; - - let error = config - .validate() - .expect_err("a relationship proof with dependencies must be rejected"); - assert!( - error - .to_string() - .contains("must not depend_on other claims so the relationship is proven before"), - "unexpected error: {error}" - ); -} - -#[test] -fn representative_oid4vci_rejects_proof_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let proof = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("relationship proof exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut proof.evidence_mode else { - panic!("relationship proof is registry-backed"); - }; - consultations - .first_entry() - .expect("relationship proof consultation exists") - .get_mut() - .inputs - .insert( - "relationship_kind".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.relationship_kind".to_string(), - ), - ); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains( - "must map exactly the authenticated requester identifier and selected target identifier" - ), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_credential_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let root = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut root.evidence_mode else { - panic!("representative credential root is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "case_reference".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.case_reference".to_string(), - ), - ); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'case_reference'") - && reason.contains("target.identifiers.case_reference") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn representative_oid4vci_rejects_transitive_inputs_the_ceremony_cannot_supply() { - let mut config = valid_representative_oid4vci_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("representative dependency is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "case_reference".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.case_reference".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("representative credential root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'case_reference'") - && reason.contains("target.identifiers.case_reference") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_wallet_authorization_code_profile() { - let mut config = valid_oid4vci_config(); - config.oid4vci.pre_authorized_code = Oid4vciPreAuthorizedCodeConfig::default(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("pre_authorized_code.enabled = true") - && reason.contains("authorization_code issuance is not supported"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn valid_oid4vci_projection_config_passes_validation() { - let config = valid_oid4vci_projection_config(); - config - .validate() - .expect("projection credential config validates"); -} - -#[test] -pub(super) fn credential_claim_and_profile_bindings_must_be_mutual() { - let mut config = valid_subject_access_config(); - config.subject_access = SubjectAccessConfig::default(); - config.oid4vci = Oid4vciConfig::default(); - config.evidence.claims[0].credential_profiles.clear(); - - let error = config - .validate() - .expect_err("one-sided profile binding must fail startup"); - assert!(matches!( - error, - EvidenceConfigError::InvalidCredentialClaimBinding { ref reason } - if reason.contains("does not reference") - )); -} - -#[test] -pub(super) fn credential_root_accepts_registry_backed_dependency_closure() { - let mut config = valid_subject_access_config(); - let root = config.evidence.claims[0].clone(); - let mut dependency = root.clone(); - dependency.id = "civil-status-source-record".to_string(); - dependency.title = "Civil status source record".to_string(); - dependency.credential_profiles.clear(); - config.evidence.claims[0] - .depends_on - .push(dependency.id.clone()); - config.evidence.claims.push(dependency); - - config - .validate() - .expect("credential root may retain an exact registry-backed dependency closure"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_claim_id_and_claims_together() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claim_id = Some("date-of-birth".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("exactly one of claim_id or claims"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_missing_claim_mode() { - let mut config = valid_oid4vci_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claim_id = None; - credential.claims.clear(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("exactly one of claim_id or claims"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_duplicate_output_paths() { - let mut config = valid_oid4vci_projection_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claims[1].output_path = vec!["birth_date".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("duplicate") && reason.contains("output_path"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_duplicate_claim_ids() { - let mut config = valid_oid4vci_projection_config(); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap(); - credential.claims[1].id = "date-of-birth".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("duplicate") && reason.contains("claims[].id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_reserved_output_paths() { - for reserved in [ - "iss", - "sub", - "aud", - "iat", - "nbf", - "exp", - "vct", - "vct#integrity", - "id", - "jti", - "_sd", - "_sd_alg", - "cnf", - "status", - "issuanceDate", - "expirationDate", - ] { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .output_path = vec![reserved.to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("reserved") && reason.contains(reserved), - "unexpected for {reserved}: {reason}" - ); - } -} - -#[test] -pub(super) fn oid4vci_projection_rejects_nested_output_paths_in_v1() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .output_path = vec!["birth".to_string(), "date".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("single segment"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_unknown_claim_reference() { - let mut config = valid_oid4vci_projection_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claims[0] - .id = "missing-claim".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_claim_outside_profile_allow_list() { - let mut config = valid_oid4vci_projection_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .allowed_claims - .retain(|claim_id| claim_id != "birth-place"); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("profile") && reason.contains("does not allow"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_mixed_claim_purposes() { - let mut config = valid_oid4vci_projection_config(); - config - .subject_access - .allowed_purposes - .push("other_purpose".to_string()); - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "birth-place") - .expect("projection claim exists"); - claim.purpose = Some("other_purpose".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("share one purpose"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_projection_rejects_non_value_default_disclosure() { - let mut config = valid_oid4vci_projection_config(); - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "birth-place") - .expect("projection claim exists"); - claim.disclosure.default = "redacted".to_string(); - claim.disclosure.allowed = vec!["redacted".to_string(), "value".to_string()]; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("must use value as the default disclosure"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_accepts_vct_under_path_prefixed_credential_issuer() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_issuer = "http://127.0.0.1:4325/notary".to_string(); - config.oid4vci.credential_endpoint = - "http://127.0.0.1:4325/notary/oid4vci/credential".to_string(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/notary/credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/notary/credentials/civil-status".to_string(); - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn oid4vci_deserializes_absent_block_with_default() { - let config = valid_subject_access_config(); - assert_eq!(config.oid4vci, Oid4vciConfig::default()); -} - -#[test] -pub(super) fn oid4vci_requires_enabled_subject_access() { - let mut config = valid_oid4vci_config(); - config.subject_access.enabled = false; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("subject_access.enabled"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_missing_accepted_audiences() { - let mut config = valid_oid4vci_config(); - config.oid4vci.accepted_token_audiences.clear(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("accepted_token_audiences"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_unknown_claim_reference() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .claim_id = Some("missing-claim".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_unknown_credential_profile_reference() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .credential_profile = "missing-profile".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("unknown credential profile 'missing-profile'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_non_loopback_http_urls() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_issuer = "http://issuer.example".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("https") && reason.contains("loopback"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_endpoint_without_path() { - let mut config = valid_oid4vci_config(); - config.oid4vci.credential_endpoint = "http://127.0.0.1:4325".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("endpoint path"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_rejects_vct_outside_credential_issuer() { - let mut config = valid_oid4vci_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "https://vct.example/credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "https://vct.example/credentials/civil-status".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("credential_configurations.vct") - && reason.contains("oid4vci.credential_issuer"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_vct_outside_credentials_path() { - let mut config = valid_oid4vci_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/not-credentials/civil-status".to_string(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .vct = "http://127.0.0.1:4325/not-credentials/civil-status".to_string(); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("vct path") && reason.contains("/credentials/"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_duplicate_credential_configuration_vct() { - let mut config = valid_oid4vci_config(); - let duplicate = config - .oid4vci - .credential_configurations - .get("date_of_birth_sd_jwt") - .unwrap() - .clone(); - config - .oid4vci - .credential_configurations - .insert("duplicate_sd_jwt".to_string(), duplicate); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("vct") && reason.contains("unique"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_public_nonce_endpoint() { - let mut config = valid_oid4vci_config(); - config.oid4vci.nonce_endpoint = Some("http://127.0.0.1:4325/oid4vci/nonce".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("nonce_endpoint") && reason.contains("must be omitted"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_nonce_and_proof_timing_bounds() { - let mut config = valid_oid4vci_config(); - config.oid4vci.nonce.ttl_seconds = 0; - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("nonce.ttl_seconds"), "unexpected: {reason}"); - - config.oid4vci.nonce.ttl_seconds = 300; - config.oid4vci.proof.max_age_seconds = 601; - - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("proof.max_age_seconds"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_algorithm_lists() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .proof_signing_alg_values_supported - .push("ES256".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("ES256"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn oid4vci_rejects_bad_binding_methods() { - let mut config = valid_oid4vci_config(); - config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .unwrap() - .cryptographic_binding_methods_supported - .push("did:key".to_string()); - - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("did:key"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_oidc_authenticator() { - let mut config = valid_subject_access_config(); - config.auth.oidc = None; - config.auth.api_keys.push(EvidenceCredentialConfig { - id: "api".to_string(), - fingerprint: CredentialFingerprintRef { - provider: registry_platform_authcommon::CredentialFingerprintProvider::Env, - name: Some("API_HASH".to_string()), - path: None, - }, - scopes: Vec::new(), - authorization_details: None, - }); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("auth.oidc"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_unsafe_subject_claim_names() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "national id".to_string(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("token_claim"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_sub_without_explicit_civil_id_opt_in() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "sub".to_string(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allow_sub_as_civil_id"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_allows_sub_with_explicit_civil_id_opt_in() { - let mut config = valid_subject_access_config(); - config.subject_access.subject_binding.token_claim = "sub".to_string(); - config.subject_access.subject_binding.allow_sub_as_civil_id = true; - - assert!(config.validate().is_ok()); -} - -#[test] -pub(super) fn subject_access_subject_request_field_only_accepts_subject_id() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/keys - audiences: - - registry-notary-citizen -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectHeader - id_type: national_id -"#, - ) - .expect_err("unsupported request_field variant must fail deserialization"); - let msg = err.to_string(); - assert!( - msg.contains("SubjectHeader") || msg.contains("unknown variant"), - "unexpected error: {msg}" - ); -} - -#[test] -pub(super) fn shared_canonical_oidc_fixture_parses() { - let config = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - audiences: - - registry-notary - jwks_url: https://id.example.gov/oauth/v2/keys - allowed_algorithms: - - EdDSA - allowed_token_types: - - JWT - leeway: 30s -"#, - ) - .expect("shared canonical OIDC fixture parses"); - let oidc = config.auth.oidc.expect("oidc config"); - - assert_eq!(oidc.issuer, "https://id.example.gov"); - assert_eq!(oidc.audiences, vec!["registry-notary"]); - assert_eq!(oidc.jwks_url, "https://id.example.gov/oauth/v2/keys"); - assert_eq!(oidc.allowed_algorithms, vec!["EdDSA"]); - assert_eq!(oidc.allowed_token_types, vec!["JWT"]); - assert_eq!(oidc.leeway, Duration::from_secs(30)); -} - -#[test] -pub(super) fn subject_access_rejects_non_exact_normalization() { - let err = serde_norway::from_str::( - r#" -evidence: - enabled: true -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/keys - audiences: - - registry-notary-citizen -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectId - id_type: national_id - normalize: lowercase -"#, - ) - .expect_err("unsupported normalize variant must fail deserialization"); - let msg = err.to_string(); - assert!( - msg.contains("lowercase") || msg.contains("unknown variant"), - "unexpected error: {msg}" - ); -} - -#[test] -pub(super) fn subject_access_requires_nonempty_allow_lists() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_claims.clear(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allowed_claims must not be empty"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_unused_allow_list_entries() { - let mut config = valid_subject_access_config(); - config - .subject_access - .allowed_formats - .push("application/unsupported".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("allowed_formats entry"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_batch_evaluate_operation() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_operations.batch_evaluate = true; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("batch_evaluate"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_wildcard_wallet_origins() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_wallet_origins = vec!["*".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("wildcards"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_allows_empty_wallet_origins_for_non_browser_flows() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_wallet_origins.clear(); - - config - .validate() - .expect("wallet origins are optional for CLI and server-side flows"); -} - -#[test] -pub(super) fn subject_access_rejects_zero_rate_limits() { - let mut config = valid_subject_access_config(); - config.subject_access.rate_limits.per_principal_per_minute = 0; - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("rate_limits"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_allowed_client_or_audience() { - let mut config = valid_subject_access_config(); - config - .subject_access - .citizen_clients - .allowed_client_ids - .clear(); - config - .subject_access - .citizen_clients - .allowed_audiences - .clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("citizen_clients"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_requires_scopes_to_be_mapped() { - let mut config = valid_subject_access_config(); - config.auth.oidc.as_mut().unwrap().scope_map.clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("scope_map"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_required_scope_policy_requires_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.required_scopes.clear(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("scope_policy requires required_scopes"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_optional_scope_policy_still_requires_scope_mapping() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Optional; - config.auth.oidc.as_mut().unwrap().scope_map.clear(); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("scope_map"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_optional_scope_policy_passes_with_required_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Optional; - - config - .validate() - .expect("optional scope policy uses configured subject-access scopes"); -} - -#[test] -pub(super) fn subject_access_disabled_scope_policy_rejects_required_scopes() { - let mut config = valid_subject_access_config(); - config.subject_access.scope_policy = SubjectAccessScopePolicy::Disabled; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("scope_policy = disabled"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_leeway_above_token_policy() { - let mut config = valid_subject_access_config(); - config.auth.oidc.as_mut().unwrap().leeway = Duration::from_secs(61); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("leeway"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_unknown_claim_references() { - let mut config = valid_subject_access_config(); - config.subject_access.allowed_claims = vec!["missing-claim".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("unknown claim 'missing-claim'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_unallowed_claim_purpose() { - let mut config = valid_subject_access_config(); - config.evidence.claims[0].purpose = Some("machine_verification".to_string()); - - let reason = expect_subject_access_error(&config); - assert!(reason.contains("unallowed purpose"), "unexpected: {reason}"); -} - -#[test] -pub(super) fn subject_access_rejects_claim_without_purpose() { - let mut config = valid_subject_access_config(); - config.evidence.claims[0].purpose = None; - - let error = config - .validate() - .expect_err("every registry-backed claim requires a purpose"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimEvidenceMode { ref reason, .. } - if reason.contains("explicit bounded purpose") - )); -} - -#[test] -pub(super) fn subject_access_rejects_unknown_profile_references() { - let mut config = valid_subject_access_config(); - config.subject_access.credential_profiles = vec!["missing-profile".to_string()]; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("unknown profile 'missing-profile'"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_citizen_profile_validity_above_ceiling() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .validity_seconds = 601; - - let error = config - .validate() - .expect_err("validity above general ceiling is rejected"); - assert!(matches!( - error, - EvidenceConfigError::InvalidCredentialProfileValidity { - profile, - validity_seconds: 601, - max_validity_seconds: 600, - } if profile == "civil_status_sd_jwt" - )); -} - -#[test] -pub(super) fn subject_access_accepts_citizen_profile_validity_at_configured_ceiling() { - const AGENCY_CREDENTIAL_VALIDITY_SECONDS: u64 = 31_536_000; - let mut config = valid_subject_access_config(); - config.evidence.max_credential_validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS; - config - .subject_access - .token_policy - .max_credential_validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS; - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .validity_seconds = AGENCY_CREDENTIAL_VALIDITY_SECONDS as i64; - - config - .validate() - .expect("wallet-held credential validity may reach the configured ceiling"); -} - -#[test] -pub(super) fn subject_access_profile_without_validity_uses_default_under_ceiling() { - let mut config = valid_subject_access_config(); - let profile: CredentialProfileConfig = serde_norway::from_str( - r#" -format: application/dc+sd-jwt -issuer: did:web:issuer.example -signing_key: issuer-key -vct: https://issuer.example/credentials/civil-status -holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk -allowed_claims: - - date-of-birth -disclosure: - allowed: - - value -"#, - ) - .expect("profile YAML is valid"); - config - .evidence - .credential_profiles - .insert("civil_status_sd_jwt".to_string(), profile); - - config - .validate() - .expect("omitted credential validity defaults under subject-access ceiling"); - assert_eq!( - config - .evidence - .credential_profiles - .get("civil_status_sd_jwt") - .unwrap() - .validity_seconds, - 600 - ); -} - -#[test] -pub(super) fn subject_access_rejects_profile_without_did_holder_binding() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .mode = "none".to_string(); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("holder_binding.mode must be did"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_rejects_profile_without_required_holder_proof() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .proof_of_possession = None; - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("holder_binding.proof_of_possession must be required"), - "unexpected: {reason}" - ); -} - -#[test] -pub(super) fn subject_access_keeps_did_jwk_proof_of_possession_validation() { - let mut config = valid_subject_access_config(); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .unwrap() - .holder_binding - .allowed_did_methods - .push("did:key".to_string()); - - let err = config - .validate() - .expect_err("did:key must still fail proof-of-possession validation"); - assert!(matches!( - err, - EvidenceConfigError::UnsupportedCredentialProfileDidMethods { .. } - )); -} - -pub(super) fn second_signing_key() -> SigningKeyConfig { - serde_norway::from_str( - r#" -provider: local_jwk_env -private_jwk_env: ACCESS_TOKEN_KEY -alg: EdDSA -kid: did:web:issuer.example#access-token-key -status: active -"#, - ) - .expect("access-token signing key is valid YAML") -} - -pub(super) fn publish_only_access_token_verification_key(kid: &str) -> SigningKeyConfig { - let mut key = second_signing_key(); - key.kid = kid.to_string(); - key.status = SigningKeyStatus::PublishOnly; - key.private_jwk_env = String::new(); - key.public_jwk_env = "ACCESS_TOKEN_PUBLIC_KEY".to_string(); - key -} - -pub(super) fn test_public_jwk(kid: &str, x: &str) -> PublicJwk { - PublicJwk::parse( - &serde_json::json!({ - "kty": "OKP", - "crv": "Ed25519", - "x": x, - "alg": "EdDSA", - "kid": kid, - }) - .to_string(), - ) - .expect("test public JWK parses") -} - -/// A pre-auth-enabled oid4vci config with a dedicated access-token signing -/// key, distinct from the credential-signing key. -pub(super) fn valid_pre_auth_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_oid4vci_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config - .evidence - .signing_keys - .insert("access-token-key".to_string(), second_signing_key()); - config.oid4vci.pre_authorized_code = serde_norway::from_str( - r#" -enabled: true -tx_code: - required: true - input_mode: numeric - length: 6 -esignet: - client_id: registry-lab-live-client - client_signing_key_id: issuer-key - redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback - authorize_url: https://id.example.gov/authorize - token_url: https://id.example.gov/oauth/v2/token - issuer: https://id.example.gov - jwks_uri: https://id.example.gov/oauth/.well-known/jwks.json - scopes: - - openid -pre_authorized_code_ttl_seconds: 300 -"#, - ) - .expect("pre-auth config is valid YAML"); - config.auth.access_token_signing = serde_norway::from_str( - r#" -enabled: true -issuer: http://127.0.0.1:4325 -audiences: - - http://127.0.0.1:4325 -allowed_algorithms: - - EdDSA -token_typ: registry-notary-access+jwt -signing_key_id: access-token-key -access_token_ttl_seconds: 300 -"#, - ) - .expect("access-token signing config is valid YAML"); - config -} - -pub(super) fn expect_access_token_signing_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("access-token signing config must fail validation") - { - EvidenceConfigError::InvalidAccessTokenSigningConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} diff --git a/crates/registry-notary-core/src/config/tests/preauth.rs b/crates/registry-notary-core/src/config/tests/preauth.rs deleted file mode 100644 index dd5b7ad3e..000000000 --- a/crates/registry-notary-core/src/config/tests/preauth.rs +++ /dev/null @@ -1,561 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, issuance::*, root::*}; - -#[test] -pub(super) fn pre_auth_and_access_token_signing_are_disabled_by_default() { - let config = minimal_config(); - assert!(!config.oid4vci.pre_authorized_code.enabled); - assert!(!config.auth.access_token_signing.enabled); - config - .validate() - .expect("a config that omits the pre-auth blocks still validates"); -} - -#[test] -pub(super) fn omitted_pre_auth_blocks_use_safe_defaults() { - let config = minimal_config(); - let tx_code = &config.oid4vci.pre_authorized_code.tx_code; - assert!(tx_code.required, "tx_code is required by default"); - assert_eq!(tx_code.input_mode, "numeric"); - let signing = &config.auth.access_token_signing; - assert_eq!(signing.allowed_algorithms, vec!["EdDSA".to_string()]); - assert_eq!(signing.token_typ, "registry-notary-access+jwt"); -} - -#[test] -pub(super) fn valid_pre_auth_config_validates() { - valid_pre_auth_config() - .validate() - .expect("a fully-configured pre-auth config validates"); -} - -#[test] -pub(super) fn access_token_signing_enabled_requires_issuer() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.issuer = String::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("issuer")); -} - -#[test] -pub(super) fn access_token_signing_enabled_requires_audiences() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.audiences = Vec::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("audiences")); -} - -#[test] -pub(super) fn access_token_signing_requires_known_signing_key() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.signing_key_id = "missing-key".to_string(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("evidence.signing_keys")); -} - -#[test] -pub(super) fn access_token_signing_key_must_be_distinct_from_credential_key() { - let mut config = valid_pre_auth_config(); - // Point the access-token key at the credential-signing key. - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("distinct from credential profile")); -} - -#[test] -pub(super) fn resolved_signing_key_material_must_not_be_reused_under_distinct_kids() { - let config = valid_pre_auth_config(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let access_token_public_jwk = test_public_jwk( - "did:web:issuer.example#access-token-key", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - let err = config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("access-token-key", &access_token_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect_err("same public key material under different kids must fail"); - - match err { - EvidenceConfigError::InvalidSigningKeyConfig { key, reason } => { - assert_eq!(key, "access-token-key"); - assert!(reason.contains("reuses public key material")); - assert!(reason.contains("issuer-key")); - } - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn resolved_signing_key_material_accepts_distinct_public_keys() { - let config = valid_pre_auth_config(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let access_token_public_jwk = test_public_jwk( - "did:web:issuer.example#access-token-key", - "pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("access-token-key", &access_token_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect("distinct public key material is valid"); -} - -#[test] -pub(super) fn resolved_signing_key_material_allows_esignet_rp_key_to_reuse_credential_material() { - // Issue #173 confines reuse detection to the separated EdDSA roles. The - // eSignet pre-authorized-code RP client key is a relaxed role that is - // deliberately allowed to share material with the credential issuer key, - // so it must not appear in the reuse-scoped set and must not trip the - // detector even when its resolved material matches a credential key. - let mut config = valid_pre_auth_config(); - let mut esignet_key = second_signing_key(); - esignet_key.kid = "did:web:rp.example#esignet-rp-key".to_string(); - config - .evidence - .signing_keys - .insert("esignet-rp-key".to_string(), esignet_key); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - let credential_public_jwk = test_public_jwk( - "did:web:issuer.example#key-1", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - let esignet_public_jwk = test_public_jwk( - "did:web:rp.example#esignet-rp-key", - "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", - ); - - let reuse_scoped_key_ids = config.reuse_scoped_signing_key_ids(); - assert!( - !reuse_scoped_key_ids.contains("esignet-rp-key"), - "the eSignet RP client key must be excluded from reuse-scoped roles" - ); - - config - .evidence - .validate_resolved_signing_key_material( - [ - ("issuer-key", &credential_public_jwk), - ("esignet-rp-key", &esignet_public_jwk), - ], - &reuse_scoped_key_ids, - ) - .expect("eSignet RP key may reuse credential key material"); -} - -#[test] -pub(super) fn access_token_signing_key_must_be_active() { - let mut config = valid_pre_auth_config(); - config - .evidence - .signing_keys - .get_mut("access-token-key") - .expect("access-token key exists") - .status = SigningKeyStatus::PublishOnly; - // PublishOnly requires public_jwk_env and no private_jwk_env. - let key = config - .evidence - .signing_keys - .get_mut("access-token-key") - .expect("access-token key exists"); - key.public_jwk_env = "ACCESS_TOKEN_PUBLIC_KEY".to_string(); - key.private_jwk_env = String::new(); - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("active signing key")); -} - -#[test] -pub(super) fn access_token_signing_rejects_non_eddsa_algorithms() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.allowed_algorithms = vec!["RS256".to_string()]; - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("EdDSA")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_accept_publish_only_old_keys() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "access-token-key-old".to_string(), - publish_only_access_token_verification_key("did:web:issuer.example#access-token-key-old"), - ); - config.auth.access_token_signing.verification_key_ids = - vec!["access-token-key-old".to_string()]; - - config - .validate() - .expect("publish-only verification keys are valid during rotation"); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_not_repeat_active_key() { - let mut config = valid_pre_auth_config(); - config.auth.access_token_signing.verification_key_ids = vec!["access-token-key".to_string()]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("must not repeat active signing_key_id")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_be_unique() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "access-token-key-old".to_string(), - publish_only_access_token_verification_key("did:web:issuer.example#access-token-key-old"), - ); - config.auth.access_token_signing.verification_key_ids = vec![ - "access-token-key-old".to_string(), - "access-token-key-old".to_string(), - ]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("duplicate key")); -} - -#[test] -pub(super) fn access_token_verification_key_ids_must_be_publish_only() { - let mut config = valid_pre_auth_config(); - let mut active_old_key = second_signing_key(); - active_old_key.kid = "did:web:issuer.example#access-token-key-old".to_string(); - config - .evidence - .signing_keys - .insert("access-token-key-old".to_string(), active_old_key); - config.auth.access_token_signing.verification_key_ids = - vec!["access-token-key-old".to_string()]; - - let reason = expect_access_token_signing_error(&config); - assert!(reason.contains("publish_only")); -} - -/// A local JWK signing key entry. Config validation only checks the alg -/// string and the per-provider fields, so a dummy private_jwk_env name -/// suffices; the JWK itself is not decoded at validation time. -pub(super) fn local_jwk_signing_key_with_alg( - alg: &str, - private_jwk_env: &str, - kid: &str, -) -> SigningKeyConfig { - SigningKeyConfig { - provider: SigningKeyProviderConfig::LocalJwkEnv, - alg: alg.to_string(), - kid: kid.to_string(), - status: SigningKeyStatus::Active, - publish_until_unix_seconds: None, - private_jwk_env: private_jwk_env.to_string(), - public_jwk_env: String::new(), - module_path: String::new(), - token_label: String::new(), - pin_env: String::new(), - key_label: String::new(), - key_id_hex: String::new(), - path: String::new(), - password_env: String::new(), - } -} - -pub(super) fn rs256_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { - local_jwk_signing_key_with_alg(CLIENT_ASSERTION_SIGNING_ALG_RS256, private_jwk_env, kid) -} - -pub(super) fn es256_signing_key(private_jwk_env: &str, kid: &str) -> SigningKeyConfig { - local_jwk_signing_key_with_alg(CREDENTIAL_SIGNING_ALG_ES256, private_jwk_env, kid) -} - -pub(super) fn expect_signing_key_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("signing-key config must fail validation") - { - EvidenceConfigError::InvalidSigningKeyConfig { reason, .. } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn esignet_rp_client_assertion_key_may_be_rs256() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - config - .validate() - .expect("an RS256 eSignet RP client-assertion key validates"); -} - -#[test] -pub(super) fn pre_auth_client_signing_key_must_exist() { - let mut config = valid_pre_auth_config(); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "missing-rp-key".to_string(); - let reason = match config - .validate() - .expect_err("a missing RP client-assertion key must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("client_signing_key_id")); - assert!(reason.contains("evidence.signing_keys")); -} - -#[test] -pub(super) fn pre_auth_client_signing_key_must_be_active() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config - .oid4vci - .pre_authorized_code - .esignet - .client_signing_key_id = "esignet-rp-key".to_string(); - // PublishOnly cannot sign; it requires public_jwk_env and no private_jwk_env. - let key = config - .evidence - .signing_keys - .get_mut("esignet-rp-key") - .expect("esignet rp key exists"); - key.status = SigningKeyStatus::PublishOnly; - key.public_jwk_env = "ESIGNET_RP_PUBLIC_KEY".to_string(); - key.private_jwk_env = String::new(); - let reason = match config - .validate() - .expect_err("an inactive RP client-assertion key must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - }; - assert!(reason.contains("active signing key")); -} - -#[test] -pub(super) fn rs256_signing_key_rejected_as_credential_profile_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - rs256_signing_key("ESIGNET_RP_KEY", "did:web:issuer.example#esignet-rp-key"), - ); - // Point a credential profile at the RS256 key. - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .signing_key = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("RS256")); - assert!(reason.contains("credential profile")); -} - -#[test] -pub(super) fn es256_signing_key_may_be_credential_profile_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "issuer-p256-key".to_string(), - es256_signing_key("ISSUER_P256_KEY", "did:web:issuer.example#p256-key"), - ); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .signing_key = "issuer-p256-key".to_string(); - config - .validate() - .expect("an ES256 credential profile signing key validates"); -} - -#[test] -pub(super) fn non_eddsa_signing_key_rejected_as_access_token_key() { - let mut config = valid_pre_auth_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - es256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config.auth.access_token_signing.signing_key_id = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("client_signing_key_id")); -} - -#[test] -pub(super) fn non_eddsa_signing_key_rejected_as_federation_key() { - let mut config = valid_federation_config(); - config.evidence.signing_keys.insert( - "esignet-rp-key".to_string(), - es256_signing_key("ESIGNET_RP_KEY", "did:web:rp.example#esignet-rp-key"), - ); - config.federation.signing.signing_key = "esignet-rp-key".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains("client_signing_key_id")); -} - -#[test] -pub(super) fn signing_key_alg_must_be_eddsa_es256_or_rs256() { - let mut config = valid_pre_auth_config(); - config - .evidence - .signing_keys - .get_mut("issuer-key") - .expect("issuer-key exists") - .alg = "PS256".to_string(); - let reason = expect_signing_key_error(&config); - assert!(reason.contains(CREDENTIAL_SIGNING_ALG_EDDSA)); - assert!(reason.contains(CREDENTIAL_SIGNING_ALG_ES256)); - assert!(reason.contains(CLIENT_ASSERTION_SIGNING_ALG_RS256)); -} - -#[test] -pub(super) fn pre_auth_enabled_requires_oid4vci_enabled() { - let mut config = valid_pre_auth_config(); - config.oid4vci.enabled = false; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("requires oid4vci.enabled = true")); -} - -#[test] -pub(super) fn pre_auth_allows_optional_tx_code() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .validate() - .expect("operators may explicitly disable tx_code when required for wallet interop"); -} - -#[test] -pub(super) fn pre_auth_optional_tx_code_caps_bearer_offer_ttl() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS + 1; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("tx_code.required = false")); - - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .validate() - .expect("bearer-offer mode validates at the explicit cap"); -} - -#[test] -pub(super) fn pre_auth_requires_esignet_client_id() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.esignet.client_id = String::new(); - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("esignet.client_id")); -} - -#[test] -pub(super) fn pre_auth_rejects_out_of_range_code_ttl() { - let mut config = valid_pre_auth_config(); - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = 0; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("pre_authorized_code_ttl_seconds")); -} - -#[test] -pub(super) fn pre_auth_requires_tx_code_rate_limit() { - let mut config = valid_pre_auth_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 0; - let reason = expect_oid4vci_error(&config); - assert!(reason.contains("tx_code_attempts_per_code_per_minute")); -} - -#[test] -pub(super) fn pre_auth_optional_tx_code_does_not_require_tx_code_rate_limit() { - let mut config = valid_pre_auth_config(); - config.oid4vci.pre_authorized_code.tx_code.required = false; - config - .oid4vci - .pre_authorized_code - .pre_authorized_code_ttl_seconds = MAX_BEARER_PRE_AUTHORIZED_CODE_TTL_SECONDS; - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 0; - config - .validate() - .expect("tx_code attempt limits are only required when tx_code is required"); -} - -#[test] -pub(super) fn pre_auth_userinfo_binding_requires_esignet_userinfo_url() { - let mut config = valid_pre_auth_config(); - config.subject_access.subject_binding.claim_source = SubjectAccessClaimSource::Userinfo; - // Satisfy the resource-server userinfo rule so the failure is - // specifically the missing pre-auth eSignet userinfo endpoint. - if let Some(oidc) = config.auth.oidc.as_mut() { - oidc.userinfo_endpoint = Some("https://id.example.gov/userinfo".to_string()); - } - config.oid4vci.pre_authorized_code.esignet.userinfo_url = String::new(); - let reason = expect_oid4vci_error(&config); - assert!( - reason.contains("esignet.userinfo_url"), - "unexpected reason: {reason}" - ); -} - -#[test] -pub(super) fn pre_auth_userinfo_binding_accepts_configured_userinfo_url() { - let mut config = valid_pre_auth_config(); - config.subject_access.subject_binding.claim_source = SubjectAccessClaimSource::Userinfo; - if let Some(oidc) = config.auth.oidc.as_mut() { - oidc.userinfo_endpoint = Some("https://id.example.gov/userinfo".to_string()); - } - config.oid4vci.pre_authorized_code.esignet.userinfo_url = - "https://id.example.gov/userinfo".to_string(); - config - .validate() - .expect("userinfo-sourced pre-auth binding validates with a userinfo_url"); -} diff --git a/crates/registry-notary-core/src/config/tests/relay.rs b/crates/registry-notary-core/src/config/tests/relay.rs deleted file mode 100644 index 6880695a3..000000000 --- a/crates/registry-notary-core/src/config/tests/relay.rs +++ /dev/null @@ -1,1789 +0,0 @@ -use super::root::{ - expect_subject_access_error, minimal_claim, valid_delegated_subject_access_config, - valid_subject_access_config, -}; -use super::support::minimal_config; -use super::*; - -const CONTRACT_HASH: &str = - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - -fn relay_connection() -> RelayConnectionConfig { - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses") -} - -fn registry_mode(consultation_name: &str) -> ClaimEvidenceMode { - ClaimEvidenceMode::RegistryBacked { - consultations: std::collections::BTreeMap::from([( - consultation_name.to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.person-status.exact".to_string(), - contract_hash: CONTRACT_HASH.to_string(), - }, - inputs: std::collections::BTreeMap::from([( - "subject_id".to_string(), - RelayConsultationInput::TargetId, - )]), - outputs: std::collections::BTreeMap::from([( - "registration_status".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 64, - }, - )]), - }, - )]), - } -} - -fn make_registry_backed(claim: &mut ClaimDefinition, consultation_name: &str) { - claim.evidence_mode = registry_mode(consultation_name); - claim.purpose = Some("benefit-verification".to_string()); - claim.required_scopes = vec!["registry:consult:person-status".to_string()]; - claim.value.value_type = "string".to_string(); - claim.value.nullable = true; - claim.rule = RuleConfig::ConsultationOutput { - consultation: consultation_name.to_string(), - output: "registration_status".to_string(), - }; -} - -fn valid_registry_backed_config() -> StandaloneRegistryNotaryConfig { - let mut config = minimal_config(); - config.evidence.relay = Some(relay_connection()); - let mut claim = minimal_claim("person-status-known"); - make_registry_backed(&mut claim, "person_status"); - config.evidence.claims = vec![claim]; - config -} - -fn registry_backed_config_with_output( - output_name: &str, - output: RelayOutputContract, -) -> StandaloneRegistryNotaryConfig { - let mut config = valid_registry_backed_config(); - let value_type = output.value_type().to_string(); - let claim = &mut config.evidence.claims[0]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation exists") - .outputs = BTreeMap::from([(output_name.to_string(), output)]); - claim.rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: output_name.to_string(), - }; - claim.value.value_type = value_type; - claim.value.nullable = true; - config -} - -fn expect_mode_error(config: &StandaloneRegistryNotaryConfig, expected: &str) { - let error = config - .validate() - .expect_err("invalid claim evidence mode must fail validation"); - assert!( - matches!( - error, - EvidenceConfigError::InvalidClaimEvidenceMode { ref reason, .. } - if reason.contains(expected) - ), - "unexpected error: {error:?}" - ); -} - -#[test] -fn consultation_rules_reject_removed_source_named_variants() { - let output: RuleConfig = serde_norway::from_str( - r#" -type: consultation_output -consultation: person_status -output: registration_status -"#, - ) - .expect("consultation output rule parses"); - assert!(matches!(output, RuleConfig::ConsultationOutput { .. })); - - let matched: RuleConfig = serde_norway::from_str( - r#" -type: consultation_matched -consultation: person_status -"#, - ) - .expect("consultation matched rule parses"); - assert!(matches!(matched, RuleConfig::ConsultationMatched { .. })); - - for removed in [ - "type: extract\nsource: person_status\nfield: registration_status\n", - "type: exists\nsource: person_status\n", - ] { - serde_norway::from_str::(removed) - .expect_err("unreleased source-named rule variants must not remain aliases"); - } -} - -#[test] -fn claim_evidence_mode_is_required_and_closed() { - let missing = serde_norway::from_str::( - r#" -id: missing-mode -title: Missing mode -version: "1" -subject_type: person -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("missing evidence_mode must fail deserialization"); - assert!(missing.to_string().contains("evidence_mode")); - - serde_norway::from_str::( - r#" -id: unknown-mode -title: Unknown mode -version: "1" -subject_type: person -evidence_mode: - type: inferred -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("unknown evidence_mode must fail deserialization"); - - serde_norway::from_str::( - r#" -id: removed-mode -title: Removed mode -version: "1" -subject_type: person -evidence_mode: - type: self_attested -rule: - type: cel - expression: "true" -"#, - ) - .expect_err("the removed self_attested evidence mode must remain rejected"); -} - -#[test] -fn consultation_shape_rejects_native_capabilities_and_redacts_bad_target_mapping() { - serde_norway::from_str::( - r#" -id: native-route -title: Native route -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - person_status: - profile: - id: example.person-status.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - route: /private/person -rule: - type: consultation_matched - consultation: person_status -"#, - ) - .expect_err("native routes must not fit the closed consultation schema"); - - let sensitive_target = "actual-person-identifier"; - let error = serde_norway::from_str::(&format!( - r#" -id: bad-input -title: Bad input -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - person_status: - profile: - id: example.person-status.exact - contract_hash: {CONTRACT_HASH} - inputs: - subject_id: {sensitive_target} -rule: - type: consultation_matched - consultation: person_status -"# - )) - .expect_err("only a closed symbolic target mapping is accepted"); - assert!(!error.to_string().contains(sensitive_target)); -} - -#[test] -fn consultation_accepts_bounded_named_target_identifiers() { - let claim: ClaimDefinition = serde_norway::from_str( - r#" -id: named-identifier -title: Named identifier -version: "1" -subject_type: person -evidence_mode: - type: registry_backed - consultations: - birth_record: - profile: - id: example.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - uin: request.target.identifiers.UIN -purpose: civil-registration-verification -required_scopes: [registry:consult:birth-record] -value: - type: boolean -rule: - type: consultation_matched - consultation: birth_record -"#, - ) - .expect("named target identifier mapping parses"); - - let ClaimEvidenceMode::RegistryBacked { consultations } = claim.evidence_mode; - let mapping = consultations["birth_record"].inputs["uin"].clone(); - assert_eq!(mapping.request_path(), "request.target.identifiers.UIN"); - assert_eq!(mapping.request_context_path(), "target.identifiers.UIN"); - assert_eq!( - serde_json::to_value(mapping).expect("mapping serializes"), - "request.target.identifiers.UIN" - ); - - for invalid in [ - "request.target.identifiers.", - "request.target.identifiers.1UIN", - "request.target.identifiers.UIN/other", - "request.target.identifiers.UIN value", - ] { - let yaml = format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - uin: {invalid} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("invalid target identifier mapping is rejected"); - } -} - -#[test] -fn consultation_accepts_closed_target_attributes() { - let consultation: RelayConsultationConfig = serde_norway::from_str(&format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - given_name: request.target.attributes.given_name - family_name: request.target.attributes.family_name - birthdate: request.target.attributes.birthdate -outputs: - exists: {{ type: boolean, nullable: false }} -"#, - )) - .expect("closed target attribute mappings parse"); - assert!(consultation.inputs["given_name"].is_target_derived()); - assert!( - !consultation.inputs["given_name"].is_authenticated_target_identifier(), - "caller-supplied target attributes are not authenticated identifiers" - ); - assert_eq!( - consultation.inputs["birthdate"].request_context_path(), - "target.attributes.birthdate" - ); - - for invalid in [ - "request.target.attributes.", - "request.target.attributes.GivenName", - "request.target.attributes.family-name", - "request.target.attributes.name.given", - "request.target.attributes.given name", - ] { - let yaml = format!( - r#" -profile: - id: example.birth-record.exact - contract_hash: {CONTRACT_HASH} -inputs: - value: {invalid} -outputs: - exists: {{ type: boolean, nullable: false }} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("open target attribute mapping is rejected"); - } -} - -#[test] -fn consultation_accepts_only_closed_requester_identifiers() { - let consultation: RelayConsultationConfig = serde_norway::from_str(&format!( - r#" -profile: - id: example.guardian-link.exact - contract_hash: {CONTRACT_HASH} -inputs: - requester_id: request.requester.id - requester_national_id: request.requester.identifiers.national_id -outputs: - established: {{ type: boolean, nullable: true }} -"#, - )) - .expect("closed requester mappings parse"); - assert!(consultation.inputs["requester_id"].is_requester_derived()); - assert_eq!( - consultation.inputs["requester_national_id"].request_context_path(), - "requester.identifiers.national_id" - ); - - for invalid in [ - "requester.id", - "request.requester.identifiers.", - "request.requester.identifiers.1national_id", - "request.requester.attributes.national_id", - ] { - let yaml = format!( - r#" -profile: - id: example.guardian-link.exact - contract_hash: {CONTRACT_HASH} -inputs: - requester: {invalid} -outputs: - established: {{ type: boolean, nullable: true }} -"#, - ); - serde_norway::from_str::(&yaml) - .expect_err("open requester mapping is rejected"); - } -} - -#[test] -fn registry_backed_claim_accepts_one_pinned_consultation() { - let config = valid_registry_backed_config(); - config - .validate() - .expect("one pinned Relay consultation validates"); - - let serialized = - serde_json::to_value(&config.evidence.claims[0].evidence_mode).expect("mode serializes"); - assert_eq!(serialized["type"], "registry_backed"); - assert_eq!( - serialized["consultations"]["person_status"]["inputs"]["subject_id"], - "target.id" - ); -} - -#[test] -fn relay_activation_allows_independent_profiles_purposes_inputs_and_outputs() { - let mut config = valid_registry_backed_config(); - let mut exists = config.evidence.claims[0].clone(); - exists.id = "person-status-present".to_string(); - exists.title = "Person status known".to_string(); - exists.rule = RuleConfig::ConsultationMatched { - consultation: "person_status".to_string(), - }; - exists.value.value_type = "boolean".to_string(); - config.evidence.claims.push(exists); - config - .validate() - .expect("consultation rules may share one pinned consultation"); - - let mut exists_only = config.clone(); - exists_only.evidence.claims.remove(0); - exists_only - .validate() - .expect("an exists-only journey retains the declared Relay output contract"); - - let mut independent = config.clone(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut independent.evidence.claims[1].evidence_mode - else { - panic!("registry-backed mode") - }; - let consultation = consultations - .get_mut("person_status") - .expect("consultation"); - consultation.profile.id = "example.other-status.exact".to_string(); - consultation.inputs = BTreeMap::from([( - "uin".to_string(), - RelayConsultationInput::TargetIdentifier("request.target.identifiers.UIN".to_string()), - )]); - consultation.outputs = BTreeMap::from([( - "other_status".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 64, - }, - )]); - independent.evidence.claims[1].purpose = Some("civil-registration-verification".to_string()); - independent.evidence.claims[1].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "other_status".to_string(), - }; - independent.evidence.claims[1].value.value_type = "string".to_string(); - independent - .validate() - .expect("independent Relay client identities may coexist"); - - let mut different_output = config.clone(); - different_output.evidence.claims[1].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "other_status".to_string(), - }; - different_output.evidence.claims[1].value.value_type = "string".to_string(); - expect_mode_error( - &different_output, - "must name a declared consultation output", - ); -} - -#[test] -fn registry_backed_consultation_accepts_one_to_sixteen_injective_inputs() { - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs = BTreeMap::from([ - ("subject_id".to_string(), RelayConsultationInput::TargetId), - ( - "birth_date".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.birth_date".to_string(), - ), - ), - ( - "country_code".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.country_code".to_string(), - ), - ), - ( - "registry_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.registry_id".to_string(), - ), - ), - ]); - config.validate().expect("four typed inputs are valid"); - - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - let inputs = &mut consultations - .get_mut("person_status") - .expect("consultation") - .inputs; - for index in 5..=16 { - inputs.insert( - format!("input_{index}"), - RelayConsultationInput::TargetIdentifier(format!( - "request.target.identifiers.input_{index}" - )), - ); - } - config.validate().expect("sixteen typed inputs are valid"); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .insert( - "input_17".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.input_17".to_string(), - ), - ); - expect_mode_error(&config, "one to sixteen"); - - let mut duplicate_mapping = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut duplicate_mapping.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .insert( - "duplicate_subject".to_string(), - RelayConsultationInput::TargetId, - ); - expect_mode_error(&duplicate_mapping, "injectively"); -} - -#[test] -fn registry_backed_claim_requires_relay_connection() { - let mut config = valid_registry_backed_config(); - config.evidence.relay = None; - expect_mode_error(&config, "requires evidence.relay"); -} - -#[test] -fn registry_backed_claim_enforces_gates_cardinality_and_rule_binding() { - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].purpose = None; - expect_mode_error(&config, "explicit bounded purpose token"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].required_scopes.clear(); - expect_mode_error(&config, "required_scopes"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].operations.batch_evaluate.enabled = true; - config - .validate() - .expect("registry-backed claims may enable the pre-1.0 batch contract"); - - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - let duplicate = consultations - .first_key_value() - .expect("consultation") - .1 - .clone(); - consultations.insert("other_status".to_string(), duplicate); - expect_mode_error(&config, "exactly one named consultation"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationMatched { - consultation: "other_status".to_string(), - }; - expect_mode_error(&config, "rule.consultation must match"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::Cel { - expression: "true".to_string(), - }; - config - .validate() - .expect("registry-backed CEL may evaluate the declared output namespace"); -} - -#[test] -fn registry_backed_claim_matches_relay_identifier_and_scalar_contract() { - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .profile - .id = "Uppercase.profile".to_string(); - expect_mode_error(&config, "profile.id"); - - let mut config = valid_registry_backed_config(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("registry-backed mode") - }; - consultations - .get_mut("person_status") - .expect("consultation") - .inputs - .clear(); - expect_mode_error(&config, "one to sixteen typed request mappings"); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationMatched { - consultation: "person_status".to_string(), - }; - expect_mode_error( - &config, - "consultation_matched claim value.type must be boolean", - ); - - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "nested.status".to_string(), - }; - config.evidence.claims[0].value.value_type = "string".to_string(); - expect_mode_error(&config, "one top-level Relay output name"); - - for unsupported in ["boolean", "integer", "number", "object"] { - let mut config = valid_registry_backed_config(); - config.evidence.claims[0].rule = RuleConfig::ConsultationOutput { - consultation: "person_status".to_string(), - output: "registration_status".to_string(), - }; - config.evidence.claims[0].value.value_type = unsupported.to_string(); - expect_mode_error(&config, "must match its declared output"); - } -} - -#[test] -fn relay_connection_is_single_closed_bounded_and_redacted() { - let relay: RelayConnectionConfig = serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/private-relay.jwt -root_certificate_path: /run/secrets/private-relay-ca.pem -allowed_private_cidrs: [10.42.0.0/16, fd42::/64] -"#, - ) - .expect("Relay connection parses"); - relay.validate(None).expect("Relay connection validates"); - assert_eq!(relay.allowed_private_cidrs.len(), 2); - assert_eq!(relay.max_in_flight, 8); - let debug = format!("{relay:?}"); - assert!(!debug.contains("relay.internal.example")); - assert!(!debug.contains("private-relay.jwt")); - assert!(!debug.contains("private-relay-ca.pem")); - assert!(debug.contains("custom_root_certificate: true")); - assert!(!debug.contains("10.42.0.0/16")); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/relay.jwt -retry_on_5xx: true -"#, - ) - .expect_err("retry controls are not part of the closed Relay connection"); - - for workload_client_id in ["", "Registry-Notary", "registry:notary"] { - let mut relay = relay_connection(); - relay.workload_client_id = workload_client_id.to_string(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("workload_client_id") - )); - } -} - -#[test] -fn relay_custom_root_requires_an_https_origin_and_canonical_absolute_path() { - let mut relay = relay_connection(); - relay.root_certificate_path = Some(PathBuf::from("relay-ca.pem")); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("root_certificate_path") - )); - - relay.root_certificate_path = Some(PathBuf::from("/run/secrets/relay-ca.pem")); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("requires an https") - )); -} - -#[test] -fn relay_token_file_and_private_cidrs_are_exact_and_bounded() { - relay_connection() - .validate(None) - .expect("target POSIX token path is valid on every configuration host"); - for token_file in [ - PathBuf::from("relative/relay.jwt"), - PathBuf::from("/run/secrets/../relay.jwt"), - PathBuf::from("/run/./secrets/relay.jwt"), - PathBuf::from("//run/secrets/relay.jwt"), - PathBuf::from("/run/secrets/relay.jwt/"), - PathBuf::from("/run\\secrets\\relay.jwt"), - PathBuf::from("C:\\run\\secrets\\relay.jwt"), - PathBuf::from("/"), - ] { - let mut relay = relay_connection(); - relay.token_file = token_file; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("token_file") - )); - } - - for cidrs in [ - vec!["10.42.0.1/16"], - vec!["93.184.216.0/24"], - vec!["100.100.100.200/32"], - vec!["fd00:ec2::254/128"], - vec!["10.42.0.0/16", "10.42.0.0/16"], - ] { - let mut relay = relay_connection(); - relay.allowed_private_cidrs = cidrs - .into_iter() - .map(|cidr| cidr.parse().expect("test CIDR parses")) - .collect(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("allowed_private_cidrs") - )); - } - - let mut relay = relay_connection(); - relay.allowed_private_cidrs = (0..=16) - .map(|index| { - format!("10.{index}.0.0/16") - .parse() - .expect("test CIDR parses") - }) - .collect(); - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("more than 16") - )); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_env: REMOVED_RELAY_TOKEN -"#, - ) - .expect_err("the removed static environment-token mode is rejected"); - - serde_norway::from_str::( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/relay.jwt -token_issuer: REMOVED_DUPLICATE_IDENTITY -"#, - ) - .expect_err("duplicated local workload-token semantics are rejected"); -} - -#[test] -fn relay_connection_concurrency_is_operator_bounded() { - for value in [0, 65] { - let mut relay = relay_connection(); - relay.max_in_flight = value; - assert!(matches!( - relay.validate(None), - Err(EvidenceConfigError::InvalidRelayConfig { ref reason }) - if reason.contains("max_in_flight") - )); - } - - let mut relay = relay_connection(); - relay.max_in_flight = 64; - relay.validate(None).expect("hard ceiling is accepted"); -} - -#[test] -fn relay_connection_is_rejected_when_no_registry_backed_claim_uses_it() { - let mut config = minimal_config(); - config.evidence.claims.clear(); - config.evidence.relay = Some(relay_connection()); - let error = config - .validate() - .expect_err("an unused Relay connection must not be silently accepted"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { ref reason } - if reason.contains("at least one registry_backed claim") - )); -} - -#[test] -fn registry_backed_notary_reserves_five_seconds_around_the_service_hop() { - let mut config = valid_registry_backed_config(); - config.server.request_timeout = Duration::from_secs(29) + Duration::from_millis(999); - let error = config - .validate() - .expect_err("the request timeout must not expire before the Relay service hop"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { ref reason } - if reason.contains("at least 30 seconds") - )); - - config.server.request_timeout = Duration::from_secs(30); - config - .validate() - .expect("the outer request minimum reserves five seconds around the service hop"); -} - -#[test] -fn relay_connection_requires_https_origin_or_explicit_loopback() { - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://relay.internal.example".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - let error = config - .validate() - .expect_err("remote HTTP must fail despite localhost escape"); - assert!(matches!( - error, - EvidenceConfigError::InvalidRelayConfig { .. } - )); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config - .validate() - .expect("explicit HTTP loopback is permitted for a colocated Relay"); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://localhost:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - config - .validate() - .expect_err("local development HTTP requires a literal loopback origin"); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "http://127.0.0.1:8080".to_string(); - relay.allow_insecure_localhost = true; - config.evidence.relay = Some(relay); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::HostedLab); - config - .validate() - .expect("hosted Notary can use the paired Relay through its loopback namespace"); - - let sensitive_path = "private-route"; - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = format!("https://relay.internal.example/{sensitive_path}"); - config.evidence.relay = Some(relay); - let error = config - .validate() - .expect_err("Relay base URL must be an origin in v1"); - let rendered = error.to_string(); - assert!(rendered.contains("path exactly /")); - assert!(!rendered.contains("relay.internal.example")); - assert!(!rendered.contains(sensitive_path)); - - let mut config = valid_registry_backed_config(); - let mut relay = relay_connection(); - relay.base_url = "https://relay.internal.example/private/..".to_string(); - config.evidence.relay = Some(relay); - config - .validate() - .expect_err("a resource path must fail before URL normalization"); -} - -#[test] -fn local_private_http_relay_requires_an_exact_ip_allowlist_entry() { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://10.89.0.4:8080".to_string(); - relay.allowed_private_cidrs = vec!["10.89.0.4/32".parse().expect("test CIDR parses")]; - config - .validate() - .expect("local profile accepts the exact private Relay IP and singleton CIDR"); - - for (base_url, cidrs) in [ - ("http://10.89.0.4:8080", vec![]), - ("http://10.89.0.4:8080", vec!["10.89.0.0/24"]), - ("http://10.89.0.4:8080", vec!["10.89.0.5/32"]), - ("http://relay.internal.example:8080", vec!["10.89.0.4/32"]), - ("http://169.254.169.254:8080", vec!["169.254.169.254/32"]), - ("http://100.100.100.200:8080", vec!["100.100.100.200/32"]), - ("http://0.0.0.0:8080", vec!["0.0.0.0/32"]), - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(crate::deployment::DeploymentProfile::Local); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = base_url.to_string(); - relay.allowed_private_cidrs = cidrs - .into_iter() - .map(|cidr| cidr.parse().expect("test CIDR parses")) - .collect(); - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn private_http_relay_is_rejected_outside_the_local_profile() { - for profile in [ - None, - Some(crate::deployment::DeploymentProfile::HostedLab), - Some(crate::deployment::DeploymentProfile::Production), - Some(crate::deployment::DeploymentProfile::EvidenceGrade), - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = profile; - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://10.89.0.4:8080".to_string(); - relay.allowed_private_cidrs = vec!["10.89.0.4/32".parse().expect("test CIDR parses")]; - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn signed_private_service_http_relay_is_profile_independent_and_explicit() { - for profile in [ - crate::deployment::DeploymentProfile::Local, - crate::deployment::DeploymentProfile::HostedLab, - crate::deployment::DeploymentProfile::Production, - crate::deployment::DeploymentProfile::EvidenceGrade, - ] { - let mut config = valid_registry_backed_config(); - config.deployment.profile = Some(profile); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = "http://registry-relay-consultation:8080".to_string(); - relay.allow_insecure_private_network = true; - config - .validate() - .expect("signed private service HTTP is valid in every deployment profile"); - } - - for base_url in [ - "http://registry-relay-consultation:8080", - "http://127.0.0.1:8080", - "https://registry-relay-consultation:8080", - ] { - let mut config = valid_registry_backed_config(); - let relay = config.evidence.relay.as_mut().expect("Relay connection"); - relay.base_url = base_url.to_string(); - relay.allow_insecure_private_network = - base_url != "http://registry-relay-consultation:8080"; - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRelayConfig { .. }) - )); - } -} - -#[test] -fn removed_plugin_rule_is_rejected_during_deserialization() { - let error = serde_norway::from_str::("type: plugin\nplugin: unavailable\n") - .expect_err("the unreleased plugin rule must not remain accepted"); - assert!(error.to_string().contains("plugin")); -} - -#[test] -fn subject_access_allows_exact_subject_bound_registry_claims() { - let mut config = valid_subject_access_config(); - config.evidence.relay = Some(relay_connection()); - make_registry_backed(&mut config.evidence.claims[0], "civil_status"); - config.evidence.claims[0].purpose = Some("citizen_subject_access".to_string()); - config.evidence.claims[0].required_scopes = vec!["subject_access".to_string()]; - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut config.evidence.claims[0].evidence_mode - else { - panic!("claim is registry backed"); - }; - consultations - .get_mut("civil_status") - .expect("consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier("target.identifiers.national_id".to_string()), - ); - config - .validate() - .expect("the exact authenticated subject identifier may be consulted"); - - let mut wrong_binding = config.clone(); - let ClaimEvidenceMode::RegistryBacked { consultations } = - &mut wrong_binding.evidence.claims[0].evidence_mode - else { - panic!("claim is registry backed"); - }; - consultations - .get_mut("civil_status") - .expect("consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier("target.identifiers.other_person".to_string()), - ); - let reason = expect_subject_access_error(&wrong_binding); - assert!(reason.contains("outside the authenticated subject binding")); -} - -#[test] -fn registry_backed_claim_accepts_registry_backed_dependency() { - let mut config = valid_registry_backed_config(); - let mut dependency = minimal_claim("registry-dependency"); - make_registry_backed(&mut dependency, "registry_dependency"); - config.evidence.claims[0] - .depends_on - .push(dependency.id.clone()); - config.evidence.claims.push(dependency); - config - .validate() - .expect("registry-backed claim dependency is supported"); -} - -#[test] -fn claim_dependency_graph_has_fixed_v1_node_and_edge_bounds() { - let mut too_many_nodes = minimal_config(); - too_many_nodes.evidence.claims = (0..=MAX_CLAIM_DEPENDENCY_NODES_V1) - .map(|index| minimal_claim(&format!("claim-{index}"))) - .collect(); - assert!(matches!( - too_many_nodes.validate(), - Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { nodes, .. }) - if nodes == MAX_CLAIM_DEPENDENCY_NODES_V1 + 1 - )); - - let mut too_many_edges = minimal_config(); - for index in 0..24 { - let mut claim = minimal_claim(&format!("claim-{index}")); - claim.depends_on = (0..index) - .map(|dependency| format!("claim-{dependency}")) - .collect(); - too_many_edges.evidence.claims.push(claim); - } - assert!(matches!( - too_many_edges.validate(), - Err(EvidenceConfigError::ClaimDependencyGraphTooLarge { edges, .. }) - if edges > MAX_CLAIM_DEPENDENCY_EDGES_V1 - )); -} - -#[test] -fn delegated_subject_access_allows_registry_backed_dependency_closures() { - let config = valid_delegated_subject_access_config(); - config - .validate() - .expect("configured delegated Relay proof edge validates"); - - let mut registry_dependent = config; - let delegated = registry_dependent - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated claim"); - make_registry_backed(delegated, "civil_status"); - delegated.purpose = Some("dependent_attestation".to_string()); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut delegated.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "subject_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - registry_dependent - .validate() - .expect("a delegated root may use its committed registry-backed dependency closure"); -} - -#[test] -fn delegated_subject_access_rejects_non_canonical_root_inputs() { - let cases = [ - RelayConsultationInput::TargetAttribute("request.target.attributes.birthdate".to_string()), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.national_id".to_string(), - ), - ]; - - for input in cases { - let mut config = valid_delegated_subject_access_config(); - delegated_claim_consultation_mut(&mut config) - .inputs - .insert("national_id".to_string(), input); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-date-of-birth'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'national_id'") - && reason.contains("target.identifiers.civil_registration_id") - && reason.contains("requester.identifiers.national_id"), - "unexpected error: {reason}" - ); - } -} - -#[test] -fn delegated_subject_access_rejects_non_canonical_transitive_inputs() { - let mut config = valid_delegated_subject_access_config(); - let mut dependency = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") - .clone(); - dependency.id = "dependent-source-record".to_string(); - dependency.title = "Dependent source record".to_string(); - dependency.depends_on.clear(); - dependency.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependency.evidence_mode else { - panic!("delegated dependency is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "national_id".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.birthdate".to_string(), - ), - ); - config.evidence.claims.push(dependency); - config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated root exists") - .depends_on - .push("dependent-source-record".to_string()); - - let reason = expect_subject_access_error(&config); - assert!( - reason.contains("delegated relationship 'guardian'") - && reason.contains("allowed claim 'dependent-date-of-birth'") - && reason.contains("closure claim 'dependent-source-record'") - && reason.contains("consultation 'civil_status'") - && reason.contains("input 'national_id'") - && reason.contains("target.attributes.birthdate") - && reason.contains("target.identifiers.civil_registration_id"), - "unexpected error: {reason}" - ); -} - -#[test] -fn delegated_relay_proof_requires_requester_target_boolean_and_purpose_alignment() { - let mut missing_requester = valid_delegated_subject_access_config(); - delegated_proof_consultation_mut(&mut missing_requester) - .inputs - .retain(|_, input| input.is_target_derived()); - let reason = expect_subject_access_error(&missing_requester); - assert!(reason.contains("authenticated target identifier")); - - let mut missing_target = valid_delegated_subject_access_config(); - delegated_proof_consultation_mut(&mut missing_target) - .inputs - .retain(|_, input| input.is_requester_derived()); - let reason = expect_subject_access_error(&missing_target); - assert!(reason.contains("authenticated target identifier")); - - let mut caller_supplied_attribute = valid_delegated_subject_access_config(); - let consultation = delegated_proof_consultation_mut(&mut caller_supplied_attribute); - consultation.inputs.remove("target_id"); - consultation.inputs.insert( - "target_context".to_string(), - RelayConsultationInput::TargetAttribute( - "request.target.attributes.person_sequence".to_string(), - ), - ); - let reason = expect_subject_access_error(&caller_supplied_attribute); - assert!(reason.contains("authenticated target identifier")); - - let mut non_boolean = valid_delegated_subject_access_config(); - let proof = non_boolean - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim"); - proof.value.value_type = "string".to_string(); - delegated_proof_consultation_mut(&mut non_boolean).outputs = BTreeMap::from([( - "established".to_string(), - RelayOutputContract::String { - nullable: true, - max_bytes: 16, - }, - )]); - let reason = expect_subject_access_error(&non_boolean); - assert!(reason.contains("must produce a boolean result")); - - let mut wrong_purpose = valid_delegated_subject_access_config(); - wrong_purpose - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim") - .purpose = Some("different-purpose".to_string()); - let reason = expect_subject_access_error(&wrong_purpose); - assert!(reason.contains("must declare the same purpose")); -} - -fn delegated_proof_consultation_mut( - config: &mut StandaloneRegistryNotaryConfig, -) -> &mut RelayConsultationConfig { - let proof = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "guardian-link") - .expect("delegated proof claim"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut proof.evidence_mode else { - panic!("delegated proof is registry backed") - }; - consultations - .get_mut("guardian_link") - .expect("delegated proof consultation") -} - -fn delegated_claim_consultation_mut( - config: &mut StandaloneRegistryNotaryConfig, -) -> &mut RelayConsultationConfig { - let claim = config - .evidence - .claims - .iter_mut() - .find(|claim| claim.id == "dependent-date-of-birth") - .expect("delegated claim exists"); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("delegated consultation exists") -} - -#[test] -fn registry_backed_cel_accepts_one_complete_typed_output_map_and_full_date_variable() { - let mut config = valid_registry_backed_config(); - config.evidence.variables.insert( - "as_of_date".to_string(), - RequestVariableConfig { - from: "request.variables.as_of_date".to_string(), - value_type: RequestVariableType::Date, - }, - ); - let claim = &mut config.evidence.claims[0]; - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut claim.evidence_mode else { - panic!("registry-backed mode") - }; - let consultation = consultations - .get_mut("person_status") - .expect("consultation exists"); - consultation.outputs = BTreeMap::from([ - ( - "date_of_birth".to_string(), - RelayOutputContract::Date { nullable: true }, - ), - ( - "sequence".to_string(), - RelayOutputContract::Integer { - nullable: false, - minimum: 0, - maximum: 9_007_199_254_740_991, - }, - ), - ( - "status".to_string(), - RelayOutputContract::String { - nullable: false, - max_bytes: 64, - }, - ), - ]); - claim.rule = RuleConfig::Cel { - expression: "person_status.matched && person_status.date_of_birth != null ? date.age_on(person_status.date_of_birth, as_of_date) >= 18 : false".to_string(), - }; - claim.value.value_type = "boolean".to_string(); - claim.value.nullable = false; - config - .validate() - .expect("typed registry CEL config validates"); - - let mut generic_number = config.clone(); - generic_number.evidence.claims[0].value.value_type = "number".to_string(); - expect_mode_error(&generic_number, "generic Number is not supported"); - - config - .evidence - .variables - .get_mut("as_of_date") - .expect("variable exists") - .from = "request.target.attributes.as_of_date".to_string(); - assert!(matches!( - config.validate(), - Err(EvidenceConfigError::InvalidRequestVariableConfig { .. }) - )); -} - -#[test] -fn authored_typed_output_and_variable_yaml_shape_is_closed() { - let evidence: EvidenceConfig = serde_norway::from_str( - r#" -enabled: true -variables: - as_of_date: - from: request.variables.as_of_date - type: date -"#, - ) - .expect("authored request-variable union parses"); - assert_eq!( - evidence.variables["as_of_date"].value_type, - RequestVariableType::Date - ); - - let consultation: RelayConsultationConfig = serde_norway::from_str( - r#" -profile: - id: opencrvs.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -inputs: - uin: request.target.identifiers.UIN -outputs: - active: { type: boolean, nullable: false } - date_of_birth: { type: date, nullable: true } - sequence: { type: integer, nullable: false, minimum: 0, maximum: 9007199254740991 } - given_name: { type: string, nullable: true, max_bytes: 128 } - parents: - type: array - nullable: false - max_bytes: 4096 - max_items: 2 - items: - type: object - nullable: false - max_bytes: 2048 - fields: - type: - required: true - schema: { type: string, nullable: false, max_bytes: 16 } - name: - required: true - schema: { type: string, nullable: false, max_bytes: 256 } - identifier: - required: false - schema: { type: string, nullable: true, max_bytes: 128 } -"#, - ) - .expect("authored typed consultation parses"); - assert_eq!(consultation.outputs.len(), 5); - assert!(matches!( - consultation.outputs.get("date_of_birth"), - Some(RelayOutputContract::Date { nullable: true }) - )); - let parents = consultation - .outputs - .get("parents") - .expect("parents contract exists"); - let RelayOutputContract::Array { - nullable, - max_bytes, - max_items, - items, - } = parents - else { - panic!("parents is an array contract") - }; - assert!(!nullable); - assert_eq!(*max_bytes, 4096); - assert_eq!(*max_items, 2); - assert!(matches!( - items.as_ref(), - RelayOutputContract::Object { fields, .. } - if !fields["identifier"].required - && matches!( - fields["name"].schema.as_ref(), - RelayOutputContract::String { - nullable: false, - max_bytes: 256 - } - ) - )); - - assert!(serde_norway::from_str::( - r#" -profile: - id: opencrvs.birth-record.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -inputs: { uin: request.target.identifiers.UIN } -outputs: - score: { type: number, nullable: false } -"#, - ) - .is_err()); -} - -#[test] -fn recursive_output_contract_serde_is_closed_and_preserves_scalar_shapes() { - let wire = serde_json::json!({ - "type": "object", - "nullable": true, - "max_bytes": 1024, - "fields": { - "active": { - "required": true, - "schema": { "type": "boolean", "nullable": false } - }, - "children": { - "required": false, - "schema": { - "type": "array", - "nullable": true, - "max_bytes": 512, - "max_items": 4, - "items": { "type": "integer", "nullable": false, "minimum": 0, "maximum": 9 } - } - } - } - }); - let contract: RelayOutputContract = - serde_json::from_value(wire.clone()).expect("recursive contract parses"); - assert_eq!( - serde_json::to_value(contract).expect("recursive contract serializes"), - wire - ); - - for invalid in [ - serde_json::json!({ - "type": "object", - "nullable": false, - "max_bytes": 64, - "fields": {}, - "open": true - }), - serde_json::json!({ - "type": "object", - "nullable": false, - "max_bytes": 64, - "fields": { - "value": { - "required": true, - "schema": { "type": "string", "nullable": false, "max_bytes": 8 }, - "default": "secret" - } - } - }), - serde_json::json!({ - "type": "array", - "nullable": false, - "max_bytes": 64, - "max_items": 2, - "items": { - "type": "string", - "nullable": false, - "max_bytes": 8, - "pattern": ".*" - } - }), - ] { - serde_json::from_value::(invalid) - .expect_err("recursive contract variants must remain closed"); - } -} - -#[test] -fn recursive_output_contract_validates_exact_values_and_composite_byte_bounds() { - let parent = RelayOutputContract::Object { - nullable: false, - max_bytes: 128, - fields: BTreeMap::from([ - ( - "birth_date".to_string(), - RelayOutputObjectFieldContract { - required: false, - schema: Box::new(RelayOutputContract::Date { nullable: false }), - }, - ), - ( - "name".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 8, - }), - }, - ), - ( - "sequence".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Integer { - nullable: false, - minimum: 0, - maximum: 2, - }), - }, - ), - ]), - }; - let parents = RelayOutputContract::Array { - nullable: true, - max_bytes: 256, - max_items: 2, - items: Box::new(parent), - }; - - assert!(parents.validates_value(&serde_json::Value::Null)); - assert!(parents.validates_value(&serde_json::json!([ - { "name": "Ada", "sequence": 0, "birth_date": "2000-02-29" }, - { "name": "Grace", "sequence": 2 } - ]))); - for invalid in [ - serde_json::json!({ "name": "Ada", "sequence": 0 }), - serde_json::json!([{ "sequence": 0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "unknown": true }]), - serde_json::json!([{ "name": "too-long-name", "sequence": 0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 3 }]), - serde_json::json!([{ "name": "Ada", "sequence": 1.0 }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "birth_date": null }]), - serde_json::json!([{ "name": "Ada", "sequence": 0, "birth_date": "2001-02-29" }]), - serde_json::json!([ - { "name": "Ada", "sequence": 0 }, - { "name": "Grace", "sequence": 1 }, - { "name": "Linus", "sequence": 2 } - ]), - ] { - assert!( - !parents.validates_value(&invalid), - "wrong type, shape, or bound must fail" - ); - } - - let byte_bounded = RelayOutputContract::Object { - nullable: false, - max_bytes: 8, - fields: BTreeMap::from([( - "a".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 64, - }), - }, - )]), - }; - assert!( - !byte_bounded.validates_value(&serde_json::json!({ "a": "x" })), - "compact serialized object is nine bytes" - ); - - let nested_byte_bounded = RelayOutputContract::Object { - nullable: false, - max_bytes: 64, - fields: BTreeMap::from([( - "inner".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(byte_bounded), - }, - )]), - }; - assert!( - !nested_byte_bounded.validates_value(&serde_json::json!({ "inner": { "a": "x" } })), - "every composite enforces its own serialized byte bound" - ); -} - -#[test] -fn direct_consultation_output_accepts_object_and_array_claim_types_but_cel_stays_scalar() { - let parent_fields = BTreeMap::from([( - "name".to_string(), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::String { - nullable: false, - max_bytes: 128, - }), - }, - )]); - let object = RelayOutputContract::Object { - nullable: false, - max_bytes: 1024, - fields: parent_fields.clone(), - }; - registry_backed_config_with_output("parent", object) - .validate() - .expect("direct object-valued consultation output validates"); - - let array = RelayOutputContract::Array { - nullable: false, - max_bytes: 4096, - max_items: 2, - items: Box::new(RelayOutputContract::Object { - nullable: false, - max_bytes: 1024, - fields: parent_fields, - }), - }; - let config = registry_backed_config_with_output("parents", array); - config - .validate() - .expect("direct array-valued consultation output validates"); - - let mut wrong_claim_type = config.clone(); - wrong_claim_type.evidence.claims[0].value.value_type = "object".to_string(); - expect_mode_error(&wrong_claim_type, "must match its declared output"); - - let mut cel = config; - cel.evidence.claims[0].rule = RuleConfig::Cel { - expression: "true".to_string(), - }; - cel.evidence.claims[0].value.value_type = "boolean".to_string(); - cel.evidence.claims[0].value.nullable = false; - cel.validate() - .expect("a scalar CEL claim may share a consultation with direct composite outputs"); -} - -#[test] -fn recursive_output_schema_enforces_envelope_compatible_platform_bounds() { - fn array_layers(depth: usize) -> RelayOutputContract { - if depth == 1 { - RelayOutputContract::Boolean { nullable: false } - } else { - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 1, - items: Box::new(array_layers(depth - 1)), - } - } - } - - registry_backed_config_with_output("value", array_layers(6)) - .validate() - .expect("six output-schema levels fit beneath the two-level Relay envelope"); - - for output in [ - array_layers(7), - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: MAX_RELAY_OUTPUT_ARRAY_ITEMS_V1 + 1, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1 + 1, - max_items: 1, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 256, - items: Box::new(RelayOutputContract::Array { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - max_items: 16, - items: Box::new(RelayOutputContract::Boolean { nullable: false }), - }), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..33) - .map(|index| { - ( - format!("field_{index}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - ) - }) - .collect(), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: BTreeMap::from([( - "x".repeat(MAX_RELAY_OUTPUT_NAME_BYTES_V1 + 1), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { nullable: false }), - }, - )]), - }, - RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..8) - .map(|outer| { - ( - format!("outer_{outer}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Object { - nullable: false, - max_bytes: MAX_RELAY_OUTPUT_VALUE_BYTES_V1, - fields: (0..32) - .map(|inner| { - ( - format!("inner_{inner}"), - RelayOutputObjectFieldContract { - required: true, - schema: Box::new(RelayOutputContract::Boolean { - nullable: false, - }), - }, - ) - }) - .collect(), - }), - }, - ) - }) - .collect(), - }, - ] { - expect_mode_error( - ®istry_backed_config_with_output("value", output), - "consultation output schema", - ); - } -} diff --git a/crates/registry-notary-core/src/config/tests/root.rs b/crates/registry-notary-core/src/config/tests/root.rs deleted file mode 100644 index aed0bc237..000000000 --- a/crates/registry-notary-core/src/config/tests/root.rs +++ /dev/null @@ -1,1279 +0,0 @@ -use super::support::*; -use super::*; -#[allow(unused_imports)] -use super::{auth::*, credentials::*, infrastructure::*, issuance::*, preauth::*}; - -#[test] -pub(super) fn batch_limits_accept_only_lower_or_equal_platform_overrides() { - let mut config = valid_subject_access_config(); - config.evidence.inline_batch_limit = MAX_BATCH_EVALUATION_MEMBERS_V1; - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = MAX_BATCH_EVALUATION_MEMBERS_V1; - config - .validate() - .expect("the hard platform ceiling is a valid operator value"); - - config.evidence.inline_batch_limit = 17; - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = 9; - config - .validate() - .expect("operators may lower either batch limit"); -} - -#[test] -pub(super) fn batch_limits_reject_zero_and_values_above_the_platform_ceiling() { - for invalid in [0, MAX_BATCH_EVALUATION_MEMBERS_V1 + 1] { - let mut config = valid_subject_access_config(); - config.evidence.inline_batch_limit = invalid; - let error = config - .validate() - .expect_err("the global batch override must stay within the platform range"); - assert!(matches!( - error, - EvidenceConfigError::InvalidBatchConfig { .. } - )); - - let mut config = valid_subject_access_config(); - config.evidence.claims[0] - .operations - .batch_evaluate - .max_subjects = invalid; - let error = config - .validate() - .expect_err("the claim batch override must stay within the platform range"); - assert!(matches!( - error, - EvidenceConfigError::InvalidBatchConfig { .. } - )); - } -} - -#[test] -pub(super) fn gate_input_defaults_are_low_risk_for_minimal_config() { - let config = minimal_config(); - let input = config.gate_input(); - // A minimal config uses PostgreSQL state and stdout audit by default. - assert!(!input.state_in_memory); - assert!(!input.audit_sink_class_durable); - // No high-risk modes are declared. - assert!(!input.requires_shared_state()); - // Local YAML config without config_trust is unsigned. - assert!(input.config_unsigned); - // Admin listener is disabled by default, so no shared exposure. - assert!(!input.admin_shared_exposure); - // OpenAPI requires auth by default. - assert!(!input.openapi_public); - // An active but unreferenced key is not a Notary signing role. - assert!(!input.signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_reports_federation_as_high_risk() { - let mut config = minimal_config(); - config.federation.enabled = true; - assert!(config.gate_input().requires_shared_state()); -} - -#[test] -pub(super) fn gate_input_reports_durable_audit_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(config.gate_input().audit_sink_class_durable); -} - -#[test] -pub(super) fn gate_input_reports_audit_retention_local_only_for_file_sink_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_reports_audit_retention_local_only_for_jsonl_sink_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "jsonl".to_string(); - assert!(config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_when_attested() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(!config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_requires_custody_approval_for_referenced_signer() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - - assert!(config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_does_not_treat_pkcs11_as_custody_approval() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - config - .evidence - .signing_keys - .get_mut("issuer-key") - .expect("issuer key exists") - .provider = SigningKeyProviderConfig::Pkcs11; - - assert!(config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_clears_signer_custody_when_approved() { - let mut config = minimal_config(); - config.auth.access_token_signing.enabled = true; - config.auth.access_token_signing.signing_key_id = "issuer-key".to_string(); - config.deployment.evidence.signer_custody_approved = true; - - assert!(!config.gate_input().signer_without_custody_approval); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_for_stdout_sink() { - // Minimal config defaults to the stdout sink. - let config = minimal_config(); - assert!(!config.gate_input().audit_retention_local_only); -} - -#[test] -pub(super) fn gate_input_clears_audit_retention_local_only_for_syslog_sink() { - let mut config = minimal_config(); - config.audit.sink = "syslog".to_string(); - assert!(!config.gate_input().audit_retention_local_only); -} - -/// The fixture ack cursor's `acked_at` (`2026-06-04T09:59:00Z`) as a -/// `SystemTime`, so tests can pin `now` relative to it deterministically. -pub(super) fn fixture_acked_at() -> SystemTime { - let acked = time::OffsetDateTime::parse( - "2026-06-04T09:59:00Z", - &time::format_description::well_known::Rfc3339, - ) - .expect("fixture acked_at parses"); - SystemTime::from(acked) -} - -pub(super) fn write_ack_cursor(dir: &std::path::Path, contents: &str) -> std::path::PathBuf { - let path = dir.join("ack-cursor.json"); - std::fs::write(&path, contents).expect("ack cursor writes"); - path -} - -#[test] -pub(super) fn gate_input_reports_shipping_declared_external_for_attested_file_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_clears_shipping_declared_external_without_attestation() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - assert!(!config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_reports_shipping_target_for_stdout_sink() { - let mut config = minimal_config(); - config.deployment.evidence.audit_offhost_shipping = true; - assert!(config.gate_input().audit_shipping_target_configured); -} - -#[test] -pub(super) fn gate_input_reports_ack_cursor_configured_when_path_set() { - let mut config = minimal_config(); - assert!(!config.gate_input().audit_ack_cursor_configured); - config.deployment.evidence.audit_ack_cursor_path = - Some(std::path::PathBuf::from("/nonexistent/ack-cursor.json")); - assert!(config.gate_input().audit_ack_cursor_configured); -} - -#[test] -pub(super) fn gate_input_reports_ack_health_ok_only_after_fresh_cursor_binds_to_tail() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - // now is 60s after the cursor's acked_at, well within the 900s window. - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); - let observation = config - .audit_ack_observation_at(now) - .bind_to_audit_tail(Some([0x44; 32])); - assert!( - config - .gate_input_with_ack_observation(&observation) - .audit_ack_health_ok - ); -} - -#[test] -pub(super) fn gate_input_at_reports_ack_health_not_ok_for_stale_cursor() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - // now is 901s after acked_at, one second past the default window. - let now = fixture_acked_at() + Duration::from_secs(901); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn gate_input_at_reports_ack_health_not_ok_for_missing_cursor() { - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = - Some(std::path::PathBuf::from("/nonexistent/ack-cursor.json")); - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn gate_input_at_honors_custom_max_age_window() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = write_ack_cursor( - dir.path(), - registry_platform_ops::AUDIT_ACK_CURSOR_FIXTURE_V1, - ); - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(path); - config.deployment.evidence.audit_ack_max_age_secs = Some(30); - // 60s after acked_at is stale under a 30s window. - let now = fixture_acked_at() + Duration::from_secs(60); - assert!(!config.gate_input_at(now).audit_ack_health_ok); -} - -#[test] -pub(super) fn validate_rejects_ack_max_age_without_cursor() { - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_max_age_secs = Some(600); - let error = config - .validate() - .expect_err("max age without cursor rejected"); - assert!(matches!( - error, - EvidenceConfigError::AuditAckMaxAgeWithoutCursor - )); -} - -#[test] -pub(super) fn validate_rejects_ack_cursor_on_local_file_sink_without_shipping_declared() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - let error = config - .validate() - .expect_err("cursor on undeclared local file sink rejected"); - assert!(matches!( - error, - EvidenceConfigError::AuditAckCursorWithoutShippingDeclared - )); -} - -#[test] -pub(super) fn validate_allows_ack_cursor_on_attested_local_file_sink() { - let mut config = minimal_config(); - config.audit.sink = "file".to_string(); - config.audit.path = Some("/var/log/registry/audit.jsonl".to_string()); - config.audit.hash_secret_env = Some("TEST_TOKEN_HASH".to_string()); - config.deployment.evidence.audit_offhost_shipping = true; - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - config - .validate() - .expect("cursor on attested local file sink is valid"); -} - -#[test] -pub(super) fn validate_allows_ack_cursor_on_stdout_sink_without_shipping_declared() { - // stdout retention is owned off-box, so a cursor there does not require - // the off-host shipping attestation. - let mut config = minimal_config(); - config.deployment.evidence.audit_ack_cursor_path = Some(std::path::PathBuf::from( - "/var/lib/registry/ack-cursor.json", - )); - config - .validate() - .expect("cursor on stdout sink is valid without attestation"); -} - -#[test] -pub(super) fn gate_input_reports_assisted_access_transaction_token_posture() { - let mut config = minimal_config(); - config.subject_access.enabled = true; - let input_without_anchor = config.gate_input(); - assert!(input_without_anchor.subject_access_enabled); - assert!(!input_without_anchor.transaction_token_anchor_configured); - - config.auth.access_token_signing.enabled = true; - let input_with_anchor = config.gate_input(); - assert!(input_with_anchor.transaction_token_anchor_configured); - assert!( - !input_with_anchor.transaction_token_sender_constrained, - "DPoP/mTLS proof validation is not implemented yet" - ); -} - -#[test] -pub(super) fn gate_input_reports_admin_shared_exposure() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::SharedWithPublic; - assert!(config.gate_input().admin_shared_exposure); -} - -#[test] -pub(super) fn gate_input_clears_admin_shared_exposure_when_listener_disabled() { - let config = minimal_config(); - // Default admin listener mode is Disabled; shared exposure must be false. - assert!(!config.gate_input().admin_shared_exposure); -} - -#[test] -pub(super) fn gate_input_reports_openapi_public() { - let mut config = minimal_config(); - config.server.openapi_requires_auth = false; - assert!(config.gate_input().openapi_public); -} - -#[test] -pub(super) fn gate_input_clears_openapi_public_when_auth_required() { - let config = minimal_config(); - // Default requires auth; openapi_public must be false. - assert!(!config.gate_input().openapi_public); -} - -#[test] -pub(super) fn gate_input_clears_config_unsigned_when_config_trust_configured() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config.config_trust = Some(valid_config_trust()); - assert!(!config.gate_input().config_unsigned); -} - -#[test] -pub(super) fn gate_input_reports_config_unsigned_without_trust() { - let config = minimal_config(); - // Minimal config has no config_trust block; must project as unsigned. - assert!(config.gate_input().config_unsigned); -} - -#[test] -pub(super) fn deployment_block_round_trips_through_yaml() { - let mut config = minimal_config(); - config.deployment = serde_norway::from_str( - r#" -profile: production -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - summary: Synthetic partner integration waiver - expires: 2099-09-30 -"#, - ) - .expect("deployment block parses"); - assert_eq!( - config.deployment.profile, - Some(crate::deployment::DeploymentProfile::Production) - ); - config - .validate() - .expect("production config with waivable waiver validates"); -} - -#[test] -pub(super) fn deployment_waiver_rejects_legacy_reason_as_unknown() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - reason: legacy waiver text - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "the removed reason field must fail strict deserialization" - ); -} - -#[test] -pub(super) fn deployment_waiver_rejects_missing_reference() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "a waiver without the required reference must fail deserialization" - ); -} - -#[test] -pub(super) fn deployment_waiver_rejects_explicit_null_summary() { - let result: Result = serde_norway::from_str( - r#" -profile: hosted_lab -waivers: - - finding: notary.openapi.public - reference: OPS-2026-0042 - summary: null - expires: 2099-09-30 -"#, - ); - assert!( - result.is_err(), - "summary must be a string when present, not null: {result:?}" - ); -} - -#[test] -pub(super) fn deployment_evidence_block_round_trips_through_yaml() { - let mut config = minimal_config(); - config.deployment = serde_norway::from_str( - r#" -profile: production -evidence: - audit_offhost_shipping: true -"#, - ) - .expect("deployment evidence block parses"); - assert!(config.deployment.evidence.audit_offhost_shipping); -} - -#[test] -pub(super) fn deployment_evidence_rejects_unknown_field_through_yaml() { - let result: Result = serde_norway::from_str( - r#" -profile: production -evidence: - audit_offhost_shipping: true - made_up_field: true -"#, - ); - assert!( - result.is_err(), - "unknown field inside deployment.evidence must fail deserialization" - ); -} - -#[test] -pub(super) fn invalid_profile_value_fails_config_load() { - let result: Result = serde_norway::from_str( - r#" -evidence: - enabled: true -auth: -deployment: - profile: prod -"#, - ); - assert!( - result.is_err(), - "an invalid profile string must fail to load" - ); -} - -pub(super) fn use_dedicated_admin_listener(config: &mut StandaloneRegistryNotaryConfig) { - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; -} - -pub(super) fn valid_config_trust() -> ConfigTrustConfig { - ConfigTrustConfig { - trust_anchor_path: PathBuf::from("/etc/registry-notary/config-anchor.json"), - bundle_path: PathBuf::from("/etc/registry-notary/config-bundle"), - antirollback_state_path: PathBuf::from( - "/var/lib/registry-notary/config-state/antirollback.json", - ), - break_glass_override_path: None, - } -} - -pub(super) fn minimal_claim(id: &str) -> ClaimDefinition { - serde_norway::from_str(&format!( - r#" -id: {id} -title: Test Claim -version: "1.0" -subject_type: person -purpose: test-purpose -required_scopes: - - registry:consult:test-source -evidence_mode: - type: registry_backed - consultations: - test_source: - profile: - id: example.test-source.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - outputs: - registration_found: - type: boolean - nullable: false -rule: - type: consultation_matched - consultation: test_source -value: - type: boolean -"# - )) - .expect("minimal claim is valid YAML") -} - -#[test] -pub(super) fn claim_value_max_bytes_is_optional_for_direct_configs() { - let value: ClaimValueConfig = serde_norway::from_str("type: string\n") - .expect("legacy direct claim value config remains valid"); - assert_eq!(value.max_bytes, None); - assert!(!serde_norway::to_string(&value) - .expect("claim value config serializes") - .contains("max_bytes")); -} - -#[test] -pub(super) fn string_claim_value_max_bytes_accepts_the_closed_platform_range() { - for max_bytes in [1, MAX_CLAIM_VALUE_STRING_BYTES_V1] { - let mut config = minimal_config(); - let mut claim = minimal_claim("bounded-string"); - claim.value.value_type = "string".to_string(); - claim.value.max_bytes = Some(max_bytes); - claim.rule = RuleConfig::Cel { - expression: r#""value""#.to_string(), - }; - config.evidence.claims.push(claim); - config - .validate() - .unwrap_or_else(|error| panic!("max_bytes {max_bytes} must validate: {error}")); - } -} - -#[test] -pub(super) fn claim_value_max_bytes_rejects_out_of_range_and_non_string_use() { - for max_bytes in [0, MAX_CLAIM_VALUE_STRING_BYTES_V1 + 1] { - let mut config = minimal_config(); - let mut claim = minimal_claim("invalid-bound"); - claim.value.value_type = "string".to_string(); - claim.value.max_bytes = Some(max_bytes); - config.evidence.claims.push(claim); - let error = config - .validate() - .expect_err("out-of-range max_bytes must fail configuration validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimValueConfig { ref claim, ref reason } - if claim == "invalid-bound" - && reason.contains("between 1 and 65536") - )); - } - - let mut config = minimal_config(); - let mut claim = minimal_claim("non-string-bound"); - claim.value.value_type = "boolean".to_string(); - claim.value.max_bytes = Some(16); - config.evidence.claims.push(claim); - let error = config - .validate() - .expect_err("non-string max_bytes must fail configuration validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidClaimValueConfig { ref claim, ref reason } - if claim == "non-string-bound" - && reason.contains("only when value.type is string") - )); -} - -pub(super) fn add_registry_credential_claim( - config: &mut StandaloneRegistryNotaryConfig, - claim_id: &str, - profile_id: &str, -) { - config.evidence.relay = Some( - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses"), - ); - config.evidence.allowed_purposes = vec!["credential-test".to_string()]; - let mut claim = minimal_claim(claim_id); - claim.purpose = Some("credential-test".to_string()); - claim.required_scopes = vec!["credential:test".to_string()]; - claim.value.value_type = "boolean".to_string(); - claim.evidence_mode = ClaimEvidenceMode::RegistryBacked { - consultations: BTreeMap::from([( - "credential_test".to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.credential-test.exact".to_string(), - contract_hash: - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }, - inputs: BTreeMap::from([( - "subject_id".to_string(), - RelayConsultationInput::TargetId, - )]), - outputs: BTreeMap::from([( - "active".to_string(), - RelayOutputContract::Boolean { nullable: false }, - )]), - }, - )]), - }; - claim.rule = RuleConfig::ConsultationMatched { - consultation: "credential_test".to_string(), - }; - claim.credential_profiles = vec![profile_id.to_string()]; - config.evidence.claims = vec![claim]; -} - -#[test] -pub(super) fn config_trust_is_optional_but_requires_explicit_antirollback_path() { - let mut config = minimal_config(); - assert!(config.config_trust.is_none()); - config.validate().expect("simple local config validates"); - use_dedicated_admin_listener(&mut config); - - let mut trust = valid_config_trust(); - trust.trust_anchor_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty trust-anchor path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.bundle_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty bundle path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.antirollback_state_path = PathBuf::from(""); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty anti-rollback path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - let mut trust = valid_config_trust(); - trust.break_glass_override_path = Some(PathBuf::from("")); - config.config_trust = Some(trust); - let error = config - .validate() - .expect_err("empty break-glass override path must fail validation"); - assert!(matches!( - error, - EvidenceConfigError::InvalidConfigTrustConfig { .. } - )); - - config.config_trust = Some(valid_config_trust()); - config - .validate() - .expect("explicit config bundle trust paths validate"); -} - -#[test] -pub(super) fn cel_config_defaults_and_validates_operator_limits() { - let config = minimal_config(); - assert_eq!(config.cel.mode, "worker"); - assert_eq!(config.cel.worker_count, 2); - assert!(!config.cel.allow_regex); - config.validate().expect("default CEL config validates"); -} - -#[test] -pub(super) fn cel_config_rejects_removed_queue_max() { - let error = serde_norway::from_str::("queue_max: 0\n") - .expect_err("the unreleased CEL queue setting must not remain accepted"); - assert!(error.to_string().contains("queue_max")); -} - -#[test] -pub(super) fn cel_config_deserializes_production_surface() { - let config: StandaloneRegistryNotaryConfig = serde_norway::from_str( - r#" -evidence: - enabled: true - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -cel: - mode: worker - worker_count: 4 - eval_timeout_ms: 1500 - allow_regex: false - max_expression_bytes: 4096 - max_binding_json_bytes: 32768 - max_result_json_bytes: 8192 - max_string_bytes: 4096 - max_list_items: 128 - max_object_depth: 8 - max_object_keys: 64 - worker_memory_bytes: 67108864 - worker_stderr_bytes: 512 -"#, - ) - .expect("CEL config deserializes"); - - assert_eq!(config.cel.worker_count, 4); - assert_eq!(config.cel.eval_timeout_ms, 1500); - assert_eq!(config.cel.max_result_json_bytes, 8192); - config.validate().expect("CEL config validates"); -} - -pub(super) fn valid_subject_access_config() -> StandaloneRegistryNotaryConfig { - serde_norway::from_str( - r#" -evidence: - enabled: true - relay: - base_url: https://relay.internal.example - workload_client_id: registry-notary - token_file: /run/secrets/registry-notary-relay.jwt - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active - credential_profiles: - civil_status_sd_jwt: - format: application/dc+sd-jwt - issuer: did:web:issuer.example - signing_key: issuer-key - vct: https://issuer.example/credentials/civil-status - validity_seconds: 600 - holder_binding: - mode: did - proof_of_possession: required - allowed_did_methods: - - did:jwk - allowed_claims: - - date-of-birth - disclosure: - allowed: - - value - claims: - - id: date-of-birth - title: Date of birth - version: "1.0" - subject_type: person - evidence_mode: - type: registry_backed - consultations: - civil_status: - profile: - id: example.civil-status.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - national_id: request.target.identifiers.national_id - outputs: - value: - type: boolean - nullable: true - value: - type: boolean - nullable: true - purpose: citizen_subject_access - required_scopes: - - subject_access - rule: - type: consultation_output - consultation: civil_status - output: value - disclosure: - default: value - allowed: - - value - formats: - - application/vnd.registry-notary.claim-result+json - credential_profiles: - - civil_status_sd_jwt -auth: - oidc: - issuer: https://id.example.gov - jwks_url: https://id.example.gov/oauth/v2/keys - audiences: - - registry-notary-citizen - allowed_clients: - - citizen-portal - scope_claim: scope - scope_map: - citizen_subject_access: - - subject_access - leeway: 30s -subject_access: - enabled: true - subject_binding: - token_claim: https://id.example.gov/claims/national_id - request_field: SubjectId - id_type: national_id - normalize: exact - allow_sub_as_civil_id: false - citizen_clients: - allowed_client_ids: - - citizen-portal - allowed_audiences: - - registry-notary-citizen - token_policy: - required_acr_values: - - urn:example:loa:substantial - max_auth_age_seconds: 900 - max_access_token_lifetime_seconds: 900 - max_evaluation_age_seconds: 600 - max_credential_validity_seconds: 600 - max_clock_leeway_seconds: 60 - allowed_operations: - evaluate: true - render: true - issue_credential: true - batch_evaluate: false - allowed_purposes: - - citizen_subject_access - allowed_claims: - - date-of-birth - allowed_formats: - - application/vnd.registry-notary.claim-result+json - allowed_disclosures: - - value - required_scopes: - - subject_access - allowed_wallet_origins: - - https://wallet.example.gov - credential_profiles: - - civil_status_sd_jwt - rate_limits: - invalid_token_per_client_address_per_minute: 20 - per_principal_per_minute: 10 - subject_mismatch_per_principal_per_hour: 5 - per_holder_per_hour: 10 - credential_issuance_per_principal_per_hour: 5 -"#, - ) - .expect("subject-access config is valid YAML") -} - -pub(super) fn valid_delegated_subject_access_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_subject_access_config(); - config.evidence.relay = Some( - serde_norway::from_str( - r#" -base_url: https://relay.internal.example -workload_client_id: registry-notary -token_file: /run/secrets/registry-notary-relay.jwt -"#, - ) - .expect("Relay connection parses"), - ); - let mut proof = config.evidence.claims[0].clone(); - proof.id = "guardian-link".to_string(); - proof.title = "Guardian link".to_string(); - proof.subject_type = "relationship".to_string(); - proof.purpose = Some("dependent_attestation".to_string()); - proof.required_scopes = vec!["subject_access".to_string()]; - proof.evidence_mode = ClaimEvidenceMode::RegistryBacked { - consultations: BTreeMap::from([( - "guardian_link".to_string(), - RelayConsultationConfig { - profile: RelayConsultationProfileRef { - id: "example.guardian-link.exact".to_string(), - contract_hash: - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }, - inputs: BTreeMap::from([ - ( - "requester_id".to_string(), - RelayConsultationInput::RequesterIdentifier( - "request.requester.identifiers.national_id".to_string(), - ), - ), - ( - "target_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ), - ]), - outputs: BTreeMap::from([( - "established".to_string(), - RelayOutputContract::Boolean { nullable: true }, - )]), - }, - )]), - }; - proof.value.nullable = true; - proof.credential_profiles.clear(); - proof.rule = RuleConfig::ConsultationOutput { - consultation: "guardian_link".to_string(), - output: "established".to_string(), - }; - - let mut dependent = config.evidence.claims[0].clone(); - dependent.id = "dependent-date-of-birth".to_string(); - dependent.title = "Dependent date of birth".to_string(); - dependent.purpose = Some("dependent_attestation".to_string()); - dependent.depends_on = vec!["guardian-link".to_string()]; - dependent.credential_profiles.clear(); - let ClaimEvidenceMode::RegistryBacked { consultations } = &mut dependent.evidence_mode else { - panic!("delegated claim is registry-backed"); - }; - consultations - .get_mut("civil_status") - .expect("civil status consultation exists") - .inputs - .insert( - "national_id".to_string(), - RelayConsultationInput::TargetIdentifier( - "request.target.identifiers.civil_registration_id".to_string(), - ), - ); - - config.evidence.claims.push(proof); - config.evidence.claims.push(dependent); - config.subject_access.delegation = SubjectAccessDelegationConfig { - enabled: true, - allowed_relationships: vec![SubjectAccessDelegatedRelationshipConfig { - relationship_type: "guardian".to_string(), - proof_claim: "guardian-link".to_string(), - target_id_type: Some("civil_registration_id".to_string()), - max_proof_age_seconds: 300, - allowed_claims: vec!["dependent-date-of-birth".to_string()], - allowed_purposes: vec!["dependent_attestation".to_string()], - allowed_formats: vec!["application/vnd.registry-notary.claim-result+json".to_string()], - allowed_disclosures: vec!["value".to_string()], - }], - }; - config -} - -pub(super) fn valid_oid4vci_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_subject_access_config(); - config - .subject_access - .rate_limits - .tx_code_attempts_per_code_per_minute = 5; - config - .evidence - .signing_keys - .insert("access-token-key".to_string(), second_signing_key()); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("civil status credential profile exists") - .vct = "http://127.0.0.1:4325/credentials/civil-status".to_string(); - config.oid4vci = serde_norway::from_str( - r#" -enabled: true -credential_issuer: http://127.0.0.1:4325 -authorization_servers: - - http://localhost:8088/v1/esignet -accepted_token_audiences: - - http://127.0.0.1:4325 -credential_endpoint: http://127.0.0.1:4325/oid4vci/credential -nonce: - enabled: true - ttl_seconds: 300 -authorization: - require_pkce_method: S256 -proof: - max_age_seconds: 300 - max_clock_skew_seconds: 30 -credential_configurations: - date_of_birth_sd_jwt: - claim_id: date-of-birth - credential_profile: civil_status_sd_jwt - format: dc+sd-jwt - scope: date-of-birth - vct: http://127.0.0.1:4325/credentials/civil-status - display_name: Date of birth - proof_signing_alg_values_supported: - - EdDSA - cryptographic_binding_methods_supported: - - did:jwk -pre_authorized_code: - enabled: true - tx_code: - required: true - input_mode: numeric - length: 6 - esignet: - client_id: registry-lab-live-client - client_signing_key_id: issuer-key - redirect_uri: http://127.0.0.1:4325/oid4vci/offer/callback - authorize_url: https://id.example.gov/authorize - token_url: https://id.example.gov/oauth/v2/token - issuer: https://id.example.gov - jwks_uri: https://id.example.gov/oauth/.well-known/jwks.json - scopes: - - openid - pre_authorized_code_ttl_seconds: 300 -"#, - ) - .expect("oid4vci config is valid YAML"); - config.auth.access_token_signing = serde_norway::from_str( - r#" -enabled: true -issuer: http://127.0.0.1:4325 -audiences: - - http://127.0.0.1:4325 -allowed_algorithms: - - EdDSA -token_typ: registry-notary-access+jwt -signing_key_id: access-token-key -access_token_ttl_seconds: 300 -"#, - ) - .expect("access-token signing config is valid YAML"); - config -} - -pub(super) fn add_oid4vci_projection_claim( - config: &mut StandaloneRegistryNotaryConfig, - claim_id: &str, - title: &str, -) { - let mut claim = config - .evidence - .claims - .iter() - .find(|claim| claim.id == "date-of-birth") - .expect("base claim exists") - .clone(); - claim.id = claim_id.to_string(); - claim.title = title.to_string(); - claim.credential_profiles = vec!["civil_status_sd_jwt".to_string()]; - config.evidence.claims.push(claim); - config - .subject_access - .allowed_claims - .push(claim_id.to_string()); - config - .evidence - .credential_profiles - .get_mut("civil_status_sd_jwt") - .expect("profile exists") - .allowed_claims - .push(claim_id.to_string()); -} - -pub(super) fn valid_oid4vci_projection_config() -> StandaloneRegistryNotaryConfig { - let mut config = valid_oid4vci_config(); - add_oid4vci_projection_claim(&mut config, "birth-place", "Birth place"); - let credential = config - .oid4vci - .credential_configurations - .get_mut("date_of_birth_sd_jwt") - .expect("credential configuration exists"); - credential.claim_id = None; - credential.claims = vec![ - Oid4vciCredentialClaimConfig { - id: "date-of-birth".to_string(), - output_path: vec!["birth_date".to_string()], - display_name: "Date of birth".to_string(), - sd: "always".to_string(), - }, - Oid4vciCredentialClaimConfig { - id: "birth-place".to_string(), - output_path: vec!["birth_place_name".to_string()], - display_name: "Birth place".to_string(), - sd: "always".to_string(), - }, - ]; - config -} - -pub(super) fn expect_subject_access_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("subject-access config must fail validation") - { - EvidenceConfigError::InvalidSubjectAccessConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_oid4vci_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("oid4vci config must fail validation") - { - EvidenceConfigError::InvalidOid4vciConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_federation_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("federation config must fail validation") - { - EvidenceConfigError::InvalidFederationConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_state_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("state config must fail validation") - { - EvidenceConfigError::InvalidStateConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -pub(super) fn expect_credential_status_error(config: &StandaloneRegistryNotaryConfig) -> String { - match config - .validate() - .expect_err("credential status config must fail validation") - { - EvidenceConfigError::InvalidCredentialStatusConfig { reason } => reason, - other => panic!("unexpected error variant: {other}"), - } -} - -#[test] -pub(super) fn admin_listener_defaults_to_disabled_for_simple_local_config() { - let config = minimal_config(); - - assert_eq!( - config.server.admin_listener.mode, - RegistryNotaryAdminListenerMode::Disabled - ); - config - .validate() - .expect("simple local config may disable admin listener by default"); -} - -#[test] -pub(super) fn server_limits_default_to_relay_parity_values() { - let config = minimal_config(); - assert_eq!(config.server.request_timeout, Duration::from_secs(30)); - assert_eq!(config.server.request_body_timeout, Duration::from_secs(10)); - assert_eq!( - config.server.http1_header_read_timeout, - Duration::from_secs(10) - ); - assert_eq!(config.server.max_connections, 1024); -} - -#[test] -pub(super) fn cel_evaluation_timeout_keeps_the_fail_closed_product_default() { - let config = minimal_config(); - assert_eq!(config.cel.eval_timeout_ms, 2_000); -} - -#[test] -pub(super) fn server_limits_must_be_nonzero() { - let mut config = minimal_config(); - config.server.request_timeout = Duration::ZERO; - config.server.request_body_timeout = Duration::ZERO; - config.server.http1_header_read_timeout = Duration::ZERO; - config.server.max_connections = 0; - - let err = config - .validate() - .expect_err("zero server limits must fail validation"); - match err { - EvidenceConfigError::InvalidServerConfig { reason } => { - assert!(reason.contains("server timeouts must be non-zero")); - assert!(reason.contains("max_connections")); - } - other => panic!("unexpected error: {other:?}"), - } -} - -#[test] -pub(super) fn governed_config_requires_dedicated_admin_listener() { - let mut config = minimal_config(); - config.config_trust = Some(valid_config_trust()); - - let error = config - .validate() - .expect_err("governed config must not default to shared admin listener"); - match error { - EvidenceConfigError::InvalidServerConfig { reason } => { - assert!(reason.contains("server.admin_listener.mode = dedicated")); - } - other => panic!("unexpected error variant: {other}"), - } - - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config - .validate() - .expect("governed config validates with dedicated admin listener"); -} - -#[test] -pub(super) fn dedicated_admin_listener_must_not_reuse_public_bind() { - let mut config = minimal_config(); - config.server.admin_listener.mode = RegistryNotaryAdminListenerMode::Dedicated; - config.server.admin_listener.bind = config.server.bind; - - let error = config - .validate() - .expect_err("dedicated admin bind must differ from public bind"); - assert!(matches!( - error, - EvidenceConfigError::InvalidServerConfig { .. } - )); -} diff --git a/crates/registry-notary-core/src/config/tests/support.rs b/crates/registry-notary-core/src/config/tests/support.rs deleted file mode 100644 index f4f09f2c4..000000000 --- a/crates/registry-notary-core/src/config/tests/support.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::*; -/// Builds a minimal valid config from which individual tests can deviate. -pub(super) fn minimal_config() -> StandaloneRegistryNotaryConfig { - serde_norway::from_str( - r#" -evidence: - enabled: true - claims: - - id: test-claim - title: Test Claim - version: "1.0" - subject_type: person - purpose: test-purpose - required_scopes: - - registry:consult:test-source - evidence_mode: - type: registry_backed - consultations: - test_source: - profile: - id: example.test-source.exact - contract_hash: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - inputs: - subject_id: target.id - outputs: - registration_found: - type: boolean - nullable: false - rule: - type: consultation_matched - consultation: test_source - value: - type: boolean - relay: - base_url: https://relay.internal.example - workload_client_id: registry-notary - token_file: /run/secrets/registry-notary-relay.jwt - signing_keys: - issuer-key: - provider: local_jwk_env - private_jwk_env: ISSUER_KEY - alg: EdDSA - kid: did:web:issuer.example#key-1 - status: active -auth: - api_keys: - - id: test-key - fingerprint: - provider: env - name: TEST_TOKEN_HASH -"#, - ) - .expect("minimal config is valid YAML") -} diff --git a/crates/registry-notary-core/src/deployment.rs b/crates/registry-notary-core/src/deployment.rs deleted file mode 100644 index 8b5e6ca33..000000000 --- a/crates/registry-notary-core/src/deployment.rs +++ /dev/null @@ -1,1660 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Operator-declared deployment profile and gate evaluation. -//! -//! A deployment profile is an explicit operator declaration of how a Notary -//! instance is deployed. It is never inferred from the environment label, the -//! hostname, or the network position. The profile binds a set of gates; each -//! gate inspects the running configuration and reports an effect at a defined -//! severity. An undeclared deployment is a startup failure; `local` is the -//! explicit opt-out for development. - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// The set of deployment profiles an operator can declare. -/// -/// Frozen at introduction; new profiles may be added but existing ones never -/// change meaning. Deserialization is strict: an unknown profile string fails, -/// which surfaces as a startup error. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentProfile { - Local, - HostedLab, - Production, - EvidenceGrade, -} - -impl DeploymentProfile { - pub const fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::HostedLab => "hosted_lab", - Self::Production => "production", - Self::EvidenceGrade => "evidence_grade", - } - } -} - -/// Severity vocabulary shared across products. -/// -/// `startup_fail` and `readiness_fail` are hard gates and bind only on declared -/// profiles. `finding_error` and `finding_warn` surface as posture findings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum GateSeverity { - StartupFail, - ReadinessFail, - FindingError, - FindingWarn, -} - -impl GateSeverity { - pub const fn as_str(self) -> &'static str { - match self { - Self::StartupFail => "startup_fail", - Self::ReadinessFail => "readiness_fail", - Self::FindingError => "finding_error", - Self::FindingWarn => "finding_warn", - } - } - - /// Hard deployment gates cannot be waived. `startup_fail` means running at - /// all would falsify the profile claim; `readiness_fail` means the process - /// may run but must not report ready until the condition is cleared. - pub const fn is_waivable(self) -> bool { - matches!(self, Self::FindingError | Self::FindingWarn) - } -} - -/// Status of a finding in posture output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum DeploymentFindingStatus { - Active, - Waived, -} - -impl DeploymentFindingStatus { - pub const fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Waived => "waived", - } - } -} - -/// The operator-declared `deployment` config block. -/// -/// An absent profile means an undeclared deployment, which refuses startup. The -/// `multi_instance` flag is an operator declaration that the instance is one of -/// several sharing the same workload; it is never inferred. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DeploymentConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, - #[serde(default)] - pub multi_instance: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub waivers: Vec, - /// Operator declarations of assurance evidence the runtime cannot observe - /// for itself. Absent declarations leave the corresponding gates active. - #[serde(default)] - pub evidence: DeploymentEvidenceConfig, -} - -impl DeploymentConfig { - pub fn is_default(&self) -> bool { - self == &Self::default() - } -} - -/// Operator-asserted assurance evidence for conditions the runtime cannot -/// observe directly. Each flag defaults to `false`, meaning "no evidence -/// declared", which keeps the corresponding gate active until the operator -/// asserts the control is in place out of band. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct DeploymentEvidenceConfig { - /// Operator asserts audit log events are shipped off-host (for example to - /// a log aggregator or SIEM) so a local file sink does not cap retention. - #[serde(default)] - pub audit_offhost_shipping: bool, - /// Optional path to a `registry.audit.ack_cursor.v1` file maintained by - /// whatever ships audit events off-host. Runtime health requires both a - /// fresh timestamp and a watermark equal to the live keyed audit-chain tail. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audit_ack_cursor_path: Option, - /// Optional freshness window, in seconds, for the off-host ack cursor. - /// Unset defaults to [`DEFAULT_AUDIT_ACK_MAX_AGE`] (900s). Meaningless - /// without `audit_ack_cursor_path`; config load rejects that combination. - /// - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`]: registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE - #[serde(default, skip_serializing_if = "Option::is_none")] - pub audit_ack_max_age_secs: Option, - /// Operator asserts a production review has approved signer custody for - /// this deployment. Provider kind is not proof of custody: PKCS#11 modules - /// can be backed by either hardware or software tokens. - #[serde(default)] - pub signer_custody_approved: bool, -} - -impl DeploymentEvidenceConfig { - /// The off-host ack cursor path, if the operator configured one. - pub fn audit_ack_cursor_path(&self) -> Option<&Path> { - self.audit_ack_cursor_path.as_deref() - } - - /// Freshness window for the off-host ack cursor, defaulting to - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`] when `audit_ack_max_age_secs` is unset. - /// - /// [`DEFAULT_AUDIT_ACK_MAX_AGE`]: registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE - pub fn audit_ack_max_age(&self) -> Duration { - self.audit_ack_max_age_secs - .map(Duration::from_secs) - .unwrap_or(registry_platform_ops::DEFAULT_AUDIT_ACK_MAX_AGE) - } -} - -/// One operator-configured waiver. -/// -/// A waiver names exactly one finding id, a required operator reference, an -/// optional summary, and a mandatory expiry date (`YYYY-MM-DD`). The shared -/// operations contract validates metadata before it can reach posture or logs. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields, from = "DeploymentWaiverConfigDocument")] -#[schemars(!from)] -pub struct DeploymentWaiverConfig { - pub finding: String, - #[schemars(with = "crate::config::schema::DeploymentWaiverReferenceSchema")] - pub reference: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(with = "crate::config::schema::DeploymentWaiverSummarySchema")] - pub summary: Option, - pub expires: String, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct DeploymentWaiverConfigDocument { - finding: String, - reference: String, - #[serde(default)] - summary: registry_platform_ops::OptionalDeploymentWaiverSummary, - expires: String, -} - -impl From for DeploymentWaiverConfig { - fn from(value: DeploymentWaiverConfigDocument) -> Self { - Self { - finding: value.finding, - reference: value.reference, - summary: value.summary.into(), - expires: value.expires, - } - } -} - -/// Errors raised while validating the deployment block at config load. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum DeploymentConfigError { - #[error("deployment.waivers[{index}].finding must not be empty")] - EmptyWaiverFinding { index: usize }, - #[error("deployment.waivers[{index}].{field} is invalid: {error}")] - InvalidWaiverMetadata { - index: usize, - field: &'static str, - error: registry_platform_ops::DeploymentWaiverMetadataError, - }, - #[error("deployment.waivers[{index}].expires must be a YYYY-MM-DD date")] - InvalidWaiverExpiry { index: usize }, - #[error( - "deployment.waivers[{index}] waives finding '{finding}', a hard deployment gate that cannot be waived; remove the waiver and fix the underlying condition it reports (audit shipping findings require deployment.evidence.audit_ack_cursor_path to name the shipper's fresh cursor and require its last_acked_hash to match the live keyed audit-chain tail, including for stdout and syslog sinks)" - )] - HardGateNotWaivable { index: usize, finding: String }, - #[error( - "deployment.waivers[{index}] waives unknown finding id '{finding}'; check the catalog" - )] - UnknownWaivedFinding { index: usize, finding: String }, -} - -impl DeploymentConfig { - /// Validate the deployment block at config load. - /// - /// This checks waiver shape (shared metadata contract, parseable expiry) - /// and the hard rule that `startup_fail` and `readiness_fail` gates can - /// never be waived under the declared profile. An undeclared profile still - /// validates waiver shape here so typos are caught early; startup refusal - /// is handled by gate evaluation. - pub fn validate(&self) -> Result<(), DeploymentConfigError> { - for (index, waiver) in self.waivers.iter().enumerate() { - if waiver.finding.trim().is_empty() { - return Err(DeploymentConfigError::EmptyWaiverFinding { index }); - } - registry_platform_ops::validate_deployment_waiver_metadata( - &waiver.reference, - waiver.summary.as_deref(), - ) - .map_err(|error| DeploymentConfigError::InvalidWaiverMetadata { - index, - field: error.field(), - error, - })?; - if parse_iso_date(&waiver.expires).is_none() { - return Err(DeploymentConfigError::InvalidWaiverExpiry { index }); - } - let Some(gate) = gate_catalog().iter().find(|gate| gate.id == waiver.finding) else { - return Err(DeploymentConfigError::UnknownWaivedFinding { - index, - finding: waiver.finding.clone(), - }); - }; - if let Some(profile) = self.profile { - if let Some(severity) = gate.severity_for(profile) { - if !severity.is_waivable() { - return Err(DeploymentConfigError::HardGateNotWaivable { - index, - finding: waiver.finding.clone(), - }); - } - } - } - } - Ok(()) - } -} - -/// Snapshot of the configuration facts every gate predicate reads. -/// -/// Building this once keeps gate predicates pure and free of config-shape -/// knowledge, which makes the catalog easy to read and test. -#[derive(Debug, Clone, Default)] -pub struct GateInput { - pub state_in_memory: bool, - pub federation_enabled: bool, - pub oid4vci_preauth_enabled: bool, - pub holder_proof_required: bool, - pub wallet_facing: bool, - pub multi_instance: bool, - pub audit_sink_class_durable: bool, - pub audit_retention_local_only: bool, - /// A shipping target is configured (stdout, syslog, or a local file with - /// `audit_offhost_shipping` attested) and therefore needs a bound ack cursor - /// under evidence-grade policy. - pub audit_shipping_target_configured: bool, - /// An off-host ack cursor path is configured, so shipping freshness is - /// observed rather than merely declared. - pub audit_ack_cursor_configured: bool, - /// The configured ack cursor's observed health is `ok` (fresh). False for - /// every other observation (stale, missing, invalid) and when no cursor is - /// configured: the shipping-stale gate fails closed on anything but `ok`. - pub audit_ack_health_ok: bool, - pub admin_shared_exposure: bool, - pub openapi_public: bool, - pub config_unsigned: bool, - pub subject_access_enabled: bool, - pub transaction_token_anchor_configured: bool, - pub transaction_token_sender_constrained: bool, - pub signer_without_custody_approval: bool, -} - -impl GateInput { - /// True when a declared mode relies on shared, durable correctness state. - pub fn requires_shared_state(&self) -> bool { - self.federation_enabled - || self.oid4vci_preauth_enabled - || self.holder_proof_required - || self.wallet_facing - || self.multi_instance - } -} - -/// A finding row in the catalog: an id and its severity under each profile that -/// binds it. A profile with no entry leaves the gate unbound. -struct Gate { - id: &'static str, - hosted_lab: Option, - production: Option, - evidence_grade: Option, - /// Predicate over the gate input; true means the gate condition is met. - condition: fn(&GateInput) -> bool, -} - -impl Gate { - fn severity_for(&self, profile: DeploymentProfile) -> Option { - match profile { - DeploymentProfile::Local => None, - DeploymentProfile::HostedLab => self.hosted_lab, - DeploymentProfile::Production => self.production, - DeploymentProfile::EvidenceGrade => self.evidence_grade, - } - } -} - -// Finding ids. Stable once shipped; consumers treat unknown ids as opaque. -pub const FINDING_STATE_IN_MEMORY_HIGH_RISK: &str = "notary.state.in_memory_high_risk"; -pub const FINDING_AUDIT_SINK_MISSING: &str = "notary.audit.sink_missing"; -pub const FINDING_AUDIT_RETENTION_LOCAL_ONLY: &str = "notary.audit.retention_local_only"; -pub const FINDING_AUDIT_SHIPPING_UNVERIFIED: &str = "notary.audit.shipping_unverified"; -pub const FINDING_AUDIT_SHIPPING_STALE: &str = "notary.audit.shipping_stale"; -pub const FINDING_ADMIN_SHARED_EXPOSURE: &str = "notary.admin.shared_exposure"; -pub const FINDING_OPENAPI_PUBLIC: &str = "notary.openapi.public"; -pub const FINDING_CONFIG_UNSIGNED: &str = "notary.config.unsigned"; -pub const FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING: &str = - "notary.assisted_access.transaction_token_anchor_missing"; -pub const FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING: &str = - "notary.assisted_access.sender_constraint_missing"; -pub const FINDING_SIGNER_CUSTODY_UNAPPROVED: &str = "notary.signer_custody.unapproved"; - -// Diagnostic finding ids emitted by the framework itself. -pub const FINDING_PROFILE_UNDECLARED: &str = "deployment.profile_undeclared"; -pub const FINDING_WAIVER_EXPIRED: &str = "deployment.waiver_expired"; - -/// The severity `gate_id` binds under `profile`, or `None` if the gate is -/// unbound at that profile (including an undeclared profile) or `gate_id` is -/// unknown. Lets callers outside the gate-evaluation path (e.g. doctor -/// diagnostics) check whether a gate already covers a finding before also -/// reporting it explicitly. -pub fn gate_severity_for_profile( - gate_id: &str, - profile: Option, -) -> Option { - let profile = profile?; - gate_catalog() - .iter() - .find(|gate| gate.id == gate_id) - .and_then(|gate| gate.severity_for(profile)) -} - -fn gate_catalog() -> &'static [Gate] { - use GateSeverity::{FindingError, FindingWarn, ReadinessFail, StartupFail}; - &[ - // notary.state.in_memory_high_risk: process-local correctness state - // while a mode requiring shared state is declared. (#206) - Gate { - id: FINDING_STATE_IN_MEMORY_HIGH_RISK, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.state_in_memory && input.requires_shared_state(), - }, - // notary.audit.sink_missing: no durable, retained audit sink. (#207) - Gate { - id: FINDING_AUDIT_SINK_MISSING, - hosted_lab: Some(FindingError), - production: Some(StartupFail), - evidence_grade: Some(StartupFail), - condition: |input| !input.audit_sink_class_durable, - }, - // notary.audit.retention_local_only: a local file sink caps retention - // and an attacker with host access can destroy audit evidence; the - // audit hash chain also cannot detect leading or trailing truncation - // of a local-only log, so off-host shipping (plus its attestation) is - // the completeness evidence that clears this gate. Under production - // this is only a warn, so the warning is the operator's single signal. - // stdout and syslog are exempt: their retention is owned by the - // orchestrator log pipeline or the syslog daemon's own forwarding - // surface. - Gate { - id: FINDING_AUDIT_RETENTION_LOCAL_ONLY, - hosted_lab: None, - production: Some(FindingWarn), - evidence_grade: Some(StartupFail), - condition: |input| input.audit_retention_local_only, - }, - // notary.audit.shipping_unverified: a shipping target is configured but - // no ack cursor is configured. This warns under production and refuses - // evidence-grade startup because the static observation capability is - // absent. Runtime loss or lag is handled by shipping_stale below. - Gate { - id: FINDING_AUDIT_SHIPPING_UNVERIFIED, - hosted_lab: None, - production: Some(FindingWarn), - evidence_grade: Some(StartupFail), - condition: |input| { - input.audit_shipping_target_configured && !input.audit_ack_cursor_configured - }, - }, - // notary.audit.shipping_stale: an ack cursor is configured but its - // observed health is not ok. Fail closed: stale, missing, unsafe, - // malformed, and chain-mismatched cursors all count. Escalates to readiness_fail under - // evidence_grade so the instance refuses to report ready. - Gate { - id: FINDING_AUDIT_SHIPPING_STALE, - hosted_lab: None, - production: Some(FindingError), - evidence_grade: Some(ReadinessFail), - condition: |input| input.audit_ack_cursor_configured && !input.audit_ack_health_ok, - }, - // Risky-but-legal defaults, surfaced as profile-bound findings. (#208) - Gate { - id: FINDING_ADMIN_SHARED_EXPOSURE, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.admin_shared_exposure, - }, - Gate { - id: FINDING_OPENAPI_PUBLIC, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(FindingError), - condition: |input| input.openapi_public, - }, - Gate { - id: FINDING_CONFIG_UNSIGNED, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(StartupFail), - condition: |input| input.config_unsigned, - }, - Gate { - id: FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING, - hosted_lab: Some(FindingError), - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| { - input.subject_access_enabled && !input.transaction_token_anchor_configured - }, - }, - Gate { - id: FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING, - hosted_lab: Some(FindingWarn), - production: Some(FindingError), - evidence_grade: Some(ReadinessFail), - condition: |input| { - input.transaction_token_anchor_configured - && !input.transaction_token_sender_constrained - }, - }, - // notary.signer_custody.unapproved: provider kind cannot prove custody - // because PKCS#11 can be hardware- or software-backed. Production and - // evidence-grade deployments therefore require explicit custody - // approval for each configured signing role. - Gate { - id: FINDING_SIGNER_CUSTODY_UNAPPROVED, - hosted_lab: None, - production: Some(ReadinessFail), - evidence_grade: Some(StartupFail), - condition: |input| input.signer_without_custody_approval, - }, - ] -} - -/// A finding produced by gate evaluation, ready to render into posture. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvaluatedFinding { - pub id: String, - pub severity: GateSeverity, - pub status: DeploymentFindingStatus, - pub waiver: Option, -} - -/// An active waiver echoed into posture so Trust Operations can review it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvaluatedWaiver { - pub finding: String, - pub reference: String, - pub summary: Option, - pub expires: String, -} - -/// The full result of evaluating gates for a declared profile. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct GateEvaluation { - /// Finding ids whose effect is `startup_fail` (never waived). A non-empty - /// list means the process must refuse to start. - pub startup_failures: Vec, - /// Finding ids whose effect is `readiness_fail`. The process runs but - /// readiness reports not-ready. - pub readiness_failures: Vec, - /// Findings to render into posture, both active and waived. - pub findings: Vec, - /// Active waivers, including ones whose gate is not currently triggered. - pub active_waivers: Vec, -} - -/// Evaluate the gate catalog for a configuration snapshot. -/// -/// `today` is the date used to decide whether a waiver has expired, passed in -/// so callers and tests can be deterministic. An undeclared profile (`None`) -/// emits `deployment.profile_undeclared` as a startup failure. -pub fn evaluate_gates( - profile: Option, - input: &GateInput, - waivers: &[DeploymentWaiverConfig], - today: &str, -) -> GateEvaluation { - let Some(profile) = profile else { - return GateEvaluation { - startup_failures: vec![FINDING_PROFILE_UNDECLARED.to_string()], - readiness_failures: Vec::new(), - findings: vec![EvaluatedFinding { - id: FINDING_PROFILE_UNDECLARED.to_string(), - severity: GateSeverity::StartupFail, - status: DeploymentFindingStatus::Active, - waiver: None, - }], - active_waivers: Vec::new(), - }; - }; - - let mut evaluation = GateEvaluation::default(); - let mut waived_findings: Vec<&DeploymentWaiverConfig> = Vec::new(); - - // An expired waiver stops suppressing its finding and additionally emits a - // diagnostic error finding so Trust Operations sees the lapse. - for waiver in waivers { - if waiver_is_expired(&waiver.expires, today) { - evaluation.findings.push(EvaluatedFinding { - id: FINDING_WAIVER_EXPIRED.to_string(), - severity: GateSeverity::FindingError, - status: DeploymentFindingStatus::Active, - waiver: Some(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }), - }); - } else { - let Some(severity) = gate_catalog() - .iter() - .find(|gate| gate.id == waiver.finding) - .and_then(|gate| gate.severity_for(profile)) - else { - continue; - }; - if !severity.is_waivable() { - continue; - } - waived_findings.push(waiver); - evaluation.active_waivers.push(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }); - } - } - - for gate in gate_catalog() { - let Some(severity) = gate.severity_for(profile) else { - continue; - }; - if !(gate.condition)(input) { - continue; - } - - // A waiver only suppresses waivable severities. startup_fail is never - // waivable, so even an active waiver leaves it as a hard failure. - let active_waiver = if severity.is_waivable() { - waived_findings - .iter() - .find(|waiver| waiver.finding == gate.id) - .copied() - } else { - None - }; - - if let Some(waiver) = active_waiver { - evaluation.findings.push(EvaluatedFinding { - id: gate.id.to_string(), - severity, - status: DeploymentFindingStatus::Waived, - waiver: Some(EvaluatedWaiver { - finding: waiver.finding.clone(), - reference: waiver.reference.clone(), - summary: waiver.summary.clone(), - expires: waiver.expires.clone(), - }), - }); - continue; - } - - match severity { - GateSeverity::StartupFail => evaluation.startup_failures.push(gate.id.to_string()), - GateSeverity::ReadinessFail => evaluation.readiness_failures.push(gate.id.to_string()), - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - evaluation.findings.push(EvaluatedFinding { - id: gate.id.to_string(), - severity, - status: DeploymentFindingStatus::Active, - waiver: None, - }); - } - - evaluation -} - -/// Parse a strict `YYYY-MM-DD` date into a comparable tuple. -/// -/// Lexicographic string comparison of `YYYY-MM-DD` dates is equivalent to -/// chronological order, so callers compare the raw strings; this function only -/// validates the shape and ranges. -fn parse_iso_date(value: &str) -> Option<(u16, u8, u8)> { - let bytes = value.as_bytes(); - if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { - return None; - } - let year: u16 = value.get(0..4)?.parse().ok()?; - let month: u8 = value.get(5..7)?.parse().ok()?; - let day: u8 = value.get(8..10)?.parse().ok()?; - if !(1..=12).contains(&month) || !(1..=31).contains(&day) { - return None; - } - Some((year, month, day)) -} - -/// A waiver is expired once its expiry date is strictly before today. -fn waiver_is_expired(expires: &str, today: &str) -> bool { - match (parse_iso_date(expires), parse_iso_date(today)) { - (Some(_), Some(_)) => expires < today, - // An unparseable expiry was rejected at config load; treat it as - // expired here so a bad value never silently suppresses a finding. - _ => true, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn waiver(finding: &str, expires: &str) -> DeploymentWaiverConfig { - DeploymentWaiverConfig { - finding: finding.to_string(), - reference: "OPS-TEST-DEPLOYMENT".to_string(), - summary: Some("Synthetic test waiver summary".to_string()), - expires: expires.to_string(), - } - } - - fn high_risk_in_memory_input() -> GateInput { - GateInput { - state_in_memory: true, - federation_enabled: true, - audit_sink_class_durable: true, - ..GateInput::default() - } - } - - #[test] - fn undeclared_profile_is_startup_failure() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates(None, &input, &[], "2026-06-13"); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_PROFILE_UNDECLARED.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - assert_eq!(evaluation.findings.len(), 1); - assert_eq!(evaluation.findings[0].id, FINDING_PROFILE_UNDECLARED); - assert_eq!(evaluation.findings[0].severity, GateSeverity::StartupFail); - } - - #[test] - fn local_profile_binds_no_gates() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates(Some(DeploymentProfile::Local), &input, &[], "2026-06-13"); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - assert!(evaluation.findings.is_empty()); - } - - #[test] - fn evidence_grade_in_memory_high_risk_is_startup_fail() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - } - - #[test] - fn production_in_memory_high_risk_is_readiness_fail() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - assert!(evaluation.startup_failures.is_empty()); - } - - #[test] - fn hosted_lab_high_risk_is_waivable_finding_error() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::HostedLab), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("high-risk finding present"); - assert_eq!(finding.severity, GateSeverity::FindingError); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - } - - #[test] - fn waiver_suppresses_waivable_finding_and_reports_waived() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::HostedLab), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("waived finding present"); - assert_eq!(finding.status, DeploymentFindingStatus::Waived); - assert!(finding.waiver.is_some()); - assert_eq!(evaluation.active_waivers.len(), 1); - } - - #[test] - fn expired_waiver_re_triggers_finding_and_emits_waiver_expired() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2020-01-01")], - "2026-06-13", - ); - // The gate re-triggers at full severity. - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - // The expiry diagnostic is emitted. - assert!(evaluation - .findings - .iter() - .any(|f| f.id == FINDING_WAIVER_EXPIRED && f.severity == GateSeverity::FindingError)); - // The expired waiver is not active. - assert!(evaluation.active_waivers.is_empty()); - } - - #[test] - fn startup_fail_gate_is_not_waivable_even_with_active_waiver() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - } - - #[test] - fn readiness_fail_gate_is_not_waivable_even_with_active_waiver() { - let input = high_risk_in_memory_input(); - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - "2026-06-13", - ); - assert!(evaluation - .readiness_failures - .contains(&FINDING_STATE_IN_MEMORY_HIGH_RISK.to_string())); - let finding = evaluation - .findings - .iter() - .find(|finding| finding.id == FINDING_STATE_IN_MEMORY_HIGH_RISK) - .expect("high-risk replay finding exists"); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.active_waivers.is_empty()); - } - - #[test] - fn validate_rejects_waiver_for_hard_startup_gate() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SINK_MISSING, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("startup_fail waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_waiver_for_hard_readiness_gate() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_STATE_IN_MEMORY_HIGH_RISK, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_unknown_waived_finding() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver("notary.made.up", "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("unknown finding rejected"); - assert!(matches!( - error, - DeploymentConfigError::UnknownWaivedFinding { .. } - )); - } - - #[test] - fn validate_accepts_absent_summary_and_ordinary_metadata() { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = "OPS-2026:INC_42".to_string(); - waiver_config.summary = None; - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver_config], - evidence: DeploymentEvidenceConfig::default(), - }; - config - .validate() - .expect("ordinary reference with absent summary is valid"); - - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = - Some("Public API catalog approved in the operations ticket".to_string()); - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver_config], - evidence: DeploymentEvidenceConfig::default(), - }; - config.validate().expect("ordinary short summary is valid"); - } - - #[test] - fn validate_rejects_invalid_waiver_metadata_with_field_and_limit() { - let mut cases = Vec::new(); - for reference in ["", " OPS-42", "OPS/42"] { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = reference.to_string(); - cases.push((waiver_config, "reference", "128")); - } - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.reference = "x".repeat(129); - cases.push((waiver_config, "reference", "128")); - - for summary in [ - "", - " summary", - "summary\ncontinued", - "Bearer credential-value", - "Basic credential-value", - "rotated leaked Bearer abcdef", - "-----BEGIN OPENSSH PRIVATE KEY-----", - concat!("-----BEGIN PGP PRIVATE KEY ", "BLOCK-----"), - ] { - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = Some(summary.to_string()); - cases.push((waiver_config, "summary", "256")); - } - let mut waiver_config = waiver(FINDING_OPENAPI_PUBLIC, "2099-01-01"); - waiver_config.summary = Some("x".repeat(257)); - cases.push((waiver_config, "summary", "256")); - - for (waiver, field, limit) in cases { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::HostedLab), - multi_instance: false, - waivers: vec![waiver], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("invalid metadata rejected"); - let rendered = error.to_string(); - assert!(rendered.contains(&format!("deployment.waivers[0].{field}"))); - assert!(rendered.contains(limit), "limit missing from: {rendered}"); - } - } - - #[test] - fn validate_rejects_missing_or_malformed_expiry() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_OPENAPI_PUBLIC, "not-a-date")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config.validate().expect_err("malformed expiry rejected"); - assert!(matches!( - error, - DeploymentConfigError::InvalidWaiverExpiry { .. } - )); - } - - #[test] - fn audit_sink_missing_binds_startup_fail_under_production() { - let input = GateInput { - audit_sink_class_durable: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation - .startup_failures - .contains(&FINDING_AUDIT_SINK_MISSING.to_string())); - } - - #[test] - fn audit_sink_durable_clears_the_gate() { - let input = GateInput { - audit_sink_class_durable: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.findings.is_empty()); - } - - #[test] - fn audit_retention_local_only_binds_finding_warn_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("retention finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingWarn); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_retention_local_only_binds_startup_fail_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("retention finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::StartupFail); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_AUDIT_RETENTION_LOCAL_ONLY.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_retention_local_only_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY), - "retention finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_retention_local_only_absent_when_condition_not_met() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: false, - ..GateInput::default() - }; - for profile in [ - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY), - "retention finding must be absent under profile '{}' when unattested sink is not local-only", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_unverified_warns_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED) - .expect("shipping_unverified finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingWarn); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.startup_failures.is_empty()); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_unverified_refuses_startup_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED) - .expect("shipping_unverified finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::StartupFail); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert_eq!( - evaluation.startup_failures, - vec![FINDING_AUDIT_SHIPPING_UNVERIFIED.to_string()] - ); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_unverified_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "shipping_unverified finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_unverified_absent_when_cursor_configured() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "a configured ack cursor clears shipping_unverified" - ); - } - - #[test] - fn audit_shipping_unverified_absent_without_declared_external() { - let input = GateInput { - audit_sink_class_durable: true, - audit_shipping_target_configured: false, - audit_ack_cursor_configured: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_UNVERIFIED), - "shipping_unverified only binds when the target is declared_external" - ); - } - - #[test] - fn audit_shipping_stale_binds_finding_error_under_production() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_STALE) - .expect("shipping_stale finding present under production"); - assert_eq!(finding.severity, GateSeverity::FindingError); - assert_eq!(finding.status, DeploymentFindingStatus::Active); - assert!(evaluation.readiness_failures.is_empty()); - } - - #[test] - fn audit_shipping_stale_binds_readiness_fail_under_evidence_grade() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::EvidenceGrade), - &input, - &[], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_SHIPPING_STALE) - .expect("shipping_stale finding present under evidence_grade"); - assert_eq!(finding.severity, GateSeverity::ReadinessFail); - assert_eq!( - evaluation.readiness_failures, - vec![FINDING_AUDIT_SHIPPING_STALE.to_string()] - ); - assert!(evaluation.startup_failures.is_empty()); - } - - #[test] - fn audit_shipping_stale_cleared_when_health_ok() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: true, - ..GateInput::default() - }; - for profile in [ - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_STALE), - "a fresh cursor clears shipping_stale under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn audit_shipping_stale_is_unbound_under_local_and_hosted_lab() { - let input = GateInput { - audit_sink_class_durable: true, - audit_ack_cursor_configured: true, - audit_ack_health_ok: false, - ..GateInput::default() - }; - for profile in [DeploymentProfile::Local, DeploymentProfile::HostedLab] { - let evaluation = evaluate_gates(Some(profile), &input, &[], "2026-06-13"); - assert!( - !evaluation - .findings - .iter() - .any(|f| f.id == FINDING_AUDIT_SHIPPING_STALE), - "shipping_stale finding must be unbound under profile '{}'", - profile.as_str() - ); - } - } - - #[test] - fn validate_rejects_waiver_for_shipping_stale_under_evidence_grade() { - // shipping_stale is readiness_fail under evidence_grade, so it is a hard - // gate that cannot be waived. A waiver naming it must be rejected at load. - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_STALE, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail shipping_stale waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_rejects_waiver_for_shipping_unverified_under_evidence_grade() { - let config = DeploymentConfig { - profile: Some(DeploymentProfile::EvidenceGrade), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_UNVERIFIED, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - let error = config - .validate() - .expect_err("readiness_fail shipping_unverified waiver rejected"); - assert!(matches!( - error, - DeploymentConfigError::HardGateNotWaivable { .. } - )); - } - - #[test] - fn validate_allows_waiver_for_shipping_stale_under_production() { - // shipping_stale is finding_error (waivable) under production, so a - // waiver naming it is accepted at load. - let config = DeploymentConfig { - profile: Some(DeploymentProfile::Production), - multi_instance: false, - waivers: vec![waiver(FINDING_AUDIT_SHIPPING_STALE, "2099-01-01")], - evidence: DeploymentEvidenceConfig::default(), - }; - config - .validate() - .expect("finding_error shipping_stale waiver accepted under production"); - } - - #[test] - fn audit_retention_local_only_waiver_suppresses_production_finding() { - let input = GateInput { - audit_sink_class_durable: true, - audit_retention_local_only: true, - ..GateInput::default() - }; - let evaluation = evaluate_gates( - Some(DeploymentProfile::Production), - &input, - &[waiver(FINDING_AUDIT_RETENTION_LOCAL_ONLY, "2099-01-01")], - "2026-06-13", - ); - let finding = evaluation - .findings - .iter() - .find(|f| f.id == FINDING_AUDIT_RETENTION_LOCAL_ONLY) - .expect("waived retention finding present"); - assert_eq!(finding.status, DeploymentFindingStatus::Waived); - assert!(finding.waiver.is_some()); - } - - #[test] - fn deployment_evidence_rejects_unknown_field() { - let result: Result = serde_json::from_str( - r#"{ "evidence": { "audit_offhost_shipping": true, "made_up": true } }"#, - ); - assert!(result.is_err()); - } - - // Gate-binding tests for the #208 risky-but-legal findings. - // - // Each case pairs a triggering GateInput with the expected severity per - // profile, and a non-triggering GateInput that must produce no finding. - // All three bound profiles (hosted_lab, production, evidence_grade) are - // checked; local is skipped because it binds no gates. - - struct GateCase { - id: &'static str, - triggering: GateInput, - non_triggering: GateInput, - hosted_lab: GateSeverity, - production: GateSeverity, - evidence_grade: GateSeverity, - } - - fn gate_cases() -> Vec { - vec![ - GateCase { - id: FINDING_ADMIN_SHARED_EXPOSURE, - triggering: GateInput { - admin_shared_exposure: true, - ..GateInput::default() - }, - non_triggering: GateInput { - admin_shared_exposure: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingError, - production: GateSeverity::ReadinessFail, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_OPENAPI_PUBLIC, - triggering: GateInput { - openapi_public: true, - ..GateInput::default() - }, - non_triggering: GateInput { - openapi_public: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::FindingError, - }, - GateCase { - id: FINDING_CONFIG_UNSIGNED, - triggering: GateInput { - config_unsigned: true, - ..GateInput::default() - }, - non_triggering: GateInput { - config_unsigned: false, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_ASSISTED_ACCESS_TRANSACTION_TOKEN_ANCHOR_MISSING, - triggering: GateInput { - subject_access_enabled: true, - transaction_token_anchor_configured: false, - ..GateInput::default() - }, - non_triggering: GateInput { - subject_access_enabled: true, - transaction_token_anchor_configured: true, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingError, - production: GateSeverity::ReadinessFail, - evidence_grade: GateSeverity::StartupFail, - }, - GateCase { - id: FINDING_ASSISTED_ACCESS_SENDER_CONSTRAINT_MISSING, - triggering: GateInput { - transaction_token_anchor_configured: true, - transaction_token_sender_constrained: false, - ..GateInput::default() - }, - non_triggering: GateInput { - transaction_token_anchor_configured: true, - transaction_token_sender_constrained: true, - ..GateInput::default() - }, - hosted_lab: GateSeverity::FindingWarn, - production: GateSeverity::FindingError, - evidence_grade: GateSeverity::ReadinessFail, - }, - ] - } - - #[test] - fn risky_default_findings_bind_correct_severity_per_profile() { - for case in gate_cases() { - for (profile, expected_severity) in [ - (DeploymentProfile::HostedLab, case.hosted_lab), - (DeploymentProfile::Production, case.production), - (DeploymentProfile::EvidenceGrade, case.evidence_grade), - ] { - let evaluation = evaluate_gates(Some(profile), &case.triggering, &[], "2026-06-13"); - - // For startup_fail findings the finding also lands in - // startup_failures; for readiness_fail it lands in - // readiness_failures. Both paths still push into findings. - let found = evaluation - .findings - .iter() - .find(|f| f.id == case.id) - .unwrap_or_else(|| { - panic!( - "expected finding '{}' under profile '{}' (triggering input)", - case.id, - profile.as_str() - ) - }); - assert_eq!( - found.severity, - expected_severity, - "finding '{}' under profile '{}': expected severity {:?}, got {:?}", - case.id, - profile.as_str(), - expected_severity, - found.severity - ); - - // startup_fail and readiness_fail findings must also appear - // in their respective hard-gate lists. - match expected_severity { - GateSeverity::StartupFail => { - assert!( - evaluation.startup_failures.contains(&case.id.to_string()), - "finding '{}' under profile '{}' must be in startup_failures", - case.id, - profile.as_str() - ); - } - GateSeverity::ReadinessFail => { - assert!( - evaluation.readiness_failures.contains(&case.id.to_string()), - "finding '{}' under profile '{}' must be in readiness_failures", - case.id, - profile.as_str() - ); - } - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - } - } - } - - #[test] - fn risky_default_findings_absent_when_condition_not_met() { - for case in gate_cases() { - for profile in [ - DeploymentProfile::HostedLab, - DeploymentProfile::Production, - DeploymentProfile::EvidenceGrade, - ] { - let evaluation = - evaluate_gates(Some(profile), &case.non_triggering, &[], "2026-06-13"); - - // The non-triggering input must not produce the finding. - assert!( - !evaluation.findings.iter().any(|f| f.id == case.id), - "finding '{}' must be absent under profile '{}' with non-triggering input", - case.id, - profile.as_str() - ); - assert!( - !evaluation.startup_failures.contains(&case.id.to_string()), - "finding '{}' must not be in startup_failures under profile '{}' (non-triggering)", - case.id, - profile.as_str() - ); - assert!( - !evaluation.readiness_failures.contains(&case.id.to_string()), - "finding '{}' must not be in readiness_failures under profile '{}' (non-triggering)", - case.id, - profile.as_str() - ); - } - } - } - - #[test] - fn signer_custody_gate_rejects_unapproved_production_custody() { - let triggering = GateInput { - signer_without_custody_approval: true, - ..GateInput::default() - }; - let non_triggering = GateInput { - signer_without_custody_approval: false, - ..GateInput::default() - }; - let cases = [ - (DeploymentProfile::Local, None), - (DeploymentProfile::HostedLab, None), - ( - DeploymentProfile::Production, - Some(GateSeverity::ReadinessFail), - ), - ( - DeploymentProfile::EvidenceGrade, - Some(GateSeverity::StartupFail), - ), - ]; - for (profile, expected_severity) in cases { - let evaluation = evaluate_gates(Some(profile), &triggering, &[], "2026-06-13"); - let found = evaluation - .findings - .iter() - .find(|finding| finding.id == FINDING_SIGNER_CUSTODY_UNAPPROVED); - match expected_severity { - Some(severity) => { - let finding = found.unwrap_or_else(|| { - panic!( - "expected finding '{}' under profile '{}'", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ) - }); - assert_eq!(finding.severity, severity); - match severity { - GateSeverity::StartupFail => assert!(evaluation - .startup_failures - .contains(&FINDING_SIGNER_CUSTODY_UNAPPROVED.to_string())), - GateSeverity::ReadinessFail => assert!(evaluation - .readiness_failures - .contains(&FINDING_SIGNER_CUSTODY_UNAPPROVED.to_string())), - GateSeverity::FindingError | GateSeverity::FindingWarn => {} - } - } - None => assert!( - found.is_none(), - "finding '{}' must be unbound under profile '{}'", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ), - } - - let clear_evaluation = - evaluate_gates(Some(profile), &non_triggering, &[], "2026-06-13"); - assert!( - !clear_evaluation - .findings - .iter() - .any(|finding| finding.id == FINDING_SIGNER_CUSTODY_UNAPPROVED), - "finding '{}' must be absent under profile '{}' with non-triggering input", - FINDING_SIGNER_CUSTODY_UNAPPROVED, - profile.as_str() - ); - } - } - - #[test] - fn invalid_profile_string_fails_deserialization() { - let result: Result = serde_json::from_str(r#"{ "profile": "prod" }"#); - assert!(result.is_err()); - } - - #[test] - fn iso_date_parser_accepts_valid_and_rejects_invalid() { - assert!(parse_iso_date("2026-06-13").is_some()); - assert!(parse_iso_date("2026-13-01").is_none()); - assert!(parse_iso_date("2026-06-32").is_none()); - assert!(parse_iso_date("2026/06/13").is_none()); - assert!(parse_iso_date("26-06-13").is_none()); - } -} diff --git a/crates/registry-notary-core/src/error.rs b/crates/registry-notary-core/src/error.rs deleted file mode 100644 index a327d5d31..000000000 --- a/crates/registry-notary-core/src/error.rs +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary stable error taxonomy. - -use crate::model::SubjectAccessDenialCode; - -use thiserror::Error; - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum EvidenceError { - #[error("evidence server is disabled")] - ServerDisabled, - #[error("claim was not found")] - ClaimNotFound, - #[error("claim version was not found")] - ClaimVersionNotFound, - #[error("claim operation is unsupported")] - OperationUnsupported, - #[error("evidence request is invalid")] - InvalidRequest, - #[error("registry-backed batch consultation request is invalid")] - ConsultationInvalidRequest, - #[error("requested disclosure is not allowed")] - DisclosureNotAllowed, - #[error("purpose is not allowed")] - PurposeNotAllowed, - #[error("policy decision denied the request: {code}")] - PolicyDenied { - code: &'static str, - policy_id: Option, - policy_hash: Option, - evaluated_rule_ids: Vec, - }, - #[error("evidence request profile is unsupported")] - ProfileUnsupported, - #[error("evidence is not available")] - EvidenceNotAvailable, - #[error("batch request is too large")] - BatchTooLarge, - #[error("evaluation was not found")] - EvaluationNotFound, - #[error("evaluation binding mismatch")] - EvaluationBindingMismatch, - #[error("format is unsupported")] - FormatUnsupported, - #[error("credential issuer is not configured")] - CredentialIssuerNotConfigured, - #[error("holder proof is required")] - HolderProofRequired, - #[error("holder proof has already been used")] - HolderProofReplay, - #[error("credential issuance failed")] - CredentialIssuanceFailed, - #[error("claim rule evaluation failed")] - RuleEvaluationFailed, - #[error("idempotency key was reused with a different request")] - IdempotencyConflict, - #[error("purpose is required")] - PurposeRequired, - #[error("credential is missing")] - MissingCredential, - #[error("multiple authentication credentials were provided")] - MultipleCredentials, - #[error("required scope is missing")] - ScopeDenied { required: String }, - #[error("subject-access request is denied")] - SubjectAccessDenied { reason: SubjectAccessDenialCode }, - #[error("subject-access request is rate limited")] - SubjectAccessRateLimited, - #[error("subject-access token is invalid")] - SubjectAccessInvalidToken, - #[error("subject-access assurance policy denied the request")] - SubjectAccessAssuranceDenied, - #[error("machine evaluation quota was exceeded")] - MachineQuotaExceeded { retry_after_seconds: u64 }, -} - -impl EvidenceError { - #[must_use] - pub fn code(&self) -> &'static str { - match self { - Self::ServerDisabled => "evidence.server_disabled", - Self::ClaimNotFound => "claim.not_found", - Self::ClaimVersionNotFound => "claim.version_not_found", - Self::OperationUnsupported => "claim.operation_unsupported", - Self::InvalidRequest => "request.invalid", - Self::ConsultationInvalidRequest => "consultation.invalid_request", - Self::DisclosureNotAllowed => "claim.disclosure_not_allowed", - Self::PurposeNotAllowed => "purpose.not_allowed", - Self::PolicyDenied { code, .. } => code, - Self::ProfileUnsupported => "profile.unsupported", - Self::EvidenceNotAvailable => "evidence.not_available", - Self::BatchTooLarge => "batch.too_large", - Self::EvaluationNotFound => "evaluation.not_found", - Self::EvaluationBindingMismatch => "evaluation.binding_mismatch", - Self::FormatUnsupported => "claim.format_not_supported", - Self::CredentialIssuerNotConfigured => "credential.issuer_not_configured", - Self::HolderProofRequired => "credential.holder_proof_required", - Self::HolderProofReplay => "credential.holder_proof_replay", - Self::CredentialIssuanceFailed => "credential.issuance_failed", - Self::RuleEvaluationFailed => "claim.rule_evaluation_failed", - Self::IdempotencyConflict => "idempotency.conflict", - Self::PurposeRequired => "auth.purpose_required", - Self::MissingCredential => "auth.missing_credential", - Self::MultipleCredentials => "auth.multiple_credentials", - Self::ScopeDenied { .. } => "auth.scope_denied", - Self::SubjectAccessDenied { .. } => "subject_access.denied", - Self::SubjectAccessRateLimited => "subject_access.rate_limited", - Self::SubjectAccessInvalidToken | Self::SubjectAccessAssuranceDenied => { - "subject_access.denied" - } - Self::MachineQuotaExceeded { .. } => "evaluation.quota_exceeded", - } - } - - #[must_use] - pub fn audit_code(&self) -> &'static str { - match self { - Self::SubjectAccessDenied { reason } => reason.as_str(), - Self::SubjectAccessInvalidToken => SubjectAccessDenialCode::InvalidToken.as_str(), - Self::SubjectAccessAssuranceDenied => SubjectAccessDenialCode::AssuranceDenied.as_str(), - Self::PolicyDenied { code, .. } => code, - _ => self.code(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn subject_access_denial_keeps_generic_public_code_and_specific_audit_code() { - let error = EvidenceError::SubjectAccessDenied { - reason: SubjectAccessDenialCode::SubjectMismatch, - }; - - assert_eq!(error.code(), "subject_access.denied"); - assert_eq!(error.audit_code(), "subject_access.subject_mismatch"); - } - - #[test] - fn subject_access_specific_errors_have_stable_codes() { - assert_eq!( - EvidenceError::SubjectAccessRateLimited.code(), - "subject_access.rate_limited" - ); - assert_eq!( - EvidenceError::SubjectAccessInvalidToken.code(), - "subject_access.denied" - ); - assert_eq!( - EvidenceError::SubjectAccessInvalidToken.audit_code(), - "subject_access.invalid_token" - ); - assert_eq!( - EvidenceError::SubjectAccessAssuranceDenied.code(), - "subject_access.denied" - ); - assert_eq!( - EvidenceError::SubjectAccessAssuranceDenied.audit_code(), - "subject_access.assurance_denied" - ); - } - - #[test] - fn machine_quota_exceeded_has_stable_code() { - let error = EvidenceError::MachineQuotaExceeded { - retry_after_seconds: 42, - }; - - assert_eq!(error.code(), "evaluation.quota_exceeded"); - assert_eq!(error.audit_code(), "evaluation.quota_exceeded"); - } - - #[test] - fn policy_denials_keep_stable_pdp_code() { - let error = EvidenceError::PolicyDenied { - code: "pdp.assurance_insufficient", - policy_id: None, - policy_hash: None, - evaluated_rule_ids: Vec::new(), - }; - - assert_eq!(error.code(), "pdp.assurance_insufficient"); - assert_eq!(error.audit_code(), "pdp.assurance_insufficient"); - } -} diff --git a/crates/registry-notary-core/src/lib.rs b/crates/registry-notary-core/src/lib.rs deleted file mode 100644 index 3c796a9d0..000000000 --- a/crates/registry-notary-core/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Shared Registry Notary domain model and credential primitives. - -pub mod config; -pub mod deployment; -pub mod error; -pub mod model; -pub mod sd_jwt; -pub mod tokens; - -pub use config::*; -pub use deployment::*; -pub use error::EvidenceError; -pub use model::*; diff --git a/crates/registry-notary-core/src/model.rs b/crates/registry-notary-core/src/model.rs deleted file mode 100644 index ce2af6467..000000000 --- a/crates/registry-notary-core/src/model.rs +++ /dev/null @@ -1,2818 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Registry Notary request, response, and view types. - -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::marker::PhantomData; - -use schemars::{JsonSchema, Schema, SchemaGenerator}; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; - -pub const FORMAT_CLAIM_RESULT_JSON: &str = "application/vnd.registry-notary.claim-result+json"; -pub const FORMAT_CCCEV_JSONLD: &str = "application/ld+json; profile=\"cccev\""; -pub const FORMAT_SD_JWT_VC: &str = "application/dc+sd-jwt"; -pub const SD_JWT_VC_JWT_TYP: &str = "dc+sd-jwt"; -pub const SD_JWT_VC_SIGNING_ALG: &str = "EdDSA"; -pub const SD_JWT_VC_ISSUER_KEY_TYPE: &str = "OKP/Ed25519"; -pub const SD_JWT_VC_HOLDER_BINDING_METHOD: &str = "did:jwk"; -pub const MAX_BOUNDED_CLAIM_ID_LEN: usize = 128; -pub const MAX_CONFIG_METADATA_LEN: usize = 256; -pub const MAX_CORRELATION_ID_LEN: usize = 128; -pub const MAX_POLICY_ID_LEN: usize = 128; -pub const MAX_RATE_LIMIT_BUCKET_LEN: usize = 128; -pub const MAX_TOKEN_CLAIM_VALUE_LEN: usize = 512; -pub const MAX_VERIFIED_CLAIM_NAME_LEN: usize = 256; -pub const MAX_VERIFIED_CLAIM_VALUE_LEN: usize = 512; -pub const MAX_REQUEST_VARIABLES_V1: usize = 16; -pub const MAX_REQUEST_VARIABLE_NAME_BYTES_V1: usize = 96; - -pub type BoundedClaimId = Bounded; -pub type BoundedCorrelationId = Bounded; -pub type BoundedPolicyId = Bounded; -pub type ConfigMetadata = Bounded; -pub type RateLimitBucket = Bounded; -pub type VerifiedClaimName = Bounded; -pub type VerifiedClaimValue = Bounded; - -/// Closed v1 request variables. The first contract admits only named RFC 3339 -/// full-date strings, with service configuration deciding which names exist. -#[derive(Clone, Default, PartialEq, Eq, Serialize, utoipa::ToSchema)] -#[serde(transparent)] -pub struct RequestVariables(BTreeMap); - -impl fmt::Debug for RequestVariables { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("RequestVariables([REDACTED])") - } -} - -impl RequestVariables { - pub fn try_new(values: BTreeMap) -> Result { - if values.len() > MAX_REQUEST_VARIABLES_V1 - || values.iter().any(|(name, value)| { - !is_request_variable_name(name) || !is_rfc3339_full_date(value) - }) - { - return Err("request variables must be bounded named RFC 3339 full-date strings"); - } - Ok(Self(values)) - } - - #[must_use] - pub fn get(&self, name: &str) -> Option<&str> { - self.0.get(name).map(String::as_str) - } - - pub fn iter(&self) -> impl ExactSizeIterator { - self.0 - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl<'de> Deserialize<'de> for RequestVariables { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let values = BTreeMap::::deserialize(deserializer)?; - Self::try_new(values).map_err(de::Error::custom) - } -} - -#[must_use] -pub fn is_request_variable_name(value: &str) -> bool { - let mut bytes = value.bytes(); - matches!(bytes.next(), Some(b'a'..=b'z')) - && value.len() <= MAX_REQUEST_VARIABLE_NAME_BYTES_V1 - && bytes.all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_')) -} - -#[must_use] -pub fn is_rfc3339_full_date(value: &str) -> bool { - let bytes = value.as_bytes(); - if bytes.len() != 10 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) - { - return false; - } - let number = |range: std::ops::Range| value[range].parse::().ok(); - let (Some(year), Some(month), Some(day)) = (number(0..4), number(5..7), number(8..10)) else { - return false; - }; - let Ok(month) = time::Month::try_from(u8::try_from(month).unwrap_or(0)) else { - return false; - }; - time::Date::from_calendar_date(i32::from(year), month, u8::try_from(day).unwrap_or(0)).is_ok() -} - -/// The authentication trust profile that produced an [`EvidencePrincipal`]. -/// -/// These identifiers are deliberately closed and credential-independent so -/// they can safely participate in caller binding without incorporating raw -/// API keys, bearer tokens, or attacker-controlled token claims. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum EvidenceAuthProfileId { - StaticApiKey, - StaticBearer, - ExternalOidc, - NotaryAccessToken, - Federation, -} - -impl EvidenceAuthProfileId { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::StaticApiKey => "static_api_key", - Self::StaticBearer => "static_bearer", - Self::ExternalOidc => "external_oidc", - Self::NotaryAccessToken => "notary_access_token", - Self::Federation => "federation", - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum AccessMode { - Unknown, - #[default] - MachineClient, - SubjectBound, - DelegatedSubjectAccess, -} - -impl AccessMode { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Unknown => "unknown", - Self::MachineClient => "machine_client", - Self::SubjectBound => "subject_bound", - Self::DelegatedSubjectAccess => "delegated_subject_access", - } - } - - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "unknown" => Some(Self::Unknown), - "machine_client" => Some(Self::MachineClient), - "subject_bound" => Some(Self::SubjectBound), - "delegated_subject_access" => Some(Self::DelegatedSubjectAccess), - _ => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubjectAccessDenialCode { - Disabled, - OperationDenied, - ClaimDenied, - DisclosureDenied, - FormatDenied, - ProfileDenied, - SubjectClaimMissing, - SubjectMismatch, - RateLimited, - InvalidToken, - AssuranceDenied, - BatchDenied, - DelegatedRelationshipUnproven, - DelegatedRelationshipNotAllowed, - DelegatedClaimDenied, - DelegatedSubjectNotPermitted, - DelegatedProofDenied, -} - -impl SubjectAccessDenialCode { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Disabled => "subject_access.disabled", - Self::OperationDenied => "subject_access.operation_denied", - Self::ClaimDenied => "subject_access.claim_denied", - Self::DisclosureDenied => "subject_access.disclosure_denied", - Self::FormatDenied => "subject_access.format_denied", - Self::ProfileDenied => "subject_access.profile_denied", - Self::SubjectClaimMissing => "subject_access.subject_claim_missing", - Self::SubjectMismatch => "subject_access.subject_mismatch", - Self::RateLimited => "subject_access.rate_limited", - Self::InvalidToken => "subject_access.invalid_token", - Self::AssuranceDenied => "subject_access.assurance_denied", - Self::BatchDenied => "subject_access.batch_denied", - Self::DelegatedRelationshipUnproven => "delegated.relationship_unproven", - Self::DelegatedRelationshipNotAllowed => "delegated.relationship_not_allowed", - Self::DelegatedClaimDenied => "delegated.claim_denied", - Self::DelegatedSubjectNotPermitted => "delegated.subject_not_permitted", - Self::DelegatedProofDenied => "delegated.proof_denied", - } - } - - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "subject_access.disabled" => Some(Self::Disabled), - "subject_access.operation_denied" => Some(Self::OperationDenied), - "subject_access.claim_denied" => Some(Self::ClaimDenied), - "subject_access.disclosure_denied" => Some(Self::DisclosureDenied), - "subject_access.format_denied" => Some(Self::FormatDenied), - "subject_access.profile_denied" => Some(Self::ProfileDenied), - "subject_access.subject_claim_missing" => Some(Self::SubjectClaimMissing), - "subject_access.subject_mismatch" => Some(Self::SubjectMismatch), - "subject_access.rate_limited" => Some(Self::RateLimited), - "subject_access.invalid_token" => Some(Self::InvalidToken), - "subject_access.assurance_denied" => Some(Self::AssuranceDenied), - "subject_access.batch_denied" => Some(Self::BatchDenied), - "delegated.relationship_unproven" => Some(Self::DelegatedRelationshipUnproven), - "delegated.relationship_not_allowed" => Some(Self::DelegatedRelationshipNotAllowed), - "delegated.claim_denied" => Some(Self::DelegatedClaimDenied), - "delegated.subject_not_permitted" => Some(Self::DelegatedSubjectNotPermitted), - "delegated.proof_denied" => Some(Self::DelegatedProofDenied), - _ => None, - } - } -} - -impl Serialize for SubjectAccessDenialCode { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for SubjectAccessDenialCode { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).ok_or_else(|| de::Error::custom("unknown subject-access denial code")) - } -} - -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Bounded(String); - -impl Bounded { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.len() > N { - return Err(BoundedStringError { - max: N, - actual: value.len(), - }); - } - Ok(Self(value)) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - - #[must_use] - pub fn into_inner(self) -> String { - self.0 - } -} - -impl fmt::Debug for Bounded { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Bounded").field(&self.0).finish() - } -} - -impl TryFrom for Bounded { - type Error = BoundedStringError; - - fn try_from(value: String) -> Result { - Self::new(value) - } -} - -impl TryFrom<&str> for Bounded { - type Error = BoundedStringError; - - fn try_from(value: &str) -> Result { - Self::new(value) - } -} - -impl Serialize for Bounded { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl<'de, const N: usize> Deserialize<'de> for Bounded { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BoundedStringError { - pub max: usize, - pub actual: usize, -} - -impl fmt::Display for BoundedStringError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "bounded string length {} exceeds maximum {}", - self.actual, self.max - ) - } -} - -impl std::error::Error for BoundedStringError {} - -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Hashed { - value: String, - _marker: PhantomData, -} - -impl Hashed { - #[must_use] - pub fn from_hash(value: impl Into) -> Self { - Self { - value: value.into(), - _marker: PhantomData, - } - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.value - } - - #[must_use] - pub fn into_inner(self) -> String { - self.value - } -} - -impl fmt::Debug for Hashed { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Hashed").field(&self.value).finish() - } -} - -impl Serialize for Hashed { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.value) - } -} - -impl<'de, T> Deserialize<'de> for Hashed { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct HashedVisitor(PhantomData); - - impl Visitor<'_> for HashedVisitor { - type Value = Hashed; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a hashed identifier string") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - Ok(Hashed::from_hash(value)) - } - - fn visit_string(self, value: String) -> Result - where - E: de::Error, - { - Ok(Hashed::from_hash(value)) - } - } - - deserializer.deserialize_string(HashedVisitor(PhantomData)) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PrincipalIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SubjectBinding {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EvidenceEntityReference {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HolderIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PreAuthorizedCodeIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CredentialIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClaimSet {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PolicyIdentifier {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RequestIdentifier {} - -#[derive(Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BoundedVerifiedClaims { - pub issuer: VerifiedClaimValue, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub audiences: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_configuration_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_transaction_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_transaction_commitment: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scopes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_binding_claim: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_binding_value: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub acr: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth_time: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exp: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub iat: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nbf: Option, -} - -impl fmt::Debug for BoundedVerifiedClaims { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BoundedVerifiedClaims") - .field("issuer", &self.issuer) - .field("audiences", &self.audiences) - .field("client_id", &self.client_id) - .field("token_type", &self.token_type) - .field( - "credential_configuration_id", - &self.credential_configuration_id, - ) - .field("issuance_transaction_id", &"") - .field( - "issuance_transaction_commitment", - &self.issuance_transaction_commitment, - ) - .field("scopes", &self.scopes) - .field("subject", &self.subject.as_ref().map(|_| "")) - .field("subject_binding_claim", &self.subject_binding_claim) - .field( - "subject_binding_value", - &self.subject_binding_value.as_ref().map(|_| ""), - ) - .field("acr", &self.acr) - .field("auth_time", &self.auth_time) - .field("exp", &self.exp) - .field("iat", &self.iat) - .field("nbf", &self.nbf) - .finish() - } -} - -impl BoundedVerifiedClaims { - #[must_use] - pub fn has_scope(&self, scope: &str) -> bool { - self.scopes - .iter() - .any(|candidate| candidate.as_str() == scope) - } - - #[must_use] - pub fn claim_value(&self, claim_name: &str) -> Option<&str> { - match claim_name { - "iss" => Some(self.issuer.as_str()), - "sub" => self.subject.as_ref().map(Bounded::as_str), - "typ" | "token_type" => self.token_type.as_ref().map(Bounded::as_str), - "credential_configuration_id" => self - .credential_configuration_id - .as_ref() - .map(Bounded::as_str), - "issuance_transaction_id" => self.issuance_transaction_id.as_ref().map(Bounded::as_str), - "issuance_transaction_commitment" => self - .issuance_transaction_commitment - .as_ref() - .map(Bounded::as_str), - "client_id" | "azp" => self.client_id.as_ref().map(Bounded::as_str), - "acr" => self.acr.as_ref().map(Bounded::as_str), - other => self - .subject_binding_claim - .as_ref() - .filter(|configured| configured.as_str() == other) - .and(self.subject_binding_value.as_ref()) - .map(Bounded::as_str), - } - } - - #[must_use] - pub fn subject_binding_value(&self, claim_name: &str) -> Option<&str> { - self.subject_binding_claim - .as_ref() - .filter(|configured| configured.as_str() == claim_name) - .and(self.subject_binding_value.as_ref()) - .map(Bounded::as_str) - .filter(|value| !value.trim().is_empty()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "mode", rename_all = "snake_case")] -pub enum EvaluationCapability { - Machine { - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - scopes: BTreeSet, - }, - SubjectBound { - #[serde(default, skip_serializing_if = "Option::is_none")] - claim_id: Option, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - allowed_claim_ids: BTreeSet, - subject_binding_hash: Hashed, - }, - DelegatedSubjectAccess { - proof_claim_id: BoundedClaimId, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - allowed_claim_ids: BTreeSet, - requester_subject_binding_hash: Hashed, - dependent_target_hash: Hashed, - relationship_type: ConfigMetadata, - }, -} - -impl EvaluationCapability { - #[must_use] - pub fn access_mode(&self) -> AccessMode { - match self { - Self::Machine { .. } => AccessMode::MachineClient, - Self::SubjectBound { .. } => AccessMode::SubjectBound, - Self::DelegatedSubjectAccess { .. } => AccessMode::DelegatedSubjectAccess, - } - } - - #[must_use] - pub fn allows_scope(&self, scope: &str) -> bool { - match self { - Self::Machine { scopes } => scopes.contains(scope), - Self::SubjectBound { .. } => false, - Self::DelegatedSubjectAccess { .. } => false, - } - } - - #[must_use] - pub fn allows_subject_access_claim(&self, claim_id: &str) -> bool { - match self { - Self::Machine { .. } => false, - Self::SubjectBound { - claim_id: allowed, - allowed_claim_ids, - .. - } => { - allowed - .as_ref() - .is_some_and(|allowed| allowed.as_str() == claim_id) - || allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) - } - Self::DelegatedSubjectAccess { .. } => false, - } - } - - #[must_use] - pub fn allows_delegated_claim(&self, claim_id: &str) -> bool { - match self { - Self::DelegatedSubjectAccess { - proof_claim_id, - allowed_claim_ids, - .. - } => { - proof_claim_id.as_str() == claim_id - || allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) - } - Self::Machine { .. } | Self::SubjectBound { .. } => false, - } - } - - #[must_use] - pub fn required_delegated_proof_for_claim(&self, claim_id: &str) -> Option<&str> { - match self { - Self::DelegatedSubjectAccess { - proof_claim_id, - allowed_claim_ids, - .. - } if proof_claim_id.as_str() != claim_id - && allowed_claim_ids - .iter() - .any(|allowed| allowed.as_str() == claim_id) => - { - Some(proof_claim_id.as_str()) - } - _ => None, - } - } - - #[must_use] - pub fn is_delegated_proof_claim(&self, claim_id: &str) -> bool { - match self { - Self::DelegatedSubjectAccess { proof_claim_id, .. } => { - proof_claim_id.as_str() == claim_id - } - Self::Machine { .. } | Self::SubjectBound { .. } => false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DisclosureProfile { - Value, - Predicate, - Redacted, -} - -impl DisclosureProfile { - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "value" => Some(Self::Value), - "predicate" => Some(Self::Predicate), - "redacted" => Some(Self::Redacted), - _ => None, - } - } - - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Value => "value", - Self::Predicate => "predicate", - Self::Redacted => "redacted", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum DisclosureDowngrade { - Deny, - Default, - Redacted, -} - -impl DisclosureDowngrade { - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "deny" | "none" => Some(Self::Deny), - "default" => Some(Self::Default), - "redacted" => Some(Self::Redacted), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct ClaimRef { - pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -impl ClaimRef { - #[must_use] - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - version: None, - } - } - - #[must_use] - pub fn with_version(id: impl Into, version: impl Into) -> Self { - Self { - id: id.into(), - version: Some(version.into()), - } - } -} - -impl From for ClaimRef { - fn from(id: String) -> Self { - Self::new(id) - } -} - -impl From<&str> for ClaimRef { - fn from(id: &str) -> Self { - Self::new(id) - } -} - -impl std::ops::Deref for ClaimRef { - type Target = str; - - fn deref(&self) -> &Self::Target { - self.id.as_str() - } -} - -#[derive(Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct ClaimRefObject { - id: String, - #[serde(default)] - version: Option, -} - -#[derive(Deserialize, JsonSchema)] -#[serde(untagged)] -enum WireClaimRef { - Id(String), - Object(ClaimRefObject), -} - -impl<'de> Deserialize<'de> for ClaimRef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match WireClaimRef::deserialize(deserializer)? { - WireClaimRef::Id(id) => Ok(Self::new(id)), - WireClaimRef::Object(object) => Ok(Self { - id: object.id, - version: object.version, - }), - } - } -} - -impl JsonSchema for ClaimRef { - fn schema_name() -> Cow<'static, str> { - "ClaimRef".into() - } - - fn json_schema(generator: &mut SchemaGenerator) -> Schema { - generator.subschema_for::() - } -} - -/// Frozen minimal actor/delegation envelope for `on_behalf_of`. -/// -/// Replaces the previous free-form `Option`. This is the beta-frozen -/// shape per the 2026-06-11 evidence-contracts decision record (D4): a -/// structured actor plus an opaque `delegation_ref`. Simple deployments send no -/// envelope at all (the field stays optional). No OAuth token exchange, RAR, or -/// CIBA machinery is required here; those arrive post-1.0 as additive profiles -/// (notary#180) that map the actor onto OAuth `act`-claim semantics. The shape -/// does not bake in a single-actor assumption: an actor chain is expressed by -/// `delegation_ref` indirection, so the additive mapping stays open. -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceOnBehalfOf { - pub actor: EvidenceActor, - /// Opaque reference to an out-of-band delegation record. The envelope does - /// not interpret its contents; it is the indirection point through which a - /// later OAuth profile resolves an actor chain. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation_ref: Option, -} - -/// A structured actor in the delegation envelope. The same vocabulary is reused -/// for stored delegation-chain entries so wire requests and stored evaluations -/// do not diverge. -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceActor { - #[serde(rename = "type")] - pub actor_type: String, - /// Keyed-hash identifier of the actor in `hmac-sha256:` format per the - /// D7 vocabulary. Never a raw principal value. - pub id_hash: String, - /// Optional assurance level of the actor (for example an `acr` value). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvaluateRequest { - #[serde(default)] - pub requester: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default)] - pub relationship: Option, - #[serde(default)] - pub on_behalf_of: Option, - #[serde(default, skip_serializing_if = "RequestVariables::is_empty")] - pub variables: RequestVariables, - pub claims: Vec, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub purpose: Option, -} - -impl EvaluateRequest { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target - .as_ref() - .and_then(EvidenceEntity::to_subject_request) - } - - #[must_use] - pub fn request_context(&self) -> Option { - self.target.as_ref().map(|target| EvidenceRequestContext { - requester: self.requester.clone(), - target: target.clone(), - relationship: self.relationship.clone(), - on_behalf_of: self.on_behalf_of.clone(), - variables: self.variables.clone(), - }) - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceRequestContext { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester: Option, - pub target: EvidenceEntity, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub on_behalf_of: Option, - #[serde(default, skip_serializing_if = "RequestVariables::is_empty")] - pub variables: RequestVariables, -} - -impl EvidenceRequestContext { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target.to_subject_request() - } - - #[must_use] - pub fn lookup_value(&self, path: &str) -> Option { - match path { - "target.id" => self - .target - .id - .as_ref() - .map(|value| Value::String(value.clone())), - "requester.id" => self - .requester - .as_ref() - .and_then(|requester| requester.id.as_ref()) - .map(|value| Value::String(value.clone())), - _ if path.starts_with("target.attributes.") => { - let key = path.strip_prefix("target.attributes.")?; - self.target.attributes.get(key).cloned() - } - _ if path.starts_with("requester.attributes.") => { - let key = path.strip_prefix("requester.attributes.")?; - self.requester - .as_ref() - .and_then(|requester| requester.attributes.get(key)) - .cloned() - } - _ if path.starts_with("relationship.attributes.") => { - let key = path.strip_prefix("relationship.attributes.")?; - self.relationship - .as_ref() - .and_then(|relationship| relationship.attributes.get(key)) - .cloned() - } - _ if path.starts_with("target.identifiers.") => { - let scheme = path.strip_prefix("target.identifiers.")?; - self.target - .identifier_value(scheme) - .map(|value| Value::String(value.to_string())) - } - _ if path.starts_with("requester.identifiers.") => { - let scheme = path.strip_prefix("requester.identifiers.")?; - self.requester - .as_ref() - .and_then(|requester| requester.identifier_value(scheme)) - .map(|value| Value::String(value.to_string())) - } - _ if path.starts_with("variables.") => { - let name = path.strip_prefix("variables.")?; - self.variables - .get(name) - .map(|value| Value::String(value.to_string())) - } - _ => None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceEntity { - #[serde(rename = "type")] - pub entity_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifiers: Vec, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub attributes: BTreeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -impl EvidenceEntity { - #[must_use] - pub fn new(entity_type: impl Into) -> Self { - Self { - entity_type: entity_type.into(), - id: None, - identifiers: Vec::new(), - attributes: BTreeMap::new(), - assurance: None, - profile: None, - } - } - - #[must_use] - pub fn with_identifier( - entity_type: impl Into, - scheme: impl Into, - value: impl Into, - ) -> Self { - Self { - entity_type: entity_type.into(), - id: None, - identifiers: vec![EvidenceIdentifier { - scheme: scheme.into(), - value: value.into(), - issuer: None, - country: None, - }], - attributes: BTreeMap::new(), - assurance: None, - profile: None, - } - } - - #[must_use] - pub fn from_subject_request(entity_type: impl Into, subject: SubjectRequest) -> Self { - match subject.id_type { - Some(id_type) => Self::with_identifier(entity_type, id_type, subject.id), - None => { - let mut entity = Self::new(entity_type); - entity.id = Some(subject.id); - entity - } - } - } - - #[must_use] - pub fn to_subject_request(&self) -> Option { - if let Some(identifier) = self.identifiers.first() { - return Some(SubjectRequest { - id: identifier.value.clone(), - id_type: Some(identifier.scheme.clone()), - }); - } - self.id.as_ref().map(|id| SubjectRequest { - id: id.clone(), - id_type: None, - }) - } - - #[must_use] - pub fn identifier_value(&self, scheme: &str) -> Option<&str> { - self.identifiers - .iter() - .find(|identifier| identifier.scheme == scheme) - .map(|identifier| identifier.value.as_str()) - } - - #[must_use] - pub fn has_matching_input(&self) -> bool { - self.id.as_ref().is_some_and(|id| !id.trim().is_empty()) - || self - .identifiers - .iter() - .any(|identifier| !identifier.value.trim().is_empty()) - || !self.attributes.is_empty() - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceIdentifier { - pub scheme: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub country: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceAssurance { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub method: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub level_scheme: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub level: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verified_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub evidence: Vec, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct EvidenceRelationship { - #[serde(rename = "type")] - pub relationship_type: String, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub attributes: BTreeMap, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct SubjectRequest { - pub id: String, - #[serde(default)] - pub id_type: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchSubjectRequest { - pub id: String, - #[serde(default)] - pub id_type: Option, - #[serde(default)] - pub purpose: Option, -} - -impl From for SubjectRequest { - fn from(subject: BatchSubjectRequest) -> Self { - Self { - id: subject.id, - id_type: subject.id_type, - } - } -} - -impl From for BatchSubjectRequest { - fn from(subject: SubjectRequest) -> Self { - Self { - id: subject.id, - id_type: subject.id_type, - purpose: None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchEvaluateItemRequest { - pub target: EvidenceEntity, - #[serde(default)] - pub requester: Option, - #[serde(default)] - pub relationship: Option, - #[serde(default)] - pub on_behalf_of: Option, - #[serde(default)] - pub purpose: Option, -} - -impl BatchEvaluateItemRequest { - #[must_use] - pub fn target_subject(&self) -> Option { - self.target.to_subject_request() - } - - #[must_use] - pub fn request_context(&self) -> EvidenceRequestContext { - EvidenceRequestContext { - requester: self.requester.clone(), - target: self.target.clone(), - relationship: self.relationship.clone(), - on_behalf_of: self.on_behalf_of.clone(), - variables: RequestVariables::default(), - } - } -} - -impl From for BatchEvaluateItemRequest { - fn from(subject: BatchSubjectRequest) -> Self { - let purpose = subject.purpose.clone(); - Self { - target: EvidenceEntity::from_subject_request("Person", SubjectRequest::from(subject)), - requester: None, - relationship: None, - on_behalf_of: None, - purpose, - } - } -} - -impl From for BatchEvaluateItemRequest { - fn from(subject: SubjectRequest) -> Self { - Self { - target: EvidenceEntity::from_subject_request("Person", subject), - requester: None, - relationship: None, - on_behalf_of: None, - purpose: None, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct BatchEvaluateRequest { - pub items: Vec, - pub claims: Vec, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub purpose: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchEvaluateResponse { - pub batch_id: String, - pub status: BatchStatus, - pub claims: Vec, - pub items: Vec, - pub summary: BatchSummary, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BatchStatus { - Completed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchSummary { - pub succeeded: usize, - pub failed: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchItemResponse { - pub input_index: usize, - pub target_ref: TargetRefView, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub evaluation_id: Option, - pub status: BatchItemStatus, - pub claim_results: Vec, - pub errors: Vec, - /// Request-local consultation evidence for restricted audit assembly. - /// This is never serialized into the public response or durable state. - #[doc(hidden)] - #[serde(skip)] - pub runtime_audit: BatchItemRuntimeAudit, -} - -/// Request-local, value-free consultation evidence for a batch member. -#[doc(hidden)] -#[derive(Clone, Default)] -pub struct BatchItemRuntimeAudit { - pub relay_forwarded_count: u64, - pub relay_consultation_ids: Vec, -} - -impl std::fmt::Debug for BatchItemRuntimeAudit { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("BatchItemRuntimeAudit") - .field("relay_forwarded_count", &self.relay_forwarded_count) - .field("relay_consultation_ids", &"[REDACTED]") - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BatchItemStatus { - Succeeded, - Failed, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchClaimResultView { - pub result_id: String, - pub claim_id: String, - pub claim_version: String, - pub value_type: String, - pub value: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub satisfied: Option, - pub disclosure: String, - pub provenance: ClaimProvenance, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BatchItemError { - pub code: String, - pub title: String, - pub retryable: bool, - #[serde(default, skip)] - pub audit_code: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RenderRequest { - pub evaluation_id: String, - pub format: String, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub purpose: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct RenderEvaluationRequest { - pub format: String, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub purpose: Option, -} - -impl RenderEvaluationRequest { - #[must_use] - pub fn with_evaluation_id(self, evaluation_id: String) -> RenderRequest { - RenderRequest { - evaluation_id, - format: self.format, - disclosure: self.disclosure, - claims: self.claims, - purpose: self.purpose, - } - } -} - -impl From for RenderEvaluationRequest { - fn from(request: RenderRequest) -> Self { - Self { - format: request.format, - disclosure: request.disclosure, - claims: request.claims, - purpose: request.purpose, - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct CredentialIssueRequest { - pub evaluation_id: String, - #[serde(default)] - pub credential_profile: Option, - #[serde(default)] - pub format: Option, - #[serde(default)] - pub claims: Option>, - #[serde(default)] - pub disclosure: Option, - #[serde(default)] - pub purpose: Option, - #[serde(default)] - pub holder: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(deny_unknown_fields)] -pub struct HolderRequest { - #[serde(default)] - pub binding: Option, - #[serde(default)] - pub id: Option, - #[serde(default)] - pub proof: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvidenceFormat { - pub id: String, - pub kind: String, - pub status: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ClaimResultView { - pub evaluation_id: String, - pub claim_id: String, - pub claim_version: String, - pub subject_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref: Option, - pub target_ref: TargetRefView, - pub value: Option, - pub satisfied: Option, - pub disclosure: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub redacted_fields: Vec, - pub format: String, - pub issued_at: String, - pub expires_at: Option, - pub provenance: ClaimProvenance, -} - -impl std::fmt::Debug for ClaimResultView { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ClaimResultView") - .field("evaluation_id", &"[REDACTED]") - .field("claim_id", &self.claim_id) - .field("claim_version", &self.claim_version) - .field("subject_type", &self.subject_type) - .field("requester_ref", &"[REDACTED]") - .field("target_ref", &"[REDACTED]") - .field("value", &"[REDACTED]") - .field("satisfied", &self.satisfied) - .field("disclosure", &self.disclosure) - .field("redacted_fields", &self.redacted_fields) - .field("format", &self.format) - .field("issued_at", &self.issued_at) - .field("expires_at", &self.expires_at) - .field("provenance", &self.provenance) - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TargetRefView { - #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")] - pub entity_type: String, - pub handle: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifier_schemes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EvidenceEntityRef { - #[serde(rename = "type")] - pub entity_type: String, - pub handle: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub identifier_schemes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile: Option, -} - -/// `schema_version` value carried by every [`ClaimProvenance`]. V2 names the -/// consumed boundary as Relay consultations and removes unreleased source -/// runtime aliases. -pub const CLAIM_PROVENANCE_SCHEMA_VERSION: &str = "registry-notary-claim-provenance/v2"; - -/// The `type` value for a claim-evaluation provenance record. -pub const PROVENANCE_GENERATED_BY_CLAIM_EVALUATION: &str = "claim_evaluation"; - -/// Versioned claim provenance attached to every public claim result. -/// -/// This contract lets a verifier answer which evaluation produced the result, -/// under which policy, and across how many Relay consultations. The shape is -/// documented as PROV-mappable but is not PROV-O. -/// Requester-side identity (client, actor, subject) is deliberately absent; -/// those live in restricted audit, never on the public wire. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ClaimProvenance { - pub schema_version: String, - pub generated_by: ProvenanceGeneratedBy, - pub used: ProvenanceUsed, - /// Upstream provenance records this result was derived from. Reserved for - /// cross-evaluation linking; always empty in v2 but present in the shape so - /// adding entries later is additive. - pub derived_from: Vec, -} - -impl ClaimProvenance { - /// Construct a provenance record at the current schema version with the - /// canonical `generated_by.type`. - #[must_use] - pub fn new( - service_id: String, - evaluation_id: String, - claim_id: String, - claim_version: String, - used: ProvenanceUsed, - ) -> Self { - Self { - schema_version: CLAIM_PROVENANCE_SCHEMA_VERSION.to_string(), - generated_by: ProvenanceGeneratedBy { - entry_type: PROVENANCE_GENERATED_BY_CLAIM_EVALUATION.to_string(), - service_id, - evaluation_id, - claim_id, - claim_version, - policy_id: None, - policy_version: None, - policy_hash: None, - pack_id: None, - pack_version: None, - }, - used, - derived_from: Vec::new(), - } - } -} - -/// The producing side of a claim provenance record. -/// -/// `policy_id` here names the *evaluation* policy under which the result was -/// produced. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvenanceGeneratedBy { - #[serde(rename = "type")] - pub entry_type: String, - /// Identifier of the service that produced the result. Replaces the - /// dropped `computed_by` field; the CCCEV renderer maps its provider agent - /// from here. - pub service_id: String, - pub evaluation_id: String, - pub claim_id: String, - pub claim_version: String, - /// Evaluation policy identifier. Present for flows evaluated under a named - /// policy (e.g. subject-access); omitted for machine-client flows with no - /// evaluation policy. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - /// `sha256:` digest of the evaluation policy. Public in v1: a hash, - /// revealing no policy content, that lets a verifier correlate the result - /// with a policy evidence-pack later. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pack_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pack_version: Option, -} - -/// The consumed side of a claim provenance record: how many Relay consultations -/// contributed to the claim. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvenanceUsed { - pub relay_consultation_count: usize, -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredEvaluation { - pub client_id: String, - pub purpose: String, - pub claim_ids: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claim_refs: Vec, - pub disclosure: String, - pub format: String, - pub results: Vec, - pub created_at: String, - pub expires_at: String, - pub request_hash: String, - /// Private issuance-only Relay provenance for the selected dependency closure. - /// - /// This is deliberately separate from public [`ClaimProvenance`]. It is - /// persisted only for credential-capable selections so a later credential - /// request can prove that every fact being signed came from the exact - /// compiler-pinned Relay consultation. Evaluation-only selections retain - /// no private Relay execution identifiers. Evaluations written before this - /// field existed remain readable, but are not credential-issuable and must - /// be evaluated again. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuance_provenance: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject_access: Option, -} - -impl std::fmt::Debug for StoredEvaluation { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredEvaluation") - .field("client_id", &"[REDACTED]") - .field("purpose", &self.purpose) - .field("claim_ids", &self.claim_ids) - .field("claim_refs", &self.claim_refs) - .field("disclosure", &self.disclosure) - .field("format", &self.format) - .field("result_count", &self.results.len()) - .field("created_at", &self.created_at) - .field("expires_at", &self.expires_at) - .field("request_hash", &"[REDACTED]") - .field("issuance_provenance", &self.issuance_provenance) - .field("subject_access", &self.subject_access) - .finish() - } -} - -impl StoredEvaluation { - #[must_use] - pub fn access_mode(&self) -> AccessMode { - self.subject_access - .as_ref() - .map(|metadata| metadata.access_mode) - .unwrap_or(AccessMode::MachineClient) - } - - #[must_use] - pub fn selected_claim_refs(&self) -> Vec { - if self.claim_refs.is_empty() { - self.claim_ids - .iter() - .map(|claim_id| ClaimRef::from(claim_id.as_str())) - .collect() - } else { - self.claim_refs.clone() - } - } -} - -/// Bounded private provenance retained for credential issuance. -/// -/// The runtime admits at most the v1 claim-closure bound and the issuance -/// verifier rejects an over-sized or incomplete set before any signer or -/// credential-status side effect. Consultation identifiers remain restricted -/// state and never appear in evaluation, render, or credential responses. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceProvenance { - pub claims: Vec, - /// Unique Relay executions referenced by the claim closure. - /// - /// Keeping executions separate permits one coalesced Relay consultation to - /// support several claim pins without duplicating the restricted execution - /// record. A missing empty legacy field is readable but nonissuable. - #[serde(default)] - pub consultations: Vec, - /// Keyed commitment joining the complete opaque evaluated target reference - /// to the target's canonical primary authorization identity. The - /// commitment permits a later RAR comparison without retaining the raw - /// identity. Evaluations stored before this field existed remain readable, - /// but cannot initiate a registry-client credential offer. - #[serde(default)] - pub authorization_target_binding: String, -} - -impl std::fmt::Debug for StoredIssuanceProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceProvenance") - .field("claim_count", &self.claims.len()) - .field("consultation_count", &self.consultations.len()) - .finish() - } -} - -/// The exact compiler pin and successful Relay execution for one claim in a -/// selected root's dependency closure. This restricted persistence shape is -/// not a public API model. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceClaimProvenance { - pub claim_id: String, - pub claim_version: String, - pub relay_profile_id: String, - pub relay_contract_hash: String, - pub canonical_purpose: String, - pub consultation_id: String, - /// Deterministic SHA-256 commitment over this compiler pin, its Relay - /// execution record, and the claim result provenance produced by the - /// evaluation. A missing legacy value remains readable but is not - /// credential-issuable. - #[serde(default)] - pub execution_binding: String, - /// Canonical commitment to the exact evaluated result retained for - /// issuance. The value itself is deliberately not duplicated into this - /// restricted provenance record. - #[serde(default)] - pub result_content_binding: String, -} - -impl std::fmt::Debug for StoredIssuanceClaimProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceClaimProvenance") - .field("claim_id", &self.claim_id) - .field("claim_version", &self.claim_version) - .field("relay_profile_id", &self.relay_profile_id) - .field("relay_contract_hash", &self.relay_contract_hash) - .field("canonical_purpose", &self.canonical_purpose) - .field("consultation_id", &"[REDACTED]") - .field("execution_binding", &self.execution_binding) - .field("result_content_binding", &self.result_content_binding) - .finish() - } -} - -/// One unique successful Relay execution retained for issuance verification. -#[derive(Clone, Serialize, Deserialize)] -pub struct StoredIssuanceConsultationProvenance { - pub consultation_id: String, - pub acquired_at: String, -} - -impl std::fmt::Debug for StoredIssuanceConsultationProvenance { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("StoredIssuanceConsultationProvenance") - .field("consultation_id", &"[REDACTED]") - .field("acquired_at", &"[REDACTED]") - .finish() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StoredSubjectAccessMetadata { - #[serde(default = "subject_access_access_mode")] - pub access_mode: AccessMode, - pub issuer: VerifiedClaimValue, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub audiences: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - pub principal_hash: Hashed, - pub subject_id_type: ConfigMetadata, - pub subject_binding_claim: ConfigMetadata, - pub subject_binding_hash: Hashed, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dependent_target_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub proof_claim_id: Option, - pub requested_claims_hash: Hashed, - pub disclosure: ConfigMetadata, - pub result_format: ConfigMetadata, - /// Delegation chain in the frozen envelope vocabulary (D4). Empty in v1; - /// populated post-1.0 by the additive OAuth profile (notary#180). The empty - /// case serializes identically to the previous placeholder. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegation_chain: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evaluation_expires_at: Option, -} - -const fn subject_access_access_mode() -> AccessMode { - AccessMode::SubjectBound -} - -/// Versioned authorization fields shared by static configuration and token/OIDC JSON. -/// -/// Unknown metadata is intentionally ignored for forward-compatible interoperability. -/// Authorization decisions consume only the modeled fields and must never infer authority -/// from an unrecognized extension. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationDetails { - #[serde(rename = "type")] - pub detail_type: String, - pub schema_version: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub locations: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub claims: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disclosure: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purpose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub legal_basis_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub consent_ref: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub jurisdiction: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assurance_level: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub subject: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relationship: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub access_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub assisted_access_context: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationSubject { - pub binding_claim: String, - pub id_type: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationTarget { - pub id_type: String, - pub id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAuthorizationRelationship { - pub relationship_type: String, - pub proof_claim: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] -pub struct EvidenceAssistedAccessContext { - pub channel: String, -} - -#[derive(Debug, Clone)] -pub struct EvidencePrincipal { - pub auth_profile_id: EvidenceAuthProfileId, - pub principal_id: String, - pub scopes: Vec, - pub access_mode: AccessMode, - pub verified_claims: Option, - pub authorization_details: Option, -} - -impl EvidencePrincipal { - #[must_use] - pub fn has_scope(&self, scope: &str) -> bool { - self.scopes.iter().any(|candidate| candidate == scope) - } - - #[must_use] - pub fn has_any_scope<'a>(&self, scopes: impl IntoIterator) -> bool { - scopes.into_iter().any(|scope| self.has_scope(scope)) - } - - #[must_use] - pub const fn access_mode(&self) -> AccessMode { - self.access_mode - } - - #[must_use] - pub const fn is_subject_access(&self) -> bool { - matches!( - self.access_mode, - AccessMode::SubjectBound | AccessMode::DelegatedSubjectAccess - ) - } - - #[must_use] - pub fn verified_claim(&self, claim_name: &str) -> Option<&str> { - self.verified_claims - .as_ref() - .and_then(|claims| claims.claim_value(claim_name)) - } - - #[must_use] - pub fn verified_subject_binding_value(&self, claim_name: &str) -> Option<&str> { - self.verified_claims - .as_ref() - .and_then(|claims| claims.subject_binding_value(claim_name)) - } -} - -#[derive(Clone, Deserialize, Serialize)] -pub struct EvidenceAuditEvent { - pub event_id: String, - pub occurred_at: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub principal_id_hash: Option>, - #[serde(default)] - pub scopes_used: Vec, - pub decision: String, - pub method: String, - pub path: String, - pub status: u16, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verification_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claim_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub purposes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub row_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay_consultation_count: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relay_consultation_ids: Vec, - /// Conservative dispatch-attempt marker. `true` means Notary committed to - /// Relay work that may have reached Relay, not that Relay received it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub forwarded: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub access_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_peer_id_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_issuer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_purpose: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_request_jti_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub federation_subject_ref_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub denial_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token_claim_name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub correlation_id_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub protocol: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_configuration_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub holder_binding_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rate_limit_bucket: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_version: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub policy_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub redacted_fields: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub batch_items: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config: Option, -} - -impl std::fmt::Debug for EvidenceAuditEvent { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("EvidenceAuditEvent") - .field("event_id", &"[REDACTED]") - .field("decision", &self.decision) - .field("method", &self.method) - .field("path", &self.path) - .field("status", &self.status) - .field("verification_id", &"[REDACTED]") - .field("relay_consultation_ids", &"[REDACTED]") - .field("forwarded", &self.forwarded) - .field("error_code", &self.error_code) - .finish_non_exhaustive() - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct ConfigAuditEvent { - pub action: String, - pub source: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub acceptance_identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundle_manifest_hash: Option, - #[serde( - default, - rename = "bundle_sequence", - skip_serializing_if = "Option::is_none" - )] - pub sequence: Option, - pub signer_kids: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub previous_config_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub previous_hash_matched: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub anchor_digest: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub anchor_version: Option, - pub product_validation_result: String, - pub apply_result: String, - pub posture_result: String, - pub applied: bool, - pub restart_required: bool, - pub change_classes: Vec, - pub break_glass: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_approval_reference: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_approved_by: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_reason_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_emergency_change_class: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_expires_at_unix_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub break_glass_rate_limit_identity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_reference: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_approved_by: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_reason_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_change_class: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_expires_at_unix_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local_approval_rate_limit_identity: Option, -} - -#[derive(Clone, Deserialize, Serialize)] -pub struct EvidenceBatchItemAuditEvent { - pub input_index: usize, - #[serde(default)] - pub outcome: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - #[serde(default)] - pub relay_consultation_count: u64, - #[serde(default)] - pub forwarded: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relay_consultation_ids: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target_ref_hash: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requester_ref_hash: Option>, -} - -impl std::fmt::Debug for EvidenceBatchItemAuditEvent { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("EvidenceBatchItemAuditEvent") - .field("input_index", &self.input_index) - .field("outcome", &self.outcome) - .field("error_code", &self.error_code) - .field("relay_consultation_count", &self.relay_consultation_count) - .field("forwarded", &self.forwarded) - .field("relay_consultation_ids", &"[REDACTED]") - .finish_non_exhaustive() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn bounded(value: &str) -> Bounded { - Bounded::new(value).expect("test value is bounded") - } - - #[test] - fn access_mode_serializes_as_stable_snake_case() { - assert_eq!( - serde_json::to_value(AccessMode::SubjectBound).expect("access mode serializes"), - json!("subject_bound") - ); - assert_eq!( - AccessMode::parse("machine_client"), - Some(AccessMode::MachineClient) - ); - } - - #[test] - fn evidence_auth_profile_ids_are_closed_and_stable() { - for (profile, expected) in [ - (EvidenceAuthProfileId::StaticApiKey, "static_api_key"), - (EvidenceAuthProfileId::StaticBearer, "static_bearer"), - (EvidenceAuthProfileId::ExternalOidc, "external_oidc"), - ( - EvidenceAuthProfileId::NotaryAccessToken, - "notary_access_token", - ), - (EvidenceAuthProfileId::Federation, "federation"), - ] { - assert_eq!(profile.as_str(), expected); - assert_eq!( - serde_json::to_value(profile).expect("auth profile serializes"), - json!(expected) - ); - assert_eq!( - serde_json::from_value::(json!(expected)) - .expect("known auth profile deserializes"), - profile - ); - } - assert!( - serde_json::from_value::(json!("attacker_selected")).is_err() - ); - } - - #[test] - fn claim_ref_deserializes_string_and_versioned_object() { - let legacy: ClaimRef = - serde_json::from_value(json!("person-is-alive")).expect("legacy claim id deserializes"); - assert_eq!(legacy.id, "person-is-alive"); - assert_eq!(legacy.version, None); - - let versioned: ClaimRef = - serde_json::from_value(json!({ "id": "person-is-alive", "version": "2026-05" })) - .expect("versioned claim ref deserializes"); - assert_eq!(versioned.id, "person-is-alive"); - assert_eq!(versioned.version.as_deref(), Some("2026-05")); - } - - #[test] - fn private_issuance_target_binding_is_backward_readable_and_debug_redacted() { - let legacy: StoredIssuanceProvenance = serde_json::from_value(json!({ - "claims": [], - "consultations": [], - })) - .expect("legacy private provenance remains readable"); - assert!(legacy.authorization_target_binding.is_empty()); - - let stored = StoredIssuanceProvenance { - claims: Vec::new(), - consultations: Vec::new(), - authorization_target_binding: - "hmac-sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - }; - let debug = format!("{stored:?}"); - assert!(!debug.contains("hmac-sha256")); - assert!(!debug.contains("aaaaaaaa")); - } - - #[test] - fn evaluate_request_deserializes_identity_bundle_target() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "requester": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-9001", "country": "RW" } - ] - }, - "target": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-1001" } - ], - "attributes": { - "given_name": "Amina", - "family_name": "Kamanzi", - "date_of_birth": "1990-01-15" - }, - "assurance": { - "method": "oidc", - "level_scheme": "example-loa", - "level": "substantial" - } - }, - "relationship": { - "type": "self" - }, - "on_behalf_of": { - "actor": { - "type": "operator", - "id_hash": "hmac-sha256:abc123" - } - }, - "claims": ["person-is-alive"], - "purpose": "https://purpose.example/social-protection" - })) - .expect("new request shape deserializes"); - - let target = request.target.as_ref().expect("target is present"); - assert_eq!(target.entity_type, "person"); - assert_eq!( - request - .target_subject() - .expect("identifier target maps to source subject") - .id_type - .as_deref(), - Some("national_id") - ); - assert_eq!(target.attributes["date_of_birth"], json!("1990-01-15")); - } - - #[test] - fn evaluate_request_variables_are_closed_bounded_full_dates() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": { "as_of_date": "2026-01-01" }, - "claims": ["age-band"] - })) - .expect("declared-shape request variable parses"); - assert_eq!(request.variables.get("as_of_date"), Some("2026-01-01")); - assert_eq!( - request - .request_context() - .and_then(|context| context.lookup_value("variables.as_of_date")), - Some(json!("2026-01-01")) - ); - let debug = format!("{:?}", request.variables); - assert!(debug.contains("[REDACTED]")); - assert!(!debug.contains("2026-01-01")); - - for variables in [ - json!({ "AsOf": "2026-01-01" }), - json!({ "as_of_date": "2026-02-30" }), - json!({ "as_of_date": 20260101 }), - ] { - assert!(serde_json::from_value::(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": variables, - "claims": ["age-band"] - })) - .is_err()); - } - let too_many = (0..=MAX_REQUEST_VARIABLES_V1) - .map(|index| (format!("date_{index}"), json!("2026-01-01"))) - .collect::>(); - assert!(serde_json::from_value::(json!({ - "target": { "type": "person", "id": "person-1" }, - "variables": too_many, - "claims": ["age-band"] - })) - .is_err()); - } - - #[test] - fn evaluate_request_allows_missing_target_for_server_derived_context() { - let request: EvaluateRequest = serde_json::from_value(json!({ - "claims": ["person-is-alive"], - "purpose": "https://purpose.example/self" - })) - .expect("target may be omitted when the server derives subject-access context"); - - assert!(request.target.is_none()); - assert!(request.target_subject().is_none()); - assert!(request.request_context().is_none()); - } - - #[test] - fn evaluate_request_rejects_old_subject_shape() { - let error = serde_json::from_value::(json!({ - "subject": { "id": "NID-1001", "id_type": "national_id" }, - "claims": ["person-is-alive"] - })) - .expect_err("old subject shape is no longer accepted"); - - assert!( - error.to_string().contains("missing field `target`") - || error.to_string().contains("unknown field `subject`"), - "unexpected serde error: {error}" - ); - } - - #[test] - fn evidence_entity_reports_matching_input_only_when_non_empty() { - let mut entity = EvidenceEntity::new("Person"); - assert!(!entity.has_matching_input()); - - entity.id = Some(" ".to_string()); - entity.identifiers.push(EvidenceIdentifier { - scheme: "national_id".to_string(), - value: " ".to_string(), - issuer: None, - country: None, - }); - assert!(!entity.has_matching_input()); - - entity.identifiers[0].value = "NID-1001".to_string(); - assert!(entity.has_matching_input()); - - entity.identifiers[0].value = " ".to_string(); - entity - .attributes - .insert("district".to_string(), json!("north")); - assert!(entity.has_matching_input()); - } - - #[test] - fn batch_evaluate_request_deserializes_items_with_mixed_targets() { - let request: BatchEvaluateRequest = serde_json::from_value(json!({ - "items": [ - { - "target": { - "type": "person", - "identifiers": [ - { "scheme": "national_id", "value": "NID-1001" } - ] - } - }, - { - "target": { - "type": "land_parcel", - "identifiers": [ - { "scheme": "parcel_id", "value": "LP-42" } - ] - }, - "purpose": "https://purpose.example/land" - } - ], - "claims": ["eligibility"] - })) - .expect("batch request shape deserializes"); - - assert_eq!(request.items.len(), 2); - assert_eq!( - request.items[1] - .target_subject() - .expect("target maps to source subject") - .id_type - .as_deref(), - Some("parcel_id") - ); - } - - #[test] - fn result_views_serialize_target_ref_without_subject_ref_or_id_type() { - let result = ClaimResultView { - evaluation_id: "eval-1".to_string(), - claim_id: "person-is-alive".to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "person".to_string(), - requester_ref: None, - target_ref: TargetRefView { - entity_type: "Person".to_string(), - handle: "rnref:v1:test".to_string(), - identifier_schemes: Vec::new(), - profile: None, - }, - value: Some(json!(true)), - satisfied: Some(true), - disclosure: "predicate".to_string(), - redacted_fields: Vec::new(), - format: FORMAT_CLAIM_RESULT_JSON.to_string(), - issued_at: "2026-05-31T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "test".to_string(), - "eval-1".to_string(), - "person-is-alive".to_string(), - "1.0.0".to_string(), - ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - }; - - let value = serde_json::to_value(result).expect("result serializes"); - assert!(value.get("target_ref").is_some()); - assert!(value.get("subject_ref").is_none()); - assert!(value["target_ref"].get("id_type").is_none()); - } - - #[test] - fn claim_provenance_v2_serializes_relay_consultation_shape() { - let mut provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "person_is_alive".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 1, - }, - ); - provenance.generated_by.policy_id = Some("subject-access".to_string()); - provenance.generated_by.policy_version = Some("v1".to_string()); - provenance.generated_by.policy_hash = Some("sha256:def456".to_string()); - - let value = serde_json::to_value(&provenance).expect("provenance serializes"); - - assert_eq!( - value["schema_version"], - json!("registry-notary-claim-provenance/v2") - ); - let generated_by = &value["generated_by"]; - assert_eq!(generated_by["type"], json!("claim_evaluation")); - assert_eq!(generated_by["service_id"], json!("registry-notary")); - assert_eq!(generated_by["evaluation_id"], json!("eval_01HX")); - assert_eq!(generated_by["claim_id"], json!("person_is_alive")); - assert_eq!(generated_by["claim_version"], json!("1")); - assert_eq!(generated_by["policy_id"], json!("subject-access")); - assert_eq!(generated_by["policy_version"], json!("v1")); - assert_eq!(generated_by["policy_hash"], json!("sha256:def456")); - - let used = &value["used"]; - assert_eq!(used["relay_consultation_count"], json!(1)); - assert!(used.get("source_count").is_none()); - assert!(used.get("source_versions").is_none()); - assert_eq!(value["derived_from"], json!([])); - } - - #[test] - fn claim_provenance_v2_round_trips() { - let provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "person_is_alive".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 2, - }, - ); - let value = serde_json::to_value(&provenance).expect("serializes"); - let parsed: ClaimProvenance = - serde_json::from_value(value).expect("provenance round-trips"); - assert_eq!(parsed.schema_version, CLAIM_PROVENANCE_SCHEMA_VERSION); - assert_eq!(parsed.used.relay_consultation_count, 2); - assert!(parsed.generated_by.policy_id.is_none()); - } - - #[test] - fn claim_provenance_omits_computed_by_and_requester_side_fields() { - let provenance = ClaimProvenance::new( - "registry-notary".to_string(), - "eval_01HX".to_string(), - "claim".to_string(), - "1".to_string(), - ProvenanceUsed { - relay_consultation_count: 0, - }, - ); - let value = serde_json::to_value(&provenance).expect("serializes"); - let text = value.to_string(); - assert!( - !text.contains("computed_by"), - "computed_by must be gone from the provenance wire shape" - ); - for forbidden in ["client", "actor", "subject"] { - assert!( - value.get(forbidden).is_none() - && value["generated_by"].get(forbidden).is_none() - && value["used"].get(forbidden).is_none(), - "requester-side field {forbidden} must not appear in claim provenance" - ); - } - } - - #[test] - fn on_behalf_of_envelope_serializes_and_round_trips() { - let envelope = EvidenceOnBehalfOf { - actor: EvidenceActor { - actor_type: "service_account".to_string(), - id_hash: "hmac-sha256:abc123".to_string(), - assurance: Some("urn:example:loa:substantial".to_string()), - }, - delegation_ref: Some("urn:delegation:42".to_string()), - }; - let value = serde_json::to_value(&envelope).expect("envelope serializes"); - assert_eq!(value["actor"]["type"], json!("service_account")); - assert_eq!(value["actor"]["id_hash"], json!("hmac-sha256:abc123")); - assert_eq!( - value["actor"]["assurance"], - json!("urn:example:loa:substantial") - ); - assert_eq!(value["delegation_ref"], json!("urn:delegation:42")); - - let parsed: EvidenceOnBehalfOf = - serde_json::from_value(value).expect("envelope round-trips"); - assert_eq!(parsed.actor.actor_type, "service_account"); - assert_eq!(parsed.delegation_ref.as_deref(), Some("urn:delegation:42")); - } - - #[test] - fn on_behalf_of_minimal_envelope_omits_optional_fields() { - let envelope = EvidenceOnBehalfOf { - actor: EvidenceActor { - actor_type: "operator".to_string(), - id_hash: "hmac-sha256:def456".to_string(), - assurance: None, - }, - delegation_ref: None, - }; - let value = serde_json::to_value(&envelope).expect("envelope serializes"); - assert!(value.get("delegation_ref").is_none()); - assert!(value["actor"].get("assurance").is_none()); - } - - #[test] - fn on_behalf_of_rejects_free_form_payloads() { - let legacy = json!({ "delegator": "did:example:123", "scope": "read" }); - let err = serde_json::from_value::(legacy) - .expect_err("free-form on_behalf_of must be rejected"); - let message = err.to_string(); - assert!( - message.contains("unknown field") || message.contains("missing field"), - "rejection should be a schema mismatch, got: {message}" - ); - } - - #[test] - fn on_behalf_of_rejects_unknown_actor_field() { - let payload = json!({ - "actor": { - "type": "operator", - "id_hash": "hmac-sha256:def456", - "raw_id": "leaked" - } - }); - let err = serde_json::from_value::(payload) - .expect_err("unknown actor field must be rejected"); - assert!(err.to_string().contains("unknown field")); - } - - #[test] - fn evaluate_request_accepts_envelope_and_rejects_loose_json() { - let accepted = serde_json::from_value::(json!({ - "target": { "type": "Person", "identifiers": [{ "scheme": "id", "value": "x" }] }, - "claims": ["person_is_alive"], - "on_behalf_of": { - "actor": { "type": "operator", "id_hash": "hmac-sha256:abc" } - } - })); - assert!(accepted.is_ok(), "structured envelope must be accepted"); - - let rejected = serde_json::from_value::(json!({ - "target": { "type": "Person", "identifiers": [{ "scheme": "id", "value": "x" }] }, - "claims": ["person_is_alive"], - "on_behalf_of": { "anything": "goes" } - })); - assert!( - rejected.is_err(), - "free-form on_behalf_of must be rejected at request level" - ); - } - - #[test] - fn bounded_rejects_values_over_limit() { - let err = Bounded::<4>::new("12345").expect_err("value exceeds limit"); - assert_eq!(err.max, 4); - assert_eq!(err.actual, 5); - } - - #[test] - fn verified_claim_lookup_exposes_only_bounded_allow_listed_claims() { - let claims = BoundedVerifiedClaims { - issuer: bounded("https://id.example.gov"), - audiences: vec![bounded("registry-notary-citizen")], - client_id: Some(bounded("citizen-portal")), - token_type: Some(bounded("JWT")), - credential_configuration_id: None, - issuance_transaction_id: None, - issuance_transaction_commitment: None, - scopes: vec![bounded("subject_access")], - subject: Some(bounded("login-subject")), - subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), - subject_binding_value: Some(bounded("NAT-123")), - acr: Some(bounded("urn:example:loa:substantial")), - auth_time: Some(1_800_000_000), - exp: Some(1_800_000_900), - iat: Some(1_800_000_000), - nbf: None, - }; - - assert!(claims.has_scope("subject_access")); - assert_eq!(claims.claim_value("sub"), Some("login-subject")); - assert_eq!(claims.claim_value("email"), None); - assert_eq!( - claims.subject_binding_value("https://id.example.gov/claims/national_id"), - Some("NAT-123") - ); - } - - #[test] - fn verified_claim_lookup_treats_blank_subject_binding_value_as_missing() { - for blank in ["", " "] { - let claims = BoundedVerifiedClaims { - issuer: bounded("https://id.example.gov"), - audiences: vec![bounded("registry-notary-citizen")], - client_id: Some(bounded("citizen-portal")), - token_type: Some(bounded("JWT")), - credential_configuration_id: None, - issuance_transaction_id: None, - issuance_transaction_commitment: None, - scopes: vec![bounded("subject_access")], - subject: Some(bounded("login-subject")), - subject_binding_claim: Some(bounded("https://id.example.gov/claims/national_id")), - subject_binding_value: Some(bounded(blank)), - acr: None, - auth_time: None, - exp: None, - iat: None, - nbf: None, - }; - - assert_eq!( - claims.subject_binding_value("https://id.example.gov/claims/national_id"), - None - ); - } - } - - #[test] - fn evaluation_capability_separates_machine_scopes_from_subject_access_claims() { - let machine = EvaluationCapability::Machine { - scopes: BTreeSet::from(["civil_registry:evidence_verification".to_string()]), - }; - assert_eq!(machine.access_mode(), AccessMode::MachineClient); - assert!(machine.allows_scope("civil_registry:evidence_verification")); - assert!(!machine.allows_subject_access_claim("person-is-alive")); - - let citizen = EvaluationCapability::SubjectBound { - claim_id: Some(bounded("person-is-alive")), - allowed_claim_ids: BTreeSet::new(), - subject_binding_hash: Hashed::from_hash("sha256:test"), - }; - assert_eq!(citizen.access_mode(), AccessMode::SubjectBound); - assert!(!citizen.allows_scope("civil_registry:evidence_verification")); - assert!(citizen.allows_subject_access_claim("person-is-alive")); - } - - #[test] - fn audit_subject_access_fields_round_trip_without_raw_values() { - let event = EvidenceAuditEvent { - event_id: "01HX".to_string(), - occurred_at: "2026-05-25T00:00:00Z".to_string(), - principal_id_hash: Some(Hashed::from_hash("hmac-sha256:principal")), - scopes_used: vec!["subject_access".to_string()], - decision: "denied".to_string(), - method: "POST".to_string(), - path: "/v1/evaluations".to_string(), - status: 403, - verification_id: None, - claim_hash: Some("sha256:claims".to_string()), - purposes: None, - row_count: None, - relay_consultation_count: None, - relay_consultation_ids: vec!["01JRELAYCORRELATIONSENSITIVE".to_string()], - forwarded: None, - error_code: Some("subject_access.denied".to_string()), - access_mode: Some(AccessMode::SubjectBound), - federation_peer_id_hash: None, - federation_issuer: None, - federation_profile: None, - federation_purpose: None, - federation_request_jti_hash: None, - federation_subject_ref_hash: None, - denial_code: Some(SubjectAccessDenialCode::SubjectMismatch), - token_claim_name: Some(bounded("national_id")), - correlation_id_hash: Some(Hashed::from_hash("hmac-sha256:req-123")), - credential_profile: None, - protocol: Some(bounded("openid4vci")), - credential_configuration_id: Some(bounded("person_is_alive_sd_jwt")), - holder_binding_mode: None, - rate_limit_bucket: None, - policy_version: Some(bounded("citizen-v1")), - policy_hash: Some(Hashed::from_hash("sha256:policy")), - target_type: Some("person".to_string()), - target_ref_hash: Some(Hashed::from_hash("hmac-sha256:target")), - requester_type: Some("person".to_string()), - requester_ref_hash: Some(Hashed::from_hash("hmac-sha256:requester")), - redacted_fields: None, - batch_items: Some(vec![EvidenceBatchItemAuditEvent { - input_index: 0, - outcome: "failed".to_string(), - error_code: Some("evidence.not_available".to_string()), - relay_consultation_count: 1, - forwarded: true, - relay_consultation_ids: vec!["01JRELAYBATCHSENSITIVE".to_string()], - target_type: Some("person".to_string()), - target_ref_hash: Some(Hashed::from_hash("hmac-sha256:batch-target")), - requester_type: Some("person".to_string()), - requester_ref_hash: Some(Hashed::from_hash("hmac-sha256:batch-requester")), - }]), - config: None, - }; - - let value = serde_json::to_value(&event).expect("audit event serializes"); - assert_eq!( - value["relay_consultation_ids"], - json!(["01JRELAYCORRELATIONSENSITIVE"]) - ); - let debug = format!("{event:?}"); - assert!(!debug.contains("01JRELAYCORRELATIONSENSITIVE")); - assert!(!debug.contains("01JRELAYBATCHSENSITIVE")); - assert!(debug.contains("relay_consultation_ids: \"[REDACTED]\"")); - assert_eq!(value["access_mode"], json!("subject_bound")); - assert_eq!( - value["denial_code"], - json!("subject_access.subject_mismatch") - ); - assert_eq!(value["token_claim_name"], json!("national_id")); - assert_eq!(value["correlation_id_hash"], json!("hmac-sha256:req-123")); - assert!(value.get("correlation_id").is_none()); - assert_eq!(value["protocol"], json!("openid4vci")); - assert_eq!( - value["credential_configuration_id"], - json!("person_is_alive_sd_jwt") - ); - assert_eq!(value["principal_id_hash"], json!("hmac-sha256:principal")); - assert_eq!(value["scopes_used"], json!(["subject_access"])); - assert!(value.get("principal_id").is_none()); - assert!(value.get("subject_binding_value").is_none()); - assert_eq!(value["target_type"], json!("person")); - assert_eq!(value["target_ref_hash"], json!("hmac-sha256:target")); - assert_eq!(value["requester_type"], json!("person")); - assert_eq!(value["requester_ref_hash"], json!("hmac-sha256:requester")); - assert_eq!( - value["batch_items"][0]["target_ref_hash"], - json!("hmac-sha256:batch-target") - ); - assert!(value.get("target_id").is_none()); - assert!(value.get("target_attributes").is_none()); - assert!(value.get("requester_id").is_none()); - - let decoded: EvidenceAuditEvent = - serde_json::from_value(value).expect("audit event deserializes"); - assert_eq!(decoded.event_id, event.event_id); - assert_eq!(decoded.scopes_used, vec!["subject_access"]); - assert_eq!(decoded.access_mode, Some(AccessMode::SubjectBound)); - assert_eq!( - decoded.denial_code, - Some(SubjectAccessDenialCode::SubjectMismatch) - ); - assert_eq!( - decoded.token_claim_name.as_ref().map(Bounded::as_str), - Some("national_id") - ); - assert_eq!( - decoded.correlation_id_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:req-123") - ); - assert_eq!( - decoded.policy_hash.as_ref().map(Hashed::as_str), - Some("sha256:policy") - ); - assert_eq!(decoded.target_type.as_deref(), Some("person")); - assert_eq!( - decoded.target_ref_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:target") - ); - assert_eq!(decoded.requester_type.as_deref(), Some("person")); - assert_eq!( - decoded.requester_ref_hash.as_ref().map(Hashed::as_str), - Some("hmac-sha256:requester") - ); - assert_eq!(decoded.batch_items.as_ref().map(Vec::len), Some(1)); - } - - #[test] - fn audit_event_missing_optional_fields_defaults_to_none() { - let decoded: EvidenceAuditEvent = serde_json::from_value(json!({ - "event_id": "01HX", - "occurred_at": "2026-05-25T00:00:00Z", - "decision": "allowed", - "method": "GET", - "path": "/v1/claims", - "status": 200 - })) - .expect("legacy audit event deserializes"); - - assert!(decoded.verification_id.is_none()); - assert!(decoded.claim_hash.is_none()); - assert!(decoded.purposes.is_none()); - assert!(decoded.scopes_used.is_empty()); - assert!(decoded.row_count.is_none()); - assert!(decoded.error_code.is_none()); - assert!(decoded.access_mode.is_none()); - assert!(decoded.target_type.is_none()); - assert!(decoded.target_ref_hash.is_none()); - assert!(decoded.requester_type.is_none()); - assert!(decoded.requester_ref_hash.is_none()); - assert!(decoded.batch_items.is_none()); - } - - #[test] - fn config_audit_governance_evidence_is_optional_for_legacy_records() { - let decoded: ConfigAuditEvent = serde_json::from_value(json!({ - "action": "boot", - "source": "signed_bundle_file", - "signer_kids": [], - "product_validation_result": "accepted", - "apply_result": "applied", - "posture_result": "accepted", - "applied": true, - "restart_required": false, - "change_classes": [], - "break_glass": false - })) - .expect("legacy config audit deserializes"); - - assert!(decoded.acceptance_identity.is_none()); - assert!(decoded.bundle_manifest_hash.is_none()); - assert!(decoded.anchor_digest.is_none()); - assert!(decoded.anchor_version.is_none()); - } - - #[test] - fn stored_evaluation_without_subject_access_defaults_to_machine_client() { - let raw = json!({ - "client_id": "client", - "purpose": "verification", - "claim_ids": ["person-is-alive"], - "disclosure": "predicate", - "format": FORMAT_CLAIM_RESULT_JSON, - "results": [], - "created_at": "2026-05-25T00:00:00Z", - "expires_at": "2026-05-25T00:15:00Z", - "request_hash": "sha256:request" - }); - let stored: StoredEvaluation = - serde_json::from_value(raw).expect("legacy stored evaluation deserializes"); - assert_eq!(stored.access_mode(), AccessMode::MachineClient); - assert_eq!( - stored.selected_claim_refs(), - vec![ClaimRef::from("person-is-alive")] - ); - assert!(stored.issuance_provenance.is_none()); - assert!(stored.subject_access.is_none()); - } -} diff --git a/crates/registry-notary-core/src/sd_jwt.rs b/crates/registry-notary-core/src/sd_jwt.rs deleted file mode 100644 index de794b316..000000000 --- a/crates/registry-notary-core/src/sd_jwt.rs +++ /dev/null @@ -1,1114 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Minimal SD-JWT VC issuer for Registry Notary claim views. - -use registry_platform_crypto::{parse_did_jwk, PrivateJwk, PublicJwk, SigningProvider}; -use registry_platform_sdjwt::{ - new_credential_id as platform_new_credential_id, Disclosure, HolderConfirmation, - SdJwtIssuanceInput, SdJwtIssuer, -}; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::fmt; -use std::sync::Arc; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -use crate::config::CredentialProfileConfig; -use crate::error::EvidenceError; -use crate::model::{ClaimResultView, SD_JWT_VC_SIGNING_ALG}; - -#[derive(Clone)] -pub struct SignedSdJwtVc { - pub credential_id: String, - pub issuer: String, - pub expires_at: String, - pub compact: String, - pub issuer_signed_jwt: String, - pub disclosures: Vec, -} - -impl fmt::Debug for SignedSdJwtVc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SignedSdJwtVc") - .field("credential_id", &self.credential_id) - .field("issuer", &self.issuer) - .field("expires_at", &self.expires_at) - .field("compact", &"[redacted]") - .field("issuer_signed_jwt", &"[redacted]") - .field("disclosures", &"[redacted]") - .finish() - } -} - -#[derive(Clone)] -pub struct EvidenceIssuer { - verification_method_id: String, - issuer: SdJwtIssuer, - public_jwk: Value, -} - -impl fmt::Debug for EvidenceIssuer { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("EvidenceIssuer") - .field("verification_method_id", &self.verification_method_id) - .field("public_jwk", &"[omitted]") - .finish_non_exhaustive() - } -} - -impl EvidenceIssuer { - pub fn from_jwk_str(raw: &str, verification_method_id: String) -> Result { - let mut jwk = - PrivateJwk::parse(raw).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - jwk.kid = Some(verification_method_id.clone()); - jwk.alg - .get_or_insert_with(|| SD_JWT_VC_SIGNING_ALG.to_string()); - let public = jwk.public(); - let public_jwk = - serde_json::to_value(public).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let issuer = - SdJwtIssuer::from_jwk(jwk).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - Ok(Self { - verification_method_id, - issuer, - public_jwk, - }) - } - - pub fn from_signing_provider( - provider: Arc, - ) -> Result { - let verification_method_id = provider.key_id().to_string(); - if verification_method_id.trim().is_empty() { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let public = provider.public_jwk(); - if public.kid.as_deref() != Some(verification_method_id.as_str()) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let public_jwk = - serde_json::to_value(public).map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let issuer = SdJwtIssuer::from_signing_provider(provider); - Ok(Self { - verification_method_id, - issuer, - public_jwk, - }) - } - - #[must_use] - pub fn public_jwk(&self) -> Value { - self.public_jwk.clone() - } - - pub async fn sign_compact_jwt( - &self, - typ: &str, - payload: Value, - ) -> Result { - self.issuer - .sign_compact_jwt(typ, payload) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed) - } -} - -#[derive(Clone, Debug, Default)] -pub struct IssueOptions { - pub credential_id: Option, - pub status: Option, - pub projection: Option>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SdJwtProjectionClaim { - pub claim_id: String, - pub output_name: String, -} - -pub async fn issue( - profile: &CredentialProfileConfig, - issuer: &EvidenceIssuer, - results: &[ClaimResultView], - subject_ref: &str, - holder_id: Option<&str>, - iat: OffsetDateTime, - options: IssueOptions, -) -> Result { - let holder_confirmation = holder_id.map(holder_confirmation).transpose()?; - if profile.holder_binding.mode != "none" && holder_confirmation.is_none() { - return Err(EvidenceError::HolderProofRequired); - } - if subject_ref.trim().is_empty() { - return Err(EvidenceError::InvalidRequest); - } - let expires_at = iat - .checked_add(time::Duration::seconds(profile.validity_seconds)) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - let public_claims = BTreeMap::from([ - ("issuanceDate".to_string(), json!(format_time(iat))), - ("expirationDate".to_string(), json!(format_time(expires_at))), - ]); - let disclosures = disclosures_for_results(results, options.projection.as_deref())?; - let signed = issuer - .issuer - .issue(SdJwtIssuanceInput { - iss: profile.issuer.clone(), - sub_ref: subject_ref.to_string(), - credential_id: options.credential_id, - iat: iat.unix_timestamp(), - exp: expires_at.unix_timestamp(), - vct: profile.vct.clone(), - status: options.status, - public_claims, - cnf: holder_confirmation, - disclosures, - }) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - let (issuer_signed_jwt, disclosures) = split_sd_jwt_compact(&signed.jwt)?; - Ok(SignedSdJwtVc { - credential_id: signed.credential_id, - issuer: profile.issuer.clone(), - expires_at: format_time(expires_at), - compact: signed.jwt, - issuer_signed_jwt, - disclosures, - }) -} - -fn disclosures_for_results( - results: &[ClaimResultView], - projection: Option<&[SdJwtProjectionClaim]>, -) -> Result, EvidenceError> { - if let Some(projection) = projection { - return projection - .iter() - .map(|entry| { - let result = results - .iter() - .find(|result| result.claim_id == entry.claim_id) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - if result.satisfied == Some(false) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - let Some(value) = result - .value - .as_ref() - .filter(|value| !value.is_null()) - .cloned() - else { - return Err(EvidenceError::CredentialIssuanceFailed); - }; - Ok(Disclosure { - name: entry.output_name.clone(), - value, - }) - }) - .collect(); - } - - Ok(results - .iter() - .map(|result| Disclosure { - name: result.claim_id.clone(), - value: json!({ - "claim_id": result.claim_id, - "version": result.claim_version, - "value": result.value, - "satisfied": result.satisfied, - "subject_type": result.subject_type, - "issued_at": result.issued_at, - }), - }) - .collect()) -} - -#[must_use] -pub fn new_credential_id() -> String { - platform_new_credential_id() -} - -fn split_sd_jwt_compact(compact: &str) -> Result<(String, Vec), EvidenceError> { - let mut parts = compact.split('~'); - let issuer_signed_jwt = parts - .next() - .filter(|jwt| !jwt.is_empty()) - .ok_or(EvidenceError::CredentialIssuanceFailed)? - .to_string(); - let disclosures = parts - .filter(|part| !part.is_empty()) - .map(ToString::to_string) - .collect(); - Ok((issuer_signed_jwt, disclosures)) -} - -fn holder_confirmation(holder_id: &str) -> Result { - Ok(HolderConfirmation { - jwk: holder_jwk(holder_id)?, - kid: Some(holder_id.to_string()), - }) -} - -pub fn holder_jwk(holder_id: &str) -> Result { - parse_did_jwk(holder_id).map_err(|_| EvidenceError::HolderProofRequired) -} - -fn format_time(value: OffsetDateTime) -> String { - value - .format(&Rfc3339) - .expect("OffsetDateTime within supported RFC3339 range") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::HolderBindingConfig; - use crate::model::{ClaimProvenance, TargetRefView, FORMAT_SD_JWT_VC, SD_JWT_VC_JWT_TYP}; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use base64::Engine; - use registry_platform_crypto::did_jwk_from_public_jwk; - use sha2::{Digest, Sha256}; - - const RAW_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; - - fn issue( - profile: &CredentialProfileConfig, - issuer: &EvidenceIssuer, - results: &[ClaimResultView], - subject_ref: &str, - holder_id: Option<&str>, - iat: OffsetDateTime, - options: IssueOptions, - ) -> Result { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime builds") - .block_on(super::issue( - profile, - issuer, - results, - subject_ref, - holder_id, - iat, - options, - )) - } - - #[test] - fn signing_algorithm_header_value_is_stable() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let compact = signed.compact.split('~').next().expect("compact jwt"); - let header = compact.split('.').next().expect("compact jwt has header"); - let header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON"); - assert_eq!(header["alg"], SD_JWT_VC_SIGNING_ALG); - assert_eq!(header["typ"], SD_JWT_VC_JWT_TYP); - } - - #[test] - fn evidence_issuer_can_be_backed_by_signing_provider() { - let mut jwk = PrivateJwk::parse(RAW_JWK).expect("test JWK parses"); - jwk.kid = Some("did:web:issuer.test#provider-key".to_string()); - jwk.alg = Some(SD_JWT_VC_SIGNING_ALG.to_string()); - let signer = - registry_platform_crypto::LocalJwkSigner::new(jwk).expect("local signer builds"); - let issuer = EvidenceIssuer::from_signing_provider(std::sync::Arc::new(signer)) - .expect("provider-backed issuer builds"); - - assert_eq!( - issuer.public_jwk()["kid"], - "did:web:issuer.test#provider-key" - ); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let compact = signed.compact.split('~').next().expect("compact jwt"); - let header = compact.split('.').next().expect("compact jwt has header"); - let header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON"); - assert_eq!(header["kid"], "did:web:issuer.test#provider-key"); - } - - #[test] - fn issued_credential_payload_includes_jti() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["jti"], signed.credential_id); - assert_eq!(payload["id"], signed.credential_id); - } - - #[test] - fn issued_credential_payload_can_include_status_claim() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let credential_id = new_credential_id(); - let status = json!({ - "status_list": { - "idx": 0, - "uri": format!("https://issuer.example/v1/credentials/{credential_id}/status") - } - }); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - credential_id: Some(credential_id.clone()), - status: Some(status.clone()), - projection: None, - }, - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(signed.credential_id, credential_id); - assert_eq!(payload["id"], credential_id); - assert_eq!(payload["jti"], credential_id); - assert_eq!(payload["status"], status); - } - - #[test] - fn issuing_with_overflowing_validity_returns_error() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut profile = test_profile(); - profile.validity_seconds = i64::MAX; - - let error = issue( - &profile, - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect_err("overflowing validity is rejected"); - - assert!(matches!(error, EvidenceError::CredentialIssuanceFailed)); - } - - #[test] - fn golden_sd_jwt_vc_fixture_matches_conformance_profile() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let iat = OffsetDateTime::from_unix_timestamp(1_700_000_000) - .expect("test fixture timestamp is valid"); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - let signed = issue( - &holder_required_profile(), - &issuer, - &[result], - &holder, - Some(&holder), - iat, - IssueOptions::default(), - ) - .expect("credential issues"); - - let header = header(&signed); - let payload = payload(&signed); - - assert_eq!(header["alg"], SD_JWT_VC_SIGNING_ALG); - assert_eq!(header["typ"], SD_JWT_VC_JWT_TYP); - assert_eq!(header["kid"], "did:web:issuer.test#key-1"); - assert_eq!(payload["iss"], "did:web:issuer.test"); - assert_eq!(payload["sub"], holder); - assert_eq!(payload["iat"], iat.unix_timestamp()); - assert_eq!(payload["exp"], iat.unix_timestamp() + 60); - assert_eq!(payload["vct"], "https://vct.example/test"); - assert_eq!(payload["jti"], signed.credential_id); - assert_eq!(payload["id"], signed.credential_id); - assert_eq!(payload["cnf"]["kid"], holder); - assert_eq!(payload["cnf"]["jwk"]["kty"], "OKP"); - assert_eq!(payload["cnf"]["jwk"]["crv"], "Ed25519"); - assert!(payload["cnf"]["jwk"].get("d").is_none()); - assert_eq!(payload_sd(&signed), disclosure_digests(&signed)); - assert_eq!(signed.disclosures.len(), 1); - assert!( - !payload.to_string().contains("registry-subject-ref"), - "holder-bound payload must not expose the raw registry subject_ref", - ); - } - - #[test] - fn issued_credential_exposes_verifiable_jwt_separately_from_disclosures() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - - assert_eq!( - signed.issuer_signed_jwt, - signed.compact.split('~').next().expect("sd-jwt has jwt") - ); - assert!(!signed.issuer_signed_jwt.contains('~')); - let segments = signed.issuer_signed_jwt.split('.').collect::>(); - assert_eq!(segments.len(), 3); - for segment in segments { - URL_SAFE_NO_PAD - .decode(segment) - .expect("JWT segment is base64url without SD-JWT disclosure tail"); - } - assert_eq!(signed.disclosures.len(), 1); - assert!(signed.compact.ends_with('~')); - } - - #[test] - fn issued_credential_disclosures_do_not_reintroduce_redacted_object_fields() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let source_value = json!({ - "name": "Ada", - "household_id": "hh-1", - "ssn": "123-45-6789" - }); - assert!( - source_value.to_string().contains("123-45-6789"), - "the source fixture must contain the PII this regression protects" - ); - let mut redacted_value = source_value - .as_object() - .expect("source fixture is an object") - .clone(); - assert!( - redacted_value.remove("ssn").is_some(), - "the test redaction fixture must remove an existing field" - ); - let mut result = claim_result("household-summary"); - result.value = Some(Value::Object(redacted_value)); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let disclosures = decoded_disclosures(&signed); - let household_disclosure = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("household-summary"))) - .expect("household-summary disclosure exists"); - let disclosure_json = serde_json::to_string(&disclosures).expect("disclosures serialize"); - - assert!(disclosure_json.contains("household-summary")); - assert!(disclosure_json.contains("Ada")); - assert_eq!(household_disclosure[2]["value"]["name"], json!("Ada")); - assert_eq!( - household_disclosure[2]["value"]["household_id"], - json!("hh-1") - ); - assert!(household_disclosure[2]["value"].get("ssn").is_none()); - assert!(!disclosure_json.contains("ssn"), "{disclosure_json}"); - assert!( - !disclosure_json.contains("123-45-6789"), - "{disclosure_json}" - ); - } - - #[test] - fn legacy_credential_disclosure_keeps_notary_result_wrapper() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut result = claim_result("date-of-birth"); - result.value = Some(json!("2016-01-15")); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let disclosures = decoded_disclosures(&signed); - let disclosure = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("date-of-birth"))) - .expect("date-of-birth disclosure exists"); - - assert_eq!(disclosure[2]["claim_id"], json!("date-of-birth")); - assert_eq!(disclosure[2]["version"], json!("1.0.0")); - assert_eq!(disclosure[2]["value"], json!("2016-01-15")); - assert_eq!(disclosure[2]["satisfied"], json!(true)); - } - - #[test] - fn projected_credential_disclosures_use_output_names_and_domain_values() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut date = claim_result("date-of-birth"); - date.value = Some(json!("2016-01-15")); - date.satisfied = None; - let mut place = claim_result("place-of-birth"); - place.value = Some(json!("North City")); - place.satisfied = None; - - let signed = issue( - &test_profile(), - &issuer, - &[date, place], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(vec![ - SdJwtProjectionClaim { - claim_id: "date-of-birth".to_string(), - output_name: "birth_date".to_string(), - }, - SdJwtProjectionClaim { - claim_id: "place-of-birth".to_string(), - output_name: "birth_place_name".to_string(), - }, - ]), - ..IssueOptions::default() - }, - ) - .expect("projected credential issues"); - let disclosures = decoded_disclosures(&signed); - let birth_date = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("birth_date"))) - .expect("birth_date disclosure exists"); - let birth_place = disclosures - .iter() - .find(|disclosure| disclosure.get(1) == Some(&json!("birth_place_name"))) - .expect("birth_place_name disclosure exists"); - - assert_eq!(birth_date[2], json!("2016-01-15")); - assert_eq!(birth_place[2], json!("North City")); - assert!(birth_date[2].get("claim_id").is_none()); - assert!(birth_place[2].get("version").is_none()); - } - - #[test] - fn projected_credential_rejects_missing_failed_or_null_results() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let projection = vec![SdJwtProjectionClaim { - claim_id: "date-of-birth".to_string(), - output_name: "birth_date".to_string(), - }]; - - let missing = issue( - &test_profile(), - &issuer, - &[claim_result("other")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection.clone()), - ..IssueOptions::default() - }, - ) - .expect_err("missing projected result rejects"); - assert!(matches!(missing, EvidenceError::CredentialIssuanceFailed)); - - let mut failed = claim_result("date-of-birth"); - failed.satisfied = Some(false); - let failed_error = issue( - &test_profile(), - &issuer, - &[failed], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection.clone()), - ..IssueOptions::default() - }, - ) - .expect_err("failed projected result rejects"); - assert!(matches!( - failed_error, - EvidenceError::CredentialIssuanceFailed - )); - - let mut null = claim_result("date-of-birth"); - null.value = Some(Value::Null); - let null_error = issue( - &test_profile(), - &issuer, - &[null], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions { - projection: Some(projection), - ..IssueOptions::default() - }, - ) - .expect_err("null projected result rejects"); - assert!(matches!( - null_error, - EvidenceError::CredentialIssuanceFailed - )); - } - - #[test] - fn issued_credential_uses_platform_holder_confirmation() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("first")], - &holder, - Some(&holder), - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["cnf"]["kid"], holder); - assert_eq!(payload["cnf"]["jwk"]["kty"], "OKP"); - assert_eq!(payload["cnf"]["jwk"]["crv"], "Ed25519"); - assert!(payload["cnf"]["jwk"].get("d").is_none()); - } - - #[test] - fn holder_bound_credential_uses_holder_did_as_subject() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let holder = holder_did_jwk(); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - &holder, - Some(&holder), - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["sub"], holder); - assert!( - !payload.to_string().contains("registry-subject-ref"), - "holder-bound JWT payload must not expose the raw registry subject_ref", - ); - } - - #[test] - fn credential_without_holder_uses_registry_subject_ref() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let mut result = claim_result("first"); - result.target_ref = target_ref_view("registry-subject-ref"); - - let signed = issue( - &test_profile(), - &issuer, - &[result], - "registry-subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let payload = payload(&signed); - - assert_eq!(payload["sub"], "registry-subject-ref"); - } - - #[test] - fn default_holder_binding_rejects_credential_without_holder() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let profile = default_bound_profile(); - - let err = issue( - &profile, - &issuer, - &[claim_result("first")], - "registry-subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect_err("default holder-bound profile requires holder material"); - - assert!(matches!(err, EvidenceError::HolderProofRequired)); - } - - #[test] - fn holder_required_profile_rejects_missing_or_unsupported_holder_binding() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let profile = holder_required_profile(); - let iat = OffsetDateTime::from_unix_timestamp(1_700_000_000) - .expect("test fixture timestamp is valid"); - - let missing_holder = issue( - &profile, - &issuer, - &[claim_result("first")], - "subject-ref", - None, - iat, - IssueOptions::default(), - ) - .expect_err("holder-bound profile requires holder proof material"); - assert!(matches!(missing_holder, EvidenceError::HolderProofRequired)); - - let unsupported_holder = issue( - &profile, - &issuer, - &[claim_result("first")], - "did:key:z6Mkunsupported", - Some("did:key:z6Mkunsupported"), - iat, - IssueOptions::default(), - ) - .expect_err("only did:jwk holder identifiers are supported"); - assert!(matches!( - unsupported_holder, - EvidenceError::HolderProofRequired - )); - } - - #[test] - fn issued_credential_iat_is_threaded_through_issue_not_recomputed() { - // Two re-issuances of the same evaluation must produce identical JWT - // `iat` because the caller threads `result.issued_at` through. The - // signed JWT payload `iat` is the load-bearing assertion: prior to - // the fix it was `OffsetDateTime::now_utc()` per call and drifted. - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let results = vec![claim_result("first"), claim_result("second")]; - // Pin iat to a fixed instant in the past so wall-clock drift between - // the two issue() calls cannot accidentally produce equal values. - let pinned_iat = - OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid unix timestamp"); - - let signed_1 = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - pinned_iat, - IssueOptions::default(), - ) - .expect("first issue"); - // Force a measurable wall-clock gap between calls. - std::thread::sleep(std::time::Duration::from_millis(20)); - let signed_2 = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - pinned_iat, - IssueOptions::default(), - ) - .expect("second issue"); - - let iat_1 = payload(&signed_1)["iat"] - .as_i64() - .expect("iat decodes as i64"); - let iat_2 = payload(&signed_2)["iat"] - .as_i64() - .expect("iat decodes as i64"); - assert_eq!( - iat_1, iat_2, - "JWT iat must be pinned to the threaded value, not OffsetDateTime::now_utc() per call", - ); - assert_eq!( - iat_1, - pinned_iat.unix_timestamp(), - "JWT iat must equal the threaded OffsetDateTime", - ); - // exp is derived from iat + validity, so it must also match. - let exp_1 = payload(&signed_1)["exp"].as_i64().expect("exp decodes"); - let exp_2 = payload(&signed_2)["exp"].as_i64().expect("exp decodes"); - assert_eq!(exp_1, exp_2, "exp must be derived from the threaded iat"); - assert_eq!( - payload(&signed_1)["issuanceDate"], - json!("2023-11-14T22:13:20Z") - ); - assert_eq!( - payload(&signed_1)["expirationDate"], - json!("2023-11-14T22:14:20Z") - ); - } - - #[test] - fn evidence_issuer_debug_omits_key_material() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let debug = format!("{issuer:?}"); - - assert!(debug.contains("EvidenceIssuer")); - assert!(debug.contains("did:web:issuer.test#key-1")); - assert!(!debug.contains("2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw")); - assert!(!debug.contains("1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc")); - assert!(!debug.contains("encoding_key")); - } - - #[test] - fn signed_sd_jwt_vc_debug_redacts_compact_material() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let signed = issue( - &test_profile(), - &issuer, - &[claim_result("person-is-alive")], - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - let debug = format!("{signed:?}"); - - assert!(debug.contains("SignedSdJwtVc")); - assert!(debug.contains(&signed.credential_id)); - assert!(debug.contains(&signed.issuer)); - assert!(debug.contains(&signed.expires_at)); - assert!(!debug.contains(&signed.compact)); - assert!(!debug.contains(&signed.issuer_signed_jwt)); - for disclosure in &signed.disclosures { - assert!(!debug.contains(disclosure)); - } - } - - #[test] - fn issued_sd_digests_are_sorted_by_digest() { - let issuer = EvidenceIssuer::from_jwk_str(RAW_JWK, "did:web:issuer.test#key-1".to_string()) - .expect("test issuer builds"); - let results = vec![ - claim_result("third"), - claim_result("first"), - claim_result("second"), - claim_result("fourth"), - ]; - let signed = issue( - &test_profile(), - &issuer, - &results, - "subject-ref", - None, - OffsetDateTime::now_utc(), - IssueOptions::default(), - ) - .expect("credential issues"); - - let sd = payload_sd(&signed); - let mut sorted_disclosure_digests = disclosure_digests(&signed); - sorted_disclosure_digests.sort_unstable(); - - assert_eq!(sd, sorted_disclosure_digests); - } - - fn test_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - format: FORMAT_SD_JWT_VC.to_string(), - issuer: "did:web:issuer.test".to_string(), - signing_key: "issuer-key".to_string(), - vct: "https://vct.example/test".to_string(), - validity_seconds: 60, - holder_binding: HolderBindingConfig { - mode: "none".to_string(), - proof_of_possession: None, - allowed_did_methods: Vec::new(), - }, - allowed_claims: Vec::new(), - disclosure: Default::default(), - } - } - - fn default_bound_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - holder_binding: Default::default(), - ..test_profile() - } - } - - fn holder_required_profile() -> CredentialProfileConfig { - let mut profile = test_profile(); - profile.holder_binding.mode = "did".to_string(); - profile.holder_binding.proof_of_possession = Some("required".to_string()); - profile.holder_binding.allowed_did_methods = vec!["did:jwk".to_string()]; - profile - } - - fn claim_result(claim_id: &str) -> ClaimResultView { - ClaimResultView { - evaluation_id: "eval-1".to_string(), - claim_id: claim_id.to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "person".to_string(), - requester_ref: None, - target_ref: target_ref_view("subject-ref"), - value: Some(json!({ "claim": claim_id })), - satisfied: Some(true), - disclosure: "redacted".to_string(), - redacted_fields: Vec::new(), - format: "json".to_string(), - issued_at: "2026-05-23T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "test".to_string(), - "eval-test".to_string(), - "claim".to_string(), - "1".to_string(), - crate::model::ProvenanceUsed { - relay_consultation_count: 0, - }, - ), - } - } - - fn holder_did_jwk() -> String { - let holder = PrivateJwk::parse(RAW_JWK).expect("holder JWK parses"); - did_jwk_from_public_jwk(&holder.public()).expect("did:jwk encodes") - } - - fn payload_sd(signed: &SignedSdJwtVc) -> Vec { - let payload = payload(signed); - payload["_sd"] - .as_array() - .expect("_sd is an array") - .iter() - .map(|value| value.as_str().expect("_sd digest is a string").to_string()) - .collect() - } - - fn payload(signed: &SignedSdJwtVc) -> Value { - let compact_jwt = signed - .compact - .split('~') - .next() - .expect("sd-jwt has compact jwt"); - let payload = compact_jwt - .split('.') - .nth(1) - .expect("compact jwt has payload"); - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(payload) - .expect("payload decodes as base64url"), - ) - .expect("payload decodes as JSON") - } - - fn header(signed: &SignedSdJwtVc) -> Value { - let compact_jwt = signed - .compact - .split('~') - .next() - .expect("sd-jwt has compact jwt"); - let header = compact_jwt - .split('.') - .next() - .expect("compact jwt has header"); - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(header) - .expect("header decodes as base64url"), - ) - .expect("header decodes as JSON") - } - - fn target_ref_view(handle: &str) -> TargetRefView { - TargetRefView { - entity_type: "Person".to_string(), - handle: handle.to_string(), - identifier_schemes: Vec::new(), - profile: None, - } - } - - fn disclosure_digests(signed: &SignedSdJwtVc) -> Vec { - signed - .compact - .split('~') - .skip(1) - .filter(|disclosure| !disclosure.is_empty()) - .map(|disclosure| URL_SAFE_NO_PAD.encode(Sha256::digest(disclosure.as_bytes()))) - .collect() - } - - fn decoded_disclosures(signed: &SignedSdJwtVc) -> Vec { - signed - .disclosures - .iter() - .map(|disclosure| { - serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(disclosure) - .expect("disclosure decodes as base64url"), - ) - .expect("disclosure decodes as JSON") - }) - .collect() - } -} diff --git a/crates/registry-notary-core/src/tokens.rs b/crates/registry-notary-core/src/tokens.rs deleted file mode 100644 index 149341d8a..000000000 --- a/crates/registry-notary-core/src/tokens.rs +++ /dev/null @@ -1,1080 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Notary-issued JWT primitives for the pre-authorized-code OID4VCI flow. -//! -//! Two token classes are minted with the dedicated access-token signing key -//! (never the SD-JWT VC credential-signing key): -//! -//! - The `pre-authorized_code`: a short-TTL JWT carrying a `jti` for single-use -//! tracking and the eSignet-verified subject claims, handed to the wallet -//! inside the credential offer. -//! - The Notary access token: redeemed at the token endpoint and accepted by -//! the existing credential-endpoint consumers unchanged. Its `iss`/`aud`/`typ` -//! and alg pin the second verifier's trust anchor; its claim set reproduces -//! what an eSignet token would carry so subject binding, audience, and -//! subject-access classification pass identically. -//! -//! The verify helpers here are sufficient for unit-testing the mint/verify -//! round-trip. PR3 wires the real middleware verification via the platform -//! `TokenVerifier`. - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use registry_platform_crypto::{verify as verify_signature, PublicJwk, SigningProvider}; -use serde_json::{Map, Value}; -use std::fmt; - -use crate::error::EvidenceError; - -/// JWT `alg` (and signing-key alg) for Notary-issued tokens. -pub const NOTARY_TOKEN_SIGNING_ALG: &str = "EdDSA"; - -/// Default header `typ` for the `pre-authorized_code` JWT. -pub const PRE_AUTHORIZED_CODE_JWT_TYP: &str = "registry-notary-preauth-code+jwt"; - -/// Default header `typ` for the Notary access token. -pub const NOTARY_ACCESS_TOKEN_JWT_TYP: &str = "registry-notary-access+jwt"; - -/// Header `typ` for Notary transaction tokens minted by the platform STS. -pub const NOTARY_TRANSACTION_TOKEN_JWT_TYP: &str = "at+jwt"; -pub const NOTARY_AUTHORIZATION_DETAILS_TYPE: &str = "registry_notary_evidence_transaction"; -pub const NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION: &str = - "registry-notary-authorization-details/v1"; - -/// The eSignet-verified subject the Notary binds a pre-authorized code and the -/// resulting access token to. Captured at the offer callback. -/// -/// `subject_binding_value` (the civil ID identified by `subject_binding_claim`) -/// is load-bearing: the credential endpoint attests the claim for this subject. -#[derive(Clone)] -pub struct BoundSubject { - /// Token `sub`. - pub subject: String, - /// Claim name holding the civil ID (e.g. - /// `subject_access.subject_binding.token_claim`). - pub subject_binding_claim: String, - /// The civil ID value, reproduced exactly from the eSignet id_token. - pub subject_binding_value: String, - /// Citizen `client_id` mapped to an allowed citizen client. - pub client_id: String, - /// OAuth scopes; must include the subject-access required scopes. - pub scopes: Vec, - /// Assurance level (`acr`), if present. - pub acr: Option, - /// Authentication time (`auth_time`), if present. - pub auth_time: Option, -} - -impl fmt::Debug for BoundSubject { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("BoundSubject") - .field("subject", &"[redacted]") - .field("subject_binding_claim", &self.subject_binding_claim) - .field("subject_binding_value", &"[redacted]") - .field("client_id", &self.client_id) - .field("scopes", &self.scopes) - .field("acr", &self.acr) - .field("auth_time", &self.auth_time) - .finish() - } -} - -/// Inputs for minting a `pre-authorized_code` JWT. -#[derive(Clone, Debug)] -pub struct PreAuthorizedCodeClaims { - /// Notary token issuer (`iss`). - pub issuer: String, - /// Single-use identifier tracked in the replay store by PR3 (`jti`). - pub jti: String, - /// Selected credential configuration the code is bound to. - pub credential_configuration_id: String, - /// Opaque identifier of the immutable registry-backed issuance transaction. - pub issuance_transaction_id: String, - /// Versioned commitment over the authority-bearing transaction fields. - pub issuance_transaction_commitment: String, - /// Whether this individual code requires the holder-presented transaction - /// code advertised with its credential offer. - pub tx_code_required: bool, - /// eSignet-verified subject claims carried into the code. - pub subject: BoundSubject, - /// Issued-at (unix seconds). - pub iat: i64, - /// Expiry (unix seconds). - pub exp: i64, -} - -/// Inputs for minting a Notary access token. -#[derive(Clone, Debug)] -pub struct AccessTokenClaims { - /// Notary token issuer (`iss`); must equal the second verifier's pinned - /// issuer. - pub issuer: String, - /// Optional JWT id (`jti`) for transaction-token replay protection. - pub jti: Option, - /// Accepted audiences (`aud`); must satisfy - /// `oid4vci.accepted_token_audiences` and - /// `subject_access.citizen_clients.allowed_audiences`. - pub audiences: Vec, - /// Token-type claim surfaced to the credential endpoint. - pub token_type: String, - /// Credential configuration the token is scoped to. - pub credential_configuration_id: String, - /// Opaque identifier of the immutable registry-backed issuance transaction. - pub issuance_transaction_id: String, - /// Versioned commitment copied from the pre-authorized code transaction. - pub issuance_transaction_commitment: String, - /// eSignet-verified subject claims. - pub subject: BoundSubject, - /// OAuth 2.0 Rich Authorization Requests-shaped authorization details. - pub authorization_details: Vec, - /// Sender constraint confirmation (`cnf`) copied from the verified subject - /// token when the deployment profile requires sender-constrained tokens. - pub confirmation: Option, - /// Verified assisted-access actor envelope. Public responses must not copy - /// this wholesale; it is for restricted audit/evaluation records. - pub actor: Option, - /// Issued-at (unix seconds). - pub iat: i64, - /// Expiry (unix seconds). - pub exp: i64, -} - -/// A minted, signed Notary JWT. The compact form is a secret (it is a bearer -/// token), so its `Debug` redacts it. -#[derive(Clone)] -pub struct SignedNotaryToken { - /// Header `typ`. - pub typ: String, - /// `jti` for the pre-authorized code; `None` for access tokens. - pub jti: Option, - /// Compact `header.payload.signature` JWT. - pub compact: String, -} - -impl fmt::Debug for SignedNotaryToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SignedNotaryToken") - .field("typ", &self.typ) - .field("jti", &self.jti) - .field("compact", &"[redacted]") - .finish() - } -} - -/// Mint a signed `pre-authorized_code` JWT with the access-token signing key. -pub async fn mint_pre_authorized_code( - signer: &dyn SigningProvider, - typ: &str, - claims: &PreAuthorizedCodeClaims, -) -> Result { - let mut payload = Map::new(); - payload.insert("iss".to_string(), Value::String(claims.issuer.clone())); - payload.insert("jti".to_string(), Value::String(claims.jti.clone())); - payload.insert( - "credential_configuration_id".to_string(), - Value::String(claims.credential_configuration_id.clone()), - ); - payload.insert( - "issuance_transaction_id".to_string(), - Value::String(claims.issuance_transaction_id.clone()), - ); - payload.insert( - "issuance_transaction_commitment".to_string(), - Value::String(claims.issuance_transaction_commitment.clone()), - ); - payload.insert( - "tx_code_required".to_string(), - Value::Bool(claims.tx_code_required), - ); - payload.insert("iat".to_string(), Value::from(claims.iat)); - payload.insert("nbf".to_string(), Value::from(claims.iat)); - payload.insert("exp".to_string(), Value::from(claims.exp)); - insert_subject_claims(&mut payload, &claims.subject)?; - let compact = sign_compact_jwt(signer, typ, Value::Object(payload)).await?; - Ok(SignedNotaryToken { - typ: typ.to_string(), - jti: Some(claims.jti.clone()), - compact, - }) -} - -/// Mint a signed Notary access token with the access-token signing key. -/// -/// The claim set is the security-critical contract consumed unchanged by the -/// credential endpoint: `iss`, `aud`, `sub`, `client_id`, `token_type`, -/// `scope`, the subject-binding claim, `acr`/`auth_time`, and `iat`/`nbf`/`exp`. -pub async fn mint_access_token( - signer: &dyn SigningProvider, - typ: &str, - claims: &AccessTokenClaims, -) -> Result { - let mut payload = Map::new(); - payload.insert("iss".to_string(), Value::String(claims.issuer.clone())); - if let Some(jti) = &claims.jti { - payload.insert("jti".to_string(), Value::String(jti.clone())); - } - payload.insert("aud".to_string(), audience_value(&claims.audiences)); - payload.insert( - "token_type".to_string(), - Value::String(claims.token_type.clone()), - ); - payload.insert( - "credential_configuration_id".to_string(), - Value::String(claims.credential_configuration_id.clone()), - ); - payload.insert( - "issuance_transaction_id".to_string(), - Value::String(claims.issuance_transaction_id.clone()), - ); - payload.insert( - "issuance_transaction_commitment".to_string(), - Value::String(claims.issuance_transaction_commitment.clone()), - ); - payload.insert("iat".to_string(), Value::from(claims.iat)); - payload.insert("nbf".to_string(), Value::from(claims.iat)); - payload.insert("exp".to_string(), Value::from(claims.exp)); - if !claims.authorization_details.is_empty() { - payload.insert( - "authorization_details".to_string(), - Value::Array(claims.authorization_details.clone()), - ); - } - if let Some(cnf) = &claims.confirmation { - payload.insert("cnf".to_string(), cnf.clone()); - } - if let Some(actor) = &claims.actor { - payload.insert("act".to_string(), actor.clone()); - } - insert_subject_claims(&mut payload, &claims.subject)?; - let compact = sign_compact_jwt(signer, typ, Value::Object(payload)).await?; - Ok(SignedNotaryToken { - typ: typ.to_string(), - jti: None, - compact, - }) -} - -/// The decoded header and payload of a verified Notary token, exposed so unit -/// tests can assert on the exact claim set. -#[derive(Clone, Debug)] -pub struct VerifiedNotaryToken { - pub header: Value, - pub payload: Value, -} - -impl VerifiedNotaryToken { - #[must_use] - pub fn claim_str(&self, name: &str) -> Option<&str> { - self.payload.get(name).and_then(Value::as_str) - } - - #[must_use] - pub fn claim_i64(&self, name: &str) -> Option { - self.payload.get(name).and_then(Value::as_i64) - } - - /// Space-separated `scope` claim split into individual scopes. Empty - /// segments (from leading, trailing, or repeated spaces) are dropped. - #[must_use] - pub fn scopes(&self) -> Vec { - self.claim_str("scope") - .map(|scope| { - scope - .split(' ') - .filter(|segment| !segment.is_empty()) - .map(ToString::to_string) - .collect() - }) - .unwrap_or_default() - } -} - -/// Verify a Notary token against the access-token signing key's public JWK. -/// -/// Enforces: header alg in the allow-list, the expected `typ`, signature, -/// `iss` exactly equal, `aud` membership (when expected audiences are given), -/// and `exp`/`nbf` against `now`. This mirrors what PR3's middleware verifier -/// pins and is sufficient for the unit-test round-trip. Every failure collapses -/// to `EvidenceError::MissingCredential`, matching the middleware's no-info-leak -/// failure mapping. -pub fn verify_notary_token( - compact: &str, - public_jwk: &PublicJwk, - expected_typ: &str, - expected_issuer: &str, - expected_audiences: &[String], - now: i64, -) -> Result { - let (header_b64, payload_b64, signature_b64) = split_compact(compact)?; - // Verify the signature over the raw segments BEFORE decoding any JSON, so a - // malformed or hostile header/payload never reaches the JSON parser on an - // unauthenticated token. The expected key is supplied by the caller, so the - // header is not needed to locate it; the key fixes the algorithm. - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = URL_SAFE_NO_PAD - .decode(signature_b64) - .map_err(|_| EvidenceError::MissingCredential)?; - verify_signature(signing_input.as_bytes(), &signature, public_jwk) - .map_err(|_| EvidenceError::MissingCredential)?; - let header = decode_segment_json(header_b64)?; - if header.get("alg").and_then(Value::as_str) != Some(NOTARY_TOKEN_SIGNING_ALG) { - return Err(EvidenceError::MissingCredential); - } - if header.get("typ").and_then(Value::as_str) != Some(expected_typ) { - return Err(EvidenceError::MissingCredential); - } - if expected_typ == NOTARY_TRANSACTION_TOKEN_JWT_TYP - && header - .get("kid") - .and_then(Value::as_str) - .map(str::is_empty) - .unwrap_or(true) - { - return Err(EvidenceError::MissingCredential); - } - let payload = decode_segment_json(payload_b64)?; - if expected_typ == NOTARY_TRANSACTION_TOKEN_JWT_TYP { - require_nonempty_claim(&payload, "jti")?; - require_nonempty_claim(&payload, "sub")?; - require_nonempty_claim(&payload, "scope")?; - require_authorization_details(&payload)?; - if payload.get("cnf").is_some() { - return Err(EvidenceError::MissingCredential); - } - } - if payload.get("iss").and_then(Value::as_str) != Some(expected_issuer) { - return Err(EvidenceError::MissingCredential); - } - if !expected_audiences.is_empty() && !audience_matches(&payload, expected_audiences) { - return Err(EvidenceError::MissingCredential); - } - let exp = payload - .get("exp") - .and_then(Value::as_i64) - .ok_or(EvidenceError::MissingCredential)?; - if now >= exp { - return Err(EvidenceError::MissingCredential); - } - if let Some(nbf) = payload.get("nbf").and_then(Value::as_i64) { - if now < nbf { - return Err(EvidenceError::MissingCredential); - } - } - Ok(VerifiedNotaryToken { header, payload }) -} - -/// Claim names the Notary tokens already populate. A configured -/// `subject_binding_claim` must not collide with any of these, or inserting it -/// would overwrite a standard or required claim (`iss`/`aud`/`exp`/...). -const RESERVED_TOKEN_CLAIMS: &[&str] = &[ - "iss", - "sub", - "aud", - "exp", - "nbf", - "iat", - "jti", - "authorization_details", - "cnf", - "act", - "scope", - "client_id", - "token_type", - "credential_configuration_id", - "issuance_transaction_id", - "issuance_transaction_commitment", - "tx_code_required", - "acr", - "auth_time", -]; - -fn insert_subject_claims( - payload: &mut Map, - subject: &BoundSubject, -) -> Result<(), EvidenceError> { - // A subject-binding claim configured to a reserved/emitted claim name would - // overwrite it (e.g. clobbering `aud` with the civil ID). Refuse to mint - // rather than emit a malformed token. - if RESERVED_TOKEN_CLAIMS.contains(&subject.subject_binding_claim.as_str()) { - return Err(EvidenceError::CredentialIssuanceFailed); - } - payload.insert("sub".to_string(), Value::String(subject.subject.clone())); - payload.insert( - "client_id".to_string(), - Value::String(subject.client_id.clone()), - ); - payload.insert("scope".to_string(), Value::String(subject.scopes.join(" "))); - // The subject-binding claim (the civil ID) is load-bearing: the credential - // endpoint reads it to identify whose status is attested. - payload.insert( - subject.subject_binding_claim.clone(), - Value::String(subject.subject_binding_value.clone()), - ); - if let Some(acr) = &subject.acr { - payload.insert("acr".to_string(), Value::String(acr.clone())); - } - if let Some(auth_time) = subject.auth_time { - payload.insert("auth_time".to_string(), Value::from(auth_time)); - } - Ok(()) -} - -fn require_nonempty_claim(payload: &Value, name: &str) -> Result<(), EvidenceError> { - if payload - .get(name) - .and_then(Value::as_str) - .is_some_and(|value| !value.trim().is_empty()) - { - return Ok(()); - } - Err(EvidenceError::MissingCredential) -} - -fn require_authorization_details(payload: &Value) -> Result<(), EvidenceError> { - let Some(details) = payload - .get("authorization_details") - .and_then(Value::as_array) - else { - return Err(EvidenceError::MissingCredential); - }; - let has_matching_detail = details.iter().filter_map(Value::as_object).any(|detail| { - detail.get("type").and_then(Value::as_str) == Some(NOTARY_AUTHORIZATION_DETAILS_TYPE) - && detail.get("schema_version").and_then(Value::as_str) - == Some(NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION) - }); - if !has_matching_detail { - return Err(EvidenceError::MissingCredential); - } - Ok(()) -} - -fn audience_value(audiences: &[String]) -> Value { - if audiences.len() == 1 { - Value::String(audiences[0].clone()) - } else { - Value::Array(audiences.iter().cloned().map(Value::String).collect()) - } -} - -fn audience_matches(payload: &Value, expected: &[String]) -> bool { - match payload.get("aud") { - Some(Value::String(aud)) => expected.iter().any(|candidate| candidate == aud), - Some(Value::Array(values)) => values - .iter() - .filter_map(Value::as_str) - .any(|aud| expected.iter().any(|candidate| candidate.as_str() == aud)), - _ => false, - } -} - -async fn sign_compact_jwt( - signer: &dyn SigningProvider, - typ: &str, - payload: Value, -) -> Result { - let public_jwk = signer.public_jwk(); - let kid = public_jwk - .kid - .clone() - .filter(|kid| kid == signer.key_id()) - .ok_or(EvidenceError::CredentialIssuanceFailed)?; - let header = serde_json::json!({ - "alg": NOTARY_TOKEN_SIGNING_ALG, - "typ": typ, - "kid": kid, - }); - let header_b64 = URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&header).map_err(|_| EvidenceError::CredentialIssuanceFailed)?); - let payload_b64 = URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&payload).map_err(|_| EvidenceError::CredentialIssuanceFailed)?); - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = signer - .sign(signing_input.as_bytes()) - .await - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - // Self-check so a misbehaving signer cannot emit an unverifiable token. - verify_signature(signing_input.as_bytes(), &signature, &public_jwk) - .map_err(|_| EvidenceError::CredentialIssuanceFailed)?; - Ok(format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature) - )) -} - -fn split_compact(compact: &str) -> Result<(&str, &str, &str), EvidenceError> { - let mut parts = compact.split('.'); - let header = parts.next().ok_or(EvidenceError::MissingCredential)?; - let payload = parts.next().ok_or(EvidenceError::MissingCredential)?; - let signature = parts.next().ok_or(EvidenceError::MissingCredential)?; - if parts.next().is_some() || header.is_empty() || payload.is_empty() || signature.is_empty() { - return Err(EvidenceError::MissingCredential); - } - Ok((header, payload, signature)) -} - -fn decode_segment_json(segment: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(segment) - .map_err(|_| EvidenceError::MissingCredential)?; - serde_json::from_slice(&bytes).map_err(|_| EvidenceError::MissingCredential) -} - -#[cfg(test)] -mod tests { - use super::*; - use registry_platform_crypto::{LocalJwkSigner, PrivateJwk}; - - // Stands in for the dedicated access-token signing key. - const ACCESS_TOKEN_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; - // Stands in for the SD-JWT VC credential-signing key (a different key). - const CREDENTIAL_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"f4QIxnAyRWzhuBOmNRgvBTE56mWePdsPL0mvCtl8Gys","x":"pv4e_hXHBLN27rcs6VDFV1ED0TiU8M3xy9vsuWFEsec","alg":"EdDSA"}"#; - - const ISSUER: &str = "http://127.0.0.1:4325"; - const AUDIENCE: &str = "http://127.0.0.1:4325"; - const SUBJECT_BINDING_CLAIM: &str = "https://id.example.gov/claims/national_id"; - const CIVIL_ID: &str = "NAT-123456"; - const NOW: i64 = 1_700_000_000; - - fn signer(raw: &str, kid: &str) -> LocalJwkSigner { - let mut jwk = PrivateJwk::parse(raw).expect("test JWK parses"); - jwk.kid = Some(kid.to_string()); - jwk.alg = Some(NOTARY_TOKEN_SIGNING_ALG.to_string()); - LocalJwkSigner::new(jwk).expect("local signer builds") - } - - fn access_token_signer() -> LocalJwkSigner { - signer(ACCESS_TOKEN_JWK, "did:web:issuer.example#access-token-key") - } - - fn credential_signer() -> LocalJwkSigner { - signer(CREDENTIAL_JWK, "did:web:issuer.example#credential-key") - } - - fn bound_subject() -> BoundSubject { - BoundSubject { - subject: "citizen-subject-1".to_string(), - subject_binding_claim: SUBJECT_BINDING_CLAIM.to_string(), - subject_binding_value: CIVIL_ID.to_string(), - client_id: "registry-lab-live-client".to_string(), - scopes: vec!["openid".to_string(), "subject_access".to_string()], - acr: Some("urn:example:loa:substantial".to_string()), - auth_time: Some(NOW - 30), - } - } - - fn access_token_claims() -> AccessTokenClaims { - AccessTokenClaims { - issuer: ISSUER.to_string(), - jti: None, - audiences: vec![AUDIENCE.to_string()], - token_type: "Bearer".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "transaction-123".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - subject: bound_subject(), - authorization_details: Vec::new(), - confirmation: None, - actor: None, - iat: NOW, - exp: NOW + 300, - } - } - - fn transaction_token_claims() -> AccessTokenClaims { - AccessTokenClaims { - issuer: ISSUER.to_string(), - jti: Some("01J0000000000000000000TXN1".to_string()), - audiences: vec![AUDIENCE.to_string()], - token_type: "Bearer".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "transaction-123".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - subject: bound_subject(), - authorization_details: vec![serde_json::json!({ - "type": NOTARY_AUTHORIZATION_DETAILS_TYPE, - "schema_version": NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION, - "actions": ["evaluate"], - "locations": [AUDIENCE], - })], - confirmation: None, - actor: Some(serde_json::json!({ - "actor_id_hash": "hmac-sha256:actor", - "assurance": "workforce-login", - "delegation_ref": "delegation-123", - })), - iat: NOW, - exp: NOW + 300, - } - } - - fn pre_authorized_code_claims() -> PreAuthorizedCodeClaims { - PreAuthorizedCodeClaims { - issuer: ISSUER.to_string(), - jti: "01J0000000000000000000PREAU".to_string(), - credential_configuration_id: "date_of_birth_sd_jwt".to_string(), - issuance_transaction_id: "01J0000000000000000000PREAU".to_string(), - issuance_transaction_commitment: "sha256:transaction".to_string(), - tx_code_required: true, - subject: bound_subject(), - iat: NOW, - exp: NOW + 120, - } - } - - fn block_on(future: F) -> F::Output { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime builds") - .block_on(future) - } - - #[test] - fn access_token_round_trip_carries_full_required_claim_set() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - assert!(token.jti.is_none()); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("access token verifies"); - - // Header: alg + the distinct access-token typ + the access-token kid. - assert_eq!(verified.header["alg"], NOTARY_TOKEN_SIGNING_ALG); - assert_eq!(verified.header["typ"], NOTARY_ACCESS_TOKEN_JWT_TYP); - assert_eq!( - verified.header["kid"], - "did:web:issuer.example#access-token-key" - ); - - // iss: pinned by the second verifier (standalone.rs authenticate_oidc). - assert_eq!(verified.claim_str("iss"), Some(ISSUER)); - // aud: require_oid4vci_token_audience (api.rs:2296) + citizen audience. - assert_eq!(verified.payload["aud"], AUDIENCE); - // sub + subject_binding claim: oid4vci_bound_subject (api.rs:2317) and - // bounded_verified_claims_from_oidc (standalone.rs:2437). - assert_eq!(verified.claim_str("sub"), Some("citizen-subject-1")); - assert_eq!(verified.claim_str(SUBJECT_BINDING_CLAIM), Some(CIVIL_ID)); - // client_id + scope: classify_subject_access_principal (api.rs:2585). - assert_eq!( - verified.claim_str("client_id"), - Some("registry-lab-live-client") - ); - assert_eq!( - verified.scopes(), - vec!["openid".to_string(), "subject_access".to_string()] - ); - // token_type, acr, auth_time, exp, iat, nbf: BoundedVerifiedClaims. - assert_eq!(verified.claim_str("token_type"), Some("Bearer")); - assert_eq!( - verified.claim_str("acr"), - Some("urn:example:loa:substantial") - ); - assert_eq!(verified.claim_i64("auth_time"), Some(NOW - 30)); - assert_eq!(verified.claim_i64("iat"), Some(NOW)); - assert_eq!(verified.claim_i64("nbf"), Some(NOW)); - assert_eq!(verified.claim_i64("exp"), Some(NOW + 300)); - assert_eq!( - verified.claim_str("credential_configuration_id"), - Some("date_of_birth_sd_jwt") - ); - } - - #[test] - fn pre_authorized_code_round_trip_carries_jti_subject_and_tx_code_requirement() { - let signer = access_token_signer(); - let claims = pre_authorized_code_claims(); - let token = block_on(mint_pre_authorized_code( - &signer, - PRE_AUTHORIZED_CODE_JWT_TYP, - &claims, - )) - .expect("pre-authorized code mints"); - assert_eq!(token.jti.as_deref(), Some(claims.jti.as_str())); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - PRE_AUTHORIZED_CODE_JWT_TYP, - ISSUER, - &[], - NOW + 1, - ) - .expect("pre-authorized code verifies"); - - assert_eq!(verified.header["typ"], PRE_AUTHORIZED_CODE_JWT_TYP); - assert_eq!(verified.claim_str("jti"), Some(claims.jti.as_str())); - assert_eq!(verified.claim_str("sub"), Some("citizen-subject-1")); - assert_eq!(verified.claim_str(SUBJECT_BINDING_CLAIM), Some(CIVIL_ID)); - assert_eq!( - verified.claim_str("credential_configuration_id"), - Some("date_of_birth_sd_jwt") - ); - assert_eq!(verified.payload["tx_code_required"], true); - } - - #[test] - fn transaction_token_round_trip_requires_jti_authz_details_and_actor() { - let signer = access_token_signer(); - let claims = transaction_token_claims(); - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let verified = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("transaction token verifies"); - - assert_eq!(verified.header["typ"], NOTARY_TRANSACTION_TOKEN_JWT_TYP); - assert_eq!( - verified.header["kid"], - "did:web:issuer.example#access-token-key" - ); - assert_eq!( - verified.claim_str("jti"), - Some("01J0000000000000000000TXN1") - ); - assert!(verified.payload.get("cnf").is_none()); - assert_eq!( - verified.payload["authorization_details"][0]["type"], - NOTARY_AUTHORIZATION_DETAILS_TYPE - ); - assert_eq!( - verified.payload["authorization_details"][0]["schema_version"], - NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION - ); - assert_eq!( - verified.payload["act"]["actor_id_hash"], - "hmac-sha256:actor" - ); - assert_eq!(verified.payload["act"]["delegation_ref"], "delegation-123"); - } - - #[test] - fn transaction_token_accepts_matching_authorization_details_after_other_entries() { - let signer = access_token_signer(); - let mut claims = transaction_token_claims(); - claims.authorization_details.insert( - 0, - serde_json::json!({ - "type": "unrelated_authorization_detail", - "schema_version": "example/v1", - }), - ); - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect("matching authorization_details need not be first"); - } - - #[test] - fn transaction_token_verify_rejects_unvalidated_sender_constraint() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - confirmation: Some(serde_json::json!({"jkt": "sender-key-thumbprint"})), - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("cnf without proof validation must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn transaction_token_verify_rejects_missing_authorization_details() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - authorization_details: Vec::new(), - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("missing authorization_details must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn transaction_token_verify_rejects_wrong_authorization_details_type() { - let signer = access_token_signer(); - let claims = AccessTokenClaims { - authorization_details: vec![serde_json::json!({ - "type": "registry_notary_subject_access", - "schema_version": NOTARY_AUTHORIZATION_DETAILS_SCHEMA_VERSION, - })], - ..transaction_token_claims() - }; - let token = block_on(mint_access_token( - &signer, - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - &claims, - )) - .expect("transaction token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_TRANSACTION_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("wrong authorization_details type must be rejected"); - - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_token_signed_by_a_different_key() { - let access = access_token_signer(); - let credential = credential_signer(); - // Sign with the access-token key, then verify against the credential - // key's public JWK. - let token = block_on(mint_access_token( - &access, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &credential.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token signed by a different key must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_token_signed_by_the_credential_key() { - // A token claiming the Notary issuer/typ but signed by the credential - // key must not verify against the access-token public key. - let credential = credential_signer(); - let access = access_token_signer(); - let token = block_on(mint_access_token( - &credential, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("token mints with the credential key"); - - let error = verify_notary_token( - &token.compact, - &access.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a credential-key-signed token must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_typ() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - PRE_AUTHORIZED_CODE_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token with the wrong typ must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_issuer() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - "https://attacker.example", - &[AUDIENCE.to_string()], - NOW + 1, - ) - .expect_err("a token with the wrong issuer must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_wrong_audience() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &["https://other.example".to_string()], - NOW + 1, - ) - .expect_err("a token with no accepted audience must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn verify_rejects_expired_token() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - - let error = verify_notary_token( - &token.compact, - &signer.public_jwk(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - ISSUER, - &[AUDIENCE.to_string()], - NOW + 301, - ) - .expect_err("an expired token must be rejected"); - assert!(matches!(error, EvidenceError::MissingCredential)); - } - - #[test] - fn signed_token_debug_redacts_compact() { - let signer = access_token_signer(); - let token = block_on(mint_access_token( - &signer, - NOTARY_ACCESS_TOKEN_JWT_TYP, - &access_token_claims(), - )) - .expect("access token mints"); - let debug = format!("{token:?}"); - - assert!(debug.contains("SignedNotaryToken")); - assert!(debug.contains(NOTARY_ACCESS_TOKEN_JWT_TYP)); - assert!(!debug.contains(&token.compact)); - } - - #[test] - fn bound_subject_debug_redacts_subject_and_civil_id() { - let subject = bound_subject(); - let debug = format!("{subject:?}"); - - assert!(debug.contains("BoundSubject")); - assert!(debug.contains(SUBJECT_BINDING_CLAIM)); - assert!(!debug.contains("citizen-subject-1")); - assert!(!debug.contains(CIVIL_ID)); - } - - #[test] - fn pre_authorized_code_claims_debug_does_not_render_civil_id() { - let claims = pre_authorized_code_claims(); - let debug = format!("{claims:?}"); - - // The derived Debug recurses into BoundSubject's redacting Debug. - assert!(!debug.contains("citizen-subject-1")); - assert!(!debug.contains(CIVIL_ID)); - } - - #[test] - fn mint_rejects_subject_binding_claim_colliding_with_a_reserved_claim() { - // A subject_binding_claim configured to a reserved/emitted claim name - // would overwrite that claim; minting must fail loudly instead. - for &reserved in RESERVED_TOKEN_CLAIMS { - let mut subject = bound_subject(); - subject.subject_binding_claim = reserved.to_string(); - let claims = AccessTokenClaims { - subject, - ..access_token_claims() - }; - let error = block_on(mint_access_token( - &access_token_signer(), - NOTARY_ACCESS_TOKEN_JWT_TYP, - &claims, - )) - .expect_err("a reserved subject-binding claim must be rejected"); - assert!(matches!(error, EvidenceError::CredentialIssuanceFailed)); - } - } -} diff --git a/crates/registry-notary-server/Cargo.toml b/crates/registry-notary-server/Cargo.toml deleted file mode 100644 index 816044f43..000000000 --- a/crates/registry-notary-server/Cargo.toml +++ /dev/null @@ -1,101 +0,0 @@ -[package] -name = "registry-notary-server" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Standalone Registry Notary routes, Relay-backed runtime, auth, and audit." -readme = "README.md" -repository.workspace = true -publish = false - -# not opted into [workspace.lints]: unsafe std::env::set_var/remove_var in -# #[cfg(test)] code (src/standalone.rs, tests/) - -[features] -default = [] -registry-notary-cel = ["dep:crosswalk-core", "dep:registry-notary-worker-harness"] -cel-worker-fixture = ["registry-notary-cel"] -pkcs11 = ["dep:cryptoki", "dep:hex"] -relay-contract-test-support = ["registry-platform-httputil/test-support"] - -[dependencies] -registry-notary-core.workspace = true -registry-platform-audit.workspace = true -registry-platform-authcommon.workspace = true -registry-platform-cache.workspace = true -registry-platform-config.workspace = true -registry-platform-crypto.workspace = true -registry-platform-httpsec.workspace = true -registry-platform-httputil.workspace = true -registry-platform-oid4vci.workspace = true -registry-platform-oidc.workspace = true -registry-platform-ops.workspace = true -registry-platform-pdp.workspace = true -registry-platform-replay.workspace = true -registry-platform-sdjwt.workspace = true -axum.workspace = true -async-trait.workspace = true -aws-lc-rs.workspace = true -base64.workspace = true -crosswalk-core = { workspace = true, optional = true } -deadpool.workspace = true -registry-notary-worker-harness = { workspace = true, optional = true } -cryptoki = { workspace = true, optional = true } -getrandom.workspace = true -hex = { workspace = true, optional = true } -hmac.workspace = true -jsonwebtoken.workspace = true -native-tls.workspace = true -postgres-native-tls.workspace = true -reqwest.workspace = true -rustix.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_norway.workspace = true -sha2.workspace = true -subtle.workspace = true -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -tokio-postgres.workspace = true -tower-http.workspace = true -tracing.workspace = true -ulid.workspace = true -utoipa.workspace = true -zeroize.workspace = true - -[dev-dependencies] -axum-test = { version = "20" } -chrono = { version = "0.4" } -registry-notary-client = { workspace = true, features = ["test-support", "verifier"] } -registry-platform-testing = { workspace = true, features = ["test-utils"] } -jsonschema.workspace = true -tempfile = { version = "3" } -wiremock = { version = "0.6" } -# Criterion drives the microbenchmarks under `benches/`. The benches are -# manually invoked via `cargo bench`; CI smoke-checks `cargo bench --no-run`. -criterion = { version = "0.8", features = ["html_reports"] } - -[[bench]] -name = "auth_bench" -harness = false - -[[bench]] -name = "sd_jwt_bench" -harness = false - -[[bench]] -name = "json_bench" -harness = false - -[[bin]] -name = "registry-notary-cel-worker" -path = "src/bin/registry_notary_cel_worker.rs" -required-features = ["registry-notary-cel"] -test = false - -[[bin]] -name = "registry-notary-cel-worker-fixture" -path = "src/bin/registry_notary_cel_worker_fixture.rs" -required-features = ["cel-worker-fixture"] -test = false diff --git a/crates/registry-notary-server/README.md b/crates/registry-notary-server/README.md deleted file mode 100644 index 69a74d3c3..000000000 --- a/crates/registry-notary-server/README.md +++ /dev/null @@ -1,263 +0,0 @@ -# registry-notary-server - -Standalone Registry Notary runtime, API routes, auth, audit, Relay -consultations, renderers, and credential issuance wiring. - -## What It Provides - -- Axum routers for the Registry Notary API. -- Runtime claim evaluation with dependency ordering and request-scoped Relay - consultation coalescing. -- Hash-pinned, semantically verified Relay consultations for registry-backed - evidence. -- Subject-bound and delegated subject access to Relay-backed evidence. -- API-key and bearer-token auth through `registry-platform` primitives. -- Redacted audit event emission. -- JSON, SD-JWT VC, and credential response renderers. -- Static-peer federated delegated evaluation at `/federation/v1/evaluations` - when federation is enabled in config. -- Prometheus metrics contract for `/metrics` with safe, low-cardinality labels. -- OpenAPI document generation. - -## Typical Use - -```rust -use registry_notary_core::StandaloneRegistryNotaryConfig; -use registry_notary_server::{ - compile_notary_runtime, notary_public_router_from_runtime, StandaloneServerError, -}; - -async fn app( - config: StandaloneRegistryNotaryConfig, -) -> Result { - let runtime = compile_notary_runtime(config)?; - let runtime = runtime.activate().await?; - notary_public_router_from_runtime(runtime) -} -``` - -The asynchronous `standalone_router` convenience helper is limited to explicit -local `state.storage: in_memory` configurations and must be awaited: - -```rust -let router = registry_notary_server::standalone_router(config).await?; -``` - -Both construction paths eagerly verify the retained audit chain before a -router can be returned. Confirmed integrity failures latch `/ready` until the -operator performs offline quarantine recovery and restarts the process. The -runtime-to-router functions reject snapshots that have not passed through this -verification boundary. PostgreSQL state and Registry-backed claims require the -full asynchronous activation sequence in the example before a listener is -built. - -The example builds only the public router. Embedders that deliberately select -`server.admin_listener.mode: shared_with_public` can instead use -`notary_shared_router_from_runtime`. Dedicated admin listeners require -`notary_routers_from_runtime` and separate public and admin binds. - -## Features - -- Default: no CEL runtime. -- `registry-notary-cel`: enables CEL-backed claim expression evaluation through - `crosswalk-core` in a hardened worker process with bounded IO, environment - scrubbing, resource limits where supported, timeout kill, and worker - replacement. -- `pkcs11`: enables HSM-backed SD-JWT VC issuer signing through PKCS#11. The - provider supports Ed25519 EdDSA keys and is configured through - `evidence.signing_keys`. See - [`../../docs/signing-key-provider.md`](../../docs/signing-key-provider.md). - -Run server tests without default features when checking the beta binary shape: - -```sh -cargo test -p registry-notary-server --no-default-features -``` - -Run the PKCS#11 feature path separately: - -```sh -cargo test -p registry-notary-server --no-default-features --features pkcs11 --lib -``` - -When SoftHSM and OpenSSL are installed, that feature test includes a live -PKCS#11 signing smoke test. - -## Correctness state configuration - -Registry Notary stores replay decisions, consumable nonces, evaluations, -idempotency records, credential status, quotas, and preauthorization state in -one Notary-owned PostgreSQL schema: - -```yaml -state: - storage: postgresql - postgresql: - url_env: REGISTRY_NOTARY_POSTGRES_URL - connect_timeout_ms: 5000 - operation_timeout_ms: 2000 - max_connections: 16 -``` - -Run `registry-notary state install` with a restricted migration login before -starting the service. Runtime connections require Transport Layer Security -(TLS), attest the exact schema and runtime role, and fail readiness when the -database is unavailable, read-only, incompatible, or configured with unsafe -durability settings. `max_connections` is a hard physical-connection cap per -Notary replica. Size the database budget as replica count multiplied by this -value, plus operator and migration connections. - -Local, single-process development can select the process-local backend -explicitly: - -```yaml -deployment: - profile: local - multi_instance: false -state: - storage: in_memory -``` - -`in_memory` is rejected outside the local, single-instance profile. It loses -correctness state on restart and does not provide cross-replica decisions. - -## Credential Lifecycle - -SD-JWT VC issuance is intentionally short-lived and status-free by default. -Each credential profile controls the credential lifetime with -`validity_seconds`, which defaults to 600 seconds when omitted. - -Set `credential_status.enabled = true` to add a storage-backed credential -status endpoint. Issued SD-JWT VC payloads then include a -`status.status_list.uri` pointing at `/v1/credentials/{credential_id}/status`. -The same URL serves `application/statuslist+jwt` for verifiers and the JSON -lifecycle representation for operational compatibility. The global -correctness-state backend stores status rows. The JSON endpoint returns -`valid`, `suspended`, `revoked`, or derived `expired`; admins update mutable -states through -`POST /admin/v1/credentials/{credential_id}/status` with the -`registry_notary:admin` scope. Status records contain only credential lifecycle -metadata, not subject ids, holder keys, claim values, disclosures, or source -rows. - -## Metrics - -`/metrics` is the Prometheus scrape surface for server metrics. Metric families -and labels must be safe for operational scraping: use bounded labels such as -endpoint kind, method, status code, status class, error code, outcome, profile, -and source id. Do not label or emit subject ids, principal ids, holder keys, -access tokens, source rows, request or correlation ids, SD-JWT disclosures, or -raw error details. The endpoint requires an authenticated principal with the -`registry_notary:metrics_read` scope, so Prometheus scrape jobs must send a -dedicated metrics credential. Static-auth deployments can use a metrics bearer -token or a metrics API key in `x-api-key`; OIDC deployments can use a token -whose mapped scopes include `registry_notary:metrics_read`. An internal-only -listener/proxy is defense in depth only and must still forward or inject a valid -metrics credential. It should still be exposed only through the deployment's -normal network and scrape controls. - -Example Prometheus scrape shape: - -```yaml -scrape_configs: - - job_name: registry-notary - metrics_path: /metrics - authorization: - type: Bearer - credentials_file: /run/secrets/registry-notary-metrics-token - static_configs: - - targets: ["registry-notary:4325"] -``` - -## Operations Posture - -`GET /admin/v1/posture` returns the redacted `registry.ops.posture.v1` -operations document for fleet polling. It requires an authenticated principal -with exactly the read-only `registry_notary:ops_read` scope; the write-capable -`registry_notary:admin` scope does not authorize posture unless the same -credential also carries `registry_notary:ops_read`. - -Registry Notary supports a dedicated admin listener with -`server.admin_listener.mode: dedicated`. In that mode `/admin/v1/*` and -`/metrics` are not mounted on the public listener. Simple local deployments may -use `server.admin_listener.mode: shared_with_public`, but governed -configuration with `config_trust` requires dedicated admin mode at startup. -Every topology still enforces the application scope checks. - -## Audit Configuration - -`standalone_router(...).await` builds the audit pipeline from -`StandaloneRegistryNotaryConfig.audit` and eagerly verifies retained records -before returning. The pipeline writes one redacted, tamper-evident JSON -envelope per security-relevant event and fails closed if the configured hash -secret is unavailable. - -```yaml -audit: - sink: file - path: /var/log/registry-notary/audit.jsonl - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET - max_size_mb: 100 - max_files: 14 -``` - -Sink options: - -- `stdout` writes JSONL to process stdout and is appropriate when platform log - collection provides durability. -- `file` and `jsonl` require `path`. Use `max_size_mb` for active-file rotation - and `max_files` for retained file count. `max_files` includes the active - file; `max_size_mb: 0` disables rotation. -- `syslog` writes JSONL envelopes to a local Unix datagram syslog socket. Set - `syslog_socket_path` to override the platform default: - -```yaml -audit: - sink: syslog - syslog_socket_path: /run/systemd/journal/syslog - hash_secret_env: REGISTRY_NOTARY_AUDIT_HASH_SECRET -``` - -`hash_secret_env` names an environment variable containing the deployment HMAC -secret used for audit identifier hashing. Use a generated, high-entropy value, -keep it out of config files, and keep it stable for the retention period where -auditors must correlate records. - -Audit envelopes contain `prev_hash` and `record_hash`. File/jsonl sinks resume -from the retained tail hash on startup. `registry-platform-audit::verify_chain` -proves internal consistency of the retained record set: edits, insertions, -reordering, and deletions of interior records are detected. It cannot prove -completeness of the retained set on its own, since a suffix truncation or a -fully replaced log stays self-consistent. Completeness is an off-host -shipping guarantee: declare `deployment.evidence.audit_offhost_shipping: true` -once audit events are actually shipped to a log aggregator or SIEM outside -this host. Evidence-grade deployments refuse to start when the audit sink is -`file` or `jsonl` and that declaration is missing. - -## Security Notes - -- The server starts fail-closed when credentials are missing or invalid. -- SD-JWT VC credential profiles default to 600-second validity when - `validity_seconds` is omitted; subject-access keeps profiles within the - configured credential validity ceiling. -- Federated evaluation routes are not mounted unless `federation.enabled` is - true, and accepted requests must be signed compact JWS bodies from configured - peers. -- Production and active-active deployments use the typed Notary PostgreSQL - state plane. Process-local state is limited to explicit local development. -- Registry-backed evaluation is available only through an authenticated, - purpose-bound Relay consultation whose public contract is verified before - readiness succeeds. -- Runtime readiness attests the PostgreSQL schema, role, write authority, and - durability settings before serving correctness-dependent traffic. - -## Testing - -```sh -cargo test -p registry-notary-server --no-default-features -cargo test -p registry-notary-server --all-features -``` - -## License - -Apache-2.0. diff --git a/crates/registry-notary-server/benches/auth_bench.rs b/crates/registry-notary-server/benches/auth_bench.rs deleted file mode 100644 index da60c9a33..000000000 --- a/crates/registry-notary-server/benches/auth_bench.rs +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the standalone-notary auth hot path. -//! -//! Covers: -//! - `find_credential` linear scan with hashed API-key verification for a hit -//! in a small token table and a miss against the same table. -//! - platform Bearer header parser on a typical "Bearer " value -//! and on an invalid input (missing scheme). -//! -//! Notary compares request tokens against fixed-length stored fingerprints. -//! These benches lock in the per-call cost at a realistic small N (4 -//! credentials, matching the medium perf profile). - -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_server::standalone::{find_credential, ResolvedCredential}; -use registry_platform_authcommon::parse_bearer_token; - -const VALID_TOKEN: &str = "perf-bench-bearer-token-target0-0000000000-099"; -const WRONG_TOKEN: &str = "perf-bench-bearer-token-wrong00-0000000000-000"; - -fn build_credentials() -> Vec { - vec![ - cred("client_a", "perf-bench-bearer-token-clienta-0000000000-001"), - cred("client_b", "perf-bench-bearer-token-clientb-0000000000-002"), - cred("client_c", "perf-bench-bearer-token-clientc-0000000000-003"), - cred("bench_client", VALID_TOKEN), - ] -} - -fn cred(id: &str, token: &str) -> ResolvedCredential { - ResolvedCredential { - id: id.to_string(), - fingerprint: registry_platform_authcommon::fingerprint_api_key(token), - scopes: vec!["civil-registry.read".into(), "farmer-registry.read".into()], - authorization_details: None, - } -} - -fn benchmark_find_credential_hit(c: &mut Criterion) { - let credentials = build_credentials(); - c.bench_function("auth/find_credential_hit", |b| { - b.iter(|| find_credential(black_box(&credentials), black_box(VALID_TOKEN))); - }); -} - -fn benchmark_find_credential_miss(c: &mut Criterion) { - let credentials = build_credentials(); - c.bench_function("auth/find_credential_miss", |b| { - b.iter(|| find_credential(black_box(&credentials), black_box(WRONG_TOKEN))); - }); -} - -fn benchmark_parse_bearer_token_ok(c: &mut Criterion) { - let header = format!("Bearer {VALID_TOKEN}"); - c.bench_function("auth/parse_bearer_token_ok", |b| { - b.iter(|| parse_bearer_token(black_box(&header))); - }); -} - -fn benchmark_parse_bearer_token_bad(c: &mut Criterion) { - let header = format!("Basic {VALID_TOKEN}"); - c.bench_function("auth/parse_bearer_token_bad_scheme", |b| { - b.iter(|| parse_bearer_token(black_box(&header))); - }); -} - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_find_credential_hit, - benchmark_find_credential_miss, - benchmark_parse_bearer_token_ok, - benchmark_parse_bearer_token_bad -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/benches/json_bench.rs b/crates/registry-notary-server/benches/json_bench.rs deleted file mode 100644 index af919c8d6..000000000 --- a/crates/registry-notary-server/benches/json_bench.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the JSON serialization/deserialization hot paths. -//! -//! Covers: -//! - `EvidenceAuditEvent` serialization (written as a JSONL line on every request). -//! - `ClaimResultView` serialization (returned in `/v1/evaluations` and -//! `/v1/batch-evaluations` responses). -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_core::model::{ - ClaimProvenance, ClaimResultView, EvidenceAuditEvent, EvidenceEntityRef, - EvidenceEntityReference, Hashed, PrincipalIdentifier, TargetRefView, -}; -use serde_json::json; - -// --------------------------------------------------------------------------- -// Builder helpers -// --------------------------------------------------------------------------- - -fn build_audit_event() -> EvidenceAuditEvent { - EvidenceAuditEvent { - event_id: "01HWQZPJ3VXKM8N2BF5CSRTE4D".to_string(), - occurred_at: "2026-05-24T12:00:00Z".to_string(), - principal_id_hash: Some(Hashed::::from_hash( - "hmac-sha256:client-bench-001", - )), - scopes_used: vec!["farmer_registry:evidence_verification".to_string()], - decision: "allow".to_string(), - method: "POST".to_string(), - path: "/v1/evaluations".to_string(), - status: 200, - verification_id: Some("01HWQZPJ3VXKM8N2BF5CSRTE4E".to_string()), - claim_hash: Some( - "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), - ), - purposes: None, - row_count: None, - relay_consultation_count: None, - relay_consultation_ids: Vec::new(), - forwarded: None, - error_code: None, - access_mode: None, - federation_peer_id_hash: None, - federation_issuer: None, - federation_profile: None, - federation_purpose: None, - federation_request_jti_hash: None, - federation_subject_ref_hash: None, - denial_code: None, - token_claim_name: None, - correlation_id_hash: None, - credential_profile: None, - protocol: None, - credential_configuration_id: None, - holder_binding_mode: None, - rate_limit_bucket: None, - policy_version: None, - policy_hash: None, - target_type: Some("Person".to_string()), - target_ref_hash: Some(Hashed::::from_hash( - "hmac-sha256:target-bench-0000007", - )), - requester_type: Some("Agency".to_string()), - requester_ref_hash: Some(Hashed::::from_hash( - "hmac-sha256:requester-bench-001", - )), - redacted_fields: None, - batch_items: None, - config: None, - } -} - -fn build_claim_result_view() -> ClaimResultView { - ClaimResultView { - evaluation_id: "01HWQZPJ3VXKM8N2BF5CSRTE4F".to_string(), - claim_id: "date-of-birth".to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "national_id".to_string(), - requester_ref: Some(EvidenceEntityRef { - entity_type: "Agency".to_string(), - handle: "rnref:v1:requester-bench-001".to_string(), - identifier_schemes: vec!["agency_id".to_string()], - profile: Some("civil-registry".to_string()), - }), - target_ref: TargetRefView { - entity_type: "Person".to_string(), - handle: "rnref:v1:target-bench-0000007".to_string(), - identifier_schemes: vec!["national_id".to_string()], - profile: Some("resident".to_string()), - }, - value: Some(json!("1990-01-01")), - satisfied: Some(true), - disclosure: "full_disclosure".to_string(), - redacted_fields: Vec::new(), - format: "json".to_string(), - issued_at: "2026-05-24T12:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "registry-notary-server".to_string(), - "eval-bench".to_string(), - "date-of-birth".to_string(), - "1".to_string(), - registry_notary_core::ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - } -} - -// --------------------------------------------------------------------------- -// Benchmark functions -// --------------------------------------------------------------------------- - -fn benchmark_serialize_audit_event(c: &mut Criterion) { - let event = build_audit_event(); - c.bench_function("json/serialize_audit_event", |b| { - b.iter(|| serde_json::to_vec(black_box(&event)).expect("audit event must serialize")); - }); -} - -fn benchmark_serialize_claim_result_view(c: &mut Criterion) { - let view = build_claim_result_view(); - c.bench_function("json/serialize_claim_result_view", |b| { - b.iter(|| serde_json::to_vec(black_box(&view)).expect("claim result view must serialize")); - }); -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_serialize_audit_event, - benchmark_serialize_claim_result_view -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/benches/sd_jwt_bench.rs b/crates/registry-notary-server/benches/sd_jwt_bench.rs deleted file mode 100644 index fa6174c3d..000000000 --- a/crates/registry-notary-server/benches/sd_jwt_bench.rs +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Microbenchmarks for the SD-JWT VC issuance hot path. -//! -//! Covers: -//! - `EvidenceIssuer::from_jwk_str`: JWK parsing and Ed25519 key loading. -//! - `issue` with one disclosure: the minimal single-claim credential. -//! - `issue` with three disclosures: a realistic multi-claim credential. -//! -//! All `issue` benches use holder_binding.mode = "none", holder_id = None, -//! and a fixed iat (OffsetDateTime::UNIX_EPOCH) to avoid wall-clock noise. - -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, Criterion}; -use registry_notary_core::config::{ - CredentialDisclosureConfig, CredentialProfileConfig, HolderBindingConfig, -}; -use registry_notary_core::model::{ - ClaimProvenance, ClaimResultView, EvidenceEntityRef, TargetRefView, -}; -use registry_notary_core::sd_jwt::{issue, EvidenceIssuer, IssueOptions}; -use time::OffsetDateTime; - -const TEST_ISSUER_JWK: &str = r#"{"kty":"OKP","crv":"Ed25519","d":"2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw","x":"1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc","alg":"EdDSA"}"#; -const VM_ID: &str = "did:web:perf.registry-notary.example#perf-key-1"; - -fn build_profile() -> CredentialProfileConfig { - CredentialProfileConfig { - format: registry_notary_core::FORMAT_SD_JWT_VC.to_string(), - issuer: "did:web:perf.registry-notary.example".to_string(), - signing_key: "perf-key".to_string(), - vct: "https://data.example.gov/credentials/smallholder/v1".to_string(), - validity_seconds: 24 * 60 * 60, - holder_binding: HolderBindingConfig { - mode: "none".to_string(), - proof_of_possession: None, - allowed_did_methods: Vec::new(), - }, - allowed_claims: vec![ - "date-of-birth".into(), - "farmer-under-4ha".into(), - "farmed-land-size".into(), - ], - disclosure: CredentialDisclosureConfig::default(), - } -} - -fn build_issuer() -> EvidenceIssuer { - EvidenceIssuer::from_jwk_str(TEST_ISSUER_JWK, VM_ID.to_string()) - .expect("test JWK must load without error") -} - -fn claim_result(claim_id: &str, value: serde_json::Value) -> ClaimResultView { - ClaimResultView { - evaluation_id: "eval-perf-bench".to_string(), - claim_id: claim_id.to_string(), - claim_version: "1.0.0".to_string(), - subject_type: "farmer".to_string(), - requester_ref: Some(EvidenceEntityRef { - entity_type: "Agency".to_string(), - handle: "rnref:v1:requester-perf-ref".to_string(), - identifier_schemes: vec!["agency_id".to_string()], - profile: Some("benefits".to_string()), - }), - target_ref: TargetRefView { - entity_type: "Farmer".to_string(), - handle: "rnref:v1:target-perf-ref".to_string(), - identifier_schemes: vec!["farmer_id".to_string()], - profile: Some("smallholder".to_string()), - }, - value: Some(value), - satisfied: Some(true), - disclosure: "value".to_string(), - redacted_fields: Vec::new(), - format: registry_notary_core::FORMAT_SD_JWT_VC.to_string(), - issued_at: "2026-01-01T00:00:00Z".to_string(), - expires_at: None, - provenance: ClaimProvenance::new( - "bench".to_string(), - "eval-bench".to_string(), - "claim".to_string(), - "1".to_string(), - registry_notary_core::ProvenanceUsed { - relay_consultation_count: 1, - }, - ), - } -} - -fn build_single_claim() -> Vec { - vec![claim_result( - "date-of-birth", - serde_json::json!("1990-01-01"), - )] -} - -fn build_three_claims() -> Vec { - vec![ - claim_result("date-of-birth", serde_json::json!("1990-01-01")), - claim_result("farmer-under-4ha", serde_json::json!(true)), - claim_result("farmed-land-size", serde_json::json!(3.2)), - ] -} - -fn benchmark_evidence_issuer_from_jwk_str(c: &mut Criterion) { - c.bench_function("sd_jwt/evidence_issuer_from_jwk_str", |b| { - b.iter(|| { - // `from_jwk_str` takes the verification-method id by value, so a - // fresh `String` allocation per call is part of the measured path. - let vm_id = black_box(VM_ID).to_string(); - EvidenceIssuer::from_jwk_str(black_box(TEST_ISSUER_JWK), vm_id).expect("JWK must load") - }); - }); -} - -// `issue()` invokes `getrandom::fill` (16 bytes of CSPRNG) once per claim -// disclosure. That entropy cost is unavoidable on the production path and is -// included in the measurement. With 3 claims it accounts for 3 syscalls per -// iteration on Linux, which dominates the noise floor of these benches. -fn benchmark_issue_single_claim(c: &mut Criterion) { - let profile = build_profile(); - let issuer = build_issuer(); - let results = build_single_claim(); - let iat = OffsetDateTime::UNIX_EPOCH; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("bench runtime builds"); - c.bench_function("sd_jwt/issue_single_claim", |b| { - b.iter(|| { - runtime - .block_on(issue( - black_box(&profile), - black_box(&issuer), - black_box(&results), - black_box("bench-subject"), - black_box(None), - black_box(iat), - black_box(IssueOptions::default()), - )) - .expect("issue must succeed") - }); - }); -} - -fn benchmark_issue_three_claims(c: &mut Criterion) { - let profile = build_profile(); - let issuer = build_issuer(); - let results = build_three_claims(); - let iat = OffsetDateTime::UNIX_EPOCH; - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("bench runtime builds"); - c.bench_function("sd_jwt/issue_three_claims", |b| { - b.iter(|| { - runtime - .block_on(issue( - black_box(&profile), - black_box(&issuer), - black_box(&results), - black_box("bench-subject"), - black_box(None), - black_box(iat), - black_box(IssueOptions::default()), - )) - .expect("issue must succeed") - }); - }); -} - -criterion_group! { - name = benches; - config = Criterion::default().sample_size(50); - targets = benchmark_evidence_issuer_from_jwk_str, - benchmark_issue_single_claim, - benchmark_issue_three_claims -} -criterion_main!(benches); diff --git a/crates/registry-notary-server/resources/scalar/api-reference.js b/crates/registry-notary-server/resources/scalar/api-reference.js deleted file mode 100644 index cf1d93a6f..000000000 --- a/crates/registry-notary-server/resources/scalar/api-reference.js +++ /dev/null @@ -1,2364 +0,0 @@ -/** - * _____ _________ __ ___ ____ - * / ___// ____/ | / / / | / __ \ - * \__ \/ / / /| | / / / /| | / /_/ / - * ___/ / /___/ ___ |/ /___/ ___ |/ _, _/ - * /____/\____/_/ |_/_____/_/ |_/_/ |_| - * - * @scalar/api-reference 1.57.1 - * - * Website: https://scalar.com - * GitHub: https://github.com/scalar/scalar - * License: https://github.com/scalar/scalar/blob/main/LICENSE -**/ - -(function(){try{if(typeof document<`u`){var e=document.createElement(`style`);e.appendChild(document.createTextNode(`/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-mask-linear:linear-gradient(#fff, #fff);--tw-mask-radial:linear-gradient(#fff, #fff);--tw-mask-conic:linear-gradient(#fff, #fff);--tw-mask-left:linear-gradient(#fff, #fff);--tw-mask-right:linear-gradient(#fff, #fff);--tw-mask-bottom:linear-gradient(#fff, #fff);--tw-mask-top:linear-gradient(#fff, #fff);--tw-mask-top-from-position:0%;--tw-mask-top-to-position:100%;--tw-mask-top-from-color:black;--tw-mask-top-to-color:transparent;--tw-mask-bottom-from-position:0%;--tw-mask-bottom-to-position:100%;--tw-mask-bottom-from-color:black;--tw-mask-bottom-to-color:transparent;--tw-leading:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}[data-v-b258a15f],[data-v-b258a15f]:before,[data-v-b258a15f]:after,[data-v-b258a15f]::backdrop{--tw-outline-style:solid}}}@layer scalar-base{body{line-height:inherit;margin:0}:root{--scalar-border-width:.5px;--scalar-radius:3px;--scalar-radius-lg:6px;--scalar-radius-xl:8px;--scalar-font:"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;--scalar-font-code:"JetBrains Mono", ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;--scalar-heading-1:24px;--scalar-page-description:16px;--scalar-heading-2:20px;--scalar-heading-3:16px;--scalar-heading-4:16px;--scalar-heading-5:16px;--scalar-heading-6:16px;--scalar-paragraph:16px;--scalar-small:14px;--scalar-mini:13px;--scalar-micro:12px;--scalar-bold:600;--scalar-semibold:500;--scalar-regular:400;--scalar-font-size-1:21px;--scalar-font-size-2:16px;--scalar-font-size-3:14px;--scalar-font-size-4:13px;--scalar-font-size-5:12px;--scalar-font-size-6:12px;--scalar-font-size-7:10px;--scalar-line-height-1:32px;--scalar-line-height-2:24px;--scalar-line-height-3:20px;--scalar-line-height-4:18px;--scalar-line-height-5:16px;--scalar-font-normal:400;--scalar-font-medium:500;--scalar-font-bold:700;--scalar-text-decoration:none;--scalar-text-decoration-hover:underline;--scalar-link-font-weight:inherit;--scalar-sidebar-indent:20px;--scalar-sidebar-padding:12px}.dark-mode{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--scalar-scrollbar-color:#ffffff2e;--scalar-scrollbar-color-active:#ffffff5c;--scalar-button-1:#fff;--scalar-button-1-hover:#ffffffe6;--scalar-button-1-color:black;--scalar-shadow-1:0 1px 3px 0 #0000001a;--scalar-shadow-2:0 0 0 .5px var(--scalar-border-color), #0f0f0f33 0px 3px 6px, #0f0f0f66 0px 9px 24px;--scalar-lifted-brightness:1.45;--scalar-backdrop-brightness:.5;--scalar-text-decoration-color:currentColor;--scalar-text-decoration-color-hover:currentColor}.light-mode{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--scalar-scrollbar-color-active:#0000005c;--scalar-scrollbar-color:#0000002e;--scalar-button-1:#000;--scalar-button-1-hover:#000c;--scalar-button-1-color:#ffffffe6;--scalar-shadow-1:0 1px 3px 0 #0000001c;--scalar-shadow-2:#00000014 0px 13px 20px 0px, #00000014 0px 3px 8px 0px, #eeeeed 0px 0 0 .5px;--scalar-lifted-brightness:1;--scalar-backdrop-brightness:1;--scalar-text-decoration-color:currentColor;--scalar-text-decoration-color-hover:currentColor}.light-mode .dark-mode{--lightningcss-light: !important;--lightningcss-dark:initial!important;color-scheme:dark!important}@media (width<=460px){:root{--scalar-font-size-1:22px;--scalar-font-size-2:14px;--scalar-font-size-3:12px}}@media (width<=720px){:root{--scalar-heading-1:24px;--scalar-page-description:20px}}:root{--scalar-text-decoration:underline;--scalar-text-decoration-hover:underline}.light-mode{--scalar-background-1:#fff;--scalar-background-2:#f6f6f6;--scalar-background-3:#e7e7e7;--scalar-background-accent:#8ab4f81f;--scalar-color-1:#1b1b1b;--scalar-color-2:#757575;--scalar-color-3:#8e8e8e;--scalar-color-accent:#09f;--scalar-border-color:#dfdfdf}.dark-mode{--scalar-background-1:#0f0f0f;--scalar-background-2:#1a1a1a;--scalar-background-3:#272727;--scalar-color-1:#e7e7e7;--scalar-color-2:#a4a4a4;--scalar-color-3:#797979;--scalar-color-accent:#00aeff;--scalar-background-accent:#3ea6ff1f;--scalar-border-color:#2d2d2d}.light-mode,.dark-mode{--scalar-sidebar-background-1:var(--scalar-background-1);--scalar-sidebar-color-1:var(--scalar-color-1);--scalar-sidebar-color-2:var(--scalar-color-2);--scalar-sidebar-border-color:var(--scalar-border-color);--scalar-sidebar-item-hover-background:var(--scalar-background-2);--scalar-sidebar-item-hover-color:var(--scalar-sidebar-color-2);--scalar-sidebar-item-active-background:var(--scalar-background-2);--scalar-sidebar-color-active:var(--scalar-sidebar-color-1);--scalar-sidebar-indent-border:var(--scalar-sidebar-border-color);--scalar-sidebar-indent-border-hover:var(--scalar-sidebar-border-color);--scalar-sidebar-indent-border-active:var(--scalar-sidebar-border-color);--scalar-sidebar-search-background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.light-mode,.dark-mode{--scalar-sidebar-search-background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.light-mode,.dark-mode{--scalar-sidebar-search-color:var(--scalar-color-3);--scalar-sidebar-search-border-color:var(--scalar-border-color)}.light-mode{--scalar-color-green:#069061;--scalar-color-red:#ef0006;--scalar-color-yellow:#edbe20;--scalar-color-blue:#0082d0;--scalar-color-orange:#ff5800;--scalar-color-purple:#5203d1;--scalar-link-color:var(--scalar-color-1);--scalar-link-color-hover:var(--scalar-link-color);--scalar-button-1:#000;--scalar-button-1-hover:#000c;--scalar-button-1-color:#ffffffe6;--scalar-tooltip-background:#1a1a1ae6;--scalar-tooltip-color:#ffffffd9;--scalar-color-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-color-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-color-1) 20%)}}.light-mode{--scalar-color-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-color-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-color-1) 20%)}}.light-mode{--scalar-background-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-background-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-background-1) 95%)}}.light-mode{--scalar-background-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.light-mode{--scalar-background-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.dark-mode{--scalar-color-green:#00b648;--scalar-color-red:#dc1b19;--scalar-color-yellow:#ffc90d;--scalar-color-blue:#4eb3ec;--scalar-color-orange:#ff8d4d;--scalar-color-purple:#b191f9;--scalar-link-color:var(--scalar-color-1);--scalar-link-color-hover:var(--scalar-link-color);--scalar-button-1:#fff;--scalar-button-1-hover:#ffffffe6;--scalar-button-1-color:black;--scalar-tooltip-background:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-tooltip-background:color-mix(in srgb, var(--scalar-background-1), #fff 10%)}}.dark-mode{--scalar-tooltip-color:#fffffff2;--scalar-color-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-color-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 20%)}}.dark-mode{--scalar-background-alert:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-background-alert:color-mix(in srgb, var(--scalar-color-orange), var(--scalar-background-1) 95%)}}.dark-mode{--scalar-background-danger:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.dark-mode{--scalar-background-danger:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}@supports (color:color(display-p3 1 1 1)){.light-mode{--scalar-color-accent:color(display-p3 0 .6 1);--scalar-color-green:color(display-p3 .023529 .564706 .380392);--scalar-color-red:color(display-p3 .937255 0 .023529);--scalar-color-yellow:color(display-p3 .929412 .745098 .12549);--scalar-color-blue:color(display-p3 0 .509804 .815686);--scalar-color-orange:color(display-p3 1 .4 .02);--scalar-color-purple:color(display-p3 .321569 .011765 .819608)}.dark-mode{--scalar-color-accent:color(display-p3 .07 .67 1);--scalar-color-green:color(display-p3 0 .713725 .282353);--scalar-color-red:color(display-p3 .862745 .105882 .098039);--scalar-color-yellow:color(display-p3 1 .788235 .05098);--scalar-color-blue:color(display-p3 .305882 .701961 .92549);--scalar-color-orange:color(display-p3 1 .552941 .301961);--scalar-color-purple:color(display-p3 .694118 .568627 .976471)}}:root,:host{--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}body{background-color:var(--scalar-background-1);margin:0}}@layer scalar-theme;:where(.scalar-app){font-family:var(--scalar-font);color:var(--scalar-color-1);-webkit-text-size-adjust:100%;tab-size:4;line-height:1.15}:where(.scalar-app) *,:where(.scalar-app) :before,:where(.scalar-app) :after{box-sizing:border-box;border-style:solid;border-width:0;border-color:var(--scalar-border-color);outline-width:1px;outline-style:none;outline-color:var(--scalar-color-accent);font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;font-style:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit;text-align:inherit;line-height:inherit;color:inherit;margin:unset;padding:unset;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(.scalar-app) :before,:where(.scalar-app) :after{--tw-content:""}:where(.scalar-app) button,:where(.scalar-app) input,:where(.scalar-app) optgroup,:where(.scalar-app) select,:where(.scalar-app) textarea{background:0 0}:where(.scalar-app) ::file-selector-button{background:0 0}:where(.scalar-app) ol,:where(.scalar-app) ul,:where(.scalar-app) menu{list-style:none}:where(.scalar-app) input:where(:not([type=button],[type=reset],[type=submit])),:where(.scalar-app) select,:where(.scalar-app) textarea{border-radius:var(--scalar-radius);border-width:1px}:where(.scalar-app) input::placeholder{color:var(--scalar-color-3);font-family:var(--scalar-font)}:where(.scalar-app) input[type=search]::-webkit-search-cancel-button{appearance:none}:where(.scalar-app) input[type=search]::-webkit-search-decoration{appearance:none}:where(.scalar-app) summary::-webkit-details-marker{display:none}:where(.scalar-app) input:-webkit-autofill{-webkit-background-clip:text!important;background-clip:text!important}:where(.scalar-app) :focus-visible{border-radius:var(--scalar-radius);outline-style:solid}:where(.scalar-app) button:focus-visible,:where(.scalar-app) [role=button]:focus-visible{outline-offset:-1px}:where(.scalar-app) button,:where(.scalar-app) [role=button]{cursor:pointer}:where(.scalar-app) :disabled{cursor:default}:where(.scalar-app) img,:where(.scalar-app) svg,:where(.scalar-app) video,:where(.scalar-app) canvas,:where(.scalar-app) audio,:where(.scalar-app) iframe,:where(.scalar-app) embed,:where(.scalar-app) object{vertical-align:middle;display:block}:where(.scalar-app) [hidden]{display:none}.scalar-app .cm-scroller,.scalar-app .custom-scroll{scrollbar-color:transparent transparent;scrollbar-width:thin;-webkit-overflow-scrolling:touch;overflow-y:auto}.scalar-app .custom-scroll-self-contain-overflow{overscroll-behavior:contain}.scalar-app .cm-scroller:hover,.scalar-app .custom-scroll:hover,.scalar-app.scalar-scrollbars-obtrusive .cm-scroller,.scalar-app.scalar-scrollbars-obtrusive .custom-scroll{scrollbar-color:var(--scalar-scrollbar-color,transparent) transparent}.scalar-app .cm-scroller:hover::-webkit-scrollbar-thumb{background:var(--scalar-scrollbar-color);background-clip:content-box;border:3px solid #0000}.scalar-app .custom-scroll:hover::-webkit-scrollbar-thumb{background:var(--scalar-scrollbar-color);background-clip:content-box;border:3px solid #0000}.scalar-app .cm-scroller::-webkit-scrollbar-thumb:active{background:var(--scalar-scrollbar-color-active);background-clip:content-box;border:3px solid #0000}.scalar-app .custom-scroll::-webkit-scrollbar-thumb:active{background:var(--scalar-scrollbar-color-active);background-clip:content-box;border:3px solid #0000}.scalar-app .cm-scroller::-webkit-scrollbar-corner{background:0 0}.scalar-app .custom-scroll::-webkit-scrollbar-corner{background:0 0}.scalar-app .cm-scroller::-webkit-scrollbar{width:12px;height:12px}.scalar-app .custom-scroll::-webkit-scrollbar{width:12px;height:12px}.scalar-app .cm-scroller::-webkit-scrollbar-track{background:0 0}.scalar-app .custom-scroll::-webkit-scrollbar-track{background:0 0}.scalar-app .cm-scroller::-webkit-scrollbar-thumb{background:padding-box content-box;border:3px solid #0000;border-radius:20px}.scalar-app .custom-scroll::-webkit-scrollbar-thumb{background:padding-box content-box;border:3px solid #0000;border-radius:20px}@media (pointer:coarse){.scalar-app .cm-scroller,.scalar-app .custom-scroll{padding-right:12px}}:where(.scalar-app) [class*=rotate-],:where(.scalar-app) [class*=translate-],:where(.scalar-app) [class*=scale-]{transform:none}.loader-wrapper[data-v-27df5cd8]{--loader-size:50%;justify-content:center;align-items:center;display:flex;position:relative}.svg-loader[data-v-27df5cd8]{width:var(--loader-size);height:var(--loader-size);fill:none;stroke:currentColor;background-color:#0000;top:1rem;right:.9rem;overflow:visible}.svg-path[data-v-27df5cd8]{stroke-width:12px;fill:none;transition:all .3s}.svg-x-mark[data-v-27df5cd8]{stroke-dasharray:57;stroke-dashoffset:57px;transition-delay:0s}.svg-check-mark[data-v-27df5cd8]{stroke-dasharray:149;stroke-dashoffset:149px;transition-delay:0s}.icon-is-invalid .svg-x-mark[data-v-27df5cd8],.icon-is-valid .svg-check-mark[data-v-27df5cd8]{stroke-dashoffset:0;transition-delay:.3s}.circular-loader[data-v-27df5cd8]{transform-origin:50%;background:0 0;animation:.7s linear infinite rotate-27df5cd8,.4s fade-in-27df5cd8;transform:scale(3.5)}.loader-path[data-v-27df5cd8]{stroke-dasharray:50 200;stroke-dashoffset:-100px;stroke-linecap:round}.loader-path-off[data-v-27df5cd8]{stroke-dasharray:50 200;stroke-dashoffset:-100px;opacity:0;transition:opacity .3s}@keyframes fade-in-27df5cd8{0%{opacity:0}70%{opacity:0}to{opacity:1}}@keyframes rotate-27df5cd8{0%{transform:scale(3.5)rotate(0)}to{transform:scale(3.5)rotate(360deg)}}.scalar-code-block.bg-b-1 .scalar-code-copy-backdrop{background-color:var(--scalar-background-1)}.scalar-code-block.bg-b-2 .scalar-code-copy-backdrop{background-color:var(--scalar-background-2)}.scalar-code-block.bg-b-2 .scalar-code-copy{background-color:var(--scalar-background-3)}.toggle-icon-ellipse[data-v-60be8692]{background:var(--scalar-background-1);border-radius:50%;width:7px;height:7px;transition:width .3s ease-in-out,height .3s ease-in-out;display:inline-block;position:relative;overflow:hidden;box-shadow:inset 0 0 0 1px}.toggle-icon-moon-mask[data-v-60be8692]{background:var(--scalar-background-1);border:1px solid;border-radius:50%;width:100%;height:100%;transition:transform .3s ease-in-out;display:block;position:absolute;bottom:2.5px;left:2.5px;transform:translate(4px,-4px)}.toggle-icon-sun-ray[data-v-60be8692]{background:currentColor;border-radius:8px;width:12px;height:1px;transition:transform .3s ease-in-out;position:absolute}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(2){transform:rotate(90deg)}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(3){transform:rotate(45deg)}.toggle-icon-sun-ray[data-v-60be8692]:nth-of-type(4){transform:rotate(-45deg)}.toggle-icon-dark .toggle-icon-ellipse[data-v-60be8692]{width:10px;height:10px;-webkit-mask-image:radial-gradient(circle at 0 100%,pink 10px,#0000 12px);mask-image:radial-gradient(circle at 0 100%,pink 10px,#0000 12px)}.toggle-icon-dark .toggle-icon-sun-ray[data-v-60be8692]{transform:scale(0)}.toggle-icon-dark .toggle-icon-moon-mask[data-v-60be8692]{transform:translate(0)}.dark-mode .scalar-dropdown-item[data-v-f5e0d3d8]:hover,.dark-mode .scalar-dropdown-item[data-highlighted][data-v-f5e0d3d8],.dark-mode .scalar-dropdown-item[data-v-3402682d]:hover{filter:brightness(1.1)}.scalar-icon[data-v-b651bb23],.scalar-icon[data-v-b651bb23] *{stroke-width:var(--c07589c2)}.scalar-app :where(code.hljs) *{font-size:inherit;font-family:var(--scalar-font-code);text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;tab-size:4;line-height:1.4}.scalar-app code.hljs{all:unset;font-size:inherit;color:var(--scalar-color-2);font-family:var(--scalar-font-code);counter-reset:linenumber}.scalar-app .hljs{color:var(--scalar-color-2);background:0 0}.scalar-app .hljs .line:before{color:var(--scalar-color-3);counter-increment:linenumber;content:counter(linenumber);min-width:calc(var(--line-digits) * 1ch);text-align:right;margin-right:.875rem;display:inline-block}.scalar-app .hljs-comment,.scalar-app .hljs-quote{color:var(--scalar-color-3);font-style:italic}.scalar-app .hljs-number{color:var(--scalar-color-orange)}.scalar-app .hljs-regexp,.scalar-app .hljs-string,.scalar-app .hljs-built_in{color:var(--scalar-color-blue)}.scalar-app .hljs-title.class_{color:var(--scalar-color-1)}.scalar-app .hljs-keyword{color:var(--scalar-color-purple)}.scalar-app .hljs-title.function_{color:var(--scalar-color-orange)}.scalar-app .hljs-subst,.scalar-app .hljs-name{color:var(--scalar-color-blue)}.scalar-app .hljs-attr,.scalar-app .hljs-attribute{color:var(--scalar-color-1)}.scalar-app .hljs-addition,.scalar-app .hljs-literal,.scalar-app .hljs-selector-tag,.scalar-app .hljs-type{color:var(--scalar-color-green)}.scalar-app .hljs-selector-attr,.scalar-app .hljs-selector-pseudo{color:var(--scalar-color-orange)}.scalar-app .hljs-doctag,.scalar-app .hljs-section,.scalar-app .hljs-title{color:var(--scalar-color-blue)}.scalar-app .hljs-selector-id,.scalar-app .hljs-template-variable,.scalar-app .hljs-variable{color:var(--scalar-color-1)}.scalar-app .hljs-name,.scalar-app .hljs-section,.scalar-app .hljs-strong{font-weight:var(--scalar-semibold)}.scalar-app .hljs-bullet,.scalar-app .hljs-link,.scalar-app .hljs-meta,.scalar-app .hljs-symbol{color:var(--scalar-color-blue)}.scalar-app .hljs-deletion{color:var(--scalar-color-red)}.scalar-app .hljs-formula{background:var(--scalar-color-1)}.scalar-app .hljs-emphasis{font-style:italic}.scalar-app .credential .credential-value{color:#0000;font-size:0}.scalar-app .credential:after{content:"·····";color:var(--scalar-color-3);-webkit-user-select:none;user-select:none}.hljs.language-html{color:var(--scalar-color-1)}.hljs.language-html .hljs-attr{color:var(--scalar-color-2)}.hljs.language-curl .hljs-string{color:var(--scalar-color-blue)}.hljs.language-curl .hljs-literal{color:var(--scalar-color-1)}.hljs.language-php .hljs-variable{color:var(--scalar-color-blue)}.hljs.language-objectivec .hljs-meta{color:var(--scalar-color-1)}.hljs.language-objectivec .hljs-built_in,.hljs-built_in{color:var(--scalar-color-orange)}.scalar-app .markdown{--scalar-refs-heading-spacing:24px;--markdown-border:var(--scalar-border-width) solid var(--scalar-border-color);--markdown-spacing-sm:12px;--markdown-spacing-md:16px;--markdown-line-height:1.625;--markdown-heading-line-height:1.15;font-family:var(--scalar-font);word-break:break-word;line-height:var(--markdown-line-height)}.scalar-app .markdown>*{margin-bottom:var(--markdown-spacing-md)}.scalar-app .markdown>:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6):last-child{margin-bottom:0}.scalar-app .markdown h1,.scalar-app .markdown h2,.scalar-app .markdown h3,.scalar-app .markdown h4,.scalar-app .markdown h5,.scalar-app .markdown h6{font-weight:var(--scalar-bold);margin-top:var(--scalar-refs-heading-spacing);margin-bottom:var(--markdown-spacing-sm);line-height:var(--markdown-heading-line-height,1.15);scroll-margin-top:1rem;display:block}.scalar-app .markdown h1{font-size:1.5rem}.scalar-app .markdown h2,.scalar-app .markdown h3{font-size:1.25rem}.scalar-app .markdown h4,.scalar-app .markdown h5,.scalar-app .markdown h6{font-size:1rem}.scalar-app .markdown b,.scalar-app .markdown strong{font-weight:var(--scalar-bold)}.scalar-app .markdown p{color:inherit;line-height:var(--markdown-line-height);display:block}.scalar-app .markdown img{border-radius:var(--scalar-radius);max-width:100%;display:inline-block;overflow:hidden}.scalar-app .markdown ul,.scalar-app .markdown ol{line-height:var(--markdown-line-height);flex-direction:column;gap:2px;padding-left:1.6em;display:flex}.scalar-app .markdown li{margin-top:2px;padding-left:7px}.scalar-app ol>li::marker{font:var(--scalar-font);font-variant-numeric:tabular-nums;font-weight:var(--scalar-semibold);white-space:nowrap}.scalar-app ol>*>li::marker{font:var(--scalar-font);font-variant-numeric:tabular-nums;font-weight:var(--scalar-semibold);white-space:nowrap}.scalar-app .markdown ol{list-style-type:decimal}.scalar-app .markdown ol ol{list-style-type:lower-alpha}.scalar-app .markdown ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol{list-style-type:decimal}.scalar-app .markdown ol ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol ol{list-style-type:lower-alpha}.scalar-app .markdown ol ol ol,.scalar-app .markdown ol ol ol ol ol ol,.scalar-app .markdown ol ol ol ol ol ol ol ol ol{list-style-type:lower-roman}.scalar-app .markdown ul>li,.scalar-app .markdown ul>*>li{list-style-type:disc}.scalar-app .markdown table{table-layout:fixed;border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);border-spacing:0;width:max-content;max-width:100%;margin:1em 0;display:table;position:relative;overflow-x:auto}.scalar-app .markdown tbody,.scalar-app .markdown thead{vertical-align:middle}.scalar-app .markdown tbody{display:table-row-group}.scalar-app .markdown thead{display:table-header-group}.scalar-app .markdown tr{border-color:inherit;vertical-align:inherit;display:table-row}.scalar-app .markdown td,.scalar-app .markdown th{vertical-align:top;min-width:1em;line-height:var(--markdown-line-height);word-break:break-word;font-size:var(--scalar-small);color:var(--scalar-color-1);border-right:var(--markdown-border);border-bottom:var(--markdown-border);padding:8.5px 16px;display:table-cell;position:relative}.scalar-app .markdown td>*,.scalar-app .markdown th>*{margin-bottom:0}.scalar-app .markdown th:empty{display:none}.scalar-app .markdown td:first-of-type,.scalar-app .markdown th:first-of-type{border-left:none}.scalar-app .markdown td:last-of-type,.scalar-app .markdown th:last-of-type{border-right:none}.scalar-app .markdown tr:last-of-type td{border-bottom:none}.scalar-app .markdown th{font-weight:var(--scalar-bold);text-align:left;background:var(--scalar-background-2);border-left-color:#0000}.scalar-app .markdown th:first-of-type{border-top-left-radius:var(--scalar-radius)}.scalar-app .markdown th:last-of-type{border-top-right-radius:var(--scalar-radius)}.scalar-app .markdown tr>[align=left]{text-align:left}.scalar-app .markdown tr>[align=right]{text-align:right}.scalar-app .markdown tr>[align=center]{text-align:center}.scalar-app .markdown details{border:var(--markdown-border);border-radius:var(--scalar-radius-xl);color:var(--scalar-color-1)}.scalar-app .markdown details>:not(summary){margin:var(--markdown-spacing-md);margin-bottom:0}.scalar-app .markdown details>p:has(>strong):not(:has(:not(strong))){margin-bottom:8px}.scalar-app .markdown details>p:has(>strong):not(:has(:not(strong)))+*{margin-top:0}.scalar-app .markdown details>table{width:calc(100% - calc(var(--markdown-spacing-md) * 2))}.scalar-app .markdown summary{min-height:40px;font-weight:var(--scalar-semibold);line-height:var(--markdown-line-height);cursor:pointer;-webkit-user-select:none;user-select:none;border-radius:2.5px;align-items:flex-start;gap:8px;padding:7px 14px;display:flex;position:relative}.scalar-app .markdown summary:hover{background-color:var(--scalar-background-2)}.scalar-app .markdown details[open]{padding-bottom:var(--markdown-spacing-md)}.scalar-app .markdown details[open]>summary{border-bottom:var(--markdown-border);border-bottom-right-radius:0;border-bottom-left-radius:0}.scalar-app .markdown summary:before{content:"";width:var(--markdown-spacing-md);height:var(--markdown-spacing-md);background-color:var(--scalar-color-3);flex-shrink:0;margin-top:5px;display:block;-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown summary:hover:before{background-color:var(--scalar-color-1)}.scalar-app .markdown details[open]>summary:before{transition:transform .1s ease-in-out;transform:rotate(90deg)}.scalar-app .markdown details:has(+details){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0;margin-bottom:0}.scalar-app .markdown details:has(+details)+details{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .markdown details:has(+details)+details>summary{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .markdown a{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown a{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.scalar-app .markdown a:hover{-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);text-decoration-color:var(--scalar-color-1,currentColor);color:var(--scalar-link-color-hover,var(--scalar-color-accent));-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);text-decoration:var(--scalar-text-decoration-hover)}.scalar-app .markdown a:visited{color:var(--font-visited)}.scalar-app .markdown em{font-style:italic}.scalar-app .markdown sup,.scalar-app .markdown sub{font-size:var(--scalar-micro);font-weight:450}.scalar-app .markdown sup{vertical-align:super}.scalar-app .markdown sub{vertical-align:sub}.scalar-app .markdown del{text-decoration:line-through}.scalar-app .markdown code{font-family:var(--scalar-font-code);background-color:var(--scalar-background-2);box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color);font-size:var(--scalar-micro);border-radius:2px;padding:0 3px}.scalar-app .markdown .hljs{font-size:var(--scalar-small)}.scalar-app .markdown pre code{white-space:pre;padding:var(--markdown-spacing-sm);margin:var(--markdown-spacing-sm) 0;-webkit-overflow-scrolling:touch;min-width:100px;max-width:100%;line-height:1.5;display:block;overflow-x:auto}.scalar-app .markdown hr{border:none;border-bottom:var(--markdown-border)}.scalar-app .markdown blockquote{border-left:2px solid var(--scalar-border-color);padding-left:var(--markdown-spacing-sm)}.scalar-app .markdown blockquote>*{margin-bottom:var(--markdown-spacing-sm)}.scalar-app .markdown li.task-list-item{list-style:none;position:relative}.scalar-app .markdown li.task-list-item>input{appearance:none;width:var(--markdown-spacing-md);height:var(--markdown-spacing-md);border:1px solid var(--scalar-color-3);border-radius:var(--scalar-radius);display:inline;position:absolute;top:.225em;left:-1.4em}.scalar-app .markdown li.task-list-item>input[type=checkbox]:checked{background-color:var(--scalar-color-1);border-color:var(--scalar-color-1)}.scalar-app .markdown li.task-list-item>input[type=checkbox]:before{content:"";border:solid var(--scalar-background-1);opacity:0;border-width:0 1.5px 1.5px 0;width:5px;height:10px;position:absolute;top:1px;left:5px;transform:rotate(45deg)}.scalar-app .markdown li.task-list-item>input[type=checkbox]:checked:before{opacity:1}.scalar-app .markdown .markdown-alert{border-radius:var(--scalar-radius);background-color:var(--scalar-background-2);align-items:stretch}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert{background-color:color-mix(in srgb, var(--scalar-background-2), transparent)}}.scalar-app .markdown .markdown-alert{border:var(--markdown-border);gap:var(--markdown-spacing-sm);padding:10px 14px;display:flex;position:relative}.scalar-app .markdown .markdown-alert .markdown-alert-icon:before{content:"";background-color:currentColor;flex-shrink:0;width:18px;height:18px;margin-top:3px;display:block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.scalar-app .markdown .markdown-alert.markdown-alert-note{background-color:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-note{background-color:color-mix(in srgb, var(--scalar-color-blue), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-note{border:var(--scalar-border-width) solid var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-note{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-blue), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-tip{background-color:var(--scalar-color-2)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-tip{background-color:color-mix(in srgb, var(--scalar-color-2), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-tip{border:var(--scalar-border-width) solid var(--scalar-color-2)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-tip{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-2), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-note .markdown-alert-icon:before,.scalar-app .markdown .markdown-alert.markdown-alert-tip .markdown-alert-icon:before{color:var(--scalar-color-blue);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{background-color:var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{background-color:color-mix(in srgb, var(--scalar-color-orange), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{border:var(--scalar-border-width) solid var(--scalar-color-orange)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-important,.scalar-app .markdown .markdown-alert.markdown-alert-warning{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-orange), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-important .markdown-alert-icon:before,.scalar-app .markdown .markdown-alert.markdown-alert-warning .markdown-alert-icon:before{-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-caution{background-color:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-caution{background-color:color-mix(in srgb, var(--scalar-color-red), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-caution{border:var(--scalar-border-width) solid var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-caution{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-red), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-caution .markdown-alert-icon:before{color:var(--scalar-color-red);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-success{background-color:var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-success{background-color:color-mix(in srgb, var(--scalar-color-green), transparent 97%)}}.scalar-app .markdown .markdown-alert.markdown-alert-success{border:var(--scalar-border-width) solid var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.scalar-app .markdown .markdown-alert.markdown-alert-success{border:var(--scalar-border-width) solid color-mix(in srgb, var(--scalar-color-green), transparent 50%)}}.scalar-app .markdown .markdown-alert.markdown-alert-success .markdown-alert-icon:before{color:var(--scalar-color-green);-webkit-mask-image:url("data:image/svg+xml,");mask-image:url("data:image/svg+xml,")}.scalar-app .markdown .markdown-alert.markdown-alert-note .markdown-alert-icon:before{color:var(--scalar-color-blue)}.scalar-app .markdown .markdown-alert.markdown-alert-tip .markdown-alert-icon:before{color:var(--scalar-color-2)}.scalar-app .markdown .markdown-alert.markdown-alert-important .markdown-alert-icon:before{color:var(--scalar-color-purple)}.scalar-app .markdown .markdown-alert.markdown-alert-warning .markdown-alert-icon:before{color:var(--scalar-color-orange)}.scalar-app .markdown .markdown-alert .markdown-alert-content{line-height:var(--markdown-line-height);margin:0}.scalar-app .markdown.markdown-summary.markdown-summary :before,.scalar-app .markdown.markdown-summary.markdown-summary :after{content:none}.scalar-app .markdown.markdown-summary.markdown-summary :not(strong,em,a){font-size:inherit;font-weight:inherit;line-height:var(--markdown-line-height);display:contents}.scalar-app .markdown.markdown-summary.markdown-summary img,.scalar-app .markdown.markdown-summary.markdown-summary svg,.scalar-app .markdown.markdown-summary.markdown-summary hr,.scalar-app .markdown.markdown-summary.markdown-summary pre{display:none}.scalar-modal-layout[data-v-5bb1dcc2]{animation:.3s ease-in-out forwards fadein-layout-5bb1dcc2}.scalar-modal[data-v-5bb1dcc2]{box-shadow:var(--scalar-shadow-2);animation:.3s ease-in-out .1s forwards fadein-modal-5bb1dcc2;transform:translateY(10px)}.scalar-modal-layout-full[data-v-5bb1dcc2]{opacity:1!important;background:0 0!important}.modal-content-search .modal-body[data-v-5bb1dcc2]{flex-direction:column;max-height:440px;padding:0;display:flex;overflow:hidden}@media (width<=720px) and (height<=480px){.scalar-modal-layout .scalar-modal[data-v-5bb1dcc2]{max-height:90svh;margin-top:5svh}}@keyframes fadein-layout-5bb1dcc2{0%{opacity:0}to{opacity:1}}@keyframes fadein-modal-5bb1dcc2{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translate(0)}}.full-size-styles[data-v-5bb1dcc2]{margin:initial;border-right:var(--scalar-border-width) solid var(--scalar-border-color);animation:.3s ease-in-out forwards fadein-layout-5bb1dcc2;left:0;transform:translate(0);background-color:var(--scalar-background-1)!important;max-height:100%!important;box-shadow:none!important;border-radius:0!important;position:absolute!important;top:0!important}@media (width>=800px){.full-size-styles[data-v-5bb1dcc2]{width:50dvw!important}}.full-size-styles[data-v-5bb1dcc2]:after{content:"";width:50dvw;height:100dvh;position:absolute;top:0;right:-50dvw}.group\\/item>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{inset-block:-1px}.group\\/item:first-child>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{top:0}.group\\/item:last-child>*>.scalar-sidebar-indent .scalar-sidebar-indent-border[data-v-3e080c68]{bottom:0}.group\\/item:last-of-type>.group\\/button>.group\\/button-label>.group\\/button-loading{width:66.6667%}.group\\/items.-translate-x-full .group\\/button{transition-behavior:allow-discrete;max-height:0;transition-property:display,max-height;transition-duration:0s;transition-delay:.3s;display:none}.group\\/item.group\\/nested-items-open>*>.group\\/items.translate-x-0 .group\\/button{max-height:3.40282e38px;display:flex}.animate-sidebar-border-bottom{animation:forwards border-bottom;animation-timeline:scroll();animation-range-end:1px}@keyframes border-bottom{0%{border-bottom-width:0}to{border-bottom-width:var(--scalar-border-width)}}.group\\/sidebar-section:first-of-type>.group\\/spacer-before,.group\\/sidebar-section:last-of-type>.group\\/spacer-after{height:0}.group\\/sidebar-section:has(+.group\\/sidebar-section)>.group\\/spacer-after{height:0;margin-bottom:-1px}:where(body)>.scalar-tooltip{--scalar-tooltip-padding:8px;padding:calc(var(--scalar-tooltip-padding) + var(--scalar-tooltip-offset));z-index:99999;max-width:320px;font-size:var(--scalar-font-size-5);--tw-font-weight:var(--scalar-semibold);line-height:16px;font-weight:var(--scalar-semibold);overflow-wrap:break-word;color:var(--scalar-tooltip-color)}:where(body)>.scalar-tooltip:before{content:"";inset:var(--scalar-tooltip-offset);z-index:calc(1 * -1);border-radius:var(--scalar-radius);background-color:var(--scalar-tooltip-background);--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );position:absolute}:where(body.dark-mode)>.scalar-tooltip:before{--tw-shadow:inset 0 0 0 var(--tw-shadow-color,var(--scalar-border-width)) var(--scalar-border-color);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}.scalar-virtual-text-search-input[data-v-95edadbb]::-webkit-search-cancel-button{display:none}.scalar-virtual-text[data-v-b50a85e1]:focus{outline:none}.scalar-virtual-text-highlight[data-v-b50a85e1]{background:var(--scalar-background-accent);color:inherit;border-radius:2px;padding:1px 0}.scalar-virtual-text-highlight-active[data-v-b50a85e1]{background:var(--scalar-color-accent);color:var(--scalar-background-1);border-radius:2px}.sidebar-heading-type[data-v-1857170e]{text-transform:uppercase;color:var(--method-color,var(--scalar-color-1));font-size:10px;line-height:14px;font-weight:var(--scalar-bold);font-family:var(--scalar-font-code);white-space:nowrap;flex-shrink:0;align-items:center;gap:4px;display:inline-flex;overflow:hidden}.http-bg-gradient[data-v-7a19f363]{background:linear-gradient(#ffffffbf,#00000009)}.http-bg-gradient[data-v-7a19f363]:hover{background:linear-gradient(#00000009,#ffffffbf)}.dark-mode .http-bg-gradient[data-v-7a19f363]{background:linear-gradient(#ffffff09,#00000026)}.dark-mode .http-bg-gradient[data-v-7a19f363]:hover{background:linear-gradient(#00000026,#ffffff09)}.ascii-art-animate .ascii-art-line[data-v-9a695e58]{border-right:1ch solid #0000;animation:4s step-end 1s both typewriter-9a695e58,.5s step-end infinite blinkTextCursor-9a695e58}@keyframes typewriter-9a695e58{0%{width:0}to{width:100%}}@keyframes blinkTextCursor-9a695e58{0%{border-right-color:currentColor}50%{border-right-color:#0000}}.open-api-client-button[data-v-264dd42f]{cursor:pointer;text-align:center;white-space:nowrap;width:100%;height:31px;font-size:var(--scalar-small);border-radius:var(--scalar-radius);box-shadow:0 0 0 .5px var(--scalar-border-color);color:var(--scalar-sidebar-color-1);justify-content:center;align-items:center;gap:6px;padding:9px 12px;line-height:1.385;text-decoration:none;display:flex}.open-api-client-button[data-v-264dd42f]:hover{background:var(--scalar-sidebar-item-hover-background,var(--scalar-background-2))}.address-bar-history-button[data-v-2cc840c9]:hover{background:var(--scalar-background-3)}.address-bar-history-button[data-v-2cc840c9]:focus-within{background:var(--scalar-background-2)}[data-v-3118bf46] .cm-editor{background:0 0;outline:none;height:100%;padding:0}[data-v-3118bf46] .cm-placeholder{color:var(--scalar-color-3)}[data-v-3118bf46] .cm-content{font-family:var(--scalar-font-code);font-size:var(--scalar-small);max-height:20px;padding:8px 0}[data-v-3118bf46] .cm-tooltip{filter:brightness(var(--scalar-lifted-brightness));border-radius:var(--scalar-radius);box-shadow:var(--scalar-shadow-2);background:0 0!important;border:none!important;outline:none!important;overflow:hidden!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li{padding:3px 6px!important}[data-v-3118bf46] .cm-completionIcon-type:after{color:var(--scalar-color-3)!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li[aria-selected]{background:var(--scalar-background-2)!important;color:var(--scalar-color-1)!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul{position:relative;padding:6px!important}[data-v-3118bf46] .cm-tooltip-autocomplete ul li:hover{border-radius:3px;color:var(--scalar-color-1)!important;background:var(--scalar-background-3)!important}[data-v-3118bf46] .cm-activeLine,[data-v-3118bf46] .cm-activeLineGutter{background-color:#0000}[data-v-3118bf46] .cm-selectionMatch,[data-v-3118bf46] .cm-matchingBracket{border-radius:var(--scalar-radius);background:var(--scalar-background-4)!important}[data-v-3118bf46] .cm-css-color-picker-wrapper{outline:1px solid var(--scalar-background-3);border-radius:3px;display:inline-flex;overflow:hidden}[data-v-3118bf46] .cm-gutters{color:var(--scalar-color-3);font-size:var(--scalar-small);background-color:#0000;border-right:none;border-radius:0 0 0 3px;line-height:22px}[data-v-3118bf46] .cm-gutters:before{content:"";border-radius:var(--scalar-radius) 0 0 var(--scalar-radius);background-color:var(--scalar-background-1);width:calc(100% - 2px);height:calc(100% - 4px);position:absolute;top:2px;left:2px}[data-v-3118bf46] .cm-gutterElement{justify-content:flex-end;align-items:center;display:flex;position:relative;font-family:var(--scalar-font-code)!important;padding-left:0!important;padding-right:6px!important}[data-v-3118bf46] .cm-lineNumbers .cm-gutterElement{min-width:fit-content}[data-v-3118bf46] .cm-gutter+.cm-gutter :not(.cm-foldGutter) .cm-gutterElement{padding-left:0!important}[data-v-3118bf46] .cm-scroller{overflow:auto}.line-wrapping[data-v-3118bf46]:focus-within .cm-content{white-space:break-spaces;word-break:break-all;min-height:fit-content;padding:3px 6px;display:inline-table}.cm-pill{font-size:var(--scalar-small);border-radius:30px;padding:0 9px;display:inline-block;color:var(--scalar-color-1)!important}.light-mode .cm-pill{background:var(--scalar-background-3)!important}.dark-mode .cm-pill{background:var(--tw-bg-base)!important}@supports (color:color-mix(in lab, red, red)){.dark-mode .cm-pill{background:color-mix(in srgb, var(--tw-bg-base), transparent 90%)!important}}.cm-pill--context-fn{border:1px dashed var(--scalar-color-3)}@supports (color:color-mix(in lab, red, red)){.cm-pill--context-fn{border:1px dashed color-mix(in srgb, var(--scalar-color-3), transparent 35%)}}.cm-pill--context-fn{padding:0 8px}.light-mode .cm-pill--context-fn{background:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.light-mode .cm-pill--context-fn{background:color-mix(in srgb, var(--scalar-background-3), transparent 40%)!important}}.dark-mode .cm-pill--context-fn{background:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.dark-mode .cm-pill--context-fn{background:color-mix(in srgb, var(--scalar-background-3), transparent 55%)!important}}.cm-pill:first-of-type{margin-left:0}.cm-editor .cm-widgetBuffer{display:none}.cm-foldPlaceholder:hover{color:var(--scalar-color-1)}.cm-foldGutter .cm-gutterElement{font-size:var(--scalar-heading-4);padding:2px!important}.cm-foldGutter .cm-gutterElement:first-of-type{display:none}.cm-foldGutter .cm-gutterElement .cm-foldMarker{padding:2px}.cm-foldGutter .cm-gutterElement:hover .cm-foldMarker{background:var(--scalar-background-2);border-radius:var(--scalar-radius);color:var(--scalar-color-1)}.description[data-v-1b7a32a4] .markdown{font-weight:var(--scalar-semibold);color:var(--scalar-color--1);padding:0;display:block}.description[data-v-1b7a32a4] .markdown>:first-child{margin-top:0}[data-v-4d85ebb9] .cm-editor{outline:none;width:100%;height:100%}[data-v-4d85ebb9] .cm-line{padding:0}[data-v-4d85ebb9] .cm-content{font-size:var(--scalar-small);align-items:center;padding:0;display:flex}.scroll-timeline-x[data-v-4d85ebb9]{-ms-overflow-style:none;-webkit-mask-image:linear-gradient(90deg,#0000 0,#000 6px calc(100% - 24px),#0000 100%);mask-image:linear-gradient(90deg,#0000 0,#000 6px calc(100% - 24px),#0000 100%)}.scroll-timeline-x-hidden[data-v-4d85ebb9]{overflow-x:auto}.scroll-timeline-x-hidden[data-v-4d85ebb9] .cm-scroller{scrollbar-width:none;-ms-overflow-style:none;padding-right:20px;overflow:auto}.scroll-timeline-x-hidden[data-v-4d85ebb9]::-webkit-scrollbar{width:0;height:0;display:none}.scroll-timeline-x-hidden[data-v-4d85ebb9] .cm-scroller::-webkit-scrollbar{width:0;height:0;display:none}.scroll-timeline-x-address[data-v-4d85ebb9]{scrollbar-width:none;line-height:27px}.scroll-timeline-x-address[data-v-4d85ebb9]:after{content:"";cursor:text;width:24px;height:100%;position:absolute;right:0}.scroll-timeline-x-address[data-v-4d85ebb9]:empty:before{content:"Enter URL or cURL request";color:var(--scalar-color-3);pointer-events:none}.address-bar-bg-states[data-v-4d85ebb9]{--scalar-address-bar-bg:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.address-bar-bg-states[data-v-4d85ebb9]{--scalar-address-bar-bg:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.address-bar-bg-states[data-v-4d85ebb9]{background:var(--scalar-address-bar-bg)}.address-bar-bg-states[data-v-4d85ebb9]:has(.cm-focused){--scalar-address-bar-bg:var(--scalar-background-1);border-color:var(--scalar-border-color);outline-width:1px;outline-style:solid}.app-exit-button[data-v-b1eef1b6]{color:#fff;background:#0000001a}.app-exit-button[data-v-b1eef1b6]:hover{background:#ffffff1a}.fade-request-section-content[data-v-f97cc68c]{background:linear-gradient(to left, var(--scalar-background-1) 64%, transparent)}.filter-hover[data-v-f97cc68c]{height:100%;padding-left:24px;padding-right:39px;transition:width 0s ease-in-out .2s;position:absolute;right:0;overflow:hidden}.filter-hover[data-v-f97cc68c]:hover{z-index:10;width:100%}.filter-hover[data-v-f97cc68c]:has(:focus-visible){z-index:10;width:100%}.filter-hover[data-v-f97cc68c]:before{content:"";background-color:var(--scalar-background-1);opacity:0;pointer-events:none;width:100%;height:fit-content;transition:all .3s ease-in-out;position:absolute;top:0;left:0}.filter-hover-item[data-v-f97cc68c]{opacity:0}.filter-hover-item[data-v-f97cc68c]:not(:last-of-type){transform:translateY(3px)}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]{transition:opacity .2s ease-in-out,transform .2s ease-in-out}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:last-of-type{transition-delay:50ms}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(2){transition-delay:.1s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(3){transition-delay:.15s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(4){transition-delay:.2s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(5){transition-delay:.25s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(6){transition-delay:.3s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]:nth-last-of-type(7){transition-delay:.35s}.filter-hover:hover .filter-hover-item[data-v-f97cc68c]{opacity:1;transform:translate(0)}.filter-hover:has(:focus-visible) .filter-hover-item[data-v-f97cc68c]{opacity:1;transform:translate(0)}.filter-hover[data-v-f97cc68c]:hover:before{opacity:.9;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.filter-hover[data-v-f97cc68c]:has(:focus-visible):before{opacity:.9;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.filter-button[data-v-f97cc68c]{top:50%;transform:translateY(-50%)}.context-bar-group:hover .context-bar-group-hover\\:text-c-1[data-v-f97cc68c]{--tw-text-opacity:1;color:rgb(var(--scalar-color-1) / var(--tw-text-opacity))}.context-bar-group:has(:focus-visible) .context-bar-group-hover\\:text-c-1[data-v-f97cc68c]{--tw-text-opacity:1;color:rgb(var(--scalar-color-1) / var(--tw-text-opacity))}.context-bar-group:hover .context-bar-group-hover\\:hidden[data-v-f97cc68c]{display:none}.context-bar-group:has(:focus-visible) .context-bar-group-hover\\:hidden[data-v-f97cc68c]{display:none}[data-v-36811e28] .cm-editor{padding:0}[data-v-36811e28] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-36811e28] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-36811e28] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-36811e28] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-36811e28] .cm-line{text-overflow:ellipsis;padding:0;overflow:hidden}.filemask[data-v-36811e28]{-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);-webkit-mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px);mask-image:linear-gradient(to right, transparent 0, var(--scalar-background-2) 20px)}[data-v-0362e671] .cm-content{font-size:var(--scalar-small)}.oauth-scope-row-action-rail{--oauth-scope-row-rail-bg:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.oauth-scope-row-action-rail{--oauth-scope-row-rail-bg:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.oauth-scope-row-action-rail{background:linear-gradient(90deg, var(--oauth-scope-row-rail-bg) 0%, var(--oauth-scope-row-rail-bg) 30%, var(--oauth-scope-row-rail-bg) 100%)}@supports (color:color-mix(in lab, red, red)){.oauth-scope-row-action-rail{background:linear-gradient(90deg, color-mix(in srgb, var(--oauth-scope-row-rail-bg), transparent 100%) 0%, color-mix(in srgb, var(--oauth-scope-row-rail-bg), transparent 20%) 30%, var(--oauth-scope-row-rail-bg) 100%)}}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}[data-v-819cea32] .cm-editor{padding:0}[data-v-819cea32] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-819cea32] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-819cea32] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-819cea32] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-819cea32] .cm-line{text-overflow:ellipsis;word-break:break-word;padding:0;overflow:hidden}.required[data-v-819cea32]:after{content:"Required"}input[data-v-819cea32]::placeholder{color:var(--scalar-color-3)}.scalar-password-input[data-v-819cea32]{text-security:disc;-webkit-text-security:disc;-moz-text-security:disc}.request-section-content[data-v-dc6f670a]{--scalar-border-width:.5px}.request-section-content-filter[data-v-dc6f670a]{box-shadow:0 -10px 0 10px var(--scalar-background-1)}.request-item:focus-within .request-meta-buttons[data-v-dc6f670a]{opacity:1}.group-hover-input[data-v-dc6f670a]{border-width:var(--scalar-border-width);border-color:#0000}.group:hover .group-hover-input[data-v-dc6f670a]{background:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.group:hover .group-hover-input[data-v-dc6f670a]{background:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.group:hover .group-hover-input[data-v-dc6f670a]{border-color:var(--scalar-border-color)}.group-hover-input[data-v-dc6f670a]:focus{border-color:var(--scalar-border-color)!important;background:0 0!important}[data-v-20050fd6] .cm-editor{font-size:var(--scalar-small);background-color:#0000;outline:none}[data-v-20050fd6] .cm-gutters{background-color:var(--scalar-background-1);border-radius:var(--scalar-radius) 0 0 var(--scalar-radius)}[data-v-20050fd6] .cm-scroller{min-width:100%;overflow:auto}.light-mode .bg-preview[data-v-65a8f4ce]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23000' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.dark-mode .bg-preview[data-v-65a8f4ce]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23FFF' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.scalar-code-block[data-v-bb31ac6f] .hljs *{font-size:var(--scalar-small)}.response-body-virtual[data-headlessui-state=open],.response-body-virtual[data-headlessui-state=open] .diclosure-panel{flex-direction:column;flex-grow:1;display:flex}.scalar-version-number[data-v-ab8886a2]{width:76px;height:76px;font-size:8px;font-family:var(--scalar-font-code);box-shadow:inset 2px 0px 0 2px var(--scalar-background-2);text-align:center;text-transform:initial;-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);text-decoration-color:var(--scalar-color-3);border-radius:9px 9px 16px 12px;flex-direction:column;justify-content:center;align-items:center;margin-top:-113px;margin-left:-36px;line-height:11px;display:flex;position:absolute;transform:skewY(13deg)}.scalar-version-number a[data-v-ab8886a2]{background:var(--scalar-background-2);border:.5px solid var(--scalar-border-color);border-radius:3px;padding:2px 4px;font-weight:700;text-decoration:none}.gitbook-show[data-v-ab8886a2]{display:none}.v-enter-active[data-v-21dc7abd]{transition:opacity .5s}.v-enter-from[data-v-21dc7abd]{opacity:0}.animate-response-heading .response-heading[data-v-82f4df98]{opacity:1;animation:.2s ease-in-out forwards push-response-82f4df98}@keyframes push-response-82f4df98{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}.animate-response-heading .animate-response-children[data-v-82f4df98]{opacity:0;animation:.2s ease-in-out 50ms forwards response-spans-82f4df98}@keyframes response-spans-82f4df98{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.request-card[data-v-8d155354]{color:var(--scalar-color-1);font-size:var(--scalar-font-size-3)}.request-method[data-v-8d155354]{font-family:var(--scalar-font-code);text-transform:uppercase;margin-right:6px}.request-card-footer[data-v-8d155354]{flex-shrink:0;justify-content:flex-end;padding:6px;display:flex;position:relative}.request-card-footer-addon[data-v-8d155354]{flex:1;align-items:center;min-width:0;display:flex}.request-editor-section[data-v-8d155354]{flex:1;display:flex}.request-card-simple[data-v-8d155354]{font-size:var(--scalar-small);justify-content:space-between;align-items:center;padding:8px 8px 8px 12px;display:flex}.code-snippet[data-v-8d155354]{flex-direction:column;width:100%;display:flex}[data-radix-popper-content-wrapper]:has(.scalar-api-client-context-menu){z-index:1000!important}.resizer[data-v-e2c54c18]{cursor:col-resize;z-index:100;border-right:2px solid #0000;width:5px;transition:border-right-color .3s;position:absolute;top:0;bottom:0;right:0}.scalar-dragging{cursor:col-resize}.resizer:hover,.scalar-dragging .resizer{border-right-color:var(--scalar-background-3)}.scalar-dragging:after{content:"";display:block;position:absolute;inset:0}[data-v-28c8509c] .cm-editor{padding:0}[data-v-28c8509c] .cm-content{font-family:var(--scalar-font);font-size:var(--scalar-small);background-color:#0000;align-items:center;width:100%;padding:5px 8px;display:flex}[data-v-28c8509c] .cm-content:has(.cm-pill){padding:5px 8px}[data-v-28c8509c] .cm-content .cm-pill:not(:last-of-type){margin-right:.5px}[data-v-28c8509c] .cm-content .cm-pill:not(:first-of-type){margin-left:.5px}[data-v-28c8509c] .cm-line{text-overflow:ellipsis;padding:0;overflow:hidden}.scalar .scalar-app-layout[data-v-ae5e8531]{background:var(--scalar-background-1);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:8px;width:100%;max-width:1390px;height:calc(100% - 120px);margin:auto;position:relative;overflow:hidden}@media (width<=720px) and (height<=480px){.scalar .scalar-app-layout[data-v-ae5e8531]{height:100%;max-height:90svh}}.scalar .scalar-app-exit[data-v-ae5e8531]{cursor:pointer;z-index:-1;background:#00000038;width:100vw;height:100vh;position:fixed;top:0;left:0}.dark-mode .scalar .scalar-app-exit[data-v-ae5e8531]{background:#00000073}.scalar .scalar-app-exit[data-v-ae5e8531]:before{text-align:center;color:#fff;opacity:.6;font-family:sans-serif;font-size:30px;font-weight:100;line-height:50px;position:absolute;top:0;right:12px}.scalar .scalar-app-exit[data-v-ae5e8531]:hover:before{opacity:1}.scalar-container[data-v-ae5e8531]{visibility:hidden;opacity:0;pointer-events:none;will-change:opacity;justify-content:center;align-items:center;width:100%;height:100%;transition:opacity .35s,visibility 0s linear .35s;display:flex;position:fixed;top:0;bottom:0;left:0;overflow:hidden}.scalar-container.scalar-client--open[data-v-ae5e8531]{opacity:1;visibility:visible;pointer-events:auto;transition:opacity .35s}.scalar .url-form-input[data-v-ae5e8531]{min-height:auto!important}.scalar .scalar-container[data-v-ae5e8531]{line-height:normal}.ref-search-meta[data-v-a00657cc]{background:var(--scalar-background-1);border-bottom-left-radius:var(--scalar-radius-lg);border-bottom-right-radius:var(--scalar-radius-lg);font-size:var(--scalar-font-size-4);color:var(--scalar-color-3);font-weight:var(--scalar-semibold);border-top:var(--scalar-border-width) solid var(--scalar-border-color);gap:12px;padding:6px 12px;display:flex}.authenticationProvided[data-v-e3416cd5]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.authenticationRequired[data-v-d15ef40b]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.askForAuthentication[data-v-3e7aa176]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;box-shadow:0 var(--scalar-border-width) 0 var(--scalar-background-1), 0 calc(-1 * var(--scalar-border-width)) 0 var(--scalar-background-1);flex-direction:column;margin-bottom:12px;padding:0;display:flex;position:relative}.authContent[data-v-3e7aa176]{grid-template-rows:0fr;width:100%;max-width:520px;min-height:0;margin:auto;transition:grid-template-rows .2s ease-out;display:grid;overflow:hidden}.authContentInner[data-v-3e7aa176]>div{margin:36px 0 48px}.authContent[data-v-3e7aa176] .markdown{margin-bottom:0!important}.askForAuthentication.open .authContent[data-v-3e7aa176]{grid-template-rows:1fr}.continueButton[data-v-3e7aa176]{align-self:flex-end}.toggleButton[data-v-3e7aa176]{cursor:pointer;text-align:left;color:var(--scalar-color-3);border-radius:var(--scalar-radius-lg);background:0 0;border:none;justify-content:space-between;align-items:center;display:flex;position:relative}.authContentInner[data-v-3e7aa176]{min-height:0;overflow:hidden}.authorizeButton[data-v-3e7aa176]{z-index:1;gap:5px;display:flex;background:var(--scalar-color-blue)!important;color:#fff!important;margin:0!important}.autosendPaused[data-v-d08225db]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.playIcon[data-v-5749c429]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-5749c429]:before{content:"";box-sizing:border-box;border:1.75px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-5749c429;display:inline-block;position:absolute}.buildingRequest[data-v-5749c429]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.buildingRequest svg[data-v-5749c429]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-5749c429{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.requestApproved[data-v-bb311586]{color:var(--scalar-color-green);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestFailed[data-v-bc27e533]{color:var(--scalar-color-red);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestFailedIcon[data-v-bc27e533]{border-radius:50%;width:16px;height:16px;padding:4px;box-shadow:inset 0 0 0 1.5px}.requestRejected[data-v-9803a54c]{color:var(--scalar-color-red);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.requestSuccess[data-v-acc2c0d8]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.light-mode .bg-preview[data-v-92f84612]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23000' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.dark-mode .bg-preview[data-v-92f84612]{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23FFF' fill-opacity='10%25'%3E%3Crect width='8' height='8' /%3E%3Crect x='8' y='8' width='8' height='8' /%3E%3C/svg%3E")}.playIcon[data-v-65dc6dfb]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-65dc6dfb]:before{content:"";box-sizing:border-box;border:1.75px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-65dc6dfb;display:inline-block;position:absolute}.sendingRequest[data-v-65dc6dfb]{color:var(--scalar-color-blue);font-weight:var(--scalar-semibold);min-height:40px;font-size:var(--scalar-font-size-3);align-items:center;gap:6px;display:flex;position:relative}.sendingRequest svg[data-v-65dc6dfb]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-65dc6dfb{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.requestHeaderContainer[data-v-0eb5f95d]{justify-content:space-between;align-items:center;padding:0 5px;display:flex}.requestPreview[data-v-0eb5f95d]{border-radius:12px;flex-direction:column;width:100%;display:flex;position:relative}.requestContent[data-v-0eb5f95d]{grid-template-rows:0fr;min-height:0;transition:grid-template-rows .2s ease-out;display:grid;overflow:hidden}.requestPreview.open .requestContent[data-v-0eb5f95d]{grid-template-rows:1fr}.requestPreview.succeeded[data-v-0eb5f95d]{padding:0}.requestContentInner[data-v-0eb5f95d]{min-height:0;overflow:hidden}.code[data-v-0eb5f95d]{font-size:var(--scalar-font-size-4);background:var(--scalar-background-2);border-radius:12px;flex-direction:column;display:flex}@supports (color:color-mix(in lab, red, red)){.code[data-v-0eb5f95d]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.code[data-v-0eb5f95d]{margin-bottom:12px;overflow:hidden}.dark-mode .code[data-v-0eb5f95d]{background:var(--scalar-background-2)}.code h1[data-v-0eb5f95d]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-3);padding:8px}.code[data-v-0eb5f95d] .codeBlock{max-height:calc(50vh - 100px);padding-top:0}.autosendContainer[data-v-0eb5f95d]{justify-content:space-between;display:flex}.sendButton[data-v-0eb5f95d]{background:var(--scalar-color-blue);color:#fff;font-weight:var(--scalar-semibold);padding:5px 10px}.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{background:color-mix(in srgb, var(--scalar-color-blue), black 10%)}}.sendButton[data-v-0eb5f95d]:hover,.sendButton[data-v-0eb5f95d]:active{color:#fff!important}.toggleButton[data-v-0eb5f95d]{cursor:pointer;text-align:left;color:var(--scalar-color-3);border-radius:var(--scalar-radius-lg);background:0 0;border:none;justify-content:space-between;align-items:center;display:flex;position:relative}.toggleButton[data-v-0eb5f95d]:hover{text-decoration:underline}.executeRequestTool[data-v-3e825a81]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);box-shadow:0 var(--scalar-border-width) 0 var(--scalar-background-1), 0 calc(-1 * var(--scalar-border-width)) 0 var(--scalar-background-1);flex-direction:column;gap:10px;margin-bottom:12px;display:flex}.tool[data-v-3e825a81]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:15px;margin-bottom:20px;padding:15px}.playIcon[data-v-9d9724d2]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-9d9724d2]:before{content:"";box-sizing:border-box;border:1.5px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-9d9724d2;display:inline-block;position:absolute}.loadingApiSpecs[data-v-9d9724d2]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);align-items:center;gap:6px;margin-bottom:10px;display:flex}.loadingApiSpecs svg[data-v-9d9724d2]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-9d9724d2{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.contextItem[data-v-e6786ce3]{white-space:nowrap;color:var(--scalar-color-2);cursor:pointer;vertical-align:middle;background:var(--scalar-background-2);border-radius:12px;align-items:center;padding:5px 10px;font-size:10px;display:flex}@supports (color:color-mix(in lab, red, red)){.contextItem[data-v-e6786ce3]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.contextItem[data-v-e6786ce3]{max-width:200px}.contextItemText[data-v-e6786ce3]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.shimmer[data-v-e6786ce3]{background:var(--scalar-background-2);background-image:linear-gradient(90deg, #202020 0%, var(--scalar-background-2) 40%, var(--scalar-background-3) 80%);background-size:200% 100%;animation:1.4s ease-in-out infinite shimmer-e6786ce3}.light-mode .shimmer[data-v-e6786ce3]{background:var(--scalar-background-2);background-image:linear-gradient(90deg, #fafafa 0%, var(--scalar-background-2) 40%, var(--scalar-background-3) 80%);background-size:200% 100%;animation:1.4s ease-in-out infinite shimmer-e6786ce3}@keyframes shimmer-e6786ce3{0%{background-position:200% 0}to{background-position:-200% 0}}.playIcon[data-v-6e0ac42c]{z-index:1;background:var(--scalar-background-1);border-radius:50%;justify-content:center;align-items:center;width:16px;height:16px;padding:4px;display:flex;position:relative}.playIcon[data-v-6e0ac42c]:before{content:"";box-sizing:border-box;border:1.5px solid;border-bottom-color:#0000;border-image:initial;background:var(--scalar-background-1);border-radius:50%;width:16px;height:16px;animation:.42s linear infinite rotation-6e0ac42c;display:inline-block;position:absolute}.sendingRequest[data-v-6e0ac42c]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);align-items:center;gap:6px;margin-bottom:10px;display:flex}.sendingRequest svg[data-v-6e0ac42c]{z-index:1;border-radius:50%;width:100%;height:100%}@keyframes rotation-6e0ac42c{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.operations[data-v-43dd2b86]{flex-wrap:wrap;align-items:center;gap:5px;margin-bottom:12px;display:flex}.operations[data-v-43dd2b86]:empty{margin-bottom:-12px}.overflowPopover[data-v-43dd2b86]{flex-direction:column;gap:5px;padding:8px;display:flex}.catalogModal .scalar-modal-body{flex-direction:column;display:flex}.searchInput[data-v-bc24f891]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-bottom:10px}.catalog[data-v-bc24f891]{font-size:var(--scalar-font-size-3);grid-template-columns:1fr 1fr;gap:10px;display:grid;overflow-y:scroll}.item[data-v-bc24f891]{background-color:var(--scalar-background-2);border-radius:var(--scalar-radius-lg);align-items:center;gap:10px;padding:15px;transition:background-color .16s;display:flex}.item[data-v-bc24f891]:hover{background-color:var(--scalar-background-3)!important}@supports (color:color-mix(in lab, red, red)){.item[data-v-bc24f891]:hover{background-color:color-mix(in srgb, var(--scalar-background-3), transparent 40%)!important}}.left[data-v-bc24f891]{align-items:center}.right[data-v-bc24f891]{flex-direction:column;display:flex}.logo[data-v-bc24f891]{width:25px}.item-top[data-v-bc24f891]{gap:10px;display:flex}.version[data-v-bc24f891]{background:var(--scalar-background-3);border-radius:var(--scalar-radius);font-size:var(--scalar-font-size-5);color:var(--scalar-color-3);padding:2px 5px}.description[data-v-bc24f891]{color:var(--scalar-color-2)}.dropdown-item[data-v-2d142bb5]{align-items:center;gap:10px;display:flex}.approvalSection[data-v-a7e6c699]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px}@supports (color:color-mix(in lab, red, red)){.approvalSection[data-v-a7e6c699]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.approvalSection[data-v-a7e6c699]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.approvalText[data-v-a7e6c699]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.approveContainer[data-v-a7e6c699]{gap:5px;display:flex}.actionButton[data-v-a7e6c699]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.rejectButton[data-v-a7e6c699]{color:#fff;background:var(--scalar-color-red)}.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 10%)}}.rejectButton[data-v-a7e6c699]:hover,.rejectButton[data-v-a7e6c699]:active{color:#fff!important}.approveButton[data-v-a7e6c699]{color:#fff;background:var(--scalar-color-blue)}.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.approveButton[data-v-a7e6c699]:hover,.approveButton[data-v-a7e6c699]:active{color:#fff!important}.error[data-v-63a481da]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:var(--scalar-color-red);border-radius:16px 16px 0 0;align-items:center;margin-bottom:-16px;padding:8px 8px 24px 12px;display:flex}@supports (color:color-mix(in lab, red, red)){.error[data-v-63a481da]{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.error[data-v-63a481da]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3);position:absolute;top:0;transform:translateY(calc(16px - 100%))}.freeMessagesInfoSection[data-v-913a3815]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.freeMessagesInfoSection[data-v-913a3815]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.freeMessagesInfoSection[data-v-913a3815]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex}.infoText[data-v-913a3815]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.actionsContainer[data-v-913a3815]{align-items:center;gap:8px;display:flex}.actionButton[data-v-913a3815]{font-weight:var(--scalar-semibold);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.upgradeButton[data-v-913a3815]{color:#fff;font-size:var(--scalar-font-size-3);background:var(--scalar-color-blue)}.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.upgradeButton[data-v-913a3815]:hover,.upgradeButton[data-v-913a3815]:active{color:#fff!important}.closeButton[data-v-913a3815]{width:28px;height:28px;color:var(--scalar-color-2);cursor:pointer;background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;display:flex}.closeButton[data-v-913a3815]:hover{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.closeButton[data-v-913a3815]:hover{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 80%)}}.closeButton[data-v-913a3815]:hover{color:var(--scalar-color-1)}.paymentSection[data-v-8f005a5c]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.paymentSection[data-v-8f005a5c]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.paymentSection[data-v-8f005a5c]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.approvalText[data-v-8f005a5c]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.paymentContainer[data-v-8f005a5c]{gap:5px;display:flex}.actionButton[data-v-8f005a5c]{font-weight:var(--scalar-semibold);border-radius:50px;align-items:center;padding:6px 12px;display:flex}.rejectButton[data-v-8f005a5c]{color:#fff;background:var(--scalar-color-red)}.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 10%)}}.rejectButton[data-v-8f005a5c]:hover,.rejectButton[data-v-8f005a5c]:active{color:#fff!important}.approveButton[data-v-8f005a5c]{color:#fff;font-size:var(--scalar-font-size-3);background:var(--scalar-color-blue)}.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{background:var(--scalar-color-blue)}@supports (color:color-mix(in lab, red, red)){.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 10%)}}.approveButton[data-v-8f005a5c]:hover,.approveButton[data-v-8f005a5c]:active{color:#fff!important}.paymentInfo[data-v-8f005a5c]{width:300px;box-shadow:var(--scalar-shadow-2);background:var(--scalar-background-1);pointer-events:none;opacity:0;border-radius:16px;padding:12px;transition:all .2s ease-in-out;position:absolute;bottom:70px;right:0;transform:translateY(-5px)}.paymentInfo h3[data-v-8f005a5c]{font-size:var(--scalar-font-size-1);font-weight:var(--scalar-bold);margin-bottom:18px}.paymentInfo h3 span[data-v-8f005a5c]{font-size:var(--scalar-font-size-2)}.dark-mode .paymentInfo[data-v-8f005a5c]{background:var(--scalar-background-2)}.paymentContainer:hover .paymentInfo[data-v-8f005a5c]{opacity:1;transform:translate(0)}.paymentInfoItem[data-v-8f005a5c]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);justify-content:space-between;margin-top:8px;display:flex}.paymentInfoSection[data-v-8f005a5c]:not(:last-child){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);padding-bottom:8px}.searchItem[data-v-7945f74c]{font-size:var(--scalar-font-size-3);align-items:center;gap:9px;padding:8px 10px;display:flex}.searchInput[data-v-7945f74c]{margin-bottom:5px}.searchItem[data-v-7945f74c]:hover{background:var(--scalar-background-2)}.searchItemLogo[data-v-7945f74c]{width:15px}.searchIcon[data-v-7945f74c]{margin-right:7px}.searchResultsEmpty[data-v-7945f74c]{font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);margin:10px}.uploadSection[data-v-9551cf83]{background:var(--scalar-color-blue);width:100%;margin-bottom:-16px;padding:8px 8px 24px 12px;position:relative}@supports (color:color-mix(in lab, red, red)){.uploadSection[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-blue), var(--scalar-background-1) 95%)}}.uploadSection[data-v-9551cf83]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px 16px 0 0;justify-content:space-between;align-items:center;display:flex;position:absolute;top:0;transform:translateY(calc(16px - 100%))}.uploadSection.error[data-v-9551cf83]{background:var(--scalar-color-red)}@supports (color:color-mix(in lab, red, red)){.uploadSection.error[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-red), var(--scalar-background-1) 95%)}}.uploadSection.done[data-v-9551cf83]{background:var(--scalar-color-green)}@supports (color:color-mix(in lab, red, red)){.uploadSection.done[data-v-9551cf83]{background:color-mix(in srgb, var(--scalar-color-green), var(--scalar-background-1) 95%)}}.uploadText[data-v-9551cf83]{font-weight:var(--scalar-semibold);font-size:var(--scalar-font-size-3)}.icon[data-v-9551cf83]{width:20px;height:20px}.actionContainer[data-v-e7c7c266]{background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.actionContainer[data-v-e7c7c266]{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.actionContainer[data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;box-shadow:0 24px 0 2px var(--scalar-background-1);border-radius:16px;position:relative}.promptForm[data-v-e7c7c266]{background:var(--scalar-background-1);width:100%;box-shadow:var(--scalar-shadow-1), 0 0 0 var(--scalar-border-width) var(--scalar-border-color);border-radius:16px;flex-direction:column;display:flex;position:relative}.inputActionsContainer[data-v-e7c7c266]{justify-content:space-between;padding:0 8px 8px;display:flex}.inputActionsLeft[data-v-e7c7c266]{flex-wrap:wrap;align-items:center;gap:5px;display:flex}.inputActionsRight[data-v-e7c7c266]{gap:5px;display:flex;position:relative}.apiPill[data-v-e7c7c266]{font-size:var(--scalar-font-size-3);border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);pointer-events:all;z-index:1;-webkit-user-select:none;user-select:none;border-radius:16px;align-items:center;gap:4px;height:28px;padding:0 8px;display:flex}.apiPillLogo[data-v-e7c7c266]{width:15px}.apiPillRemove[data-v-e7c7c266]{border-radius:50%;justify-content:center;align-items:center;width:24px;height:24px;margin-right:-6px;display:flex}.apiPill:hover .apiPillRemove[data-v-e7c7c266]{background:var(--scalar-background-2)}.dark-mode .apiPill:hover .apiPillRemove[data-v-e7c7c266]{background:var(--scalar-background-3)}.apiPillRemove[data-v-e7c7c266]:hover{color:var(--scalar-color-1)}.prompt[data-v-e7c7c266]{resize:none;field-sizing:content;z-index:1;scrollbar-width:thin;word-wrap:break-word;width:100%;max-width:100%;min-height:64px;max-height:250px;font-family:var(--scalar-font);border:none;outline:none;padding:12px 12px 14px;font-size:16px;overflow-y:auto}.dark-mode .promptForm[data-v-e7c7c266]{background:var(--scalar-background-2)}.prompt[data-v-e7c7c266]:disabled{color:var(--scalar-color-3)}.addAPIButton[data-v-e7c7c266]{color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);width:28px;height:28px;font-weight:var(--scalar-bold);pointer-events:all;z-index:1;box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color);border-radius:100%;justify-content:center;align-items:center;gap:4px;display:flex}.addAPIButton[data-v-e7c7c266]:hover{background:var(--scalar-background-2)}@supports (color:color-mix(in lab, red, red)){.addAPIButton[data-v-e7c7c266]:hover{background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1))}}.addAPIButton[data-v-e7c7c266]:hover{box-shadow:0 0 0 var(--scalar-border-width) var(--scalar-border-color)}.dark-mode .addAPIButton[data-v-e7c7c266]:hover{background:var(--scalar-background-3)}.settingsButton[data-v-e7c7c266]{z-index:1;color:var(--scalar-color-3)!important;border-radius:50%!important;margin:0!important}.settingsButton[aria-disabled=true][data-v-e7c7c266]{background:var(--scalar-background-2)}.dark-mode .settingsButton[data-v-e7c7c266]:hover{background:var(--scalar-background-3)}.sendButton[data-v-e7c7c266]{z-index:1;border:var(--scalar-border-width) solid var(--scalar-color-blue);background:var(--scalar-color-blue)!important;border-radius:50%!important;margin:0!important}.sendButton[data-v-e7c7c266]:not([aria-disabled=true]){color:#fff!important}.sendButton[data-v-e7c7c266]:not([aria-disabled=true]):hover{background:var(--scalar-color-blue)!important}@supports (color:color-mix(in lab, red, red)){.sendButton[data-v-e7c7c266]:not([aria-disabled=true]):hover{background:color-mix(in srgb, var(--scalar-color-blue), transparent 10%)!important}}.sendButton[aria-disabled=true][data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:var(--scalar-background-2)!important;color:var(--scalar-color-3)!important}.dark-mode .sendButton[aria-disabled=true][data-v-e7c7c266]{background:var(--scalar-background-3)!important}.contextContainer[data-v-e7c7c266]{width:100%;color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);-webkit-user-select:none;user-select:none;justify-content:space-between;padding:10px 12px 12px;display:flex}.settingsButton[data-v-e7c7c266]{font-weight:var(--scalar-semibold);border-radius:var(--scalar-radius-lg);margin:-4px -6px;padding:4px 6px}.settingsButton[data-v-e7c7c266]:hover{background:var(--scalar-background-2);box-shadow:0 0 var(--scalar-border-width) 0 var(--scalar-border-color);cursor:pointer}.agentLabel[data-v-e7c7c266]{cursor:text;width:100%;height:100%;font-size:0;position:absolute}.sendCheckboxContinue[data-v-e7c7c266]:has(input){background:var(--scalar-background-2);box-shadow:0 0 0 1.5px var(--scalar-background-2);color:var(--scalar-color-2);font-size:var(--scalar-font-size-3);font-weight:var(--scalar-semibold);-webkit-user-select:none;user-select:none;border-radius:14px;align-items:center;height:28px;display:flex}.dark-mode .sendCheckboxContinue[data-v-e7c7c266]:has(input){background:var(--scalar-background-3);box-shadow:0 0 0 1.5px var(--scalar-background-3)}.addMoreContext[data-v-e7c7c266]{height:40px;font-size:var(--scalar-font-size-3);color:var(--scalar-color-3);align-items:center;padding:0 8px 0 12px;display:flex;position:relative}.addMoreContext[data-v-e7c7c266]:before{content:"";background:var(--scalar-background-1);width:8px;height:8px}@supports (color:color-mix(in lab, red, red)){.addMoreContext[data-v-e7c7c266]:before{background:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.addMoreContext[data-v-e7c7c266]:before{box-shadow:-.5px -.5px 0 var(--scalar-border-color), inset .5px .5px 1px var(--scalar-border-color);position:absolute;top:-3px;left:18px;transform:rotate(45deg)}.dark-mode .addMoreContext[data-v-e7c7c266]:before{box-shadow:-.5px -.5px 0 var(--scalar-border-color)}.addAPIContext[data-v-e7c7c266]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:50%;justify-content:center;align-items:center;width:28px;height:28px;display:flex}.termsAgree[data-v-e7c7c266]{cursor:pointer;height:inherit;border-radius:14px;align-items:center;gap:5px;margin:0 5px;display:flex}.termsAgree[data-v-e7c7c266]:hover{color:var(--scalar-color-1)}.termsAgree:hover .termsAgreeIcon[data-v-e7c7c266]{background:var(--scalar-color-1);color:var(--scalar-background-1)}.termsAgreeIcon[data-v-e7c7c266]{width:inherit;height:inherit;background:var(--scalar-background-2);border-radius:50%;padding:2px}.chat[data-v-8e43ed7a]{flex-direction:column;flex:1;width:100%;max-width:744px;padding:24px 0;display:flex}.userMessage[data-v-8e43ed7a]{background:var(--scalar-background-2);width:fit-content;max-width:80%;color:var(--scalar-color-1);padding-top:6px;padding-bottom:6px;border-radius:18px;margin-bottom:12px;margin-left:auto;padding-inline:16px;font-size:16px;line-height:24px}div+.userMessage[data-v-8e43ed7a]{margin-top:64px}.chat[data-v-8e43ed7a]>div:has(.executeRequestTool)+div:has(.executeRequestTool){margin-top:-12px}.spacer[data-v-8e43ed7a]{width:100%;min-height:280px}.formContainer[data-v-8e43ed7a]{z-index:1;width:100%;max-width:744px;position:fixed;bottom:20px}.chat[data-v-8e43ed7a] .markdown{margin-bottom:12px}.agentLogo[data-v-56f7e8dd]{margin-bottom:15px}.startContainer[data-v-56f7e8dd]{flex-direction:column;justify-content:center;align-items:center;width:100%;max-width:720px;height:100%;display:flex;position:relative}.heading[data-v-56f7e8dd]{font-size:1.5rem;font-weight:var(--scalar-font-bold);margin-bottom:50px}.disclaimerText[data-v-56f7e8dd]{text-align:center;color:var(--scalar-color-3);font-size:var(--scalar-font-size-3);text-wrap:balance;margin-top:40px;line-height:1.44}.disclaimerLink[data-v-56f7e8dd]{text-decoration:underline}.wrapper[data-v-f1eee0af]{flex-direction:column;align-items:center;width:100%;height:100%;display:flex}.docSettings[data-v-01a25619]{font-size:var(--scalar-font-size-3);flex-direction:column;gap:12px;max-height:600px;margin-bottom:12px;display:flex}.documentName[data-v-01a25619]{font-weight:var(--scalar-semibold)}.settingsModal .scalar-modal-layout{z-index:10!important}.settingsModal .scalar-modal-body{overflow:hidden auto}.documentList[data-v-9843da37]{font-size:var(--scalar-font-size-3);flex-direction:column;margin-bottom:12px;display:flex}.document[data-v-9843da37]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;width:calc(100% + 24px);padding:0 12px;display:flex;position:relative;left:-12px}.document[data-v-9843da37]:first-of-type:not(:last-of-type){border-bottom:none}.documentName[data-v-9843da37]{font-weight:var(--scalar-semibold);color:var(--scalar-color-2);align-items:center;gap:4px;padding:12px 0;display:flex}.documentNameActive[data-v-9843da37]{color:var(--scalar-color-1)}.settingsHeading[data-v-9843da37]{font-size:19px;font-weight:var(--scalar-semibold);align-items:center;gap:5px;margin-bottom:12px;display:flex}.proxyUrlContainer[data-v-9843da37]{font-size:var(--scalar-font-size-3);flex-direction:column;gap:5px;display:flex}.proxyUrlContainer label[data-v-9843da37]{font-weight:var(--scalar-semibold)}.noDocuments[data-v-9843da37]{color:var(--scalar-color-2);margin-bottom:10px}.scalar-app .\\@container{container-type:inline-size}.scalar-app .pointer-events-auto{pointer-events:auto}.scalar-app .pointer-events-none{pointer-events:none}.scalar-app .collapse{visibility:collapse}.scalar-app .invisible{visibility:hidden}.scalar-app .visible{visibility:visible}.scalar-app .floating-bg:before{background-color:var(--scalar-background-2);border-radius:var(--scalar-radius);content:"";opacity:0;z-index:1;width:calc(100% + 8px);height:calc(100% - 4px);transition:opacity .2s ease-in-out;position:absolute;top:2.5px;left:-4px}.scalar-app .floating-bg:hover:before{opacity:1}.scalar-app .centered{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);position:absolute;top:50%;left:50%}.scalar-app .centered-y{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y);position:absolute;top:50%}.scalar-app .sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.scalar-app .absolute{position:absolute}.scalar-app .fixed{position:fixed}.scalar-app .relative{position:relative}.scalar-app .static{position:static}.scalar-app .sticky{position:sticky}.scalar-app .inset-0{inset:0}.scalar-app .inset-x-0{inset-inline:0}.scalar-app .inset-x-1{inset-inline:4px}.scalar-app .inset-x-px{inset-inline:1px}.scalar-app .-inset-y-0\\.5{inset-block:-2px}.scalar-app .-inset-y-0\\.75{inset-block:-3px}.scalar-app .inset-y-0{inset-block:0}.scalar-app .inset-y-0\\.5{inset-block:2px}.scalar-app .start{inset-inline-start:4px}.scalar-app .end{inset-inline-end:4px}.scalar-app .-top-1{top:-4px}.scalar-app .-top-2{top:-8px}.scalar-app .top-\\(--nested-items-offset\\)\\!{top:var(--nested-items-offset)!important}.scalar-app .top-\\(--refs-header-height\\){top:var(--refs-header-height)}.scalar-app .top-\\(--scalar-custom-header-height\\,0\\){top:var(--scalar-custom-header-height,0)}.scalar-app .top-\\(--scalar-sidebar-sticky-offset\\,0\\){top:var(--scalar-sidebar-sticky-offset,0)}.scalar-app .top-0{top:0}.scalar-app .top-0\\.5{top:2px}.scalar-app .top-1\\/2{top:50%}.scalar-app .top-2{top:8px}.scalar-app .top-2\\.5{top:10px}.scalar-app .top-3\\.5{top:14px}.scalar-app .top-12{top:48px}.scalar-app .top-\\[1lh\\]{top:1lh}.scalar-app .top-\\[calc\\(10px\\+0\\.5lh\\)\\]{top:calc(10px + .5lh)}.scalar-app .top-\\[calc\\(100\\%\\+4px\\)\\]{top:calc(100% + 4px)}.scalar-app .top-px{top:1px}.scalar-app .-right-1{right:-4px}.scalar-app .-right-1\\.5{right:-6px}.scalar-app .-right-\\[30px\\]{right:-30px}.scalar-app .right-0{right:0}.scalar-app .right-0\\.75{right:3px}.scalar-app .right-1{right:4px}.scalar-app .right-1\\.5{right:6px}.scalar-app .right-1\\.25{right:5px}.scalar-app .right-2{right:8px}.scalar-app .right-2\\.5{right:10px}.scalar-app .right-4{right:16px}.scalar-app .right-7{right:28px}.scalar-app .right-12{right:48px}.scalar-app .bottom-1{bottom:4px}.scalar-app .bottom-4{bottom:16px}.scalar-app .bottom-\\[var\\(--scalar-border-width\\)\\]{bottom:var(--scalar-border-width)}.scalar-app .-left-4\\.5{left:-18px}.scalar-app .-left-5{left:-20px}.scalar-app .-left-6{left:-24px}.scalar-app .left-0{left:0}.scalar-app .left-1\\/2{left:50%}.scalar-app .left-2{left:8px}.scalar-app .left-2\\.5{left:10px}.scalar-app .left-3{left:12px}.scalar-app .left-4{left:16px}.scalar-app .left-border{left:var(--scalar-border-width)}.scalar-app .left-px{left:1px}.scalar-app .left-refs-w-sidebar{left:var(--refs-sidebar-width)}.scalar-app .-z-1{z-index:calc(1 * -1)}.scalar-app .-z-2{z-index:calc(2 * -1)}.scalar-app .z-0{z-index:0}.scalar-app .z-1{z-index:1}.scalar-app .z-10{z-index:10;z-index:10}.scalar-app .z-50{z-index:50;z-index:50}.scalar-app .z-\\[1\\]{z-index:1}.scalar-app .z-context{z-index:1000}.scalar-app .z-context-plus{z-index:1001}.scalar-app .z-overlay{z-index:10000}.scalar-app .z-tooltip{z-index:99999}.scalar-app .order-789{order:789}.scalar-app .order-last{order:9999}.scalar-app .col-span-full{grid-column:1/-1}.scalar-app .container{width:100%}@media (width>=400px){.scalar-app .container{max-width:400px}}@media (width>=600px){.scalar-app .container{max-width:600px}}@media (width>=800px){.scalar-app .container{max-width:800px}}@media (width>=1000px){.scalar-app .container{max-width:1000px}}@media (width>=1200px){.scalar-app .container{max-width:1200px}}@media (width>=96rem){.scalar-app .container{max-width:96rem}}.scalar-app .\\!m-0{margin:0!important}.scalar-app .-m-0\\.5{margin:-2px}.scalar-app .-m-1{margin:-4px}.scalar-app .-m-2{margin:-8px}.scalar-app .-m-px{margin:-1px}.scalar-app .m-0{margin:0}.scalar-app .m-1{margin:4px}.scalar-app .m-auto{margin:auto}.scalar-app .-mx-\\(--scalar-sidebar-padding\\){margin-inline:calc(var(--scalar-sidebar-padding) * -1)}.scalar-app .-mx-0\\.25{margin-inline:-1px}.scalar-app .-mx-0\\.75{margin-inline:-3px}.scalar-app .-mx-2{margin-inline:-8px}.scalar-app .-mx-px{margin-inline:-1px}.scalar-app .mx-0\\.5{margin-inline:2px}.scalar-app .mx-1{margin-inline:4px}.scalar-app .mx-1\\.5{margin-inline:6px}.scalar-app .mx-px{margin-inline:1px}.scalar-app .-my-0\\.5{margin-block:-2px}.scalar-app .-my-1{margin-block:-4px}.scalar-app .-my-1\\.5{margin-block:-6px}.scalar-app .-my-2{margin-block:-8px}.scalar-app .-my-px{margin-block:-1px}.scalar-app .my-0\\.75{margin-block:3px}.scalar-app .my-1\\.5{margin-block:6px}.scalar-app .my-2{margin-block:8px}.scalar-app .my-3{margin-block:12px}.scalar-app .my-12{margin-block:48px}.scalar-app .-mt-\\(--scalar-sidebar-padding\\){margin-top:calc(var(--scalar-sidebar-padding) * -1)}.scalar-app .-mt-1{margin-top:-4px}.scalar-app .-mt-1\\.5{margin-top:-6px}.scalar-app .-mt-\\[\\.5px\\]{margin-top:-.5px}.scalar-app .mt-0{margin-top:0}.scalar-app .mt-0\\.5{margin-top:2px}.scalar-app .mt-0\\.25{margin-top:1px}.scalar-app .mt-1{margin-top:4px}.scalar-app .mt-2{margin-top:8px}.scalar-app .mt-3{margin-top:12px}.scalar-app .mt-6{margin-top:24px}.scalar-app .mt-\\[15svh\\]{margin-top:15svh}.scalar-app .mt-\\[20svh\\]{margin-top:20svh}.scalar-app .mt-auto{margin-top:auto}.scalar-app .mt-px{margin-top:1px}.scalar-app .-mr-0\\.5{margin-right:-2px}.scalar-app .-mr-1{margin-right:-4px}.scalar-app .-mr-1\\.5{margin-right:-6px}.scalar-app .-mr-px{margin-right:-1px}.scalar-app .mr-0{margin-right:0}.scalar-app .mr-0\\.5{margin-right:2px}.scalar-app .mr-0\\.75{margin-right:3px}.scalar-app .mr-1{margin-right:4px}.scalar-app .mr-1\\.5{margin-right:6px}.scalar-app .mr-1\\.25{margin-right:5px}.scalar-app .mr-2{margin-right:8px}.scalar-app .mr-3{margin-right:12px}.scalar-app .mr-\\[calc\\(20px-var\\(--scalar-sidebar-indent\\)\\)\\]{margin-right:calc(20px - var(--scalar-sidebar-indent))}.scalar-app .-mb-1{margin-bottom:-4px}.scalar-app .-mb-\\[var\\(--scalar-border-width\\)\\]{margin-bottom:calc(var(--scalar-border-width) * -1)}.scalar-app .mb-0{margin-bottom:0}.scalar-app .mb-1{margin-bottom:4px}.scalar-app .mb-2{margin-bottom:8px}.scalar-app .mb-3{margin-bottom:12px}.scalar-app .mb-4{margin-bottom:16px}.scalar-app .-ml-0\\.5{margin-left:-2px}.scalar-app .-ml-0\\.25{margin-left:-1px}.scalar-app .-ml-0\\.75{margin-left:-3px}.scalar-app .ml-1{margin-left:4px}.scalar-app .ml-2{margin-left:8px}.scalar-app .ml-auto{margin-left:auto}.scalar-app .box-border{box-sizing:border-box}.scalar-app .box-content{box-sizing:content-box}.scalar-app .flex-center{justify-content:center;align-items:center;display:flex}.scalar-app .line-clamp-\\(--markdown-clamp\\){-webkit-line-clamp:var(--markdown-clamp);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.scalar-app .line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.scalar-app .\\!block{display:block!important}.scalar-app .\\!hidden{display:none!important}.scalar-app .block{display:block}.scalar-app .contents{display:contents}.scalar-app .flex{display:flex}.scalar-app .grid{display:grid}.scalar-app .hidden{display:none}.scalar-app .inline{display:inline}.scalar-app .inline-block{display:inline-block}.scalar-app .inline-flex{display:inline-flex}.scalar-app .table{display:table}.scalar-app .field-sizing-content{field-sizing:content}.scalar-app .aspect-\\[4\\/3\\]{aspect-ratio:4/3}.scalar-app .aspect-square{aspect-ratio:1}.scalar-app .size-1\\.5{width:6px;height:6px}.scalar-app .size-2\\.5{width:10px;height:10px}.scalar-app .size-2\\.75{width:11px;height:11px}.scalar-app .size-3{width:12px;height:12px}.scalar-app .size-3\\.5{width:14px;height:14px}.scalar-app .size-3\\/4{width:75%;height:75%}.scalar-app .size-4{width:16px;height:16px}.scalar-app .size-4\\.5{width:18px;height:18px}.scalar-app .size-5{width:20px;height:20px}.scalar-app .size-6{width:24px;height:24px}.scalar-app .size-8{width:32px;height:32px}.scalar-app .size-10{width:40px;height:40px}.scalar-app .size-\\[23px\\]{width:23px;height:23px}.scalar-app .size-full{width:100%;height:100%}.scalar-app .h-\\(--refs-sidebar-height\\){height:var(--refs-sidebar-height)}.scalar-app .h-\\(--scalar-address-bar-height\\){height:var(--scalar-address-bar-height)}.scalar-app .h-\\(--scalar-header-height\\){height:var(--scalar-header-height)}.scalar-app .h-0{height:0}.scalar-app .h-1{height:4px}.scalar-app .h-1\\.5{height:6px}.scalar-app .h-2{height:8px}.scalar-app .h-2\\.5{height:10px}.scalar-app .h-2\\.25{height:9px}.scalar-app .h-3{height:12px}.scalar-app .h-3\\.5{height:14px}.scalar-app .h-4{height:16px}.scalar-app .h-4\\.5{height:18px}.scalar-app .h-5{height:20px}.scalar-app .h-6{height:24px}.scalar-app .h-7{height:28px}.scalar-app .h-8{height:32px}.scalar-app .h-10{height:40px}.scalar-app .h-\\[calc\\(100\\%\\+16px\\)\\]{height:calc(100% + 16px)}.scalar-app .h-\\[calc\\(100\\%_-_50px\\)\\]{height:calc(100% - 50px)}.scalar-app .h-auto{height:auto}.scalar-app .h-border{height:var(--scalar-border-width)}.scalar-app .h-dvh{height:100dvh}.scalar-app .h-fit{height:fit-content}.scalar-app .h-full{height:100%}.scalar-app .h-lh{height:1lh}.scalar-app .h-min{height:min-content}.scalar-app .h-px{height:1px}.scalar-app .\\!max-h-\\[initial\\]{max-height:initial!important}.scalar-app .max-h-8{max-height:32px}.scalar-app .max-h-40{max-height:160px}.scalar-app .max-h-80{max-height:320px}.scalar-app .max-h-\\[60svh\\]{max-height:60svh}.scalar-app .max-h-\\[60vh\\]{max-height:60vh}.scalar-app .max-h-\\[80svh\\]{max-height:80svh}.scalar-app .max-h-\\[90svh\\]{max-height:90svh}.scalar-app .max-h-\\[auto\\]{max-height:auto}.scalar-app .max-h-\\[calc\\(100\\%-32px\\)\\]{max-height:calc(100% - 32px)}.scalar-app .max-h-\\[inherit\\]{max-height:inherit}.scalar-app .max-h-dvh{max-height:100dvh}.scalar-app .max-h-fit{max-height:fit-content}.scalar-app .max-h-radix-popper{max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px);max-height:calc(var(--radix-popper-available-height) - 8px)}.scalar-app .max-h-screen{max-height:100vh}.scalar-app .min-h-0{min-height:0}.scalar-app .min-h-3{min-height:12px}.scalar-app .min-h-7{min-height:28px}.scalar-app .min-h-8{min-height:32px}.scalar-app .min-h-10{min-height:40px}.scalar-app .min-h-11{min-height:44px}.scalar-app .min-h-16{min-height:64px}.scalar-app .min-h-20{min-height:80px}.scalar-app .min-h-\\[64px\\]{min-height:64px}.scalar-app .min-h-\\[calc\\(4rem\\+0\\.5px\\)\\]{min-height:calc(4rem + .5px)}.scalar-app .min-h-fit{min-height:fit-content}.scalar-app .min-h-header{min-height:48px}.scalar-app .\\!w-fit{width:fit-content!important}.scalar-app .w-\\(--refs-sidebar-width\\){width:var(--refs-sidebar-width)}.scalar-app .w-0\\.5{width:2px}.scalar-app .w-1\\.5{width:6px}.scalar-app .w-2\\.5{width:10px}.scalar-app .w-2\\.25{width:9px}.scalar-app .w-3{width:12px}.scalar-app .w-4{width:16px}.scalar-app .w-4\\.5{width:18px}.scalar-app .w-5{width:20px}.scalar-app .w-6{width:24px}.scalar-app .w-7{width:28px}.scalar-app .w-8{width:32px}.scalar-app .w-36{width:144px}.scalar-app .w-40{width:160px}.scalar-app .w-56{width:224px}.scalar-app .w-64{width:256px}.scalar-app .w-72{width:288px}.scalar-app .w-120{width:480px}.scalar-app .w-\\[38px\\]{width:38px}.scalar-app .w-\\[calc\\(100vw-12px\\)\\]{width:calc(100vw - 12px)}.scalar-app .w-\\[var\\(--scalar-sidebar-indent\\)\\]{width:var(--scalar-sidebar-indent)}.scalar-app .w-auto{width:auto}.scalar-app .w-border{width:var(--scalar-border-width)}.scalar-app .w-content{width:720px}.scalar-app .w-dvw{width:100dvw}.scalar-app .w-fit{width:fit-content}.scalar-app .w-full{width:100%}.scalar-app .w-max{width:max-content}.scalar-app .w-px{width:1px}.scalar-app .max-w-\\(--refs-content-max-width\\){max-width:var(--refs-content-max-width)}.scalar-app .max-w-3xl{max-width:768px}.scalar-app .max-w-64{max-width:256px}.scalar-app .max-w-\\[9rem\\]{max-width:9rem}.scalar-app .max-w-\\[100\\%\\]{max-width:100%}.scalar-app .max-w-\\[160px\\]{max-width:160px}.scalar-app .max-w-\\[220px\\]{max-width:220px}.scalar-app .max-w-\\[360px\\]{max-width:360px}.scalar-app .max-w-\\[480px\\]{max-width:480px}.scalar-app .max-w-\\[540px\\]{max-width:540px}.scalar-app .max-w-\\[640px\\]{max-width:640px}.scalar-app .max-w-\\[800px\\]{max-width:800px}.scalar-app .max-w-\\[1000px\\]{max-width:1000px}.scalar-app .max-w-\\[inherit\\]{max-width:inherit}.scalar-app .max-w-full{max-width:100%}.scalar-app .max-w-screen-padded-4{max-width:calc(100vw - 32px)}.scalar-app .max-w-xs{max-width:320px}.scalar-app .min-w-0{min-width:0}.scalar-app .min-w-2\\.25{min-width:9px}.scalar-app .min-w-3{min-width:12px}.scalar-app .min-w-4{min-width:16px}.scalar-app .min-w-4\\.5{min-width:18px}.scalar-app .min-w-6{min-width:24px}.scalar-app .min-w-7{min-width:28px}.scalar-app .min-w-8{min-width:32px}.scalar-app .min-w-32{min-width:128px}.scalar-app .min-w-48{min-width:192px}.scalar-app .min-w-\\[4\\.5rem\\]{min-width:4.5rem}.scalar-app .min-w-fit{min-width:fit-content}.scalar-app .min-w-full{min-width:100%}.scalar-app .min-w-min{min-width:min-content}.scalar-app .flex-1{flex:1}.scalar-app .flex-shrink,.scalar-app .shrink{flex-shrink:1}.scalar-app .shrink-0{flex-shrink:0}.scalar-app .flex-grow,.scalar-app .grow{flex-grow:1}.scalar-app .grow-3{flex-grow:3}.scalar-app .-translate-x-1\\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-0{--tw-translate-x:calc(4px * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-2\\.5{--tw-translate-x:calc(4px * 2.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-\\[14px\\]{--tw-translate-x:14px;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-y-1\\.5{--tw-translate-y:calc(4px * -1.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-0{--tw-translate-y:calc(4px * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-1\\.5{--tw-translate-y:calc(4px * 1.5);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .translate-y-\\[200\\%\\]{--tw-translate-y:200%;translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scalar-app .scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scalar-app .rotate-45{rotate:45deg}.scalar-app .rotate-90{rotate:90deg}.scalar-app .rotate-180{rotate:180deg}.scalar-app .transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.scalar-app .animate-pulse{animation:var(--animate-pulse)}.scalar-app .cursor-auto{cursor:auto}.scalar-app .cursor-default{cursor:default}.scalar-app .cursor-help{cursor:help}.scalar-app .cursor-not-allowed{cursor:not-allowed}.scalar-app .cursor-pointer{cursor:pointer}.scalar-app .cursor-text{cursor:text}.scalar-app .resize{resize:both}.scalar-app .resize-none{resize:none}.scalar-app .scroll-mt-16{scroll-margin-top:64px}.scalar-app .scroll-mt-24{scroll-margin-top:96px}.scalar-app .list-none{list-style-type:none}.scalar-app .appearance-none{appearance:none}.scalar-app .grid-flow-col{grid-auto-flow:column}.scalar-app .auto-rows-auto{grid-auto-rows:auto}.scalar-app .grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.scalar-app .grid-cols-\\[44px_1fr_repeat\\(3\\,auto\\)\\]{grid-template-columns:44px 1fr repeat(3,auto)}.scalar-app .flex-col{flex-direction:column}.scalar-app .flex-row{flex-direction:row}.scalar-app .flex-wrap{flex-wrap:wrap}.scalar-app .content-end{align-content:flex-end}.scalar-app .content-start{align-content:flex-start}.scalar-app .items-baseline{align-items:baseline}.scalar-app .items-center{align-items:center}.scalar-app .items-end{align-items:flex-end}.scalar-app .items-start{align-items:flex-start}.scalar-app .items-stretch{align-items:stretch}.scalar-app .justify-between{justify-content:space-between}.scalar-app .justify-center{justify-content:center}.scalar-app .justify-end{justify-content:flex-end}.scalar-app .justify-start{justify-content:flex-start}.scalar-app .justify-stretch{justify-content:stretch}.scalar-app .gap-0\\.5{gap:2px}.scalar-app .gap-0\\.75{gap:3px}.scalar-app .gap-1{gap:4px}.scalar-app .gap-1\\.5{gap:6px}.scalar-app .gap-1\\.75{gap:7px}.scalar-app .gap-2{gap:8px}.scalar-app .gap-2\\.5{gap:10px}.scalar-app .gap-2\\.25{gap:9px}.scalar-app .gap-3{gap:12px}.scalar-app .gap-4{gap:16px}.scalar-app .gap-6{gap:24px}.scalar-app .gap-7{gap:28px}.scalar-app .gap-10{gap:40px}.scalar-app .gap-12{gap:48px}.scalar-app .gap-\\[1\\.5px\\]{gap:1.5px}.scalar-app .gap-px{gap:1px}.scalar-app .gap-x-2\\.5{column-gap:10px}:where(.scalar-app .space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(4px * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(4px * 1) * calc(1 - var(--tw-space-x-reverse)))}:where(.scalar-app .divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(var(--scalar-border-width) * var(--tw-divide-y-reverse));border-bottom-width:calc(var(--scalar-border-width) * calc(1 - var(--tw-divide-y-reverse)))}.scalar-app .self-center{align-self:center}.scalar-app .self-end{align-self:flex-end}.scalar-app .self-start{align-self:flex-start}.scalar-app .truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.scalar-app .overflow-auto{overflow:auto}.scalar-app .overflow-hidden{overflow:hidden}.scalar-app .overflow-visible{overflow:visible}.scalar-app .overflow-x-auto{overflow-x:auto}.scalar-app .overflow-x-hidden{overflow-x:hidden}.scalar-app .overflow-y-auto{overflow-y:auto}.scalar-app .overflow-y-hidden{overflow-y:hidden}.scalar-app .overflow-y-scroll{overflow-y:scroll}.scalar-app .overscroll-contain{overscroll-behavior:contain}.scalar-app .\\!rounded-none{border-radius:0!important}.scalar-app .rounded{border-radius:var(--scalar-radius)}.scalar-app .rounded-\\[inherit\\]{border-radius:inherit}.scalar-app .rounded-full{border-radius:9999px}.scalar-app .rounded-lg{border-radius:var(--scalar-radius-lg)}.scalar-app .rounded-md{border-radius:var(--scalar-radius)}.scalar-app .rounded-none{border-radius:0}.scalar-app .rounded-px{border-radius:1px}.scalar-app .rounded-xl{border-radius:var(--scalar-radius-xl)}.scalar-app .rounded-t{border-top-left-radius:var(--scalar-radius);border-top-right-radius:var(--scalar-radius)}.scalar-app .rounded-t-lg{border-top-left-radius:var(--scalar-radius-lg);border-top-right-radius:var(--scalar-radius-lg)}.scalar-app .rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.scalar-app .rounded-t-xl{border-top-left-radius:var(--scalar-radius-xl);border-top-right-radius:var(--scalar-radius-xl)}.scalar-app .\\!rounded-b-xl{border-bottom-right-radius:var(--scalar-radius-xl)!important;border-bottom-left-radius:var(--scalar-radius-xl)!important}.scalar-app .rounded-b{border-bottom-right-radius:var(--scalar-radius);border-bottom-left-radius:var(--scalar-radius)}.scalar-app .rounded-b-lg{border-bottom-right-radius:var(--scalar-radius-lg);border-bottom-left-radius:var(--scalar-radius-lg)}.scalar-app .rounded-b-none{border-bottom-right-radius:0;border-bottom-left-radius:0}.scalar-app .rounded-b-xl{border-bottom-right-radius:var(--scalar-radius-xl);border-bottom-left-radius:var(--scalar-radius-xl)}.scalar-app .border{border-style:var(--tw-border-style);border-width:var(--scalar-border-width)}.scalar-app .border-0{border-style:var(--tw-border-style);border-width:0}.scalar-app .border-1,.scalar-app .border-\\[1px\\]{border-style:var(--tw-border-style);border-width:1px}.scalar-app .border-x{border-inline-style:var(--tw-border-style);border-inline-width:var(--scalar-border-width)}.scalar-app .border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}.scalar-app .border-y{border-block-style:var(--tw-border-style);border-block-width:var(--scalar-border-width)}.scalar-app .border-t{border-top-style:var(--tw-border-style);border-top-width:var(--scalar-border-width)}.scalar-app .border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.scalar-app .\\!border-r{border-right-style:var(--tw-border-style)!important;border-right-width:var(--scalar-border-width)!important}.scalar-app .border-r{border-right-style:var(--tw-border-style);border-right-width:var(--scalar-border-width)}.scalar-app .border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.scalar-app .border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:var(--scalar-border-width)}.scalar-app .border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .border-l{border-left-style:var(--tw-border-style);border-left-width:var(--scalar-border-width)}.scalar-app .border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.scalar-app .border-dashed{--tw-border-style:dashed;border-style:dashed}.scalar-app .border-none{--tw-border-style:none;border-style:none}.scalar-app .\\!border-current{border-color:currentColor!important}.scalar-app .border-\\(--scalar-background-3\\){border-color:var(--scalar-background-3)}.scalar-app .border-border-header{border-color:var(--scalar-header-border-color,var(--scalar-border-color))}.scalar-app .border-c-accent,.scalar-app .border-c-accent\\/30{border-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .border-c-accent\\/30{border-color:color-mix(in oklab, var(--scalar-color-accent) 30%, transparent)}}.scalar-app .border-c-alert{border-color:var(--scalar-color-alert)}.scalar-app .border-c-danger{border-color:var(--scalar-color-danger)}.scalar-app .border-sidebar-border{border-color:var(--scalar-sidebar-border-color,var(--scalar-border-color))}.scalar-app .border-sidebar-border-search{border-color:var(--scalar-sidebar-search-border-color,var(--scalar-border-color))}.scalar-app .border-transparent{border-color:#0000}.scalar-app .border-r-transparent{border-right-color:#0000}.scalar-app .border-b-sidebar-border{border-bottom-color:var(--scalar-sidebar-border-color,var(--scalar-border-color))}.scalar-app .bg-\\(--bg-light\\){background-color:var(--bg-light)}.scalar-app .bg-b-1,.scalar-app .bg-b-1\\.5{background-color:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-b-1\\.5{background-color:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.scalar-app .bg-b-2{background-color:var(--scalar-background-2)}.scalar-app .bg-b-3{background-color:var(--scalar-background-3)}.scalar-app .bg-b-alert{background-color:var(--scalar-background-alert)}.scalar-app .bg-b-btn{background-color:var(--scalar-button-1)}.scalar-app .bg-b-danger{background-color:var(--scalar-background-danger)}.scalar-app .bg-b-header-1{background-color:var(--scalar-header-background-1,var(--scalar-background-1))}.scalar-app .bg-b-header-cta{background-color:var(--scalar-header-call-to-action-color,var(--scalar-button-1))}.scalar-app .bg-b-tooltip{background-color:var(--scalar-tooltip-background)}.scalar-app .bg-backdrop{background-color:#00000038}.scalar-app .bg-border{background-color:var(--scalar-border-color)}.scalar-app .bg-c-accent,.scalar-app .bg-c-accent\\/5{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-c-accent\\/5{background-color:color-mix(in oklab, var(--scalar-color-accent) 5%, transparent)}}.scalar-app .bg-c-accent\\/10{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-c-accent\\/10{background-color:color-mix(in oklab, var(--scalar-color-accent) 10%, transparent)}}.scalar-app .bg-c-danger{background-color:var(--scalar-color-danger)}.scalar-app .bg-current{background-color:currentColor}.scalar-app .bg-inherit{background-color:inherit}.scalar-app .bg-sidebar-b-1{background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .bg-sidebar-b-active{background-color:var(--scalar-sidebar-item-active-background,var(--scalar-background-2))}.scalar-app .bg-sidebar-b-search{background-color:var(--scalar-sidebar-search-background,var(--scalar-background-2))}.scalar-app .bg-sidebar-c-2\\/15{background-color:var(--scalar-sidebar-color-2,var(--scalar-color-2))}@supports (color:color-mix(in lab, red, red)){.scalar-app .bg-sidebar-c-2\\/15{background-color:color-mix(in oklab, var(--scalar-sidebar-color-2,var(--scalar-color-2)) 15%, transparent)}}.scalar-app .bg-sidebar-indent-border{background-color:var(--scalar-sidebar-indent-border,var(--scalar-border-color))}.scalar-app .bg-sidebar-indent-border-active{background-color:var(--scalar-sidebar-indent-border-active,var(--scalar-color-accent))}.scalar-app .bg-transparent{background-color:#0000}.scalar-app .bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.scalar-app .bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .bg-linear-to-l{--tw-gradient-position:to left}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .bg-linear-to-l{--tw-gradient-position:to left in oklab}}.scalar-app .bg-linear-to-l{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .from-b-1{--tw-gradient-from:var(--scalar-background-1);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .from-40\\%{--tw-gradient-from-position:40%}.scalar-app .to-b-1\\.5{--tw-gradient-to:var(--scalar-background-1)}@supports (color:color-mix(in lab, red, red)){.scalar-app .to-b-1\\.5{--tw-gradient-to:color-mix(in srgb, var(--scalar-background-1), var(--scalar-background-2))}}.scalar-app .to-b-1\\.5{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .to-b-2{--tw-gradient-to:var(--scalar-background-2);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.scalar-app .mask-y-from-\\[calc\\(100\\%-8px\\)\\]{-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top);--tw-mask-top:linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position));--tw-mask-top-from-position:calc(100% - 8px);--tw-mask-bottom:linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position));--tw-mask-bottom-from-position:calc(100% - 8px);-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.scalar-app .mask-y-to-100\\%{-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);-webkit-mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);mask-image:var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic);--tw-mask-linear:var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top);--tw-mask-top:linear-gradient(to top, var(--tw-mask-top-from-color) var(--tw-mask-top-from-position), var(--tw-mask-top-to-color) var(--tw-mask-top-to-position));--tw-mask-top-to-position:100%;--tw-mask-bottom:linear-gradient(to bottom, var(--tw-mask-bottom-from-color) var(--tw-mask-bottom-from-position), var(--tw-mask-bottom-to-color) var(--tw-mask-bottom-to-position));--tw-mask-bottom-to-position:100%;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;-webkit-mask-composite:source-in;mask-composite:intersect}.scalar-app .mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.scalar-app .fill-current{fill:currentColor}.scalar-app .\\!p-0{padding:0!important}.scalar-app .p-\\(--scalar-sidebar-padding\\){padding:var(--scalar-sidebar-padding)}.scalar-app .p-0{padding:0}.scalar-app .p-0\\.5{padding:2px}.scalar-app .p-0\\.75{padding:3px}.scalar-app .p-1{padding:4px}.scalar-app .p-1\\.5{padding:6px}.scalar-app .p-1\\.25{padding:5px}.scalar-app .p-1\\.75{padding:7px}.scalar-app .p-2{padding:8px}.scalar-app .p-2\\.5{padding:10px}.scalar-app .p-3{padding:12px}.scalar-app .p-4{padding:16px}.scalar-app .p-7{padding:28px}.scalar-app .p-\\[3px\\]{padding:3px}.scalar-app .p-px{padding:1px}.scalar-app .px-\\(--scalar-sidebar-padding\\){padding-inline:var(--scalar-sidebar-padding)}.scalar-app .px-0{padding-inline:0}.scalar-app .px-0\\.5{padding-inline:2px}.scalar-app .px-0\\.75{padding-inline:3px}.scalar-app .px-1{padding-inline:4px}.scalar-app .px-1\\.5{padding-inline:6px}.scalar-app .px-1\\.25{padding-inline:5px}.scalar-app .px-2{padding-inline:8px}.scalar-app .px-2\\.5{padding-inline:10px}.scalar-app .px-3{padding-inline:12px}.scalar-app .px-3\\.5{padding-inline:14px}.scalar-app .px-4{padding-inline:16px}.scalar-app .px-5{padding-inline:20px}.scalar-app .px-6{padding-inline:24px}.scalar-app .px-15{padding-inline:60px}.scalar-app .py-0{padding-block:0}.scalar-app .py-0\\.5{padding-block:2px}.scalar-app .py-0\\.25{padding-block:1px}.scalar-app .py-0\\.75{padding-block:3px}.scalar-app .py-1{padding-block:4px}.scalar-app .py-1\\.5{padding-block:6px}.scalar-app .py-1\\.25{padding-block:5px}.scalar-app .py-1\\.75{padding-block:7px}.scalar-app .py-2{padding-block:8px}.scalar-app .py-2\\.5{padding-block:10px}.scalar-app .py-2\\.25{padding-block:9px}.scalar-app .py-3{padding-block:12px}.scalar-app .py-4{padding-block:16px}.scalar-app .py-\\[6\\.75px\\]{padding-block:6.75px}.scalar-app .py-px{padding-block:1px}.scalar-app .pt-\\(--scalar-sidebar-padding\\){padding-top:var(--scalar-sidebar-padding)}.scalar-app .pt-1{padding-top:4px}.scalar-app .pt-2{padding-top:8px}.scalar-app .pt-3{padding-top:12px}.scalar-app .pt-px{padding-top:1px}.scalar-app .pr-0{padding-right:0}.scalar-app .pr-0\\.75{padding-right:3px}.scalar-app .pr-1{padding-right:4px}.scalar-app .pr-1\\.5{padding-right:6px}.scalar-app .pr-2{padding-right:8px}.scalar-app .pr-2\\.5{padding-right:10px}.scalar-app .pr-2\\.25{padding-right:9px}.scalar-app .pr-3{padding-right:12px}.scalar-app .pr-6{padding-right:24px}.scalar-app .pr-8{padding-right:32px}.scalar-app .pr-10{padding-right:40px}.scalar-app .pr-12{padding-right:48px}.scalar-app .pr-20{padding-right:80px}.scalar-app .pr-\\[100\\%\\]{padding-right:100%}.scalar-app .pb-1{padding-bottom:4px}.scalar-app .pb-1\\.5{padding-bottom:6px}.scalar-app .pb-3{padding-bottom:12px}.scalar-app .pb-6{padding-bottom:24px}.scalar-app .pb-12{padding-bottom:48px}.scalar-app .\\!pl-3{padding-left:12px!important}.scalar-app .pl-1{padding-left:4px}.scalar-app .pl-1\\.25{padding-left:5px}.scalar-app .pl-2{padding-left:8px}.scalar-app .pl-3{padding-left:12px}.scalar-app .pl-4{padding-left:16px}.scalar-app .pl-8{padding-left:32px}.scalar-app .pl-8\\.5{padding-left:34px}.scalar-app .pl-\\[100\\%\\]{padding-left:100%}.scalar-app .pl-px{padding-left:1px}.scalar-app .text-center{text-align:center}.scalar-app .text-left{text-align:left}.scalar-app .text-right{text-align:right}.scalar-app .font-code{font-family:var(--scalar-font-code)}.scalar-app .font-sans{font-family:var(--scalar-font)}.scalar-app .text-base{font-size:var(--scalar-font-size-3);line-height:var(--tw-leading,calc(1 / .875))}.scalar-app .text-base\\/4{font-size:var(--scalar-font-size-3);line-height:16px}.scalar-app .text-base\\/5{font-size:var(--scalar-font-size-3);line-height:20px}.scalar-app .text-base\\/5\\.25{font-size:var(--scalar-font-size-3);line-height:21px}.scalar-app .text-lg{font-size:var(--scalar-font-size-2);line-height:var(--tw-leading,calc(1.25 / 1))}.scalar-app .text-sm\\/4{font-size:var(--scalar-font-size-4);line-height:16px}.scalar-app .text-sm\\/none{font-size:var(--scalar-font-size-4);line-height:1}.scalar-app .text-xs\\/4{font-size:var(--scalar-font-size-5);line-height:16px}.scalar-app .text-3xs{font-size:var(--scalar-font-size-7)}.scalar-app .text-\\[6px\\]{font-size:6px}.scalar-app .text-\\[9px\\]{font-size:9px}.scalar-app .text-\\[10px\\]{font-size:10px}.scalar-app .text-\\[11px\\]{font-size:11px}.scalar-app .text-sm{font-size:var(--scalar-font-size-4)}.scalar-app .text-xs{font-size:var(--scalar-font-size-5)}.scalar-app .text-xxs{font-size:var(--scalar-font-size-6)}.scalar-app .leading-5{--tw-leading:calc(4px * 5);line-height:20px}.scalar-app .leading-5\\.5{--tw-leading:calc(4px * 5.5);line-height:22px}.scalar-app .leading-6{--tw-leading:calc(4px * 6);line-height:24px}.scalar-app .leading-\\[1\\.44\\]{--tw-leading:1.44;line-height:1.44}.scalar-app .leading-\\[1\\.45\\]{--tw-leading:1.45;line-height:1.45}.scalar-app .leading-\\[7px\\]{--tw-leading:7px;line-height:7px}.scalar-app .leading-\\[20px\\]{--tw-leading:20px;line-height:20px}.scalar-app .leading-\\[22px\\]{--tw-leading:22px;line-height:22px}.scalar-app .leading-\\[normal\\]{--tw-leading:normal;line-height:normal}.scalar-app .leading-none{--tw-leading:1;line-height:1}.scalar-app .leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.scalar-app .leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.scalar-app .leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.scalar-app .font-bold{--tw-font-weight:var(--scalar-bold);font-weight:var(--scalar-bold)}.scalar-app .font-medium{--tw-font-weight:var(--scalar-semibold);font-weight:var(--scalar-semibold)}.scalar-app .font-normal{--tw-font-weight:var(--scalar-regular);font-weight:var(--scalar-regular)}.scalar-app .font-sidebar{--tw-font-weight:var(--scalar-sidebar-font-weight,var(--scalar-regular));font-weight:var(--scalar-sidebar-font-weight,var(--scalar-regular))}.scalar-app .font-sidebar-active{--tw-font-weight:var(--scalar-sidebar-font-weight-active,var(--scalar-semibold));font-weight:var(--scalar-sidebar-font-weight-active,var(--scalar-semibold))}.scalar-app .text-balance{text-wrap:balance}.scalar-app .text-nowrap{text-wrap:nowrap}.scalar-app .text-pretty{text-wrap:pretty}.scalar-app .text-wrap{text-wrap:wrap}.scalar-app .break-words,.scalar-app .wrap-break-word{overflow-wrap:break-word}.scalar-app .text-ellipsis{text-overflow:ellipsis}.scalar-app .whitespace-nowrap{white-space:nowrap}.scalar-app .whitespace-pre{white-space:pre}.scalar-app .whitespace-pre-wrap{white-space:pre-wrap}.scalar-app .\\!text-c-1{color:var(--scalar-color-1)!important}.scalar-app .text-\\[color\\:var\\(--scalar-color-3\\)\\]{color:var(--scalar-color-3)}.scalar-app .text-b-1{color:var(--scalar-background-1)}.scalar-app .text-b-2{color:var(--scalar-background-2)}.scalar-app .text-blue{color:var(--scalar-color-blue)}.scalar-app .text-c-1{color:var(--scalar-color-1)}.scalar-app .text-c-2{color:var(--scalar-color-2)}.scalar-app .text-c-3{color:var(--scalar-color-3)}.scalar-app .text-c-accent{color:var(--scalar-color-accent)}.scalar-app .text-c-alert{color:var(--scalar-color-alert)}.scalar-app .text-c-btn{color:var(--scalar-button-1-color)}.scalar-app .text-c-danger{color:var(--scalar-color-danger)}.scalar-app .text-c-header-1{color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .text-c-header-2{color:var(--scalar-header-color-2,var(--scalar-color-2))}.scalar-app .text-c-header-cta{color:var(--scalar-button-1-color)}.scalar-app .text-c-tooltip{color:var(--scalar-tooltip-color)}.scalar-app .text-current{color:currentColor}.scalar-app .text-green{color:var(--scalar-color-green)}.scalar-app .text-grey{color:var(--scalar-color-3)}.scalar-app .text-orange{color:var(--scalar-color-orange)}.scalar-app .text-purple{color:var(--scalar-color-purple)}.scalar-app .text-red{color:var(--scalar-color-red)}.scalar-app .text-sidebar-c-1{color:var(--scalar-sidebar-color-1,var(--scalar-color-1))}.scalar-app .text-sidebar-c-2{color:var(--scalar-sidebar-color-2,var(--scalar-color-2))}.scalar-app .text-sidebar-c-active{color:var(--scalar-sidebar-color-active,var(--scalar-sidebar-color-1))}.scalar-app .text-sidebar-c-search{color:var(--scalar-sidebar-search-color,var(--scalar-color-3))}.scalar-app .text-transparent{color:#0000}.scalar-app .text-white{color:#fff}.scalar-app .text-yellow{color:var(--scalar-color-yellow)}.scalar-app .capitalize{text-transform:capitalize}.scalar-app .uppercase{text-transform:uppercase}.scalar-app .italic{font-style:italic}.scalar-app .tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.scalar-app .line-through{text-decoration-line:line-through}.scalar-app .no-underline{text-decoration-line:none}.scalar-app .underline{text-decoration-line:underline}.scalar-app .decoration-c-3{-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);-webkit-text-decoration-color:var(--scalar-color-3);text-decoration-color:var(--scalar-color-3)}.scalar-app .decoration-dotted{text-decoration-style:dotted}.scalar-app .underline-offset-2{text-underline-offset:2px}.scalar-app .opacity-0{opacity:0}.scalar-app .opacity-40{opacity:.4}.scalar-app .opacity-50{opacity:.5}.scalar-app .opacity-100{opacity:1}.scalar-app .bg-blend-normal{background-blend-mode:normal}.scalar-app .shadow{--tw-shadow:var(--scalar-shadow-1);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-\\[-8px_0_4px_var\\(--scalar-background-1\\)\\]{--tw-shadow:-8px 0 4px var(--tw-shadow-color,var(--scalar-background-1));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-border{--tw-shadow:inset 0 0 0 var(--tw-shadow-color,var(--scalar-border-width)) var(--scalar-border-color);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-lg{--tw-shadow:var(--scalar-shadow-2);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-md{--tw-shadow:var(--scalar-shadow-1);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .outline{outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .-outline-offset-1{outline-offset:calc(1px * -1)}.scalar-app .-outline-offset-2{outline-offset:calc(2px * -1)}.scalar-app .outline-offset-1{outline-offset:1px}.scalar-app .outline-offset-2{outline-offset:2px}.scalar-app .outline-offset-\\[-1px\\]{outline-offset:-1px}.scalar-app .outline-c-danger{outline-color:var(--scalar-color-danger)}.scalar-app .blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .brightness-90{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .brightness-lifted{--tw-brightness:brightness(var(--scalar-lifted-brightness));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.scalar-app .backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.scalar-app .transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.scalar-app .transition-none{transition-property:none}.scalar-app .duration-100{--tw-duration:.1s;transition-duration:.1s}.scalar-app .duration-150{--tw-duration:.15s;transition-duration:.15s}.scalar-app .duration-200{--tw-duration:.2s;transition-duration:.2s}.scalar-app .duration-300{--tw-duration:.3s;transition-duration:.3s}.scalar-app .duration-400{--tw-duration:.4s;transition-duration:.4s}.scalar-app .duration-500{--tw-duration:.5s;transition-duration:.5s}.scalar-app .ease-\\[cubic-bezier\\(0\\.77\\,0\\,0\\.175\\,1\\)\\]{--tw-ease:cubic-bezier(.77,0,.175,1);transition-timing-function:cubic-bezier(.77,0,.175,1)}.scalar-app .ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.scalar-app .ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.scalar-app .ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.scalar-app .ease-spring{--tw-ease:linear(0, .008 1.1%, .034 2.3%, .134 4.9%, .264 7.3%, .683 14.3%, .797 16.5%, .89 18.6%, .967 20.7%, 1.027 22.8%, 1.073 25%, 1.104 27.3%, 1.123 30.6%, 1.119 34.3%, 1.018 49.5%, .988 58.6%, .985 65.2%, 1 84.5%, 1);transition-timing-function:linear(0, .008 1.1%, .034 2.3%, .134 4.9%, .264 7.3%, .683 14.3%, .797 16.5%, .89 18.6%, .967 20.7%, 1.027 22.8%, 1.073 25%, 1.104 27.3%, 1.123 30.6%, 1.119 34.3%, 1.018 49.5%, .988 58.6%, .985 65.2%, 1 84.5%, 1)}.scalar-app .outline-none{--tw-outline-style:none;outline-style:none}.scalar-app .select-none{-webkit-user-select:none;user-select:none}.scalar-app .\\[--scalar-address-bar-height\\:0px\\]{--scalar-address-bar-height:0px}.scalar-app .\\[--scalar-address-bar-height\\:32px\\]{--scalar-address-bar-height:32px}.scalar-app .\\[grid-area\\:header\\]{grid-area:header}.scalar-app .\\[grid-area\\:navigation\\]{grid-area:navigation}:is(.scalar-app .\\*\\:flex>*){display:flex}:is(.scalar-app .\\*\\:size-3>*){width:12px;height:12px}:is(.scalar-app .\\*\\:size-4>*){width:16px;height:16px}:is(.scalar-app .\\*\\:h-5>*){height:20px}:is(.scalar-app .\\*\\:h-8>*){height:32px}:is(.scalar-app .\\*\\:min-w-5>*){min-width:20px}:is(.scalar-app .\\*\\:flex-1>*){flex:1}:is(.scalar-app .\\*\\:cursor-pointer>*){cursor:pointer}:is(.scalar-app .\\*\\:items-center>*){align-items:center}:is(.scalar-app .\\*\\:justify-center>*){justify-content:center}:is(.scalar-app .\\*\\:gap-1>*){gap:4px}:is(.scalar-app .\\*\\:rounded>*){border-radius:var(--scalar-radius)}:is(.scalar-app .\\*\\:rounded-none>*){border-radius:0}:is(.scalar-app .\\*\\:border>*){border-style:var(--tw-border-style);border-width:var(--scalar-border-width)}:is(.scalar-app .\\*\\:border-t>*){border-top-style:var(--tw-border-style);border-top-width:var(--scalar-border-width)}:is(.scalar-app .\\*\\:border-border-tooltip>*){border-color:var(--scalar-tooltip-color)}@supports (color:color-mix(in lab, red, red)){:is(.scalar-app .\\*\\:border-border-tooltip>*){border-color:color-mix(in srgb, var(--scalar-tooltip-color), var(--scalar-tooltip-background))}}:is(.scalar-app .\\*\\:px-1>*){padding-inline:4px}:is(.scalar-app .\\*\\:px-1\\.5>*){padding-inline:6px}:is(.scalar-app .\\*\\:pl-4>*){padding-left:16px}.scalar-app .group-first\\/row\\:border-t-0:is(:where(.group\\/row):first-child *){border-top-style:var(--tw-border-style);border-top-width:0}.scalar-app .group-last\\:mr-0:is(:where(.group):last-child *){margin-right:0}.scalar-app .group-last\\:border-b-transparent:is(:where(.group):last-child *){border-bottom-color:#0000}.scalar-app .group-last\\/label\\:rounded-br-lg:is(:where(.group\\/label):last-child *){border-bottom-right-radius:var(--scalar-radius-lg)}.scalar-app .group-open\\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.scalar-app .group-open\\:flex-wrap:is(:where(.group):is([open],:popover-open,:open) *){flex-wrap:wrap}.scalar-app .group-open\\:whitespace-normal:is(:where(.group):is([open],:popover-open,:open) *){white-space:normal}.scalar-app .group-focus-within\\:flex:is(:where(.group):focus-within *){display:flex}.scalar-app .group-focus-within\\/parameter-item\\:opacity-100:is(:where(.group\\/parameter-item):focus-within *),.scalar-app .group-focus-within\\/scope-row\\:opacity-100:is(:where(.group\\/scope-row):focus-within *){opacity:1}@media (hover:hover){.scalar-app .group-hover\\:flex:is(:where(.group):hover *){display:flex}.scalar-app .group-hover\\:pr-10:is(:where(.group):hover *){padding-right:40px}.scalar-app .group-hover\\:text-c-1:is(:where(.group):hover *){color:var(--scalar-color-1)}.scalar-app .group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.scalar-app .group-hover\\/button\\:bg-sidebar-indent-border-hover:is(:where(.group\\/button):hover *){background-color:var(--scalar-sidebar-indent-border-hover,var(--scalar-border-color))}.scalar-app .group-hover\\/button\\:text-c-header-1:is(:where(.group\\/button):hover *){color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .group-hover\\/button\\:opacity-0:is(:where(.group\\/button):hover *){opacity:0}.scalar-app .group-hover\\/heading\\:opacity-100:is(:where(.group\\/heading):hover *),.scalar-app .group-hover\\/item\\:opacity-100:is(:where(.group\\/item):hover *),.scalar-app .group-hover\\/parameter-item\\:opacity-100:is(:where(.group\\/parameter-item):hover *),.scalar-app .group-hover\\/params\\:opacity-100:is(:where(.group\\/params):hover *){opacity:1}.scalar-app .group-hover\\/row\\:flex:is(:where(.group\\/row):hover *){display:flex}.scalar-app .group-hover\\/scope-row\\:text-c-1:is(:where(.group\\/scope-row):hover *){color:var(--scalar-color-1)}.scalar-app .group-hover\\/scope-row\\:opacity-100:is(:where(.group\\/scope-row):hover *){opacity:1}.scalar-app .group-hover\\/scopes-accordion\\:text-c-2:is(:where(.group\\/scopes-accordion):hover *){color:var(--scalar-color-2)}}.scalar-app .group-focus-visible\\:outline:is(:where(.group):focus-visible *){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .group-focus-visible\\/button\\:opacity-0:is(:where(.group\\/button):focus-visible *){opacity:0}.scalar-app .group-focus-visible\\/toggle\\:outline:is(:where(.group\\/toggle):focus-visible *){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .group-has-focus-visible\\/heading\\:opacity-100:is(:where(.group\\/heading):has(:focus-visible) *){opacity:1}.scalar-app .group-has-\\[\\.cm-focused\\]\\:z-1:is(:where(.group):has(.cm-focused) *){z-index:1}.scalar-app .group-has-\\[\\.cm-focused\\]\\:flex:is(:where(.group):has(.cm-focused) *){display:flex}.scalar-app .group-has-\\[\\.cm-focused\\]\\:pr-10:is(:where(.group):has(.cm-focused) *){padding-right:40px}.scalar-app .group-has-\\[\\:focus-visible\\]\\/cell\\:border-c-accent:is(:where(.group\\/cell):has(:focus-visible) *){border-color:var(--scalar-color-accent)}.scalar-app .group-has-\\[\\:focus-visible\\]\\/cell\\:opacity-100:is(:where(.group\\/cell):has(:focus-visible) *){opacity:1}.scalar-app .group-has-\\[\\:focus-visible\\]\\/input\\:block:is(:where(.group\\/input):has(:focus-visible) *){display:block}.scalar-app .group-has-\\[input\\]\\/label\\:mr-0:is(:where(.group\\/label):has(:is(input)) *){margin-right:0}.scalar-app .group-has-\\[\\~\\*_\\[aria-expanded\\=true\\]\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~* [aria-expanded=true]) *),.scalar-app .group-has-\\[\\~\\*\\:focus-within\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~:focus-within) *),.scalar-app .group-has-\\[\\~\\*\\:hover\\]\\/button\\:opacity-0:is(:where(.group\\/button):has(~:hover) *){opacity:0}.scalar-app .group-aria-expanded\\/button\\:rotate-180:is(:where(.group\\/button)[aria-expanded=true] *),.scalar-app .group-aria-expanded\\/combobox-button\\:rotate-180:is(:where(.group\\/combobox-button)[aria-expanded=true] *){rotate:180deg}.scalar-app .group-hocus\\/copy-button\\:sr-only:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.scalar-app .group-hocus\\/copy-button\\:not-sr-only:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.scalar-app .group-hocus\\/copy-button\\:block:is(:is(:where(.group\\/copy-button):hover,:where(.group\\/copy-button):focus-visible) *){display:block}.scalar-app .group-hocus-within\\/code-block\\:-left-0\\.5:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){left:-2px}.scalar-app .group-hocus-within\\/code-block\\:inline:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){display:inline}.scalar-app .group-hocus-within\\/code-block\\:opacity-100:is(:is(:where(.group\\/code-block):hover,:where(.group\\/code-block):focus-within) *){opacity:1}.scalar-app .group-\\[\\.alert\\]\\:bg-b-alert:is(:where(.group).alert *){background-color:var(--scalar-background-alert)}.scalar-app .group-\\[\\.alert\\]\\:bg-transparent:is(:where(.group).alert *){background-color:#0000}.scalar-app .group-\\[\\.alert\\]\\:shadow-none:is(:where(.group).alert *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .group-\\[\\.alert\\]\\:outline-orange:is(:where(.group).alert *){outline-color:var(--scalar-color-orange)}.scalar-app .group-\\[\\.error\\]\\:bg-b-danger:is(:where(.group).error *){background-color:var(--scalar-background-danger)}.scalar-app .group-\\[\\.error\\]\\:bg-transparent:is(:where(.group).error *){background-color:#0000}.scalar-app .group-\\[\\.error\\]\\:text-red:is(:where(.group).error *){color:var(--scalar-color-red)}.scalar-app .group-\\[\\.error\\]\\:shadow-none:is(:where(.group).error *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.scalar-app .group-\\[\\.error\\]\\:outline-red:is(:where(.group).error *){outline-color:var(--scalar-color-red)}.scalar-app .peer-checked\\:text-c-1:is(:where(.peer):checked~*){color:var(--scalar-color-1)}@media (hover:hover){.scalar-app .peer-hover\\/button\\:opacity-100:is(:where(.peer\\/button):hover~*){opacity:1}}.scalar-app .peer-focus-visible\\/button\\:opacity-100:is(:where(.peer\\/button):focus-visible~*){opacity:1}.scalar-app .peer-has-\\[\\.cm-focused\\]\\:opacity-0:is(:where(.peer):has(.cm-focused)~*){opacity:0}.scalar-app .peer-has-\\[\\.color-selector\\]\\:hidden:is(:where(.peer):has(.color-selector)~*){display:none}.scalar-app .placeholder\\:font-\\[inherit\\]::placeholder{font-family:inherit}:is(.scalar-app .\\*\\:not-first\\:before\\:content-\\[\\'_·_\\'\\]>*):not(:first-child):before{--tw-content:" · ";content:var(--tw-content)}.scalar-app .after\\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.scalar-app .after\\:absolute:after{content:var(--tw-content);position:absolute}.scalar-app .after\\:inset-0:after{content:var(--tw-content);inset:0}.scalar-app .after\\:inset-x-0:after{content:var(--tw-content);inset-inline:0}.scalar-app .after\\:-top-0\\.5:after{content:var(--tw-content);top:-2px}.scalar-app .after\\:-bottom-0\\.5:after{content:var(--tw-content);bottom:-2px}.scalar-app .after\\:block:after{content:var(--tw-content);display:block}.scalar-app .after\\:h-0\\.75:after{content:var(--tw-content);height:3px}.scalar-app .after\\:rounded:after{content:var(--tw-content);border-radius:var(--scalar-radius)}.scalar-app .after\\:bg-blue:after{content:var(--tw-content);background-color:var(--scalar-color-blue)}.scalar-app .after\\:opacity-15:after{content:var(--tw-content);opacity:.15}.scalar-app .after\\:content-\\[\\'\\:\\'\\]:after{--tw-content:":";content:var(--tw-content)}.scalar-app .first\\:rounded-t-\\[inherit\\]:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}:is(.scalar-app .\\*\\:first\\:rounded-l>*):first-child{border-top-left-radius:var(--scalar-radius);border-bottom-left-radius:var(--scalar-radius)}:is(.scalar-app .\\*\\:first\\:border-t-0>*):first-child,:is(.scalar-app .first\\:\\*\\:border-t-0:first-child>*){border-top-style:var(--tw-border-style);border-top-width:0}:is(.scalar-app .\\*\\:first\\:p-3>*):first-child{padding:12px}.scalar-app .last\\:rounded-b-\\[inherit\\]:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.scalar-app .last\\:rounded-b-lg:last-child{border-bottom-right-radius:var(--scalar-radius-lg);border-bottom-left-radius:var(--scalar-radius-lg)}.scalar-app .last\\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}:is(.scalar-app .\\*\\:last\\:rounded-r>*):last-child{border-top-right-radius:var(--scalar-radius);border-bottom-right-radius:var(--scalar-radius)}.scalar-app .last-of-type\\:first-of-type\\:border-b-0:last-of-type:first-of-type{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .empty\\:hidden:empty{display:none}.scalar-app .focus-within\\:bg-b-1:focus-within{background-color:var(--scalar-background-1)}.scalar-app .focus-within\\:text-c-1:focus-within{color:var(--scalar-color-1)}.scalar-app .focus-within\\:opacity-100:focus-within{opacity:1}.scalar-app .focus-within\\:outline-none:focus-within{--tw-outline-style:none;outline-style:none}@media (hover:hover){.scalar-app .hover\\:bg-b-2:hover{background-color:var(--scalar-background-2)}.scalar-app .hover\\:bg-b-3:hover{background-color:var(--scalar-background-3)}.scalar-app .hover\\:bg-b-header-2:hover{background-color:var(--scalar-header-background-2,var(--scalar-background-2))}.scalar-app .hover\\:bg-c-accent\\/20:hover{background-color:var(--scalar-color-accent)}@supports (color:color-mix(in lab, red, red)){.scalar-app .hover\\:bg-c-accent\\/20:hover{background-color:color-mix(in oklab, var(--scalar-color-accent) 20%, transparent)}}.scalar-app .hover\\:bg-h-btn:hover{background-color:var(--scalar-button-1-hover)}.scalar-app .hover\\:bg-h-header-cta:hover{background-color:var(--scalar-header-call-to-action-color,var(--scalar-button-1))}@supports (color:color-mix(in lab, red, red)){.scalar-app .hover\\:bg-h-header-cta:hover{background-color:color-mix(in srgb, var(--scalar-header-call-to-action-color,var(--scalar-button-1)), var(--scalar-header-background-1,var(--scalar-background-1)) 15%)}}.scalar-app .hover\\:bg-sidebar-b-1:hover{background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .hover\\:bg-sidebar-b-hover:hover{background-color:var(--scalar-sidebar-item-hover-background,var(--scalar-background-2))}.scalar-app .hover\\:bg-linear-to-b:hover{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .hover\\:bg-linear-to-b:hover{--tw-gradient-position:to bottom in oklab}}.scalar-app .hover\\:bg-linear-to-b:hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .hover\\:bg-linear-to-t:hover{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .hover\\:bg-linear-to-t:hover{--tw-gradient-position:to top in oklab}}.scalar-app .hover\\:bg-linear-to-t:hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .hover\\:text-c-1:hover{color:var(--scalar-color-1)}.scalar-app .hover\\:text-c-2:hover{color:var(--scalar-color-2)}.scalar-app .hover\\:text-c-header-1:hover{color:var(--scalar-header-color-1,var(--scalar-color-1))}.scalar-app .hover\\:text-sidebar-c-1:hover{color:var(--scalar-sidebar-color-1,var(--scalar-color-1))}.scalar-app .hover\\:text-sidebar-c-hover:hover{color:var(--scalar-sidebar-item-hover-color,var(--scalar-sidebar-color-2))}.scalar-app .hover\\:underline:hover{text-decoration-line:underline}.scalar-app .hover\\:opacity-100:hover{opacity:1}.scalar-app .hover\\:brightness-75:hover{--tw-brightness:brightness(75%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .hover\\:brightness-90:hover{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.scalar-app .focus\\:border-b-1:focus{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--scalar-background-1)}.scalar-app .focus\\:text-c-1:focus{color:var(--scalar-color-1)}.scalar-app .focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.scalar-app .focus-visible\\:border-c-btn:focus-visible{border-color:var(--scalar-button-1-color)}.scalar-app .focus-visible\\:opacity-100:focus-visible{opacity:1}.scalar-app .focus-visible\\:outline:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .focus-visible\\:outline-offset-2:focus-visible{outline-offset:2px}.scalar-app .active\\:bg-b-btn:active{background-color:var(--scalar-button-1)}.scalar-app .active\\:text-c-1:active{color:var(--scalar-color-1)}.scalar-app .active\\:brightness-90:active{--tw-brightness:brightness(90%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.scalar-app .disabled\\:pointer-events-none:disabled{pointer-events:none}.scalar-app .disabled\\:cursor-default:disabled{cursor:default}.scalar-app .disabled\\:text-c-2:disabled{color:var(--scalar-color-2)}.scalar-app .disabled\\:opacity-30:disabled{opacity:.3}.scalar-app .has-focus-visible\\:outline:has(:focus-visible){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .has-\\[\\:focus-visible\\]\\:absolute:has(:focus-visible){position:absolute}.scalar-app .has-\\[\\:focus-visible\\]\\:z-1:has(:focus-visible){z-index:1}.scalar-app .has-\\[\\:focus-visible\\]\\:rounded-\\[4px\\]:has(:focus-visible){border-radius:4px}.scalar-app .has-\\[\\:focus-visible\\]\\:bg-b-1:has(:focus-visible){background-color:var(--scalar-background-1)}.scalar-app .has-\\[\\:focus-visible\\]\\:bg-sidebar-b-1:has(:focus-visible){background-color:var(--scalar-sidebar-background-1,var(--scalar-background-1))}.scalar-app .has-\\[\\:focus-visible\\]\\:opacity-100:has(:focus-visible){opacity:1}:is(.scalar-app .has-\\[\\:focus-visible\\]\\:outline:has(:focus-visible),.scalar-app .has-\\[input\\:focus-visible\\]\\:outline:has(:is(input:focus-visible))){outline-style:var(--tw-outline-style);outline-width:1px}.scalar-app .has-\\[\\&\\[aria-expanded\\=true\\]\\]\\:opacity-100:has([aria-expanded=true]){opacity:1}@media not all and (width>=800px){.scalar-app .max-md\\:absolute\\!{position:absolute!important}.scalar-app .max-md\\:top-4{top:16px}.scalar-app .max-md\\:z-5{z-index:5}.scalar-app .max-md\\:w-full\\!{width:100%!important}.scalar-app .max-md\\:pt-2{padding-top:8px}.scalar-app .max-md\\:pt-12{padding-top:48px}.scalar-app .max-md\\:pl-4\\!{padding-left:16px!important}.scalar-app .max-md\\:pl-10{padding-left:40px}.scalar-app .max-md\\:pl-14{padding-left:56px}}@media (width>=800px){.scalar-app .md\\:pointer-events-none{pointer-events:none}.scalar-app .md\\:absolute{position:absolute}.scalar-app .md\\:inset-x-1{inset-inline:4px}.scalar-app .md\\:top-1\\/2{top:50%}.scalar-app .md\\:mt-0{margin-top:0}.scalar-app .md\\:-ml-1\\.25{margin-left:-5px}.scalar-app .md\\:w-\\[calc\\(100vw-16px\\)\\]{width:calc(100vw - 16px)}.scalar-app .md\\:-translate-y-1\\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scalar-app .md\\:flex-row{flex-direction:row}.scalar-app .md\\:px-1\\.5{padding-inline:6px}.scalar-app .md\\:opacity-0{opacity:0}:is(.scalar-app .md\\:\\*\\:border-t-0>*){border-top-style:var(--tw-border-style);border-top-width:0}@media (hover:hover){.scalar-app .md\\:group-hover\\/upload\\:pointer-events-auto:is(:where(.group\\/upload):hover *){pointer-events:auto}.scalar-app .md\\:group-hover\\/upload\\:opacity-100:is(:where(.group\\/upload):hover *){opacity:1}}}@media (width>=1000px){.scalar-app .lg\\:-mr-1{margin-right:-4px}.scalar-app .lg\\:flex{display:flex}.scalar-app .lg\\:hidden{display:none}.scalar-app .lg\\:min-h-header{min-height:48px}.scalar-app .lg\\:w-\\[calc\\(100vw-32px\\)\\]{width:calc(100vw - 32px)}.scalar-app .lg\\:w-full{width:100%}.scalar-app .lg\\:p-0{padding:0}.scalar-app .lg\\:pr-24{padding-right:96px}}@media (width>=1200px){.scalar-app .xl\\:mb-1\\.5{margin-bottom:6px}.scalar-app .xl\\:flex{display:flex}.scalar-app .xl\\:h-full{height:100%}.scalar-app .xl\\:min-w-0{min-width:0}.scalar-app .xl\\:flex-row{flex-direction:row}.scalar-app .xl\\:gap-12{gap:48px}.scalar-app .xl\\:overflow-hidden{overflow:hidden}.scalar-app .xl\\:rounded-none{border-radius:0}.scalar-app .xl\\:border-r{border-right-style:var(--tw-border-style);border-right-width:var(--scalar-border-width)}.scalar-app .xl\\:border-none{--tw-border-style:none;border-style:none}.scalar-app .xl\\:pr-0\\.5{padding-right:2px}.scalar-app .xl\\:pl-2{padding-left:8px}:is(.scalar-app .\\*\\:xl\\:border-t-0>*){border-top-style:var(--tw-border-style);border-top-width:0}:is(.scalar-app .\\*\\:xl\\:border-l>*){border-left-style:var(--tw-border-style);border-left-width:var(--scalar-border-width)}.scalar-app .xl\\:first\\:ml-auto:first-child{margin-left:auto}:is(.scalar-app .\\*\\:first\\:xl\\:border-l-0>*):first-child{border-left-style:var(--tw-border-style);border-left-width:0}}@container (width>=768px){.scalar-app .\\@3xl\\:order-0{order:0}.scalar-app .\\@3xl\\:mb-0{margin-bottom:0}.scalar-app .\\@3xl\\:ml-0\\.75{margin-left:3px}.scalar-app .\\@3xl\\:flex{display:flex}.scalar-app .\\@3xl\\:hidden{display:none}.scalar-app .\\@3xl\\:flex-nowrap{flex-wrap:nowrap}}.scalar-app .dark\\:bg-\\(--bg-dark\\):where(.dark-mode,.dark-mode *){background-color:var(--bg-dark)}.scalar-app .dark\\:bg-b-3:where(.dark-mode,.dark-mode *){background-color:var(--scalar-background-3)}.scalar-app .dark\\:bg-backdrop-dark:where(.dark-mode,.dark-mode *){background-color:#00000073}.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){--tw-gradient-position:to top in oklab}}.scalar-app .dark\\:bg-linear-to-t:where(.dark-mode,.dark-mode *){background-image:linear-gradient(var(--tw-gradient-stops))}@media (hover:hover){.scalar-app .dark\\:hover\\:bg-b-3:where(.dark-mode,.dark-mode *):hover{background-color:var(--scalar-background-3)}.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to bottom in oklab}}.scalar-app .dark\\:hover\\:bg-linear-to-b:where(.dark-mode,.dark-mode *):hover{background-image:linear-gradient(var(--tw-gradient-stops))}.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to top}@supports (background-image:linear-gradient(in lab, red, red)){.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{--tw-gradient-position:to top in oklab}}.scalar-app .dark\\:hover\\:bg-linear-to-t:where(.dark-mode,.dark-mode *):hover{background-image:linear-gradient(var(--tw-gradient-stops))}}.scalar-app .ui-open\\:rotate-90[data-headlessui-state~=open],:where([data-headlessui-state~=open]) :is(.scalar-app .ui-open\\:rotate-90){rotate:90deg}.scalar-app .ui-open\\:rotate-180[data-headlessui-state~=open],:where([data-headlessui-state~=open]) :is(.scalar-app .ui-open\\:rotate-180){rotate:180deg}.scalar-app .last\\:ui-open\\:border-b-0:last-child[data-headlessui-state~=open],:where([data-headlessui-state~=open]) .scalar-app .last\\:ui-open\\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.scalar-app .ui-not-open\\:hidden[data-headlessui-state]:not([data-headlessui-state~=open]),:where([data-headlessui-state]:not([data-headlessui-state~=open])) :is(.scalar-app .ui-not-open\\:hidden):not([data-headlessui-state]){display:none}.scalar-app .ui-not-open\\:rotate-0[data-headlessui-state]:not([data-headlessui-state~=open]),:where([data-headlessui-state]:not([data-headlessui-state~=open])) :is(.scalar-app .ui-not-open\\:rotate-0):not([data-headlessui-state]){rotate:none}:is(.scalar-app .ui-active\\:\\*\\:bg-b-2[data-headlessui-state~=active]>*),:is(:where([data-headlessui-state~=active]) :is(.scalar-app .ui-active\\:\\*\\:bg-b-2)>*){background-color:var(--scalar-background-2)}@media (width<=720px) and (height<=480px){.scalar-app .zoomed\\:static{position:static}.scalar-app .zoomed\\:p-1{padding:4px}.scalar-app .zoomed\\:whitespace-normal\\!{white-space:normal!important}}.scalar-app .highlighted\\:bg-b-2[data-highlighted]{background-color:var(--scalar-background-2)}.app-platform-mac :is(.scalar-app .mac\\:h-12){height:48px}.app-platform-mac :is(.scalar-app .mac\\:app-drag-region){-webkit-app-region:drag;-webkit-app-region:drag}.scalar-app .\\[\\&_a\\]\\:underline a{text-decoration-line:underline}.scalar-app .\\[\\&_a\\:hover\\]\\:text-c-1 a:hover{color:var(--scalar-color-1)}.scalar-app .\\[\\&_code\\]\\:font-code code{font-family:var(--scalar-font-code)}.scalar-app .\\[\\&_em\\]\\:text-c-1 em{color:var(--scalar-color-1)}.scalar-app .\\[\\&_em\\]\\:not-italic em{font-style:normal}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-mask-linear{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-radial{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-conic{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-left{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-right{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-bottom{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-top{syntax:"*";inherits:false;initial-value:linear-gradient(#fff, #fff)}@property --tw-mask-top-from-position{syntax:"*";inherits:false;initial-value:0%}@property --tw-mask-top-to-position{syntax:"*";inherits:false;initial-value:100%}@property --tw-mask-top-from-color{syntax:"*";inherits:false;initial-value:black}@property --tw-mask-top-to-color{syntax:"*";inherits:false;initial-value:transparent}@property --tw-mask-bottom-from-position{syntax:"*";inherits:false;initial-value:0%}@property --tw-mask-bottom-to-position{syntax:"*";inherits:false;initial-value:100%}@property --tw-mask-bottom-from-color{syntax:"*";inherits:false;initial-value:black}@property --tw-mask-bottom-to-color{syntax:"*";inherits:false;initial-value:transparent}@property --tw-leading{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes pulse{50%{opacity:.5}}@media (width<=1000px){.agent-scalar.agent-scalar[data-v-a9f22fa8]{border-top-left-radius:var(--scalar-radius-lg);border-top-right-radius:var(--scalar-radius-lg);inset-inline:0;top:48px}.agent-scalar.agent-scalar[data-v-a9f22fa8],.agent-scalar-overlay.agent-scalar-overlay[data-v-a9f22fa8]{z-index:15}}.scalar-mcp-layer[data-v-496918d9]{background:0 0;flex-direction:column;justify-content:flex-end;gap:2px;height:32px;transition:all .4s ease-in-out;display:flex;position:relative}.scalar-mcp-layer[data-v-496918d9]:hover{height:172px}.scalar-mcp-layer-link[data-v-496918d9]:hover{cursor:pointer!important}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]{cursor:pointer;text-align:center;white-space:nowrap;width:100%;height:31px;font-size:var(--scalar-small);border-radius:var(--scalar-radius);border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-sidebar-color-1);background:var(--scalar-background-1);align-items:center;gap:6px;padding:9px 6px;line-height:1.385;text-decoration:none;transition:transform .2s ease-in-out;display:flex;position:absolute;bottom:0}.scalar-mcp-layer-link[data-v-496918d9]:after{content:"";width:100%;height:2px;position:absolute;bottom:-2px;left:0}.scalar-mcp-layer div.scalar-mcp-layer-link[data-v-496918d9]{cursor:default}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:last-child{position:relative;transform:translate(0,0)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(2){transform:translateY(-2px)scale(.99)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(2){transform:translateY(calc(-100% - 2px))scale(.99)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(3){transform:translateY(-4px)scale(.98)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(3){transform:translateY(calc(-200% - 4px))scale(1)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(4){transform:translateY(-6px)scale(.97)}.scalar-mcp-layer:hover a[data-v-496918d9]:nth-last-child(4){transform:translateY(calc(-300% - 6px))scale(1)}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(5){transform:translateY(-8px)scale(.96)}.scalar-mcp-layer:hover .scalar-mcp-layer-link[data-v-496918d9]:nth-last-child(5){transform:translateY(calc(-400% - 8px))scale(1)}.scalar-mcp-layer:hover .scalar-mcp-layer-link[data-v-496918d9]{transition:transform .2s ease-in-out .1s}.scalar-mcp-layer .scalar-mcp-layer-link[data-v-496918d9]:hover{background:var(--scalar-background-2)}.scalar-mcp-layer .mcp-logo[data-v-496918d9]{width:16px;height:16px;color:var(--scalar-sidebar-color-1)}.mcp-nav[data-v-496918d9]{color:var(--scalar-sidebar-color-2)}.references-classic-header[data-v-8a3822ca]{max-width:var(--refs-content-max-width);align-items:center;gap:12px;margin:auto;padding:12px 0;display:flex}.references-classic-header-content[data-v-8a3822ca]{flex-grow:1;gap:12px;display:flex}.references-classic-header-container[data-v-8a3822ca]{padding:0 60px;position:relative}@container narrow-references-container (width<=900px){.references-classic-header[data-v-8a3822ca]{padding:12px 24px}.references-classic-header-container[data-v-8a3822ca]{padding:0}}.references-classic-header-icon[data-v-8a3822ca]{height:24px;color:var(--scalar-color-1)}.client-libraries-content[data-v-75d206b8]{background-color:var(--scalar-background-1);border-left:var(--scalar-border-width) solid var(--scalar-border-color);border-right:var(--scalar-border-width) solid var(--scalar-border-color);justify-content:center;padding:0 12px;display:flex;overflow:hidden;container:client-libraries-content/inline-size}.client-libraries[data-v-75d206b8]{cursor:pointer;white-space:nowrap;width:100%;color:var(--scalar-color-3);-webkit-user-select:none;user-select:none;border-bottom:1px solid #0000;justify-content:center;align-items:center;gap:6px;padding:8px 2px;display:flex;position:relative}.client-libraries[data-v-75d206b8]:not(.client-libraries__active):hover:before{content:"";background:var(--scalar-background-2);z-index:0;border-radius:var(--scalar-radius);width:calc(100% - 4px);height:calc(100% - 4px);position:absolute;top:2px;left:2px}.client-libraries[data-v-75d206b8]:active{color:var(--scalar-color-1)}.client-libraries[data-v-75d206b8]:focus-visible{box-shadow:inset 0 0 0 1px var(--scalar-color-accent);outline:none}@media screen and (width<=450px){.client-libraries[data-v-75d206b8]:nth-of-type(4),.client-libraries[data-v-75d206b8]:nth-of-type(5){display:none}}.client-libraries-icon[data-v-75d206b8]{aspect-ratio:1;box-sizing:border-box;color:currentColor;justify-content:center;align-items:center;width:100%;min-width:14px;max-width:14px;max-height:14px;display:flex;position:relative}.client-libraries-icon__more svg[data-v-75d206b8]{height:initial}@container client-libraries-content (width<400px){.client-libraries__select[data-v-75d206b8]{width:fit-content}.client-libraries__select .client-libraries-icon__more+span[data-v-75d206b8]{display:none}}@container client-libraries-content (width<380px){.client-libraries[data-v-75d206b8]{width:100%}.client-libraries span[data-v-75d206b8]{display:none}}.client-libraries__active[data-v-75d206b8]{color:var(--scalar-color-1);border-bottom:1px solid var(--scalar-color-1)}@keyframes codeloader-75d206b8{0%{transform:rotate(0)}to{transform:rotate(1turn)}}.client-libraries .client-libraries-text[data-v-75d206b8]{font-size:var(--scalar-small);align-items:center;display:flex;position:relative}.client-libraries__active .client-libraries-text[data-v-75d206b8]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}@media screen and (width<=600px){.references-classic .client-libraries[data-v-75d206b8]{flex-direction:column}}.selected-client[data-v-c6251633]{color:var(--scalar-color-1);font-size:var(--scalar-small);font-family:var(--scalar-font-code);white-space:nowrap;text-overflow:ellipsis;background:var(--scalar-background-1);border-top:none;border:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-left-radius:var(--scalar-radius-xl);border-bottom-right-radius:var(--scalar-radius-xl);min-height:fit-content;padding:9px 12px;overflow:hidden}.client-libraries-heading[data-v-c6251633]{font-size:var(--scalar-small);font-weight:var(--scalar-font-medium);color:var(--scalar-color-1);background-color:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-top-left-radius:var(--scalar-radius-xl);border-top-right-radius:var(--scalar-radius-xl);align-items:center;max-height:32px;padding:9px 12px;display:flex}[data-v-c6251633] .scalar-codeblock-pre .hljs{margin-top:8px}:where(.badge[data-v-fb946bb8]){color:var(--badge-text-color,var(--scalar-color-2));font-size:var(--scalar-mini);background:var(--badge-background-color,var(--scalar-background-2));border:var(--scalar-border-width) solid var(--badge-border-color,var(--scalar-border-color));border-radius:12px;padding:2px 6px;display:inline-block}:where(.badge).text-orange[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-orange), transparent 90%);border:#0000}:where(.badge).text-yellow[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-yellow), transparent 90%);border:#0000}:where(.badge).text-red[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-red), transparent 90%);border:#0000}:where(.badge).text-purple[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-purple), transparent 90%);border:#0000}:where(.badge).text-green[data-v-fb946bb8]{background:color-mix(in srgb, var(--scalar-color-green), transparent 90%);border:#0000}.download-container[data-v-b258a15f]{z-index:1;flex-direction:column;gap:16px;width:fit-content;margin:0 .5px 8px;display:flex;position:relative}.download-container.download-both[data-v-b258a15f]:hover:before{content:"";border-radius:var(--scalar-radius-lg);width:calc(100% + 24px);height:90px;box-shadow:var(--scalar-shadow-2);pointer-events:none;background:var(--scalar-background-1);position:absolute;top:-11px;left:-12px}.download-container[data-v-b258a15f]:has(:focus-visible):before{content:"";border-radius:var(--scalar-radius-lg);width:calc(100% + 24px);height:90px;box-shadow:var(--scalar-shadow-2);pointer-events:none;background:var(--scalar-background-1);position:absolute;top:-11px;left:-12px}.download-button[data-v-b258a15f]{color:var(--scalar-link-color);cursor:pointer;outline:none;justify-content:center;align-items:center;gap:4px;height:fit-content;padding:0;display:flex;position:relative;white-space:nowrap!important}.download-button[data-v-b258a15f]:before{border-radius:var(--scalar-radius);content:"";width:calc(100% + 18px);height:calc(100% + 16px);position:absolute;top:-8px;left:-9px}.download-button[data-v-b258a15f]:last-of-type:before{width:calc(100% + 15px)}.download-button[data-v-b258a15f]:hover:before{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color)}.download-button[data-v-b258a15f]:focus-visible:before{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);outline-style:var(--tw-outline-style);outline-width:1px}.download-button span[data-v-b258a15f]{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.download-button span[data-v-b258a15f]{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.download-button span[data-v-b258a15f]{z-index:1;align-items:center;gap:6px;line-height:1.625;display:flex}.download-button:hover span[data-v-b258a15f]{-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);-webkit-text-decoration-color:var(--scalar-color-1,currentColor);text-decoration-color:var(--scalar-color-1,currentColor);color:var(--scalar-link-color-hover,var(--scalar-color-accent));-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);-webkit-text-decoration:var(--scalar-text-decoration-hover);text-decoration:var(--scalar-text-decoration-hover)}.download-button[data-v-b258a15f]:nth-of-type(2){clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.download-container:hover .download-button[data-v-b258a15f]:nth-of-type(2){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:absolute;top:42px;overflow:visible}.download-container:has(:focus-visible) .download-button[data-v-b258a15f]:nth-of-type(2){clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:absolute;top:42px;overflow:visible}.extension[data-v-b258a15f]{z-index:1;background:var(--scalar-link-color,var(--scalar-color-accent));color:var(--scalar-background-1)}.download-container:hover .extension[data-v-b258a15f]{opacity:1}.download-container:has(:focus-visible) .extension[data-v-b258a15f]{opacity:1}.download-link[data-v-b258a15f]{--font-color:var(--scalar-link-color,var(--scalar-color-accent));--font-visited:var(--scalar-link-color-visited,var(--scalar-color-2));-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);-webkit-text-decoration:var(--scalar-text-decoration);text-decoration:var(--scalar-text-decoration);color:var(--font-color);font-weight:var(--scalar-link-font-weight,var(--scalar-semibold));text-underline-offset:.25rem;text-decoration-thickness:1px;-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}@supports (color:color-mix(in lab, red, red)){.download-link[data-v-b258a15f]{-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);-webkit-text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent);text-decoration-color:color-mix(in srgb, var(--font-color) 30%, transparent)}}.download-link[data-v-b258a15f]:hover{--font-color:var(--scalar-link-color,var(--scalar-color-accent));-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);-webkit-text-decoration-color:var(--font-color);text-decoration-color:var(--font-color)}.introduction-card[data-v-5764c94a]{flex-direction:column;gap:12px;display:flex}.introduction-card-row[data-v-5764c94a]{gap:24px}@media (width>=600px){.introduction-card-row[data-v-5764c94a]{flex-flow:wrap}}.introduction-card-row[data-v-5764c94a]>*{flex:1}@media (width>=600px){.introduction-card-row[data-v-5764c94a]>*{min-width:min-content}}@media (width<=600px){.introduction-card-row[data-v-5764c94a]>*{max-width:100%}}@container (width<=900px){.introduction-card-row[data-v-5764c94a]{flex-direction:column;align-items:stretch;gap:0}}.introduction-card[data-v-5764c94a] .security-scheme-label{text-transform:uppercase;font-weight:var(--scalar-semibold)}.introduction-card-row[data-v-5764c94a] .scalar-card:nth-of-type(2) .scalar-card-header{display:none}.introduction-card-row[data-v-5764c94a] .scalar-card:nth-of-type(2) .scalar-card-header.scalar-card--borderless+.scalar-card-content{margin-top:0}.section[data-v-be4443e9]{max-width:var(--refs-content-max-width);scroll-margin-top:var(--refs-viewport-offset);flex-direction:column;margin:auto;padding:90px 0;display:flex;position:relative}.section[data-v-be4443e9]:has(~div.contents){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.references-classic .section[data-v-be4443e9]{gap:24px;padding:48px 0}@container narrow-references-container (width<=900px){.references-classic .section[data-v-be4443e9],.section[data-v-be4443e9]{padding:48px 24px}}.section[data-v-be4443e9]:not(:last-of-type){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.section-wrapper[data-v-ff689b94]{color:var(--scalar-color-1);margin-top:-12px;padding-top:12px}.section-accordion[data-v-ff689b94]{border-radius:var(--scalar-radius-lg);background:var(--scalar-background-2);scroll-margin-top:var(--refs-viewport-offset);flex-direction:column;display:flex}.section-accordion-transparent[data-v-ff689b94]{border:var(--scalar-border-width) solid var(--scalar-border-color);background:0 0}.section-accordion-button[data-v-ff689b94]{cursor:pointer;align-items:center;gap:6px;padding:6px;display:flex}.section-accordion-button-content[data-v-ff689b94]{flex:1;min-width:0}.section-accordion-button-actions[data-v-ff689b94]{color:var(--scalar-color-3);align-items:center;gap:6px;display:flex}.section-accordion-chevron[data-v-ff689b94]{cursor:pointer;opacity:1;color:var(--scalar-color-3);margin-right:4px}.section-accordion-button:hover .section-accordion-chevron[data-v-ff689b94]{color:var(--scalar-color-1)}.section-accordion-content[data-v-ff689b94]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;display:flex}.section-accordion-description[data-v-ff689b94]{font-weight:var(--scalar-semibold);font-size:var(--scalar-mini);color:var(--scalar-color--1);padding:10px 12px 0}.section-accordion-content-card[data-v-ff689b94] .property:last-of-type{padding-bottom:9px}.section-column[data-v-699c28e3]{flex:1;min-width:0}@container narrow-references-container (width<=900px){.section-column[data-v-699c28e3]:nth-of-type(2){padding-top:0}}.section-columns[data-v-8b9602bf]{gap:48px;display:flex}@container narrow-references-container (width<=900px){.section-columns[data-v-8b9602bf]{flex-direction:column;gap:24px}}.section-container[data-v-20a1472a]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);width:100%;padding:0 60px;position:relative}.section-container[data-v-20a1472a]:has(.introduction-section){border-top:none}@container narrow-references-container (width<=900px){.section-container[data-v-20a1472a]{padding:0}}.section-accordion-wrapper[data-v-9419dd23]{padding:0 60px}.section-accordion[data-v-9419dd23]{width:100%;max-width:var(--refs-content-max-width);margin:auto;position:relative}.section-accordion-content[data-v-9419dd23]{flex-direction:column;gap:12px;padding-top:12px;display:flex}.section-accordion-button[data-v-9419dd23]{cursor:pointer;border-radius:var(--scalar-radius);width:100%;margin:-6px 0;padding:6px 0;display:flex}.section-accordion-chevron[data-v-9419dd23]{color:var(--scalar-color-3);position:absolute;top:12px;left:-22px}.section-accordion-button:hover .section-accordion-chevron[data-v-9419dd23]{color:var(--scalar-color-1)}.section-accordion-title[data-v-9419dd23]{flex-direction:column;flex:1;align-items:flex-start;padding:0 6px;display:flex}.section-accordion-title[data-v-9419dd23] .section-header-wrapper{grid-template-columns:1fr}.section-accordion-title[data-v-9419dd23] .section-header{margin-bottom:0}@container narrow-references-container (width<=900px){.section-accordion-chevron[data-v-9419dd23]{width:16px;top:14px;left:-16px}.section-accordion-wrapper[data-v-9419dd23]{padding:calc(var(--refs-viewport-offset)) 24px 0 24px}}.loading[data-v-8e0226d7]{background:var(--scalar-background-3);border-radius:var(--scalar-radius-lg);max-width:100%;min-height:1.6em;margin:.6em 0;animation:1.5s infinite alternate loading-skeleton-8e0226d7}.loading[data-v-8e0226d7]:first-of-type{min-height:3em;margin-top:0;margin-bottom:24px}.loading[data-v-8e0226d7]:last-of-type{width:60%}.loading.single-line[data-v-8e0226d7]{max-width:80%;min-height:3em;margin:.6em 0}@keyframes loading-skeleton-8e0226d7{0%{opacity:1}to{opacity:.33}}@container narrow-references-container (width<=900px){.section-content--with-columns[data-v-9735459e]{flex-direction:column;gap:24px}}.section-header-wrapper[data-v-8a5913a9]{grid-template-columns:1fr;display:grid}@media (width>=1200px){.section-header-wrapper[data-v-8a5913a9]{grid-template-columns:repeat(2,1fr)}}.section-header[data-v-8a5913a9]{font-size:var(--font-size,var(--scalar-heading-1));font-weight:var(--font-weight,var(--scalar-bold));color:var(--scalar-color-1);word-wrap:break-word;margin-top:0;line-height:1.45}.section-header.tight[data-v-8a5913a9]{margin-bottom:6px}.section-header.loading[data-v-8a5913a9]{width:80%}.section-header-label[data-v-f1ac6c38]{display:inline}.screenreader-only[data-v-df2e1026]{clip:rect(0, 0, 0, 0);border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.collapsible-section[data-v-999a158a]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);position:relative}.collapsible-section-header[data-v-999a158a]{color:var(--scalar-color-1)}.collapsible-section .collapsible-section-trigger[data-v-999a158a]{cursor:pointer;font-size:var(--scalar-font-size-3);z-index:1;align-items:center;padding:10px 0;display:flex;position:relative}.collapsible-section-trigger svg[data-v-999a158a]{color:var(--scalar-color-3);position:absolute;left:-19px}.collapsible-section:hover .collapsible-section-trigger svg[data-v-999a158a]{color:var(--scalar-color-1)}.collapsible-section .collapsible-section-trigger[data-v-999a158a] .anchor-copy{line-height:18.5px}.collapsible-section-content[data-v-999a158a]{margin:0 0 10px;padding:0;scroll-margin-top:140px}.references-classic .introduction-description[data-v-0370764f] img{max-width:720px}.icons-only[data-v-b59b0acf] span{display:none}.sticky-cards[data-v-6c24d687]{top:calc(var(--refs-viewport-offset) + 24px);flex-direction:column;display:flex;position:sticky}.introduction-card-item[data-v-dfab866f]{flex-direction:column;justify-content:flex-start;display:flex}.introduction-card-item[data-v-dfab866f]:empty{display:none}.introduction-card-item[data-v-dfab866f]:has(.description) .server-form-container{border-bottom-right-radius:0;border-bottom-left-radius:0}.introduction-card-item[data-v-dfab866f] .request-item{border-bottom:0}.schema-type-icon[data-v-70cb5c13]{color:var(--scalar-color-1);display:none}.schema-type[data-v-70cb5c13]{font-family:var(--scalar-font-code);color:var(--scalar-color-1)}.property-enum-value[data-v-f4b54bdd]{color:var(--scalar-color-3);overflow-wrap:break-word;--decorator-width:1px;--decorator-color:color-mix(in srgb, var(--scalar-background-1), var(--scalar-color-1) 25%);align-items:stretch;line-height:1.5;display:flex;position:relative}.property-enum-value-content[data-v-f4b54bdd]{flex-direction:column;padding:3px 0;display:flex}.property-enum-value-label[data-v-f4b54bdd]{font-family:var(--scalar-font-code);color:var(--scalar-color-1);font-size:var(--scalar-font-size-4);position:relative}.property-enum-value:last-of-type .property-enum-value-label[data-v-f4b54bdd]{padding-bottom:0}.property-enum-value[data-v-f4b54bdd]:before{content:"";width:var(--decorator-width);background-color:var(--decorator-color);margin-right:12px;display:block}.property-enum-value[data-v-f4b54bdd]:last-of-type:before{height:calc(.5lh + 4px)}.property-enum-values:has(.enum-toggle-button) .property-enum-value[data-v-f4b54bdd]:nth-last-child(2):before{height:calc(.5lh + 4px)}.property-enum-value-label[data-v-f4b54bdd]:after{content:"";width:8px;height:var(--decorator-width);background-color:var(--decorator-color);position:absolute;top:.5lh;left:-12px}.property-enum-value[data-v-f4b54bdd]:last-of-type:after{background:var(--scalar-background-1);border-top:var(--scalar-border-width) solid var(--decorator-color);height:50%;bottom:0}.property-enum-value-description[data-v-f4b54bdd]{color:var(--scalar-color-3)}.property-heading:empty+.property-description[data-v-55c01b89]:last-of-type,.property-description[data-v-55c01b89]:first-of-type:last-of-type{margin-top:0}.property-list[data-v-55c01b89]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);margin-top:10px}.property-list .property[data-v-55c01b89]:last-of-type{padding-bottom:10px}.property-enum-values[data-v-55c01b89]{font-size:var(--scalar-font-size-3);margin-top:8px;padding-left:2px;list-style:none}.enum-toggle-button[data-v-55c01b89]:hover{color:var(--scalar-color-1)}.property-enum-property-names[data-v-55c01b89]{font-size:var(--scalar-font-size-4);color:var(--scalar-color-2);margin-top:8px;padding:0 2px;display:inline-block}.property-default[data-v-4da5c70a]{font-size:var(--scalar-mini);flex-direction:column;display:flex;position:relative}.property-default[data-v-4da5c70a]:hover:before{content:"";border-radius:var(--scalar-radius);width:100%;height:20px;position:absolute;top:0;left:0}.property-default:hover .property-default-label span[data-v-4da5c70a]{color:var(--scalar-color-1)}.property-default-label span[data-v-4da5c70a]{color:var(--scalar-color-3);border-bottom:var(--scalar-border-width) dotted currentColor;position:relative}.property-default-value[data-v-4da5c70a]{font-family:var(--scalar-font-code);align-items:center;gap:8px;width:100%;padding:6px;display:flex}.property-default-value span[data-v-4da5c70a]{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.property-default-value[data-v-4da5c70a] svg{color:var(--scalar-color-3)}.property-default-value[data-v-4da5c70a]:hover svg{color:var(--scalar-color-1)}.property-default-value[data-v-4da5c70a]{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius)}.property-default-value-list[data-v-4da5c70a]{background-color:var(--scalar-background-1);box-shadow:var(--scalar-shadow-1);border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);z-index:2;flex-direction:column;gap:3px;min-width:200px;max-width:300px;padding:9px;display:none;position:absolute;top:18px;left:50%;overflow:auto;transform:translate(-50%)}.property-default:hover .property-default-value-list[data-v-4da5c70a],.property-default:focus-within .property-default-value-list[data-v-4da5c70a]{display:flex}.property-detail[data-v-1295f965]{display:inline-flex}.property-detail+.property-detail[data-v-1295f965]:before{content:"·";margin:0 .5ch;display:block}.property-detail-truncate[data-v-1295f965]{overflow:hidden}.property-detail-truncate>.property-detail-value[data-v-1295f965]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.property-detail-prefix[data-v-1295f965]{color:var(--scalar-color-2)}code.property-detail-value[data-v-1295f965]{font-family:var(--scalar-font-code);font-size:var(--scalar-font-size-3);color:var(--scalar-color-2);background:color-mix(in srgb, var(--scalar-background-2), var(--scalar-background-1));border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius);padding:0 4px}.property-example[data-v-77f8279d]{font-size:var(--scalar-mini);flex-direction:column;display:flex;position:relative}.property-example[data-v-77f8279d]:hover:before{content:"";border-radius:var(--scalar-radius);width:100%;height:20px;position:absolute;top:0;left:0}.property-example-value[data-v-77f8279d]{font-family:var(--scalar-font-code);align-items:center;gap:8px;width:100%;padding:6px;display:flex}.property-example-value span[data-v-77f8279d]{text-overflow:ellipsis;white-space:nowrap;display:block;overflow:hidden}.property-example-value[data-v-77f8279d] svg{color:var(--scalar-color-3)}.property-example-value[data-v-77f8279d]:hover svg{color:var(--scalar-color-1)}.property-example-value[data-v-77f8279d]{background:var(--scalar-background-2);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius)}.property-example-value-list[data-v-77f8279d]{background-color:var(--scalar-background-1);box-shadow:var(--scalar-shadow-1);border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);z-index:1000;flex-direction:column;gap:3px;min-width:200px;max-width:300px;padding:9px;display:none;position:absolute;top:18px;left:50%;overflow:auto;transform:translate(-50%)}.property-example:hover .property-example-value-list[data-v-77f8279d],.property-example:focus-within .property-example-value-list[data-v-77f8279d]{display:flex}.property-heading[data-v-3264097c]{white-space:nowrap;flex-wrap:wrap;align-items:baseline;row-gap:9px;display:flex}:is(.property-heading[data-v-3264097c]:has(+.children),.property-heading[data-v-3264097c]:has(+.property-rule)){margin-bottom:9px}.property-heading[data-v-3264097c]>*{margin-right:9px}.property-heading[data-v-3264097c]:last-child,.property-heading>.property-detail[data-v-3264097c]:not(:last-of-type){margin-right:0}.property-name[data-v-3264097c]{max-width:100%;font-family:var(--scalar-font-code);font-weight:var(--scalar-bold);font-size:var(--scalar-font-size-4);white-space:normal;overflow-wrap:break-word}.property-additional[data-v-3264097c]{font-family:var(--scalar-font-code)}.property-required[data-v-3264097c],.property-optional[data-v-3264097c]{color:var(--scalar-color-2)}.property-required[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-orange)}.property-read-only[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-blue)}.property-write-only[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-green)}.property-discriminator[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-purple)}.property-detail[data-v-3264097c]{font-size:var(--scalar-mini);color:var(--scalar-color-2);align-items:center;min-width:0;display:flex}.property-const[data-v-3264097c]{color:var(--scalar-color-1)}.deprecated[data-v-3264097c]{text-decoration:line-through}.property[data-v-c8e3c666]{color:var(--scalar-color-1);font-size:var(--scalar-small);flex-direction:column;padding:10px;display:flex;position:relative}.property.property--level-0[data-v-c8e3c666]:has(>.property-rule>.schema-card>.schema-properties.schema-properties-open>ul>li.property){padding-top:0}.property--compact.property--level-0[data-v-c8e3c666],.property--compact.property--level-1[data-v-c8e3c666]{padding:10px 0}.composition-panel .property.property.property.property--level-0[data-v-c8e3c666]{padding:0}.property--compact.property--level-0 .composition-panel .property--compact.property--level-1[data-v-c8e3c666]{padding:8px}.property[data-v-c8e3c666]:has(>.property-rule:first-of-type):not(.property--compact){padding-top:8px;padding-bottom:8px}.property--deprecated[data-v-c8e3c666]{background:repeating-linear-gradient(-45deg, var(--scalar-background-2) 0, var(--scalar-background-2) 2px, transparent 2px, transparent 5px);background-size:100%}.property--deprecated[data-v-c8e3c666]>*{opacity:.75}.property-description[data-v-c8e3c666]{line-height:1.4;font-size:var(--scalar-small);margin-top:6px}.property-description[data-v-c8e3c666]:has(+.property-rule){margin-bottom:9px}[data-v-c8e3c666] .property-description *{color:var(--scalar-color-2)!important}.property[data-v-c8e3c666]:not(:last-of-type){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color)}.property-description+.children[data-v-c8e3c666],.children+.property-rule[data-v-c8e3c666]{margin-top:9px}.children[data-v-c8e3c666]{flex-direction:column;display:flex}.children .property--compact.property--level-1[data-v-c8e3c666]{padding:12px}.property-example-value[data-v-c8e3c666]{all:unset;font-family:var(--scalar-font-code);border-top:var(--scalar-border-width) solid var(--scalar-border-color);padding:6px}.property-rule[data-v-c8e3c666]{border-radius:var(--scalar-radius-lg);flex-direction:column;display:flex}.property--level-2[data-v-c8e3c666] .relative>button{left:-2rem}.property-rule[data-v-c8e3c666] .composition-panel .schema-card--level-1>.schema-properties.schema-properties-open{border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg)}.property-rule[data-v-c8e3c666] .composition-panel>.schema-card>.schema-card-description{border-left:var(--scalar-border-width) solid var(--scalar-border-color);border-right:var(--scalar-border-width) solid var(--scalar-border-color);padding:10px}.property-rule[data-v-c8e3c666] .composition-panel>.schema-card>.schema-card-description+.schema-properties{margin-top:0}.property-example[data-v-c8e3c666]{background:0 0;border:none;flex-direction:row;gap:8px;display:flex}.property-example-label[data-v-c8e3c666],.property-example-value[data-v-c8e3c666]{padding:3px 0 0}.property-example-value[data-v-c8e3c666]{background:var(--scalar-background-2);border-radius:var(--scalar-radius);border-top:0;padding:3px 4px}.property-name[data-v-c8e3c666]{font-family:var(--scalar-font-code);font-weight:var(--scalar-semibold)}.property-name-additional-properties[data-v-c8e3c666]:before,.property-name-pattern-properties[data-v-c8e3c666]:before{text-transform:uppercase;font-size:var(--scalar-micro);border-radius:var(--scalar-radius);color:var(--scalar-color-1);border:var(--scalar-border-width) solid var(--scalar-border-color);background-color:var(--scalar-background-2);margin-right:4px;padding:2px 4px;display:inline-block}.property-name-pattern-properties[data-v-c8e3c666]:before{content:"regex"}.property-name-additional-properties[data-v-c8e3c666],.property-name-pattern-properties[data-v-c8e3c666]{border:1px dashed var(--scalar-border-color);color:var(--scalar-color-accent);border-radius:var(--scalar-radius);padding:2px;display:inline-block}.error[data-v-c988b726]{background-color:var(--scalar-color-red)}.schema-card[data-v-c988b726]{font-size:var(--scalar-font-size-4);color:var(--scalar-color-1)}.schema-card-title[data-v-c988b726]{height:var(--schema-title-height);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);font-size:var(--scalar-mini);border-bottom:var(--scalar-border-width) solid transparent;align-items:center;gap:4px;padding:6px 8px;display:flex}button.schema-card-title[data-v-c988b726]{cursor:pointer}button.schema-card-title[data-v-c988b726]:hover{color:var(--scalar-color-1)}.schema-card-title-icon--open[data-v-c988b726]{transform:rotate(45deg)}.schema-properties-open>.schema-card-title[data-v-c988b726]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-right-radius:0;border-bottom-left-radius:0}.schema-properties-open>.schema-properties[data-v-c988b726]{width:fit-content}.schema-card-description[data-v-c988b726]{color:var(--scalar-color-2)}.schema-card-description+.schema-properties[data-v-c988b726]{width:fit-content;margin-top:8px}.schema-card--level-0:first-of-type>.schema-card-description[data-v-c988b726]:has(+.schema-properties){border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);margin-bottom:-8px;padding-bottom:8px}.schema-card--level-0~.schema-card--level-0>.schema-card-description[data-v-c988b726]:has(+.schema-properties){padding-top:8px}.schema-properties-open.schema-properties[data-v-c988b726],.schema-properties-open>.schema-card--open[data-v-c988b726]{width:100%}.schema-properties[data-v-c988b726]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);flex-direction:column;width:fit-content;display:flex}.schema-properties-name[data-v-c988b726]{width:100%}.schema-properties .schema-properties[data-v-c988b726]{border-radius:13.5px}.schema-properties .schema-properties.schema-properties-open[data-v-c988b726]{border-radius:var(--scalar-radius-lg)}.schema-properties-open[data-v-c988b726]{width:100%}.schema-card--compact[data-v-c988b726]{align-self:flex-start}.schema-card--compact.schema-card--open[data-v-c988b726]{align-self:initial}.schema-card-title--compact[data-v-c988b726]{color:var(--scalar-color-2);border-bottom:none;height:auto;padding:6px 10px 6px 8px}.schema-card-title--compact>.schema-card-title-icon[data-v-c988b726]{margin:0}.schema-card-title--compact>.schema-card-title-icon--open[data-v-c988b726]{transform:rotate(45deg)}.schema-properties-open>.schema-card-title--compact[data-v-c988b726]{position:static}.property--level-0>.schema-properties>.schema-card--level-0>.schema-properties[data-v-c988b726]{border:none}.property--level-0 .schema-card--level-0:not(.schema-card--compact) .property--level-1[data-v-c988b726]{padding:0 0 8px}:not(.composition-panel)>.schema-card--compact.schema-card--level-0>.schema-properties[data-v-c988b726]{border:none}[data-v-c988b726] .schema-card-description p{font-size:var(--scalar-small,var(--scalar-paragraph));color:var(--scalar-color-2);margin-bottom:6px;display:block}.children .schema-card-description[data-v-c988b726]:first-of-type{padding-top:0}.reference-models-anchor[data-v-5aa6b456]{color:var(--scalar-color-1);align-items:center;padding-left:6px;font-size:20px;display:flex}.reference-models-label[data-v-5aa6b456]{font-size:var(--scalar-mini);display:block}.reference-models-label[data-v-5aa6b456] em{font-weight:var(--scalar-bold)}.show-more[data-v-d1c2b649]{appearance:none;border:none;border:var(--scalar-border-width) solid var(--scalar-border-color);color:var(--scalar-color-1);font-weight:var(--scalar-semibold);font-size:var(--scalar-small);border-radius:30px;justify-content:center;align-items:center;gap:6px;margin:auto;padding:8px 12px 8px 16px;display:flex;position:relative;top:-48px}.show-more[data-v-d1c2b649]:hover{background:var(--scalar-background-2);cursor:pointer}.show-more[data-v-d1c2b649]:active{box-shadow:0 0 0 1px var(--scalar-border-color)}@container narrow-references-container (width<=900px){.show-more[data-v-d1c2b649]{top:-24px}}.tag-section[data-v-d4f47ce7]{margin-bottom:48px}.tag-name[data-v-d4f47ce7]{text-transform:capitalize}.tag-description[data-v-d4f47ce7]{text-align:left;padding-bottom:4px}.tag-section-group .tag-group-name[data-v-d4f47ce7]{grid-template-columns:auto 1fr;align-self:stretch;gap:12px}.tag-group-name[data-v-d4f47ce7]:after{content:"";background:var(--scalar-border-color);align-self:center;height:1px;display:block}:is(.tag-group-name[data-v-d4f47ce7]:has(*>:hover),.tag-group-name[data-v-d4f47ce7]:has(:focus-visible)){gap:32px}.tag-section-group .tag-section[data-v-d4f47ce7]{margin-bottom:24px;padding-inline:0}.tag-section-group .tag-section[data-v-d4f47ce7]:last-of-type{margin-bottom:0}.endpoint[data-v-ad8530a6]{white-space:nowrap;cursor:pointer;text-decoration:none;display:flex}.endpoint:hover .endpoint-path[data-v-ad8530a6],.endpoint:focus-visible .endpoint-path[data-v-ad8530a6]{text-decoration:underline}.endpoint .post[data-v-ad8530a6],.endpoint .get[data-v-ad8530a6],.endpoint .delete[data-v-ad8530a6],.endpoint .put[data-v-ad8530a6]{white-space:nowrap}.endpoint-method[data-v-ad8530a6],.endpoint-path[data-v-ad8530a6]{color:var(--scalar-color-1);min-width:62px;line-height:1.55;font-family:var(--scalar-font-code);font-size:var(--scalar-small);cursor:pointer;display:inline-flex}.endpoint-method[data-v-ad8530a6]{text-align:right}.endpoint-path[data-v-ad8530a6]{text-transform:initial;margin-left:12px}.deprecated[data-v-ad8530a6]{text-decoration:line-through}.endpoints-card[data-v-f726f753]{top:calc(var(--refs-viewport-offset) + 24px);font-size:var(--scalar-font-size-3);position:sticky}.endpoints[data-v-f726f753]{background:var(--scalar-background-2);width:100%;padding:10px 12px;overflow:auto}.section-container[data-v-8f1a275c]{border-top:var(--scalar-border-width) solid var(--scalar-border-color)}.section-container[data-v-8f1a275c]:has(.show-more){background-color:color-mix(in srgb, var(--scalar-background-2), transparent)}.operation-path[data-v-ec6c8861]{word-wrap:break-word;font-weight:var(--scalar-semibold);line-break:anywhere;overflow:hidden}.deprecated[data-v-ec6c8861]{text-decoration:line-through}.empty-state[data-v-6aae906d]{text-align:center;font-size:var(--scalar-mini);border-radius:var(--scalar-radius-lg);min-height:56px;color:var(--scalar-color-2);justify-content:center;align-items:center;margin:10px 0 10px 12px;display:flex}.rule-title[data-v-6aae906d]{font-family:var(--scalar-font-code);color:var(--scalar-color-1);border-radius:var(--scalar-radius);margin:12px 0 6px;display:inline-block}.rule[data-v-6aae906d]{border-radius:var(--scalar-radius-lg);margin:0 12px}.rule-items[data-v-6aae906d]{counter-reset:list-number;border-left:1px solid var(--scalar-border-color);flex-direction:column;gap:12px;padding:12px 0;display:flex}.rule-item[data-v-6aae906d]{counter-increment:list-number;border:1px solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-left:24px;overflow:hidden}.rule-item[data-v-6aae906d]:before{border:1px solid var(--scalar-border-color);content:" ";border-radius:0 0 0 var(--scalar-radius-lg);width:24px;height:6px;color:var(--scalar-color-1);border-top:0;border-right:0;margin-top:6px;display:block;position:absolute;transform:translate(-25px)}.tab[data-v-804dba49]{font-size:var(--scalar-small);font-family:var(--scalar-font);font-weight:var(--scalar-font-normal);color:var(--scalar-color-2);line-height:calc(var(--scalar-small) + 2px);white-space:nowrap;cursor:pointer;text-transform:uppercase;background:0 0;border:none;margin-right:3px;padding:0;line-height:22px;position:relative}.tab[data-v-804dba49]:before{content:"";z-index:0;border-radius:var(--scalar-radius);background:var(--scalar-background-3);opacity:0;width:calc(100% + 12px);height:calc(100% + 4px);position:absolute;top:-2px;left:-6px}.tab[data-v-804dba49]:hover:before,.tab[data-v-804dba49]:focus-visible:before{opacity:1}.tab[data-v-804dba49]:focus-visible:before{outline:1px solid var(--scalar-color-accent)}.tab span[data-v-804dba49]{z-index:1;position:relative}.tab-selected[data-v-804dba49]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}.tab-selected[data-v-804dba49]:after{content:"";width:100%;height:1px;left:0;bottom:calc(var(--tab-list-padding-y) * -1);background:currentColor;position:absolute}.tab-list[data-v-fec8fbbb]{--tab-list-padding-y:7px;--tab-list-padding-x:12px;padding:var(--tab-list-padding-y) var(--tab-list-padding-x);flex:1;gap:6px;display:flex;position:relative;overflow:auto}.scalar-card-header.scalar-card-header-tabs[data-v-fec8fbbb]{padding:0}.response-card[data-v-4e56e3cf]{font-size:var(--scalar-font-size-3)}.code-copy[data-v-4e56e3cf]{appearance:none;cursor:pointer;color:var(--scalar-color-3);background:0 0;border:none;outline:none;justify-content:center;align-items:center;margin-right:12px;padding:0;display:flex}.code-copy[data-v-4e56e3cf]:hover{color:var(--scalar-color-1)}.code-copy svg[data-v-4e56e3cf]{width:13px;height:13px}.response-card-footer[data-v-4e56e3cf]{flex-flow:row-reverse wrap;flex-shrink:0;justify-content:start;column-gap:8px;padding:7px 12px;display:flex}.response-example-selector[data-v-4e56e3cf]{flex-shrink:0;margin:-4px}.response-description[data-v-4e56e3cf]{font-weight:var(--scalar-semibold);font-size:var(--scalar-small);color:var(--scalar-color--1);box-sizing:border-box;flex-grow:1}.response-description-markdown[data-v-4e56e3cf]{max-height:3lh}.response-description-markdown[data-v-4e56e3cf] *{margin:0}.schema-type[data-v-4e56e3cf]{font-size:var(--scalar-micro);color:var(--scalar-color-2);font-weight:var(--scalar-semibold);background:var(--scalar-background-3);border-radius:4px;margin-right:4px;padding:2px 4px}.schema-example[data-v-4e56e3cf]{font-size:var(--scalar-micro);color:var(--scalar-color-2);font-weight:var(--scalar-semibold)}.example-response-tab[data-v-4e56e3cf]{margin:6px;display:block}.scalar-card-checkbox[data-v-4e56e3cf]{cursor:pointer;-webkit-user-select:none;user-select:none;min-height:17px;font-size:var(--scalar-small);font-weight:var(--scalar-font-normal);color:var(--scalar-color-2);white-space:nowrap;justify-content:center;align-items:center;gap:6px;width:fit-content;padding:7px 6px;display:flex;position:relative}.scalar-card-checkbox:has(.scalar-card-checkbox-input:focus-visible) .scalar-card-checkbox-checkmark[data-v-4e56e3cf]{outline:1px solid var(--scalar-color-accent)}.scalar-card-checkbox[data-v-4e56e3cf]:hover{color:var(--scalar-color--1)}.scalar-card-checkbox .scalar-card-checkbox-input[data-v-4e56e3cf]{opacity:0;cursor:pointer;width:0;height:0;position:absolute}.scalar-card-checkbox-checkmark[data-v-4e56e3cf]{border-radius:var(--scalar-radius);background-color:#0000;background-color:var(--scalar-background-3);width:16px;height:16px;box-shadow:inset 0 0 0 var(--scalar-border-width) var(--scalar-border-color)}.scalar-card-checkbox[data-v-4e56e3cf]:has(.scalar-card-checkbox-input:checked){color:var(--scalar-color-1);font-weight:var(--scalar-semibold)}.scalar-card-checkbox .scalar-card-checkbox-input:checked~.scalar-card-checkbox-checkmark[data-v-4e56e3cf]{background-color:var(--scalar-button-1);box-shadow:none}.scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{content:"";display:none;position:absolute}.scalar-card-checkbox .scalar-card-checkbox-input:checked~.scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{display:block}.scalar-card-checkbox .scalar-card-checkbox-checkmark[data-v-4e56e3cf]:after{border:solid 1px var(--scalar-button-1-color);border-width:0 1.5px 1.5px 0;width:5px;height:9px;top:12.5px;right:11.5px;transform:rotate(45deg)}.headers-card[data-v-ab19704d]{z-index:0;font-size:var(--scalar-font-size-4);color:var(--scalar-color-1);align-self:flex-start;margin-top:12px;margin-bottom:6px;position:relative}.headers-card.headers-card--open[data-v-ab19704d]{align-self:initial}.headers-card-title[data-v-ab19704d]{color:var(--scalar-color-3);font-weight:var(--scalar-semibold);font-size:var(--scalar-micro);border-radius:13.5px;align-items:center;gap:4px;padding:6px 10px;display:flex}button.headers-card-title[data-v-ab19704d]{cursor:pointer}button.headers-card-title[data-v-ab19704d]:hover{color:var(--scalar-color-1)}.headers-card-title-icon--open[data-v-ab19704d]{transform:rotate(45deg)}.headers-properties[data-v-ab19704d]{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:13.5px;flex-direction:column;width:fit-content;display:flex}.headers-properties-open>.headers-card-title[data-v-ab19704d]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);border-bottom-right-radius:0;border-bottom-left-radius:0}.headers-properties-open[data-v-ab19704d]{border-radius:var(--scalar-radius-lg);width:100%}.headers-card .property[data-v-ab19704d]:last-of-type{padding-bottom:10px}.headers-card-title>.headers-card-title-icon[data-v-ab19704d]{width:14px;height:14px;margin:0}.headers-card-title>.headers-card-title-icon--open[data-v-ab19704d]{transform:rotate(45deg)}.parameter-item[data-v-bd50387f]{border-top:var(--scalar-border-width) solid var(--scalar-border-color);flex-direction:column;display:flex;position:relative}.parameter-item:last-of-type .parameter-schema[data-v-bd50387f]{padding-bottom:0}.parameter-item-container[data-v-bd50387f]{padding:0}.parameter-item-headers[data-v-bd50387f]{border:var(--scalar-border-width) solid var(--scalar-border-color)}.parameter-item-name[data-v-bd50387f]{font-weight:var(--scalar-bold);font-size:var(--scalar-font-size-4);font-family:var(--scalar-font-code);color:var(--scalar-color-1);overflow-wrap:break-word;position:relative}.parameter-item-description[data-v-bd50387f],.parameter-item-description-summary[data-v-bd50387f]{font-size:var(--scalar-mini);color:var(--scalar-color-2)}.parameter-item-description-summary.parameter-item-description-summary[data-v-bd50387f]>*{--markdown-line-height:var(--scalar-line-height-5)}.parameter-item-trigger~.parameter-item-container[data-v-bd50387f] .property--level-0>.property-heading .property-detail-value{font-size:var(--scalar-micro)}.parameter-item-required-optional[data-v-bd50387f]{color:var(--scalar-color-2);font-weight:var(--scalar-semibold);margin-right:6px;position:relative}.parameter-item--required[data-v-bd50387f]{text-transform:uppercase;font-size:var(--scalar-micro);font-weight:var(--scalar-semibold);color:var(--scalar-color-orange)}.parameter-item-description[data-v-bd50387f]{font-size:var(--scalar-small);color:var(--scalar-color-2);margin-top:6px;line-height:1.4}.parameter-item-description[data-v-bd50387f] p{font-size:var(--scalar-small);color:var(--scalar-color-2);margin-top:4px}.parameter-schema[data-v-bd50387f]{margin-top:3px;padding-bottom:9px}.parameter-item-trigger[data-v-bd50387f]{line-height:var(--scalar-line-height-5);outline:none;flex-wrap:wrap;align-items:baseline;gap:6px;padding:10px 0;display:flex}.parameter-item-trigger-open[data-v-bd50387f]{padding-bottom:0}.parameter-item-icon[data-v-bd50387f]{color:var(--scalar-color-3);position:absolute;top:.5lh;left:-19px;translate:0 -50%}.parameter-item-trigger:hover .parameter-item-icon[data-v-bd50387f],.parameter-item-trigger:focus-visible .parameter-item-icon[data-v-bd50387f]{color:var(--scalar-color-1)}.parameter-item-trigger:focus-visible .parameter-item-icon[data-v-bd50387f]{outline:1px solid var(--scalar-color-accent);outline-offset:2px;border-radius:var(--scalar-radius)}.request-body[data-v-e475e108]{margin-top:24px}.request-body-header[data-v-e475e108]{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);flex-flow:wrap;justify-content:space-between;align-items:center;padding-bottom:12px;display:flex}.request-body-title[data-v-e475e108]{font-size:var(--scalar-font-size-2);font-weight:var(--scalar-semibold);color:var(--scalar-color-1);align-items:center;gap:8px;display:flex}.request-body-required[data-v-e475e108]{font-size:var(--scalar-micro);color:var(--scalar-color-orange);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:16px;height:20px;padding:2px 8px;font-weight:400}.request-body-description[data-v-e475e108]{font-size:var(--scalar-small);width:100%;margin-top:6px}:is(.request-body-header+.request-body-schema[data-v-e475e108]:has(>.schema-card>.schema-card-description),.request-body-header+.request-body-schema[data-v-e475e108]:has(>.schema-card>.schema-properties>*>.property--level-0)){padding-top:8px}.request-body-description[data-v-e475e108] .markdown *{color:var(--scalar-color-2)!important}.callback-sticky-offset[data-v-12fe1373]{top:var(--refs-viewport-offset,0px);z-index:1}.callback-operation-container[data-v-12fe1373] .request-body,.callback-operation-container[data-v-12fe1373] .request-body-description,.callback-operation-container[data-v-12fe1373] .request-body-header{margin-top:0}.callback-operation-container[data-v-12fe1373] .request-body-header{--scalar-font-size-2:var(--scalar-font-size-4);border-bottom:none;border:.5px solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg) var(--scalar-radius-lg) 0 0;background:color-mix(in srgb, var(--scalar-background-2) 50%, transparent);padding:10px}.callback-operation-container[data-v-12fe1373] .request-body-schema>.schema-card>.schema-card-description{padding-inline:8px}.callback-operation-container[data-v-12fe1373] ul li.property.property--level-1{padding:10px}.callback-operation-container[data-v-12fe1373] .request-body-schema{background-color:var(--scalar-background-1);border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg);border-top:none;overflow:hidden}.callback-operation-container[data-v-12fe1373] .parameter-list{margin-top:0}.callback-operation-container[data-v-12fe1373] .parameter-list-title{background:color-mix(in srgb, var(--scalar-background-2) 50%, transparent);border-radius:var(--scalar-radius-lg) var(--scalar-radius-lg) 0 0;border:var(--scalar-border-width) solid var(--scalar-border-color);--scalar-font-size-2:var(--scalar-font-size-4);border-bottom:none;margin-bottom:0;padding:10px}.callback-operation-container[data-v-12fe1373] .parameter-list-items{border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:0 0 var(--scalar-radius-lg) var(--scalar-radius-lg)}.callback-operation-container[data-v-12fe1373] .parameter-list-items>li:first-of-type{border-top:none}.callback-operation-container[data-v-12fe1373] .parameter-list-items>li{padding:0 8px}.show-api-client-button[data-v-15e312d9]{appearance:none;white-space:nowrap;border-radius:var(--scalar-radius);font-weight:var(--scalar-semibold);font-size:var(--scalar-small);color:var(--scalar-background-2);line-height:22px;font-family:var(--scalar-font);background:var(--scalar-button-1);cursor:pointer;box-sizing:border-box;outline-offset:2px;border:none;justify-content:center;align-items:center;padding:1px 6px;display:flex;position:relative;box-shadow:inset 0 0 0 1px #0000001a}.show-api-client-button span[data-v-15e312d9],.show-api-client-button svg[data-v-15e312d9]{fill:currentColor;color:var(--scalar-button-1-color);z-index:1}.show-api-client-button[data-v-15e312d9]:hover{background:var(--scalar-button-1-hover)}.show-api-client-button svg[data-v-15e312d9]{margin-right:4px}.operation-title[data-v-8f643b1c]{justify-content:space-between;display:flex}.operation-details[data-v-8f643b1c]{flex-shrink:1;align-items:center;gap:9px;min-width:0;margin-top:0;display:flex}.operation-details[data-v-8f643b1c] .endpoint-anchor .scalar-button svg{width:16px;height:16px}.endpoint-type[data-v-8f643b1c]{z-index:0;width:60px;font-size:var(--scalar-small);text-transform:uppercase;font-weight:var(--scalar-bold);font-family:var(--scalar-font);flex-shrink:0;justify-content:center;align-items:center;gap:6px;padding:6px;display:flex;position:relative}.endpoint-type[data-v-8f643b1c]:after{content:"";z-index:-1;opacity:.15;border-radius:var(--scalar-radius);background:currentColor;position:absolute;inset:0}.endpoint-anchor[data-v-8f643b1c]{flex-shrink:1;align-items:center;min-width:0;display:flex}.endpoint-anchor.label[data-v-8f643b1c]{display:flex}.endpoint-label[data-v-8f643b1c]{min-width:0;color:var(--scalar-color-1);flex-shrink:1;align-items:baseline;gap:9px;display:flex}.endpoint-label-path[data-v-8f643b1c]{font-family:var(--scalar-font-code);font-size:var(--scalar-mini);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.endpoint-label-path[data-v-8f643b1c] em{color:var(--scalar-color-2)}.endpoint-label-name[data-v-8f643b1c]{color:var(--scalar-color-2);font-size:var(--scalar-small);text-overflow:ellipsis;white-space:nowrap;flex-shrink:1000000000;overflow:hidden}.endpoint-try-hint[data-v-8f643b1c]{flex-shrink:0;padding:2px}.endpoint-copy[data-v-8f643b1c]{color:currentColor}.endpoint-copy[data-v-8f643b1c] svg{stroke-width:2px}.endpoint-content[data-v-8f643b1c]{grid-auto-columns:1fr;grid-auto-flow:row;gap:9px;padding:9px;display:grid}@media (width>=1000px){.endpoint-content[data-v-8f643b1c]{grid-auto-flow:column}}@container (width<=900px){.endpoint-content[data-v-8f643b1c]{grid-template-columns:1fr}}.endpoint-content[data-v-8f643b1c]>*{min-width:0}.operation-details-card[data-v-8f643b1c]{flex-direction:column;gap:12px;min-width:0;display:flex}:is(.operation-details-card-item[data-v-8f643b1c] .parameter-list,.operation-details-card-item[data-v-8f643b1c] .callbacks-list){border:var(--scalar-border-width) solid var(--scalar-border-color);border-radius:var(--scalar-radius-lg);margin-top:0}.operation-details-card-item[data-v-8f643b1c]{flex-direction:column;gap:12px;display:flex}.operation-details-card-item[data-v-8f643b1c] .parameter-list-items{margin-bottom:0}.operation-details-card[data-v-8f643b1c] .parameter-item:last-of-type .parameter-schema{padding-bottom:12px}.operation-details-card[data-v-8f643b1c] .parameter-list .parameter-list{margin-bottom:12px}.operation-details-card[data-v-8f643b1c] .parameter-item{margin:0;padding:0}.operation-details-card[data-v-8f643b1c] .property{margin:0;padding:9px}:is(.operation-details-card[data-v-8f643b1c] .parameter-list-title,.operation-details-card[data-v-8f643b1c] .request-body-title,.operation-details-card[data-v-8f643b1c] .callbacks-title){text-transform:uppercase;font-weight:var(--scalar-bold);font-size:var(--scalar-mini);color:var(--scalar-color-2);margin:0;padding:9px;line-height:1.33}.operation-details-card[data-v-8f643b1c] .callback-list-item-title{padding-left:28px;padding-right:12px}.operation-details-card[data-v-8f643b1c] .callback-list-item-icon{left:6px}.operation-details-card[data-v-8f643b1c] .callback-operation-container{padding-inline:9px;padding-bottom:9px}:is(.operation-details-card[data-v-8f643b1c] .callback-operation-container>.request-body,.operation-details-card[data-v-8f643b1c] .callback-operation-container>.parameter-list){border:none}.operation-details-card[data-v-8f643b1c] .callback-operation-container>.request-body>.request-body-header{border-bottom:var(--scalar-border-width) solid var(--scalar-border-color);padding:0 0 9px}.operation-details-card[data-v-8f643b1c] .request-body-description{border-top:var(--scalar-border-width) solid var(--scalar-border-color);margin-top:0;padding:9px 9px 0}.operation-details-card[data-v-8f643b1c] .request-body{border-radius:var(--scalar-radius-lg);border:var(--scalar-border-width) solid var(--scalar-border-color);margin-top:0}.operation-details-card[data-v-8f643b1c] .request-body .schema-card--level-0>.schema-card-description{padding-inline:9px}.operation-details-card[data-v-8f643b1c] .request-body-header{border-bottom:0;padding-bottom:0}.operation-details-card[data-v-8f643b1c] .contents button{margin-right:9px}.operation-details-card[data-v-8f643b1c] .schema-card--open+.schema-card:not(.schema-card--open){margin-inline:9px;margin-bottom:9px}.operation-details-card[data-v-8f643b1c] .request-body-schema .property--level-0{padding:0}.operation-details-card[data-v-8f643b1c] .selected-content-type{margin-right:9px}.operation-example-card[data-v-8f643b1c]{top:calc(var(--refs-viewport-offset) + 24px);max-height:calc(var(--refs-viewport-height) - 48px);position:sticky}@media (width<=600px){.operation-example-card[data-v-8f643b1c]{max-height:unset;position:static}}.agent-button-container[data-v-78f5377c]{color:var(--scalar-color-1);background:color-mix(in srgb, var(--scalar-background-3), white 15%);cursor:pointer;border-radius:var(--scalar-radius);z-index:2;align-items:center;height:100%;margin-right:4px;padding:1px 6px;display:flex;position:relative}.agent-button-container[data-v-78f5377c]:hover:not(:focus-within){background:color-mix(in srgb, var(--scalar-background-3), white 20%)}.agent-button-container[data-v-78f5377c]:focus-within{border-radius:var(--scalar-radius-lg);height:auto;margin-right:0;position:absolute;inset:2px}.agent-button-container[data-v-78f5377c]:has(.ask-agent-scalar-input-not-empty){border-radius:var(--scalar-radius-lg);height:auto;margin-right:0;position:absolute;inset:2px}.agent-button-container[data-v-78f5377c]:has(.ask-agent-scalar-input:focus-visible){outline-style:solid}.ask-agent-scalar-input[data-v-78f5377c]{opacity:0;border:none;width:0;font-size:0}.agent-button-container:focus-within .ask-agent-scalar-input[data-v-78f5377c]{width:100%;font-size:inherit;opacity:1;outline:none;padding-inline:4px;line-height:20px}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-input[data-v-78f5377c]{width:100%;font-size:inherit;opacity:1;outline:none;padding-inline:4px;line-height:20px}.ask-agent-scalar-input[data-v-78f5377c]::placeholder{color:var(--scalar-color-2);font-family:inherit}.ask-agent-scalar-input-label[data-v-78f5377c]{color:var(--scalar-color-1);font-weight:var(--scalar-semibold);margin-left:4px}.agent-button-container:focus-within .ask-agent-scalar-input-label[data-v-78f5377c]{display:none}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-input-label[data-v-78f5377c]{display:none}.ask-agent-scalar-send[data-v-78f5377c]{background:var(--scalar-color-blue);color:#fff;border-radius:var(--scalar-radius);outline-offset:1px;flex-shrink:0;justify-content:center;align-items:center;width:24px;height:24px;display:none}.agent-button-container:has(.ask-agent-scalar-input-not-empty) .ask-agent-scalar-send[data-v-78f5377c]{display:flex}.ask-agent-scalar-send[data-v-78f5377c]:hover{background:color-mix(in srgb, var(--scalar-color-blue), transparent 10%)}.examples[data-v-1f2d95d2]{top:calc(var(--refs-viewport-offset) + 24px);position:sticky}.examples[data-v-1f2d95d2]>*{max-height:calc((var(--refs-viewport-height) - 60px) / 2);position:relative}.examples[data-v-1f2d95d2]>:first-of-type:last-of-type{max-height:calc((var(--refs-viewport-height) - 60px))}@media (width<=600px){.examples[data-v-1f2d95d2]>*{max-height:unset}}.deprecated[data-v-1f2d95d2] *{text-decoration:line-through}.operation-header[data-v-1f2d95d2]{flex-direction:column;justify-content:space-between;align-items:flex-start;gap:4px;margin-bottom:12px;display:flex}@media (width>=600px){.operation-header[data-v-1f2d95d2]{flex-direction:row;align-items:center}}.section-flare[data-v-5cebff7a]{pointer-events:none;position:fixed;top:0;right:0}.narrow-references-container{container:narrow-references-container/inline-size}.ref-search-meta[data-v-9b0e55ef]{background:var(--scalar-background-1);border-bottom-left-radius:var(--scalar-radius-lg);border-bottom-right-radius:var(--scalar-radius-lg);font-size:var(--scalar-font-size-4);color:var(--scalar-color-3);font-weight:var(--scalar-semibold);border-top:var(--scalar-border-width) solid var(--scalar-border-color);gap:12px;padding:6px 12px;display:flex}:root{--scalar-loaded-api-reference:true}@layer scalar-config{.scalar-api-reference[data-v-e26e2526]{--refs-header-height:calc(var(--scalar-custom-header-height,0px) + var(--scalar-header-height,0px));--refs-viewport-offset:calc(var(--refs-header-height,0px) + var(--refs-content-offset,0px));--refs-viewport-height:calc(var(--full-height,100dvh) - var(--refs-viewport-offset,0px));--refs-sidebar-width:var(--scalar-sidebar-width,0px);--refs-sidebar-height:calc(var(--full-height,100dvh) - var(--refs-header-height,0px));--refs-content-max-width:var(--scalar-content-max-width,1540px)}.scalar-api-reference.references-classic[data-v-e26e2526]{--refs-content-max-width:var(--scalar-content-max-width,1420px);--refs-sidebar-width:0;min-height:100dvh}.references-sidebar[data-v-e26e2526]{--refs-sidebar-width:var(--scalar-sidebar-width,288px)}}.t-doc__sidebar[data-v-e26e2526]{z-index:10}.references-layout[data-v-e26e2526]{--full-height:100dvh;grid-template-rows:var(--scalar-header-height,0px) repeat(2, auto);background:var(--scalar-background-1);flex:1;grid-template-columns:auto 1fr;grid-template-areas:"header header""navigation rendered""footer footer";min-width:100%;max-width:100%;min-height:100dvh;display:grid}.references-editor[data-v-e26e2526]{background:var(--scalar-background-1);grid-area:editor;min-width:0;display:flex}.references-rendered[data-v-e26e2526]{background:var(--scalar-background-1);grid-area:rendered;min-width:0;position:relative}.scalar-api-reference.references-classic[data-v-e26e2526],.references-classic .references-rendered[data-v-e26e2526]{height:initial!important;max-height:initial!important}.references-footer[data-v-e26e2526]{grid-area:footer}@media (width<=1000px){.references-developer-tools[data-v-e26e2526]{display:none}.references-layout[data-v-e26e2526]{--refs-sidebar-height:calc(var(--full-height,100dvh) - var(--scalar-custom-header-height,0px));grid-template-columns:100%;grid-template-rows:var(--scalar-header-height,0px) 0px auto auto;grid-template-areas:"header""navigation""rendered""footer"}.references-editable[data-v-e26e2526]{grid-template-areas:"header""navigation""editor"}.references-rendered[data-v-e26e2526]{position:static}.scalar-api-references-standalone-mobile[data-v-e26e2526]:not(.references-classic){--scalar-header-height:50px}}.darklight-reference[data-v-e26e2526]{width:100%;margin-top:auto}/*$vite$:1*/`)),document.head.appendChild(e)}}catch(e){console.error(`vite-plugin-css-injected-by-js`,e)}})(); -(function(e,t){typeof exports==`object`&&typeof module<`u`?t(require(`radix-vue/namespaced`)):typeof define==`function`&&define.amd?define([`radix-vue/namespaced`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e[`{}`]))})(this,function(e){var t=Object.create,n=Object.defineProperty,r=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getPrototypeOf,o=Object.prototype.hasOwnProperty,s=(e,t)=>()=>(e&&(t=e(e=0)),t),c=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),l=(e,t)=>{let r={};for(var i in e)n(r,i,{get:e[i],enumerable:!0});return t||n(r,Symbol.toStringTag,{value:`Module`}),r},u=(e,t,a,s)=>{if(t&&typeof t==`object`||typeof t==`function`)for(var c=i(t),l=0,u=c.length,d;lt[e]).bind(null,d),enumerable:!(s=r(t,d))||s.enumerable});return e},d=(e,r,i)=>(i=e==null?{}:t(a(e)),u(r||!e||!e.__esModule?n(i,`default`,{value:e,enumerable:!0}):i,e)),f,p=s((()=>{f=e=>{if(typeof e!=`object`||!e)return!1;let t=Object.getPrototypeOf(e);return t===Object.prototype||t===null}})),m,h=s((()=>{p(),m=(e,t)=>{if(!e)return!1;if(e.type===`any`||e.type===`unknown`)return!0;if(e.type===`function`)return typeof t==`function`;if(e.type===`number`)return typeof t==`number`&&!Number.isNaN(t)&&Number.isFinite(t);if(e.type===`string`)return typeof t==`string`;if(e.type===`boolean`)return typeof t==`boolean`;if(e.type===`nullable`)return t===null;if(e.type===`notDefined`)return t===void 0;if(e.type===`array`)return Array.isArray(t)&&t.every(t=>m(e.items,t));if(e.type===`record`)return f(t)?Object.keys(t).every(n=>m(e.key,n)&&m(e.value,t[n])):!1;if(e.type===`object`)return f(t)?Object.keys(e.properties).every(n=>m(e.properties[n],t[n])):!1;if(e.type===`optional`)return t===void 0||m(e.schema,t);if(e.type===`union`)return e.schemas.some(e=>m(e,t));if(e.type===`intersection`)return e.schemas.length===0?!0:f(t)?e.schemas.every(e=>m(e,t)):!1;if(e.type===`literal`)return t===e.value;if(e.type===`lazy`)return m(e.schema(),t);if(e.type===`evaluate`)return m(e.schema,e.expression(t));let n=e;return console.warn(`Unknown schema type:`,n),!1}})),g,_,v,y=s((()=>{p(),h(),g=e=>e.type===`optional`?g(e.schema):e.type===`literal`?!0:e.type===`union`?e.schemas.length>0&&e.schemas.every(g):!1,_=(e,t)=>e.type===`object`?f(t)?Object.keys(e.properties).reduce((n,r)=>{if(!(r in t))return n;let i=e.properties[r],a=t[r],o=_(i,a);return g(i)?n+(o>0?o*10:0):n+(o>0?o:1)},0):0:e.type===`array`?Array.isArray(t)?1:0:e.type===`record`?f(t)?1:0:e.type===`optional`?t===void 0?1:_(e.schema,t):e.type===`union`?Math.max(...e.schemas.map(e=>_(e,t))):e.type===`intersection`?e.schemas.length===0?1:e.schemas.reduce((e,n)=>e+_(n,t),0):e.type===`lazy`?_(e.schema(),t):e.type===`evaluate`?_(e.schema,e.expression(t)):m(e,t)?1:0,v=(e,t,n=new WeakMap)=>{if(f(t)&&n.get(t)?.has(e))return t;if(f(t)){let r=n.get(t)||new Set;r.add(e),n.set(t,r)}if(!e||e.type===`any`||e.type===`unknown`)return t;if(e.type===`function`)return typeof t==`function`?t:(()=>void 0);if(e.type===`number`)return m(e,t)?t:e.default??0;if(e.type===`string`)return m(e,t)?t:e.default??``;if(e.type===`boolean`)return m(e,t)?t:e.default??!1;if(e.type===`nullable`)return null;if(e.type===`notDefined`)return;if(e.type===`optional`)return t===void 0?void 0:v(e.schema,t,n);if(e.type===`array`)return Array.isArray(t)?t.map(t=>v(e.items,t,n)):[];if(e.type===`record`)return f(t)?Object.fromEntries(Object.entries(t).map(([t,r])=>[t,v(e.value,r,n)])):{};if(e.type===`object`){let r=Object.keys(e.properties),i=f(t)?t:null,a=[];for(let t of r){let r=e.properties[t],o=i?.[t];r.type===`optional`&&o===void 0||a.push([t,v(r,o,n)])}return Object.fromEntries(a)}if(e.type===`union`)return v(e.schemas.reduce((e,n)=>{let r=_(n,t);return r>e.score?{schema:n,score:r}:e},{schema:e.schemas[0],score:0}).schema,t,n);if(e.type===`intersection`)return e.schemas.reduce((e,r)=>Object.assign(e,v(r,t,n)),{});if(e.type===`literal`)return e.value;if(e.type===`lazy`)return v(e.schema(),t,n);if(e.type===`evaluate`)return v(e.schema,e.expression(t),n);let r=e;return console.warn(`Unknown schema type:`,r),t}})),b,x,S,C,w,ee,te,ne,re,T,ie,ae,E,D,oe,se,ce=s((()=>{b=e=>({type:`number`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),x=e=>({type:`string`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),S=e=>({type:`boolean`,default:e?.default,typeName:e?.typeName,typeComment:e?.typeComment}),C=e=>({type:`nullable`,typeName:e?.typeName,typeComment:e?.typeComment}),w=e=>({type:`any`,typeName:e?.typeName,typeComment:e?.typeComment}),ee=e=>({type:`unknown`,typeName:e?.typeName,typeComment:e?.typeComment}),te=e=>({type:`function`,typeName:e?.typeName,typeComment:e?.typeComment}),ne=(e,t)=>({type:`array`,items:e,typeName:t?.typeName,typeComment:t?.typeComment}),re=(e,t,n)=>({type:`record`,key:e,value:t,typeName:n?.typeName,typeComment:n?.typeComment}),T=(e,t)=>({type:`object`,properties:e,typeName:t?.typeName,typeComment:t?.typeComment}),ie=(e,t)=>({type:`union`,schemas:e,typeName:t?.typeName,typeComment:t?.typeComment}),ae=(e,t)=>({type:`intersection`,schemas:e,typeName:t?.typeName,typeComment:t?.typeComment}),E=(e,t)=>({type:`optional`,schema:e,typeName:t?.typeName,typeComment:t?.typeComment}),D=e=>({type:`literal`,value:e}),oe=e=>({type:`lazy`,schema:e}),se=(e,t)=>({type:`evaluate`,expression:e,schema:t})})),le=s((()=>{y(),ce(),h()})),ue,de,fe=s((()=>{le(),T({name:x({typeComment:"Name of specification extension property. Has to start with `x-`."}),component:ee({typeComment:`Vue component to render the specification extension`}),renderer:E(ee({typeComment:`Custom renderer to render the specification extension`}))}),ue=T({component:ee({typeComment:`Vue component to render in the view`}),renderer:E(ee({typeComment:`Custom renderer to render the view component (e.g., ReactRenderer)`})),props:E(re(x(),w()),{typeComment:`Additional props to pass to the component`})}),T({"content.end":E(ne(ue),{typeComment:`Components to render at specific views in the API Reference`})}),T({onInit:E(te()),onConfigChange:E(te()),onDestroy:E(te())}),de=te()})),pe,me,he=s((()=>{le(),pe=T({dashboardUrl:x({default:`https://dashboard.scalar.com`}),registryUrl:x({default:`https://registry.scalar.com`}),proxyUrl:x({default:`https://proxy.scalar.com`}),apiBaseUrl:x({default:`https://api.scalar.com`})},{typeComment:`External service URLs used by Scalar packages`}),me=T({title:E(x(),{typeComment:`The title of the OpenAPI document.`}),slug:E(x(),{typeComment:`The slug of the OpenAPI document used in the URL. If none is passed, the title will be used. If no title is used, it will just use the index.`}),authentication:E(w(),{typeComment:`Prefill authentication`}),baseServerURL:E(x(),{typeComment:`Base URL for the API server`}),hideClientButton:S({default:!1,typeComment:`Whether to hide the client button`}),proxyUrl:E(x(),{typeComment:`URL to a request proxy for the API client`}),oauth2RedirectUri:E(x(),{typeComment:`Default OAuth 2.0 redirect URI used to prefill auth flows in the API client.`}),searchHotKey:E(ie([D(`a`),D(`b`),D(`c`),D(`d`),D(`e`),D(`f`),D(`g`),D(`h`),D(`i`),D(`j`),D(`k`),D(`l`),D(`m`),D(`n`),D(`o`),D(`p`),D(`q`),D(`r`),D(`s`),D(`t`),D(`u`),D(`v`),D(`w`),D(`x`),D(`y`),D(`z`)]),{typeComment:`Key used with CTRL/CMD to open the search modal (defaults to 'k' e.g. CMD+k)`}),servers:E(ne(w()),{typeComment:`List of OpenAPI server objects`}),showSidebar:S({default:!0,typeComment:`Whether to show the sidebar`}),showDeveloperTools:ie([D(`localhost`),D(`always`),D(`never`)],{typeComment:`Whether and when to show the developer tools.`}),showToolbar:ie([D(`localhost`),D(`always`),D(`never`)],{typeComment:`@deprecated Use showDeveloperTools instead`}),operationTitleSource:ie([D(`summary`),D(`path`)],{typeComment:`Whether to use the operation summary or the operation path for the sidebar and search`}),theme:ie([D(`default`),D(`alternate`),D(`moon`),D(`purple`),D(`solarized`),D(`bluePlanet`),D(`deepSpace`),D(`saturn`),D(`kepler`),D(`elysiajs`),D(`fastify`),D(`mars`),D(`laserwave`),D(`none`)],{typeComment:`A string to use one of the color presets`}),_integration:E(ie([D(`adonisjs`),D(`astro`),D(`docusaurus`),D(`dotnet`),D(`elysiajs`),D(`express`),D(`fastapi`),D(`fastify`),D(`go`),D(`hono`),D(`html`),D(`laravel`),D(`litestar`),D(`nestjs`),D(`nextjs`),D(`nitro`),D(`nuxt`),D(`platformatic`),D(`react`),D(`rust`),D(`svelte`),D(`vue`),C()]),{typeComment:`Integration type identifier`}),onRequestSent:E(te(),{typeComment:`onRequestSent is fired when a request is sent`}),persistAuth:S({default:!1,typeComment:`Whether to persist auth to local storage`}),telemetry:S({default:!0,typeComment:`Enables / disables telemetry`}),externalUrls:pe})})),ge,_e,ve=s((()=>{le(),ge=ie([x(),C(),re(x(),w()),te()]),_e=T({default:S({default:!1}),url:E(x(),{typeComment:`URL to an OpenAPI/Swagger document`}),content:E(ge,{typeComment:`Directly embed the OpenAPI document. Can be a string, object, function returning an object, or null. It is recommended to pass a URL instead of content.`}),title:E(x(),{typeComment:"The title of the OpenAPI document. @deprecated Please move `title` to the top level and remove the `spec` prefix."}),slug:E(x(),{typeComment:"The slug of the OpenAPI document used in the URL. @deprecated Please move `slug` to the top level and remove the `spec` prefix."}),spec:E(T({url:E(x()),content:E(ge)}),{typeComment:"@deprecated Use `url` and `content` on the top level instead."}),agent:E(T({key:E(x()),disabled:E(S()),hideAddApi:E(S(),{typeComment:`When true, hide the control to add more APIs in the agent chat. Only preloaded/registry documents are shown; the public API list is not offered.`})}),{typeComment:`Agent Scalar configuration`})})})),ye,be,xe,Se,Ce=s((()=>{le(),fe(),he(),ve(),ye=ae([me,_e,T({layout:ie([D(`modern`),D(`classic`)],{typeComment:`The layout to use for the references`}),proxy:E(x(),{typeComment:`@deprecated Use proxyUrl instead`}),fetch:E(te(),{typeComment:`Custom fetch function for custom logic. Can be used to add custom headers, handle auth, etc.`}),plugins:E(ne(de),{typeComment:`Plugins for the API reference`}),isEditable:S({default:!1,typeComment:`Allows the user to inject an editor for the spec`}),isLoading:S({default:!1,typeComment:`Controls whether the references show a loading state in the intro`}),hideModels:S({default:!1,typeComment:`Whether to show models in the sidebar, search, and content.`}),documentDownloadType:ie([D(`both`),D(`yaml`),D(`json`),D(`direct`),D(`none`)],{typeComment:"Sets the file type of the document to download, set to `none` to hide the download button"}),hideDownloadButton:E(S(),{typeComment:"@deprecated Use `documentDownloadType: 'none'` instead"}),hideTestRequestButton:S({default:!1,typeComment:`Whether to show the "Test Request" button`}),hideSearch:S({default:!1,typeComment:`Whether to show the sidebar search bar`}),showOperationId:S({default:!1,typeComment:`Whether to show the operationId`}),darkMode:E(S(),{typeComment:`Whether dark mode is on or off initially (light mode)`}),forceDarkModeState:E(ie([D(`dark`),D(`light`)]),{typeComment:`forceDarkModeState makes it always this state no matter what`}),hideDarkModeToggle:S({default:!1,typeComment:`Whether to show the dark mode toggle`}),metaData:E(w(),{typeComment:`If used, passed data will be added to the HTML header. @see https://unhead.unjs.io/usage/composables/use-seo-meta`}),favicon:E(x(),{typeComment:`Path to a favicon image`}),hiddenClients:E(ie([re(x(),ie([S(),ne(x())])),ne(x()),D(!0)]),{typeComment:"List of httpsnippet clients to hide from the clients menu. By default hides Unirest, pass `[]` to show all clients"}),defaultHttpClient:E(T({targetKey:x(),clientKey:x()}),{typeComment:`Determine the HTTP client that is selected by default`}),customCss:E(x(),{typeComment:`Custom CSS to be added to the page`}),onSpecUpdate:E(te(),{typeComment:`onSpecUpdate is fired on spec/swagger content change`}),onServerChange:E(te(),{typeComment:`onServerChange is fired on selected server change`}),onDocumentSelect:E(te(),{typeComment:`onDocumentSelect is fired when the config is selected`}),onLoaded:E(te(),{typeComment:`Callback fired when the reference is fully loaded`}),onBeforeRequest:E(te(),{typeComment:`Fired before the outbound request is built; callback receives a mutable request builder. Experimental API.`}),onShowMore:E(te(),{typeComment:`onShowMore is fired when the user clicks the "Show more" button on the references`}),onSidebarClick:E(te(),{typeComment:`onSidebarClick is fired when the user clicks on a sidebar item`}),pathRouting:E(T({basePath:x()}),{typeComment:`Route using paths instead of hashes, your server MUST support this. @experimental`}),mcp:E(T({name:E(x(),{typeComment:`Display name for the MCP server`}),url:E(x(),{typeComment:`URL of the MCP server`}),disabled:E(S(),{typeComment:`When true, disables the MCP integration`})}),{typeComment:`MCP (Model Context Protocol) configuration. When provided, enables MCP integration with the given name and url.`}),generateHeadingSlug:E(te(),{typeComment:`Customize the heading portion of the hash`}),generateModelSlug:E(te(),{typeComment:`Customize the model portion of the hash`}),generateTagSlug:E(te(),{typeComment:`Customize the tag portion of the hash`}),generateOperationSlug:E(te(),{typeComment:`Customize the operation portion of the hash`}),generateWebhookSlug:E(te(),{typeComment:`Customize the webhook portion of the hash`}),redirect:E(te(),{typeComment:`To handle redirects, pass a function that receives the current path/hash and passes that to history.replaceState`}),withDefaultFonts:S({default:!0,typeComment:`Whether to include default fonts`}),defaultOpenFirstTag:S({default:!0,typeComment:`Whether to expand the first tag in the sidebar when no specific URL target is present`}),defaultOpenAllTags:S({default:!1,typeComment:`Whether to expand all tags by default. Warning: this can cause performance issues on big documents`}),expandAllModelSections:S({default:!1,typeComment:`Whether to expand all models by default. Warning: this can cause performance issues on big documents`}),expandAllResponses:S({default:!1,typeComment:`Whether to expand all responses by default. Warning: this can cause performance issues on big documents`}),tagsSorter:E(ie([D(`alpha`),te()]),{typeComment:`Function to sort tags`}),operationsSorter:E(ie([D(`alpha`),D(`method`),te()]),{typeComment:`Function to sort operations`}),orderSchemaPropertiesBy:ie([D(`alpha`),D(`preserve`)],{typeComment:`Order the schema properties by`}),orderRequiredPropertiesFirst:S({default:!0,typeComment:`Sort the schema properties by required ones first`})})]),be=`https://api.scalar.com/request-proxy`,xe=`https://proxy.scalar.com`,Se=e=>{let t=v(ye,e);return t.hideDownloadButton&&(console.warn(`[DEPRECATED] You're using the deprecated 'hideDownloadButton' attribute. Use 'documentDownloadType: 'none'' instead.`),t.documentDownloadType=`none`),t.spec?.url&&(console.warn(`[DEPRECATED] You're using the deprecated 'spec.url' attribute. Remove the spec prefix and move the 'url' attribute to the top level.`),t.url=t.spec.url,delete t.spec),t.spec?.content&&(console.warn(`[DEPRECATED] You're using the deprecated 'spec.content' attribute. Remove the spec prefix and move the 'content' attribute to the top level.`),t.content=t.spec.content,delete t.spec),t.proxy&&(console.warn(`[DEPRECATED] You're using the deprecated 'proxy' attribute. Use 'proxyUrl' instead.`),t.proxyUrl||=t.proxy,delete t.proxy),t.proxyUrl===be&&(console.warn(`[DEPRECATED] Warning: configuration.proxyUrl points to our old proxy (${be}).`),console.warn(`[DEPRECATED] We are overwriting the value and use the new proxy URL (${xe}) instead.`),console.warn(`[DEPRECATED] Action Required: You should manually update your configuration to use the new URL (${xe}). Read more: https://github.com/scalar/scalar`),t.proxyUrl=xe),t.showToolbar&&t.showToolbar!==`localhost`&&(console.warn(`[DEPRECATED] You're using the deprecated 'showToolbar' attribute. Use 'showDeveloperTools' instead.`),t.showDeveloperTools=t.showToolbar,delete t.showToolbar),t}})),we=s((()=>{le(),T({cdn:x({default:`https://cdn.jsdelivr.net/npm/@scalar/api-reference`}),pageTitle:x({default:`Scalar API Reference`})})})),Te=s((()=>{Ce(),we()}));Te();function Ee(e,t={},n){for(let r in e){let i=e[r],a=n?`${n}:${r}`:r;typeof i==`object`&&i?Ee(i,t,a):typeof i==`function`&&(t[a]=i)}return t}var De=(()=>{if(console.createTask)return console.createTask;let e={run:e=>e()};return()=>e})();function Oe(e,t,n,r){for(let i=n;ie[i](...t)):e[i](...t);if(n instanceof Promise)return n.then(()=>Oe(e,t,i+1,r))}catch(e){return Promise.reject(e)}}function ke(e,t,n){if(e.length>0)return Oe(e,t,0,De(n))}function Ae(e,t,n){if(e.length>0){let r=De(n);return Promise.all(e.map(e=>r.run(()=>e(...t))))}}function je(e,t){for(let n of[...e])n(t)}var Me=class{_hooks;_before;_after;_deprecatedHooks;_deprecatedMessages;constructor(){this._hooks={},this._before=void 0,this._after=void 0,this._deprecatedMessages=void 0,this._deprecatedHooks={},this.hook=this.hook.bind(this),this.callHook=this.callHook.bind(this),this.callHookWith=this.callHookWith.bind(this)}hook(e,t,n={}){if(!e||typeof t!=`function`)return()=>{};let r=e,i;for(;this._deprecatedHooks[e];)i=this._deprecatedHooks[e],e=i.to;if(i&&!n.allowDeprecated){let e=i.message;e||=`${r} hook has been deprecated`+(i.to?`, please use ${i.to}`:``),this._deprecatedMessages||=new Set,this._deprecatedMessages.has(e)||(console.warn(e),this._deprecatedMessages.add(e))}if(!t.name)try{Object.defineProperty(t,`name`,{get:()=>`_`+e.replace(/\W+/g,`_`)+`_hook_cb`,configurable:!0})}catch{}return this._hooks[e]=this._hooks[e]||[],this._hooks[e].push(t),()=>{t&&=(this.removeHook(e,t),void 0)}}hookOnce(e,t){let n,r=(...e)=>(typeof n==`function`&&n(),n=void 0,r=void 0,t(...e));return n=this.hook(e,r),n}removeHook(e,t){let n=this._hooks[e];if(n){let r=n.indexOf(t);r!==-1&&n.splice(r,1),n.length===0&&(this._hooks[e]=void 0)}}deprecateHook(e,t){this._deprecatedHooks[e]=typeof t==`string`?{to:t}:t;let n=this._hooks[e]||[];this._hooks[e]=void 0;for(let t of n)this.hook(e,t)}deprecateHooks(e){for(let t in e)this.deprecateHook(t,e[t])}addHooks(e){let t=Ee(e),n=Object.keys(t).map(e=>this.hook(e,t[e]));return()=>{for(let e of n)e();n.length=0}}removeHooks(e){let t=Ee(e);for(let e in t)this.removeHook(e,t[e])}removeAllHooks(){this._hooks={}}callHook(e,...t){return this.callHookWith(ke,e,t)}callHookParallel(e,...t){return this.callHookWith(Ae,e,t)}callHookWith(e,t,n){let r=this._before||this._after?{name:t,args:n,context:{}}:void 0;this._before&&je(this._before,r);let i=e(this._hooks[t]?[...this._hooks[t]]:[],n,t);return i instanceof Promise?i.finally(()=>{this._after&&r&&je(this._after,r)}):(this._after&&r&&je(this._after,r),i)}beforeEach(e){return this._before=this._before||[],this._before.push(e),()=>{if(this._before!==void 0){let t=this._before.indexOf(e);t!==-1&&this._before.splice(t,1)}}}afterEach(e){return this._after=this._after||[],this._after.push(e),()=>{if(this._after!==void 0){let t=this._after.indexOf(e);t!==-1&&this._after.splice(t,1)}}}};function Ne(){return new Me}var Pe=new Set([`link`,`style`,`script`,`noscript`]),Fe=new Set([`title`,`titleTemplate`,`script`,`style`,`noscript`]),Ie=new Set([`base`,`meta`,`link`,`style`,`script`,`noscript`]),Le=new Set([`title`,`base`,`htmlAttrs`,`bodyAttrs`,`meta`,`link`,`style`,`script`,`noscript`]),Re=new Set([`base`,`title`,`titleTemplate`,`bodyAttrs`,`htmlAttrs`,`templateParams`]),ze=new Set([`key`,`tagPosition`,`tagPriority`,`tagDuplicateStrategy`,`innerHTML`,`textContent`,`processTemplateParams`]),Be=new Set([`templateParams`,`htmlAttrs`,`bodyAttrs`]),Ve=new Set([`theme-color`,`google-site-verification`,`og`,`article`,`book`,`profile`,`twitter`,`author`]),He=[`name`,`property`,`http-equiv`],Ue=new Set([`viewport`,`description`,`keywords`,`robots`]);function We(e){let t=e.split(`:`);return t.length?Ve.has(t[1]):!1}function Ge(e){let{props:t,tag:n}=e;if(Re.has(n))return n;if(n===`link`&&t.rel===`canonical`)return`canonical`;if(n===`link`&&t.rel===`alternate`){let e=t.hreflang||t.type;if(e)return`alternate:${e}`}if(t.charset)return`charset`;if(e.tag===`meta`){for(let r of He)if(t[r]!==void 0){let i=t[r],a=i&&typeof i==`string`&&i.includes(`:`),o=i&&Ue.has(i);return`${n}:${i}${!(a||o)&&e.key?`:key:${e.key}`:``}`}}if(e.key)return`${n}:key:${e.key}`;if(t.id)return`${n}:id:${t.id}`;if(n===`link`&&t.rel===`alternate`)return`alternate:${t.href||``}`;if(Fe.has(n)){let t=e.textContent||e.innerHTML;if(t)return`${n}:content:${t}`}}function Ke(e){return e._h||e._d||e.textContent||e.innerHTML||`${e.tag}:${Object.entries(e.props).map(([e,t])=>`${e}:${String(t)}`).join(`,`)}`}function qe(e,t,n){typeof e==`function`&&(!n||n!==`titleTemplate`&&!(n[0]===`o`&&n[1]===`n`))&&(e=e());let r=t?t(n,e):e;if(Array.isArray(r))return r.map(e=>qe(e,t));if(r?.constructor===Object){let e={};for(let n of Object.keys(r))e[n]=qe(r[n],t,n);return e}return r}function Je(e,t){let n=e===`style`?new Map:new Set;function r(t){if(t==null||t===void 0)return;let r=String(t).trim();if(r)if(e===`style`){let[e,...t]=r.split(`:`).map(e=>e?e.trim():``);e&&t.length&&n.set(e,t.join(`:`))}else r.split(` `).filter(Boolean).forEach(e=>n.add(e))}return typeof t==`string`?e===`style`?t.split(`;`).forEach(r):r(t):Array.isArray(t)?t.forEach(e=>r(e)):t&&typeof t==`object`&&Object.entries(t).forEach(([t,i])=>{i&&i!==`false`&&(e===`style`?n.set(String(t).trim(),String(i)):r(t))}),n}function Ye(e,t){if(e.props=e.props||{},!t)return e;if(e.tag===`templateParams`)return e.props=t,e;let n=Ie.has(e.tag)||e.tag===`htmlAttrs`||e.tag===`bodyAttrs`;return Object.entries(t).forEach(([r,i])=>{if(r===`__proto__`||r===`constructor`||r===`prototype`)return;if(i===null){e.props[r]=null;return}if(r===`class`||r===`style`){e.props[r]=Je(r,i);return}if(ze.has(r)){if((r===`textContent`||r===`innerHTML`)&&typeof i==`object`){let n=t.type;if(t.type||(n=`application/json`),!n?.endsWith(`json`)&&n!==`speculationrules`)return;t.type=n,e.props.type=n,e[r]=JSON.stringify(i)}else e[r]=i;return}let a=r.startsWith(`data-`),o=n&&!a?r.toLowerCase():r,s=String(i),c=e.tag===`meta`&&o===`content`;s===`true`||s===``?e.props[o]=a||c?s:!0:!i&&a&&s===`false`?e.props[o]=`false`:i!==void 0&&(e.props[o]=i)}),e}function eee(e,t){let n=Ye({tag:e,props:{}},typeof t==`object`&&typeof t!=`function`?t:{[e===`script`||e===`noscript`||e===`style`?`innerHTML`:`textContent`]:t});return n.key&&Pe.has(n.tag)&&(n.props[`data-hid`]=n._h=n.key),n.tag===`script`&&typeof n.innerHTML==`object`&&(n.innerHTML=JSON.stringify(n.innerHTML),n.props.type=n.props.type||`application/json`),Array.isArray(n.props.content)?n.props.content.map(e=>({...n,props:{...n.props,content:e}})):n}function Xe(e,t){if(!e)return[];typeof e==`function`&&(e=e());let n=(e,n)=>{for(let r=0;r{if(t!==void 0)for(let n of Array.isArray(t)?t:[t])r.push(eee(e,n))}),r.flat()}var Ze=(e,t)=>e._w===t._w?e._p-t._p:e._w-t._w,tee={base:-10,title:10},nee={critical:-8,high:-1,low:2},ree={meta:{"content-security-policy":-30,charset:-20,viewport:-15},link:{preconnect:20,stylesheet:60,preload:70,modulepreload:70,prefetch:90,"dns-prefetch":90,prerender:90},script:{async:30,defer:80,sync:50},style:{imported:40,sync:60}},iee=/@import/,Qe=e=>e===``||e===!0;function aee(e,t){if(typeof t.tagPriority==`number`)return t.tagPriority;let n=100,r=nee[t.tagPriority]||0,i=e.resolvedOptions.disableCapoSorting?{link:{},script:{},style:{}}:ree;if(t.tag in tee)n=tee[t.tag];else if(t.tag===`meta`){let e=t.props[`http-equiv`]===`content-security-policy`?`content-security-policy`:t.props.charset?`charset`:t.props.name===`viewport`?`viewport`:null;e&&(n=ree.meta[e])}else if(t.tag===`link`&&t.props.rel)n=i.link[t.props.rel];else if(t.tag===`script`){let e=String(t.props.type);Qe(t.props.async)?n=i.script.async:t.props.src&&!Qe(t.props.defer)&&!Qe(t.props.async)&&e!==`module`&&!e.endsWith(`json`)||t.innerHTML&&!e.endsWith(`json`)?n=i.script.sync:(Qe(t.props.defer)&&t.props.src&&!Qe(t.props.async)||e===`module`)&&(n=i.script.defer)}else t.tag===`style`&&(n=t.innerHTML&&iee.test(t.innerHTML)?i.style.imported:i.style.sync);return(n||100)+r}function oee(e,t){let n=typeof t==`function`?t(e):t,r=n.key||String(e.plugins.size+1);e.plugins.get(r)||(e.plugins.set(r,n),e.hooks.addHooks(n.hooks||{}))}function see(e={}){let t=Ne();t.addHooks(e.hooks||{});let n=!e.document,r=new Map,i=new Map,a=new Set,o={_entryCount:1,plugins:i,dirty:!1,resolvedOptions:e,hooks:t,ssr:n,entries:r,headEntries(){return[...r.values()]},use:e=>oee(o,e),push(e,i){let s={...i||{}};delete s.head;let c=s._index??o._entryCount++,l={_i:c,input:e,options:s},u={_poll(e=!1){o.dirty=!0,!e&&a.add(c),t.callHook(`entries:updated`,o)},dispose(){r.delete(c)&&o.invalidate()},patch(e){(!s.mode||s.mode===`server`&&n||s.mode===`client`&&!n)&&(l.input=e,r.set(c,l),u._poll())}};return u.patch(e),u},async resolveTags(){let n={tagMap:new Map,tags:[],entries:[...o.entries.values()]};for(await t.callHook(`entries:resolve`,n);a.size;){let n=a.values().next().value;a.delete(n);let i=r.get(n);if(i){let n={tags:Xe(i.input,e.propResolvers||[]).map(e=>Object.assign(e,i.options)),entry:i};await t.callHook(`entries:normalize`,n),i._tags=n.tags.map((e,t)=>(e._w=aee(o,e),e._p=(i._i<<10)+t,e._d=Ge(e),e._d||(e._h=Ke(e)),e))}}let i=!1;n.entries.flatMap(e=>(e._tags||[]).map(e=>({...e,props:{...e.props}}))).sort(Ze).reduce((e,t)=>{let n=t._d||t._h;if(!e.has(n))return e.set(n,t);let r=e.get(n);if((t?.tagDuplicateStrategy||(Be.has(t.tag)?`merge`:null)||(t.key&&t.key===r.key?`merge`:null))===`merge`){let i={...r.props};Object.entries(t.props).forEach(([e,t])=>i[e]=e===`style`?new Map([...r.props.style||new Map,...t]):e===`class`?new Set([...r.props.class||new Set,...t]):t),e.set(n,{...t,props:i})}else t._p>>10==r._p>>10&&t.tag===`meta`&&We(n)?(e.set(n,Object.assign([...Array.isArray(r)?r:[r],t],t)),i=!0):(t._w===r._w?t._p>r._p:t?._woee(o,e)),o.hooks.callHook(`init`,o),e.init?.forEach(e=>e&&o.push(e)),o}async function cee(e,t={}){let n=t.document||e.resolvedOptions.document;if(!n||!e.dirty)return;let r={shouldRender:!0,tags:[]};if(await e.hooks.callHook(`dom:beforeRender`,r),r.shouldRender)return e._domUpdatePromise||=new Promise(async t=>{let r=new Map,i=new Promise(t=>{e.resolveTags().then(e=>{t(e.map(e=>{let t=r.get(e._d)||0,n={tag:e,id:(t?`${e._d}:${t}`:e._d)||e._h,shouldRender:!0};return e._d&&We(e._d)&&r.set(e._d,t+1),n}))})}),a=e._dom;if(!a){a={title:n.title,elMap:new Map().set(`htmlAttrs`,n.documentElement).set(`bodyAttrs`,n.body)};for(let e of[`body`,`head`]){let t=n[e]?.children;for(let e of t){let t=e.tagName.toLowerCase();if(!Ie.has(t))continue;let n=Ye({tag:t,props:{}},{innerHTML:e.innerHTML,...e.getAttributeNames().reduce((t,n)=>(t[n]=e.getAttribute(n),t),{})||{}});if(n.key=e.getAttribute(`data-hid`)||void 0,n._d=Ge(n)||Ke(n),a.elMap.has(n._d)){let t=1,r=n._d;for(;a.elMap.has(r);)r=`${n._d}:${t++}`;a.elMap.set(r,e)}else a.elMap.set(n._d,e)}}}a.pendingSideEffects={...a.sideEffects},a.sideEffects={};function o(e,t,n){let r=`${e}:${t}`;a.sideEffects[r]=n,delete a.pendingSideEffects[r]}function s({id:e,$el:t,tag:r}){let i=r.tag.endsWith(`Attrs`);a.elMap.set(e,t),i||(r.textContent&&r.textContent!==t.textContent&&(t.textContent=r.textContent),r.innerHTML&&r.innerHTML!==t.innerHTML&&(t.innerHTML=r.innerHTML),o(e,`el`,()=>{t?.remove(),a.elMap.delete(e)}));for(let a in r.props){if(!Object.prototype.hasOwnProperty.call(r.props,a))continue;let s=r.props[a];if(a.startsWith(`on`)&&typeof s==`function`){let e=t?.dataset;if(e&&e[`${a}fired`]){let e=a.slice(0,-5);s.call(t,new Event(e.substring(2)))}t.getAttribute(`data-${a}`)!==``&&((r.tag===`bodyAttrs`?n.defaultView:t).addEventListener(a.substring(2),s.bind(t)),t.setAttribute(`data-${a}`,``));continue}let c=`attr:${a}`;if(a===`class`){if(!s)continue;for(let n of s)i&&o(e,`${c}:${n}`,()=>t.classList.remove(n)),!t.classList.contains(n)&&t.classList.add(n)}else if(a===`style`){if(!s)continue;for(let[n,r]of s)o(e,`${c}:${n}`,()=>{t.style.removeProperty(n)}),t.style.setProperty(n,r)}else s!==!1&&s!==null&&(t.getAttribute(a)!==s&&t.setAttribute(a,s===!0?``:String(s)),i&&o(e,c,()=>t.removeAttribute(a)))}}let c=[],l={bodyClose:void 0,bodyOpen:void 0,head:void 0},u=await i;for(let e of u){let{tag:t,shouldRender:r,id:i}=e;if(r){if(t.tag===`title`){n.title=t.textContent,o(`title`,``,()=>n.title=a.title);continue}e.$el=e.$el||a.elMap.get(i),e.$el?s(e):Ie.has(t.tag)&&c.push(e)}}for(let e of c){let t=e.tag.tagPosition||`head`;e.$el=n.createElement(e.tag.tag),s(e),l[t]=l[t]||n.createDocumentFragment(),l[t].appendChild(e.$el)}for(let t of u)await e.hooks.callHook(`dom:renderTag`,t,n,o);l.head&&n.head.appendChild(l.head),l.bodyOpen&&n.body.insertBefore(l.bodyOpen,n.body.firstChild),l.bodyClose&&n.body.appendChild(l.bodyClose);for(let e in a.pendingSideEffects)a.pendingSideEffects[e]();e._dom=a,await e.hooks.callHook(`dom:rendered`,{renders:u}),t()}).finally(()=>{e._domUpdatePromise=void 0,e.dirty=!1}),e._domUpdatePromise}function lee(e={}){let t=e.domOptions?.render||cee;e.document=e.document||(typeof window<`u`?document:void 0);let n=e.document?.head.querySelector(`script[id="unhead:payload"]`)?.innerHTML||!1;return see({...e,plugins:[...e.plugins||[],{key:`client`,hooks:{"entries:updated":t}}],init:[n?JSON.parse(n):!1,...e.init||[]]})}function uee(e,t){let n=0;return()=>{let r=++n;t(()=>{n===r&&e()})}}var $e={META:new Set([`twitter`]),OG:new Set([`og`,`book`,`article`,`profile`,`fb`]),MEDIA:new Set([`ogImage`,`ogVideo`,`ogAudio`,`twitterImage`]),HTTP_EQUIV:new Set([`contentType`,`defaultStyle`,`xUaCompatible`])},dee={articleExpirationTime:`article:expiration_time`,articleModifiedTime:`article:modified_time`,articlePublishedTime:`article:published_time`,bookReleaseDate:`book:release_date`,fbAppId:`fb:app_id`,ogAudioSecureUrl:`og:audio:secure_url`,ogAudioUrl:`og:audio`,ogImageSecureUrl:`og:image:secure_url`,ogImageUrl:`og:image`,ogSiteName:`og:site_name`,ogVideoSecureUrl:`og:video:secure_url`,ogVideoUrl:`og:video`,profileFirstName:`profile:first_name`,profileLastName:`profile:last_name`,profileUsername:`profile:username`,msapplicationConfig:`msapplication-Config`,msapplicationTileColor:`msapplication-TileColor`,msapplicationTileImage:`msapplication-TileImage`},fee={appleItunesApp:{unpack:{entrySeparator:`, `,resolve:({key:e,value:t})=>`${et(e)}=${t}`}},refresh:{metaKey:`http-equiv`,unpack:{entrySeparator:`;`,resolve:({key:e,value:t})=>e===`seconds`?`${t}`:void 0}},robots:{unpack:{entrySeparator:`, `,resolve:({key:e,value:t})=>typeof t==`boolean`?et(e):`${et(e)}:${t}`}},contentSecurityPolicy:{metaKey:`http-equiv`,unpack:{entrySeparator:`; `,resolve:({key:e,value:t})=>`${et(e)} ${t}`}},charset:{}};function et(e){let t=e.replace(/([A-Z])/g,`-$1`).toLowerCase(),n=t.indexOf(`-`);return n===-1?t:$e.META.has(t.slice(0,n))||$e.OG.has(t.slice(0,n))?e.replace(/([A-Z])/g,`:$1`).toLowerCase():t}function pee(e){return Object.fromEntries(Object.entries(e).filter(([e,t])=>String(t)!==`false`&&e))}function tt(e){return Array.isArray(e)?e.map(tt):!e||typeof e!=`object`?e:Object.fromEntries(Object.entries(e).map(([e,t])=>[et(e),tt(t)]))}function mee(e,t={}){let{entrySeparator:n=``,keyValueSeparator:r=``,wrapValue:i,resolve:a}=t;return Object.entries(e).map(([e,n])=>{if(a){let t=a({key:e,value:n});if(t!==void 0)return t}return`${e}${r}${typeof n==`object`?mee(n,t):typeof n==`number`?n.toString():typeof n==`string`&&i?`${i}${n.replace(new RegExp(i,`g`),`\\${i}`)}${i}`:n}`}).join(n)}function hee(e,t){let n=pee(t),r=et(e),i=gee(r);return Ve.has(r)?nt(Object.fromEntries(Object.entries(n).map(([t,n])=>[`${e}${t===`url`?``:`${t[0].toUpperCase()}${t.slice(1)}`}`,n]))||{}).sort((e,t)=>(e[i]?.length||0)-(t[i]?.length||0)):[{[i]:r,...n}]}function gee(e){if(fee[e]?.metaKey===`http-equiv`||$e.HTTP_EQUIV.has(e))return`http-equiv`;let t=et(e),n=t.indexOf(`:`);return n===-1?`name`:$e.OG.has(t.slice(0,n))?`property`:`name`}function _ee(e){return dee[e]||et(e)}function vee(e,t){return t===`refresh`?`${e.seconds};url=${e.url}`:mee(tt(e),{keyValueSeparator:`=`,entrySeparator:`, `,resolve:({value:e,key:t})=>e===null?``:typeof e==`boolean`?t:void 0,...fee[t]?.unpack})}function nt(e){let t=[],n={};for(let[r,i]of Object.entries(e)){if(Array.isArray(i)){if(r===`themeColor`){i.forEach(e=>{typeof e==`object`&&e&&t.push({name:`theme-color`,...e})});continue}for(let e of i)if(typeof e==`object`&&e){let n=[],i=[];for(let[t,a]of Object.entries(e)){let e=nt({[`${r}${t===`url`?``:`:${t}`}`]:a});(t===`url`?n:i).push(...e)}t.push(...n,...i)}else t.push(...typeof e==`string`?nt({[r]:e}):hee(r,e));continue}if(typeof i==`object`&&i)if($e.MEDIA.has(r)){let e=r.startsWith(`twitter`)?`twitter`:`og`,n=r.replace(/^(og|twitter)/,``).toLowerCase(),a=e===`twitter`?`name`:`property`;i.url&&t.push({[a]:`${e}:${n}`,content:i.url}),i.secureUrl&&t.push({[a]:`${e}:${n}:secure_url`,content:i.secureUrl});for(let[r,o]of Object.entries(i))r!==`url`&&r!==`secureUrl`&&t.push({[a]:`${e}:${n}:${r}`,content:o})}else Ve.has(et(r))?t.push(...hee(r,i)):n[r]=pee(i);else n[r]=i}let r=Object.entries(n).map(([e,t])=>{if(e===`charset`)return{charset:t===null?`_null`:t};let n=gee(e),r=_ee(e),i=t===null?`_null`:typeof t==`object`?vee(t,e):typeof t==`number`?t.toString():t;return n===`http-equiv`?{"http-equiv":r,content:i}:{[n]:r,content:i}});return[...t,...r].map(e=>`content`in e&&e.content===`_null`?{...e,content:null}:e)}function yee(e){return e}var bee=yee({key:`flatMeta`,hooks:{"entries:normalize":e=>{let t=[];e.tags=e.tags.map(e=>e.tag===`_flatMeta`?(t.push(nt(e.props).map(t=>({...e,tag:`meta`,props:t}))),!1):e).filter(Boolean).concat(...t)}}});function xee(e){if(Array.isArray(e))return e.map(xee);if(e&&typeof e==`object`){let t={};for(let n of Object.keys(e))n===`__proto__`||n===`constructor`||n===`prototype`||(t[n]=xee(e[n]));return t}return e}function rt(e){let t=Object.create(null);for(let n of e.split(`,`))t[n]=1;return e=>e in t}function it(e){if(_t(e)){let t={};for(let n=0;n{if(e){let n=e.split(Nee);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function O(e){let t=``;if(St(e))t=e;else if(_t(e))for(let n=0;not(e,t))}function Tee(e){return e==null?`initial`:typeof e==`string`?e===``?` `:e:String(e)}var ct,lt,ut,dt,ft,pt,mt,ht,Eee,gt,_t,vt,yt,bt,xt,St,Ct,wt,Tt,Et,Dt,Dee,Ot,kt,At,jt,Oee,Mt,kee,Nt,Pt,Ft,It,Lt,Rt,zt,Aee,jee,Bt,Mee,Nee,Pee,Vt,Fee,Ht,k,Ut,Wt,Gt=s((()=>{ct={},lt=[],ut=()=>{},dt=()=>!1,ft=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),pt=e=>e.startsWith(`onUpdate:`),mt=Object.assign,ht=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},Eee=Object.prototype.hasOwnProperty,gt=(e,t)=>Eee.call(e,t),_t=Array.isArray,vt=e=>Dt(e)===`[object Map]`,yt=e=>Dt(e)===`[object Set]`,bt=e=>Dt(e)===`[object Date]`,xt=e=>typeof e==`function`,St=e=>typeof e==`string`,Ct=e=>typeof e==`symbol`,wt=e=>typeof e==`object`&&!!e,Tt=e=>(wt(e)||xt(e))&&xt(e.then)&&xt(e.catch),Et=Object.prototype.toString,Dt=e=>Et.call(e),Dee=e=>Dt(e).slice(8,-1),Ot=e=>Dt(e)===`[object Object]`,kt=e=>St(e)&&e!==`NaN`&&e[0]!==`-`&&``+parseInt(e,10)===e,At=rt(`,key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted`),jt=e=>{let t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Oee=/-\w/g,Mt=jt(e=>e.replace(Oee,e=>e.slice(1).toUpperCase())),kee=/\B([A-Z])/g,Nt=jt(e=>e.replace(kee,`-$1`).toLowerCase()),Pt=jt(e=>e.charAt(0).toUpperCase()+e.slice(1)),Ft=jt(e=>e?`on${Pt(e)}`:``),It=(e,t)=>!Object.is(e,t),Lt=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},zt=e=>{let t=parseFloat(e);return isNaN(t)?e:t},Aee=e=>{let t=St(e)?Number(e):NaN;return isNaN(t)?e:t},Bt=()=>jee||=typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:typeof global<`u`?global:{},Mee=/;(?![^(]*\))/g,Nee=/:([^]+)/,Pee=/\/\*[^]*?\*\//g,Vt=`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`,Fee=rt(Vt),rt(Vt+`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,inert,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected`),Ht=e=>!!(e&&e.__v_isRef===!0),k=e=>St(e)?e:e==null?``:_t(e)||wt(e)&&(e.toString===Et||!xt(e.toString))?Ht(e)?k(e.value):JSON.stringify(e,Ut,2):String(e),Ut=(e,t)=>Ht(t)?Ut(e,t.value):vt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[Wt(t,r)+` =>`]=n,e),{})}:yt(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>Wt(e))}:Ct(t)?Wt(t):wt(t)&&!_t(t)&&!Ot(t)?String(t):t,Wt=(e,t=``)=>Ct(e)?`Symbol(${e.description??t})`:e}));function Kt(){return En}function qt(e,t=!1){En&&En.cleanups.push(e)}function Iee(e,t=!1){if(e.flags|=8,t){e.next=Mn,Mn=e;return}e.next=jn,jn=e}function Jt(){An++}function Yt(){if(--An>0)return;if(Mn){let e=Mn;for(Mn=void 0;e;){let t=e.next;e.next=void 0,e.flags&=-9,e=t}}let e;for(;jn;){let t=jn;for(jn=void 0;t;){let n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(t){e||=t}t=n}}if(e)throw e}function Lee(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Ree(e){let t,n=e.depsTail,r=n;for(;r;){let e=r.prevDep;r.version===-1?(r===n&&(n=e),Zt(r),Bee(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function Xt(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(zee(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function zee(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Fn)||(e.globalVersion=Fn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Xt(e))))return;e.flags|=2;let t=e.dep,n=Dn,r=Nn;Dn=e,Nn=!0;try{Lee(e);let n=e.fn(e._value);(t.version===0||It(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(e){throw t.version++,e}finally{Dn=n,Nn=r,Ree(e),e.flags&=-3}}function Zt(e,t=!1){let{dep:n,prevSub:r,nextSub:i}=e;if(r&&(r.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)Zt(e,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Bee(e){let{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}function Qt(){Pn.push(Nn),Nn=!1}function $t(){let e=Pn.pop();Nn=e===void 0?!0:e}function Vee(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let e=Dn;Dn=void 0;try{t()}finally{Dn=e}}}function Hee(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)Hee(e)}let n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}function en(e,t,n){if(Nn&&Dn){let t=Ln.get(e);t||Ln.set(e,t=new Map);let r=t.get(n);r||(t.set(n,r=new In),r.map=t,r.key=n),r.track()}}function tn(e,t,n,r,i,a){let o=Ln.get(e);if(!o){Fn++;return}let s=e=>{e&&e.trigger()};if(Jt(),t===`clear`)o.forEach(s);else{let i=_t(e),a=i&&kt(n);if(i&&n===`length`){let e=Number(r);o.forEach((t,n)=>{(n===`length`||n===Bn||!Ct(n)&&n>=e)&&s(t)})}else switch((n!==void 0||o.has(void 0))&&s(o.get(n)),a&&s(o.get(Bn)),t){case`add`:i?a&&s(o.get(`length`)):(s(o.get(Rn)),vt(e)&&s(o.get(zn)));break;case`delete`:i||(s(o.get(Rn)),vt(e)&&s(o.get(zn)));break;case`set`:vt(e)&&s(o.get(Rn));break}}Yt()}function Uee(e,t){let n=Ln.get(e);return n&&n.get(t)}function nn(e){let t=bn(e);return t===e?t:(en(t,`iterate`,Bn),vn(e)?t:t.map(Zn))}function rn(e){return en(e=bn(e),`iterate`,Bn),e}function an(e,t){return _n(e)?gn(e)?Qn(Zn(t)):Qn(t):Zn(t)}function on(e,t,n){let r=rn(e),i=r[t]();return r!==e&&!vn(e)&&(i._next=i.next,i.next=()=>{let e=i._next();return e.done||(e.value=n(e.value)),e}),i}function sn(e,t,n,r,i,a){let o=rn(e),s=o!==e&&!vn(e),c=o[t];if(c!==ute[t]){let t=c.apply(e,a);return s?Zn(t):t}let l=n;o!==e&&(s?l=function(t,r){return n.call(this,an(e,t),r,e)}:n.length>2&&(l=function(t,r){return n.call(this,t,r,e)}));let u=c.call(o,l,r);return s&&i?i(u):u}function Wee(e,t,n,r){let i=rn(e),a=i!==e&&!vn(e),o=n,s=!1;i!==e&&(a?(s=r.length===0,o=function(t,r,i){return s&&(s=!1,t=an(e,t)),n.call(this,t,an(e,r),i,e)}):n.length>3&&(o=function(t,r,i){return n.call(this,t,r,i,e)}));let c=i[t](o,...r);return s?an(e,c):c}function cn(e,t,n){let r=bn(e);en(r,`iterate`,Bn);let i=r[t](...n);return(i===-1||i===!1)&&yn(n[0])?(n[0]=bn(n[0]),r[t](...n)):i}function ln(e,t,n=[]){Qt(),Jt();let r=bn(e)[t].apply(e,n);return Yt(),$t(),r}function Gee(e){Ct(e)||(e=String(e));let t=bn(this);return en(t,`has`,e),t.hasOwnProperty(e)}function Kee(e,t,n){return function(...r){let i=this.__v_raw,a=bn(i),o=vt(a),s=e===`entries`||e===Symbol.iterator&&o,c=e===`keys`&&o,l=i[e](...r),u=n?Gn:t?Qn:Zn;return!t&&en(a,`iterate`,c?zn:Rn),mt(Object.create(l),{next(){let{value:e,done:t}=l.next();return t?{value:e,done:t}:{value:s?[u(e[0]),u(e[1])]:u(e),done:t}}})}}function un(e){return function(...t){return e===`delete`?!1:e===`clear`?void 0:this}}function qee(e,t){let n={get(n){let r=this.__v_raw,i=bn(r),a=bn(n);e||(It(n,a)&&en(i,`get`,n),en(i,`get`,a));let{has:o}=Kn(i),s=t?Gn:e?Qn:Zn;if(o.call(i,n))return s(r.get(n));if(o.call(i,a))return s(r.get(a));r!==i&&r.get(n)},get size(){let t=this.__v_raw;return!e&&en(bn(t),`iterate`,Rn),t.size},has(t){let n=this.__v_raw,r=bn(n),i=bn(t);return e||(It(t,i)&&en(r,`has`,t),en(r,`has`,i)),t===i?n.has(t):n.has(t)||n.has(i)},forEach(n,r){let i=this,a=i.__v_raw,o=bn(a),s=t?Gn:e?Qn:Zn;return!e&&en(o,`iterate`,Rn),a.forEach((e,t)=>n.call(r,s(e),s(t),i))}};return mt(n,e?{add:un(`add`),set:un(`set`),delete:un(`delete`),clear:un(`clear`)}:{add(e){let n=bn(this),r=Kn(n),i=bn(e),a=!t&&!vn(e)&&!_n(e)?i:e;return r.has.call(n,a)||It(e,a)&&r.has.call(n,e)||It(i,a)&&r.has.call(n,i)||(n.add(a),tn(n,`add`,a,a)),this},set(e,n){!t&&!vn(n)&&!_n(n)&&(n=bn(n));let r=bn(this),{has:i,get:a}=Kn(r),o=i.call(r,e);o||=(e=bn(e),i.call(r,e));let s=a.call(r,e);return r.set(e,n),o?It(n,s)&&tn(r,`set`,e,n,s):tn(r,`add`,e,n),this},delete(e){let t=bn(this),{has:n,get:r}=Kn(t),i=n.call(t,e);i||=(e=bn(e),n.call(t,e));let a=r?r.call(t,e):void 0,o=t.delete(e);return i&&tn(t,`delete`,e,void 0,a),o},clear(){let e=bn(this),t=e.size!==0,n=e.clear();return t&&tn(e,`clear`,void 0,void 0,void 0),n}}),[`keys`,`values`,`entries`,Symbol.iterator].forEach(r=>{n[r]=Kee(r,e,t)}),n}function dn(e,t){let n=qee(e,t);return(t,r,i)=>r===`__v_isReactive`?!e:r===`__v_isReadonly`?e:r===`__v_raw`?t:Reflect.get(gt(n,r)&&r in t?n:t,r,i)}function Jee(e){switch(e){case`Object`:case`Array`:return 1;case`Map`:case`Set`:case`WeakMap`:case`WeakSet`:return 2;default:return 0}}function Yee(e){return e.__v_skip||!Object.isExtensible(e)?0:Jee(Dee(e))}function fn(e){return _n(e)?e:hn(e,!1,fte,gte,qn)}function Xee(e){return hn(e,!1,mte,_te,Jn)}function pn(e){return hn(e,!0,pte,vte,Yn)}function mn(e){return hn(e,!0,hte,yte,Xn)}function hn(e,t,n,r,i){if(!wt(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let a=Yee(e);if(a===0)return e;let o=i.get(e);if(o)return o;let s=new Proxy(e,a===2?r:n);return i.set(e,s),s}function gn(e){return _n(e)?gn(e.__v_raw):!!(e&&e.__v_isReactive)}function _n(e){return!!(e&&e.__v_isReadonly)}function vn(e){return!!(e&&e.__v_isShallow)}function yn(e){return e?!!e.__v_raw:!1}function bn(e){let t=e&&e.__v_raw;return t?bn(t):e}function Zee(e){return!gt(e,`__v_skip`)&&Object.isExtensible(e)&&Rt(e,`__v_skip`,!0),e}function xn(e){return e?e.__v_isRef===!0:!1}function A(e){return Qee(e,!1)}function Sn(e){return Qee(e,!0)}function Qee(e,t){return xn(e)?e:new bte(e,t)}function $ee(e){e.dep&&e.dep.trigger()}function j(e){return xn(e)?e.value:e}function Cn(e){return xt(e)?e():j(e)}function ete(e){return gn(e)?e:new Proxy(e,xte)}function tte(e){return new Ste(e)}function nte(e){let t=_t(e)?Array(e.length):{};for(let n in e)t[n]=rte(e,n);return t}function wn(e,t,n){return xn(e)?e:xt(e)?new wte(e):wt(e)&&arguments.length>1?rte(e,t,n):A(e)}function rte(e,t,n){return new Cte(e,t,n)}function ite(e,t,n=!1){let r,i;return xt(e)?r=e:(r=e.get,i=e.set),new Tte(r,i,n)}function ate(e,t=!1,n=tr){if(n){let t=er.get(n);t||er.set(n,t=[]),t.push(e)}}function ote(e,t,n=ct){let{immediate:r,deep:i,once:a,scheduler:o,augmentJob:s,call:c}=n,l=e=>i?e:vn(e)||i===!1||i===0?Tn(e,1):Tn(e),u,d,f,p,m=!1,h=!1;if(xn(e)?(d=()=>e.value,m=vn(e)):gn(e)?(d=()=>l(e),m=!0):_t(e)?(h=!0,m=e.some(e=>gn(e)||vn(e)),d=()=>e.map(e=>{if(xn(e))return e.value;if(gn(e))return l(e);if(xt(e))return c?c(e,2):e()})):d=xt(e)?t?c?()=>c(e,2):e:()=>{if(f){Qt();try{f()}finally{$t()}}let t=tr;tr=u;try{return c?c(e,3,[p]):e(p)}finally{tr=t}}:ut,t&&i){let e=d,t=i===!0?1/0:i;d=()=>Tn(e(),t)}let g=Kt(),_=()=>{u.stop(),g&&g.active&&ht(g.effects,u)};if(a&&t){let e=t;t=(...t)=>{e(...t),_()}}let v=h?Array(e.length).fill($n):$n,y=e=>{if(!(!(u.flags&1)||!u.dirty&&!e))if(t){let e=u.run();if(i||m||(h?e.some((e,t)=>It(e,v[t])):It(e,v))){f&&f();let n=tr;tr=u;try{let n=[e,v===$n?void 0:h&&v[0]===$n?[]:v,p];v=e,c?c(t,3,n):t(...n)}finally{tr=n}}}else u.run()};return s&&s(y),u=new kn(d),u.scheduler=o?()=>o(y,!1):y,p=e=>ate(e,!1,u),f=u.onStop=()=>{let e=er.get(u);if(e){if(c)c(e,4);else for(let t of e)t();er.delete(u)}},t?r?y(!0):v=u.run():o?o(y.bind(null,!0),!0):u.run(),_.pause=u.pause.bind(u),_.resume=u.resume.bind(u),_.stop=_,_}function Tn(e,t=1/0,n){if(t<=0||!wt(e)||e.__v_skip||(n||=new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,xn(e))Tn(e.value,t,n);else if(_t(e))for(let r=0;r{Tn(e,t,n)});else if(Ot(e)){for(let r in e)Tn(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&Tn(e[r],t,n)}return e}var En,ste,Dn,On,kn,An,jn,Mn,Nn,Pn,Fn,cte,In,Ln,Rn,zn,Bn,lte,ute,dte,Vn,Hn,Un,Wn,fte,pte,mte,hte,Gn,Kn,gte,_te,vte,yte,qn,Jn,Yn,Xn,Zn,Qn,bte,xte,Ste,Cte,wte,Tte,$n,er,tr,Ete=s((()=>{Gt(),ste=class{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=En,!e&&En&&(this.index=(En.scopes||=[]).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let e,t;if(this.scopes)for(e=0,t=this.scopes.length;e0&&--this._on===0&&(En=this.prevScope,this.prevScope=void 0)}stop(e){if(this._active){this._active=!1;let t,n;for(t=0,n=this.effects.length;tan(this,e))},concat(...e){return nn(this).concat(...e.map(e=>_t(e)?nn(e):e))},entries(){return on(this,`entries`,e=>(e[1]=an(this,e[1]),e))},every(e,t){return sn(this,`every`,e,t,void 0,arguments)},filter(e,t){return sn(this,`filter`,e,t,e=>e.map(e=>an(this,e)),arguments)},find(e,t){return sn(this,`find`,e,t,e=>an(this,e),arguments)},findIndex(e,t){return sn(this,`findIndex`,e,t,void 0,arguments)},findLast(e,t){return sn(this,`findLast`,e,t,e=>an(this,e),arguments)},findLastIndex(e,t){return sn(this,`findLastIndex`,e,t,void 0,arguments)},forEach(e,t){return sn(this,`forEach`,e,t,void 0,arguments)},includes(...e){return cn(this,`includes`,e)},indexOf(...e){return cn(this,`indexOf`,e)},join(e){return nn(this).join(e)},lastIndexOf(...e){return cn(this,`lastIndexOf`,e)},map(e,t){return sn(this,`map`,e,t,void 0,arguments)},pop(){return ln(this,`pop`)},push(...e){return ln(this,`push`,e)},reduce(e,...t){return Wee(this,`reduce`,e,t)},reduceRight(e,...t){return Wee(this,`reduceRight`,e,t)},shift(){return ln(this,`shift`)},some(e,t){return sn(this,`some`,e,t,void 0,arguments)},splice(...e){return ln(this,`splice`,e)},toReversed(){return nn(this).toReversed()},toSorted(e){return nn(this).toSorted(e)},toSpliced(...e){return nn(this).toSpliced(...e)},unshift(...e){return ln(this,`unshift`,e)},values(){return on(this,`values`,e=>an(this,e))}},ute=Array.prototype,dte=rt(`__proto__,__v_isRef,__isVue`),Vn=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!==`arguments`&&e!==`caller`).map(e=>Symbol[e]).filter(Ct)),Hn=class{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){if(t===`__v_skip`)return e.__v_skip;let r=this._isReadonly,i=this._isShallow;if(t===`__v_isReactive`)return!r;if(t===`__v_isReadonly`)return r;if(t===`__v_isShallow`)return i;if(t===`__v_raw`)return n===(r?i?Xn:Yn:i?Jn:qn).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let a=_t(e);if(!r){let e;if(a&&(e=lte[t]))return e;if(t===`hasOwnProperty`)return Gee}let o=Reflect.get(e,t,xn(e)?e:n);if((Ct(t)?Vn.has(t):dte(t))||(r||en(e,`get`,t),i))return o;if(xn(o)){let e=a&&kt(t)?o:o.value;return r&&wt(e)?pn(e):e}return wt(o)?r?pn(o):fn(o):o}},Un=class extends Hn{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t],a=_t(e)&&kt(t);if(!this._isShallow){let e=_n(i);if(!vn(n)&&!_n(n)&&(i=bn(i),n=bn(n)),!a&&xn(i)&&!xn(n))return e||(i.value=n),!0}let o=a?Number(t)e,Kn=e=>Reflect.getPrototypeOf(e),gte={get:dn(!1,!1)},_te={get:dn(!1,!0)},vte={get:dn(!0,!1)},yte={get:dn(!0,!0)},qn=new WeakMap,Jn=new WeakMap,Yn=new WeakMap,Xn=new WeakMap,Zn=e=>wt(e)?fn(e):e,Qn=e=>wt(e)?pn(e):e,bte=class{constructor(e,t){this.dep=new In,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:bn(e),this._value=t?e:Zn(e),this.__v_isShallow=t}get value(){return this.dep.track(),this._value}set value(e){let t=this._rawValue,n=this.__v_isShallow||vn(e)||_n(e);e=n?e:bn(e),It(e,t)&&(this._rawValue=e,this._value=n?e:Zn(e),this.dep.trigger())}},xte={get:(e,t,n)=>t===`__v_raw`?e:j(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return xn(i)&&!xn(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}},Ste=class{constructor(e){this.__v_isRef=!0,this._value=void 0;let t=this.dep=new In,{get:n,set:r}=e(t.track.bind(t),t.trigger.bind(t));this._get=n,this._set=r}get value(){return this._value=this._get()}set value(e){this._set(e)}},Cte=class{constructor(e,t,n){this._object=e,this._key=t,this._defaultValue=n,this.__v_isRef=!0,this._value=void 0,this._raw=bn(e);let r=!0,i=e;if(!_t(e)||!kt(String(t)))do r=!yn(i)||vn(i);while(r&&(i=i.__v_raw));this._shallow=r}get value(){let e=this._object[this._key];return this._shallow&&(e=j(e)),this._value=e===void 0?this._defaultValue:e}set value(e){if(this._shallow&&xn(this._raw[this._key])){let t=this._object[this._key];if(xn(t)){t.value=e;return}}this._object[this._key]=e}get dep(){return Uee(this._raw,this._key)}},wte=class{constructor(e){this._getter=e,this.__v_isRef=!0,this.__v_isReadonly=!0,this._value=void 0}get value(){return this._value=this._getter()}},Tte=class{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new In(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Fn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&Dn!==this)return Iee(this,!0),!0}get value(){let e=this.dep.track();return zee(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}},$n={},er=new WeakMap,tr=void 0}));function nr(e,t,n,r){try{return r?e(...r):e()}catch(e){ir(e,t,n)}}function rr(e,t,n,r){if(xt(e)){let i=nr(e,t,n,r);return i&&Tt(i)&&i.catch(e=>{ir(e,t,n)}),i}if(_t(e)){let i=[];for(let a=0;a>>1,i=li[r],a=gi(i);a=gi(n)?li.push(e):li.splice(Ote(t),0,e),e.flags|=1,kte()}}function kte(){hi||=mi.then(Mte)}function Ate(e){_t(e)?di.push(...e):fi&&e.id===-1?fi.splice(pi+1,0,e):e.flags&1||(di.push(e),e.flags|=1),kte()}function jte(e,t,n=ui+1){for(;ngi(e)-gi(t));if(di.length=0,fi){fi.push(...e);return}for(fi=e,pi=0;pi{r._d&&Xr(-1);let i=cr(t),a;try{a=e(...n)}finally{cr(i),r._d&&Xr(1)}return a};return r._n=!0,r._c=!0,r._d=!0,r}function lr(e,t){if(_i===null)return e;let n=si(_i),r=e.dirs||=[];for(let e=0;e1)return n&&xt(t)?t.call(r&&r.proxy):t}}function Nte(){return!!(ba()||$i)}function pr(e,t){return hr(e,null,t)}function Pte(e,t){return hr(e,null,{flush:`sync`})}function mr(e,t,n){return hr(e,t,n)}function hr(e,t,n=ct){let{immediate:r,deep:i,flush:a,once:o}=n,s=mt({},n),c=t&&r||!t&&a!==`post`,l;if(Ta){if(a===`sync`){let e=qne();l=e.__watcherHandles||=[]}else if(!c){let e=()=>{};return e.stop=ut,e.resume=ut,e.pause=ut,e}}let u=ya;s.call=(e,t,n)=>rr(e,u,t,n);let d=!1;a===`post`?s.scheduler=e=>{la(e,u&&u.suspense)}:a!==`sync`&&(d=!0,s.scheduler=(e,t)=>{t?e():or(e)}),s.augmentJob=e=>{t&&(e.flags|=4),d&&(e.flags|=2,u&&(e.id=u.uid,e.i=u))};let f=ote(e,t,s);return Ta&&(l?l.push(f):c&&f()),f}function Fte(e,t,n){let r=this.proxy,i=St(e)?e.includes(`.`)?Ite(r,e):()=>r[e]:e.bind(r,r),a;xt(t)?a=t:(a=t.handler,n=t);let o=Ca(this),s=hr(i,a.bind(r),n);return o(),s}function Ite(e,t){let n=t.split(`.`);return()=>{let t=e;for(let e=0;e{e.isMounted=!0}),Wi(()=>{e.isUnmounting=!0}),e}function zte(e){let t=e[0];if(e.length>1){for(let n of e)if(n.type!==fa){t=n;break}}return t}function Bte(e,t){let{leavingVNodes:n}=e,r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function yr(e,t,n,r,i){let{appear:a,mode:o,persisted:s=!1,onBeforeEnter:c,onEnter:l,onAfterEnter:u,onEnterCancelled:d,onBeforeLeave:f,onLeave:p,onAfterLeave:m,onLeaveCancelled:h,onBeforeAppear:g,onAppear:_,onAfterAppear:v,onAppearCancelled:y}=t,b=String(e.key),x=Bte(n,e),S=(e,t)=>{e&&rr(e,r,9,t)},C=(e,t)=>{let n=t[1];S(e,t),_t(e)?e.every(e=>e.length<=1)&&n():e.length<=1&&n()},w={mode:o,persisted:s,beforeEnter(t){let r=c;if(!n.isMounted)if(a)r=g||c;else return;t[Oi]&&t[Oi](!0);let i=x[b];i&&Qr(e,i)&&i.el[Oi]&&i.el[Oi](),S(r,[t])},enter(t){if(x[b]===e)return;let r=l,i=u,o=d;if(!n.isMounted)if(a)r=_||l,i=v||u,o=y||d;else return;let s=!1;t[ki]=e=>{s||(s=!0,S(e?o:i,[t]),w.delayedLeave&&w.delayedLeave(),t[ki]=void 0)};let c=t[ki].bind(null,!1);r?C(r,[t,c]):c()},leave(t,r){let i=String(e.key);if(t[ki]&&t[ki](!0),n.isUnmounting)return r();S(f,[t]);let a=!1;t[Oi]=n=>{a||(a=!0,r(),S(n?h:m,[t]),t[Oi]=void 0,x[i]===e&&delete x[i])};let o=t[Oi].bind(null,!1);x[i]=e,p?C(p,[t,o]):o()},clone(e){let a=yr(e,t,n,r,i);return i&&i(a),a}};return w}function br(e){if(zi(e))return e=ei(e),e.children=null,e}function Vte(e){if(!zi(e))return bi(e.type)&&e.children?zte(e.children):e;if(e.component)return e.component.subTree;let{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&xt(n.default))return n.default()}}function xr(e,t){e.shapeFlag&6&&e.component?(e.transition=t,xr(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Hte(e,t=!1,n){let r=[],i=0;for(let a=0;a1)for(let e=0;en.value,set:e=>n.value=e})}return n}function Ute(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}function Tr(e,t,n,r,i=!1){if(_t(e)){e.forEach((e,a)=>Tr(e,t&&(_t(t)?t[a]:t),n,r,i));return}if(Ri(r)&&!i){r.shapeFlag&512&&r.type.__asyncResolved&&r.component.subTree.component&&Tr(e,t,n,r.component.subTree);return}let a=r.shapeFlag&4?si(r.component):r.el,o=i?null:a,{i:s,r:c}=e,l=t&&t.r,u=s.refs===ct?s.refs={}:s.refs,d=s.setupState,f=bn(d),p=d===ct?dt:e=>Ute(u,e)?!1:gt(f,e),m=(e,t)=>!(t&&Ute(u,t));if(l!=null&&l!==c){if(Wte(t),St(l))u[l]=null,p(l)&&(d[l]=null);else if(xn(l)){let e=t;m(l,e.k)&&(l.value=null),e.k&&(u[e.k]=null)}}if(xt(c))nr(c,s,12,[o,u]);else{let t=St(c),r=xn(c);if(t||r){let s=()=>{if(e.f){let n=t?p(c)?d[c]:u[c]:m(c)||!e.k?c.value:u[e.k];if(i)_t(n)&&ht(n,a);else if(_t(n))n.includes(a)||n.push(a);else if(t)u[c]=[a],p(c)&&(d[c]=u[c]);else{let t=[a];m(c,e.k)&&(c.value=t),e.k&&(u[e.k]=t)}}else t?(u[c]=o,p(c)&&(d[c]=o)):r&&(m(c,e.k)&&(c.value=o),e.k&&(u[e.k]=o))};if(o){let t=()=>{s(),Ni.delete(e)};t.id=-1,Ni.set(e,t),la(t,n)}else Wte(e),s()}}}function Wte(e){let t=Ni.get(e);t&&(t.flags|=8,Ni.delete(e))}function Gte(e){let{mt:t,p:n,o:{patchProp:r,createText:i,nextSibling:a,parentNode:o,remove:s,insert:c,createComment:l}}=e,u=(e,t)=>{if(!t.hasChildNodes()){n(null,e,t),sr(),t._vnode=e;return}d(t.firstChild,e,null,null,null),sr(),t._vnode=e},d=(n,r,s,l,u,y=!1)=>{y||=!!r.dynamicChildren;let b=Ii(n)&&n.data===`[`,x=()=>h(n,r,s,l,u,b),{type:S,ref:C,shapeFlag:w,patchFlag:ee}=r,te=n.nodeType;r.el=n,ee===-2&&(y=!1,r.dynamicChildren=null);let ne=null;switch(S){case da:te===3?(n.data!==r.children&&(Pi(),n.data=r.children),ne=a(n)):r.children===``?(c(r.el=i(``),o(n),n),ne=n):ne=x();break;case fa:v(n)?(ne=a(n),_(r.el=n.content.firstChild,n,s)):ne=te!==8||b?x():a(n);break;case pa:if(b&&(n=a(n),te=n.nodeType),te===1||te===3){ne=n;let e=!r.children.length;for(let t=0;t{o||=!!t.dynamicChildren;let{type:c,props:l,patchFlag:u,shapeFlag:d,dirs:f,transition:m}=t,h=c===`input`||c===`option`;if(h||u!==-1){f&&ur(t,null,n,`created`);let c=!1;if(v(e)){c=One(null,m)&&n&&n.vnode.props&&n.vnode.props.appear;let r=e.content.firstChild;if(c){let e=r.getAttribute(`class`);e&&(r.$cls=e),m.beforeEnter(r)}_(r,e,n),t.el=e=r}if(d&16&&!(l&&(l.innerHTML||l.textContent))){let r=p(e.firstChild,t,e,n,i,a,o);for(;r;){Er(e,1)||Pi();let t=r;r=r.nextSibling,s(t)}}else if(d&8){let n=t.children;n[0]===` -`&&(e.tagName===`PRE`||e.tagName===`TEXTAREA`)&&(n=n.slice(1));let{textContent:r}=e;r!==n&&r!==n.replace(/\r\n|\r/g,` -`)&&(Er(e,0)||Pi(),e.textContent=t.children)}if(l){if(h||!o||u&48){let t=e.tagName.includes(`-`);for(let i in l)(h&&(i.endsWith(`value`)||i===`indeterminate`)||ft(i)&&!At(i)||i[0]===`.`||t&&!At(i))&&r(e,i,null,l[i],void 0,n)}else if(l.onClick)r(e,`onClick`,null,l.onClick,void 0,n);else if(u&4&&gn(l.style))for(let e in l.style)l.style[e]}let g;(g=l&&l.onVnodeBeforeMount)&&oi(g,n,t),f&&ur(t,null,n,`beforeMount`),((g=l&&l.onVnodeMounted)||f||c)&&Nne(()=>{g&&oi(g,n,t),c&&m.enter(e),f&&ur(t,null,n,`mounted`)},i)}return e.nextSibling},p=(e,t,r,o,s,l,u)=>{u||=!!t.dynamicChildren;let f=t.children,p=f.length;for(let t=0;t{let{slotScopeIds:u}=t;u&&(i=i?i.concat(u):u);let d=o(e),f=p(a(e),t,d,n,r,i,s);return f&&Ii(f)&&f.data===`]`?a(t.anchor=f):(Pi(),c(t.anchor=l(`]`),d,f),f)},h=(e,t,r,i,c,l)=>{if(Er(e.parentElement,1)||Pi(),t.el=null,l){let t=g(e);for(;;){let n=a(e);if(n&&n!==t)s(n);else break}}let u=a(e),d=o(e);return s(e),n(null,t,d,u,r,i,Fi(d),c),r&&(r.vnode.el=t.el,yne(r,t.el)),u},g=(e,t=`[`,n=`]`)=>{let r=0;for(;e;)if(e=a(e),e&&Ii(e)&&(e.data===t&&r++,e.data===n)){if(r===0)return a(e);r--}return e},_=(e,t,n)=>{let r=t.parentNode;r&&r.replaceChild(e,t);let i=n;for(;i;)i.vnode.el===t&&(i.vnode.el=i.subTree.el=e),i=i.parent},v=e=>e.nodeType===1&&e.tagName===`TEMPLATE`;return[u,d]}function Er(e,t){if(t===0||t===1)for(;e&&!e.hasAttribute(Li);)e=e.parentElement;let n=e&&e.getAttribute(Li);if(n==null)return!1;if(n===``)return!0;{let e=n.split(`,`);return t===0&&e.includes(`children`)?!0:e.includes($ne[t])}}function Kte(e,t){if(Ii(e)&&e.data===`[`){let n=1,r=e.nextSibling;for(;r;){if(r.nodeType===1){if(t(r)===!1)break}else if(Ii(r))if(r.data===`]`){if(--n===0)break}else r.data===`[`&&n++;r=r.nextSibling}}else t(e)}function qte(e){xt(e)&&(e={loader:e});let{loader:t,loadingComponent:n,errorComponent:r,delay:i=200,hydrate:a,timeout:o,suspensible:s=!0,onError:c}=e,l=null,u,d=0,f=()=>(d++,l=null,p()),p=()=>{let e;return l||(e=l=t().catch(e=>{if(e=e instanceof Error?e:Error(String(e)),c)return new Promise((t,n)=>{c(e,()=>t(f()),()=>n(e),d+1)});throw e}).then(t=>e!==l&&l?l:(t&&(t.__esModule||t[Symbol.toStringTag]===`Module`)&&(t=t.default),u=t,t)))};return N({name:`AsyncComponentWrapper`,__asyncLoader:p,__asyncHydrate(e,t,n){let r=!1;(t.bu||=[]).push(()=>r=!0);let i=()=>{r||n()},o=a?()=>{let n=a(i,t=>Kte(e,t));n&&(t.bum||=[]).push(n)}:i;u?o():p().then(()=>!t.isUnmounted&&o())},get __asyncResolved(){return u},setup(){let e=ya;if(Cr(e),u)return()=>Dr(u,e);let t=t=>{l=null,ir(t,e,13,!r)};if(s&&e.suspense||Ta)return p().then(t=>()=>Dr(t,e)).catch(e=>(t(e),()=>r?H(r,{error:e}):null));let a=A(!1),c=A(),d=A(!!i);return i&&setTimeout(()=>{d.value=!1},i),o!=null&&setTimeout(()=>{if(!a.value&&!c.value){let e=Error(`Async component timed out after ${o}ms.`);t(e),c.value=e}},o),p().then(()=>{a.value=!0,e.parent&&zi(e.parent.vnode)&&e.parent.update()}).catch(e=>{t(e),c.value=e}),()=>{if(a.value&&u)return Dr(u,e);if(c.value&&r)return H(r,{error:c.value});if(n&&!d.value)return Dr(n,e)}}})}function Dr(e,t){let{ref:n,props:r,children:i,ce:a}=t.vnode,o=H(e,r,i);return o.ref=n,o.ce=a,delete t.vnode.ce,o}function Jte(e,t){Xte(e,`a`,t)}function Yte(e,t){Xte(e,`da`,t)}function Xte(e,t,n=ya){let r=e.__wdc||=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()};if(Or(t,r,n),n){let e=n.parent;for(;e&&e.parent;)zi(e.parent.vnode)&&Zte(r,t,n,e),e=e.parent}}function Zte(e,t,n,r){let i=Or(t,e,r,!0);Gi(()=>{ht(r[t],i)},n)}function Or(e,t,n=ya,r=!1){if(n){let i=n[e]||(n[e]=[]),a=t.__weh||=(...r)=>{Qt();let i=Ca(n),a=rr(t,n,e,r);return i(),$t(),a};return r?i.unshift(a):i.push(a),a}}function Qte(e,t=ya){Or(`ec`,e,t)}function $te(e,t){return ene(qi,e,!0,t)||e}function kr(e){return St(e)?ene(qi,e,!1)||e:e||Ji}function ene(e,t,n=!0,r=!1){let i=_i||ya;if(i){let n=i.type;if(e===qi){let e=Wne(n,!1);if(e&&(e===t||e===Mt(t)||e===Pt(Mt(t))))return n}let a=tne(i[e]||n[e],t)||tne(i.appContext[e],t);return!a&&r?n:a}}function tne(e,t){return e&&(e[t]||e[Mt(t)]||e[Pt(Mt(t))])}function Ar(e,t,n,r){let i,a=n&&n[r],o=_t(e);if(o||St(e)){let n=o&&gn(e),r=!1,s=!1;n&&(r=!vn(e),s=_n(e),e=rn(e)),i=Array(e.length);for(let n=0,o=e.length;nt(e,n,void 0,a&&a[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,o=n.length;r{let t=r.fn(...e);return t&&(t.key=r.key),t}:r.fn)}return e}function P(e,t,n={},r,i){if(_i.ce||_i.parent&&Ri(_i.parent)&&_i.parent.ce){let e=Object.keys(n).length>0;return t!==`default`&&(n.name=t),F(),L(V,null,[H(`slot`,n,r&&r())],e?-2:64)}let a=e[t];a&&a._c&&(a._d=!1),F();let o=a&&Mr(a(n)),s=n.key||o&&o.key,c=L(V,{key:(s&&!Ct(s)?s:`_${t}`)+(!o&&r?`_fb`:``)},o||(r?r():[]),o&&e._===1?64:-2);return!i&&c.scopeId&&(c.slotScopeIds=[c.scopeId+`-s`]),a&&a._c&&(a._d=!0),c}function Mr(e){return e.some(e=>Zr(e)?!(e.type===fa||e.type===V&&!Mr(e.children)):!0)?e:null}function Nr(e,t){let n={};for(let r in e)n[t&&/[A-Z]/.test(r)?`on:${r}`:Ft(r)]=e[r];return n}function Pr(){return nne(`useSlots`).slots}function Fr(){return nne(`useAttrs`).attrs}function nne(e){let t=ba();return t.setupContext||=Une(t)}function Ir(e){return _t(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}function Lr(e,t){return!e||!t?e||t:_t(e)&&_t(t)?e.concat(t):mt({},Ir(e),Ir(t))}function rne(e,t){let n={};for(let r in e)t.includes(r)||Object.defineProperty(n,r,{enumerable:!0,get:()=>e[r]});return n}function ine(e){let t=Rr(e),n=e.proxy,r=e.ctx;Qi=!1,t.beforeCreate&&one(t.beforeCreate,e,`bc`);let{data:i,computed:a,methods:o,watch:s,provide:c,inject:l,created:u,beforeMount:d,mounted:f,beforeUpdate:p,updated:m,activated:h,deactivated:g,beforeDestroy:_,beforeUnmount:v,destroyed:y,unmounted:b,render:x,renderTracked:S,renderTriggered:C,errorCaptured:w,serverPrefetch:ee,expose:te,inheritAttrs:ne,components:re,directives:T,filters:ie}=t;if(l&&ane(l,r,null),o)for(let e in o){let t=o[e];xt(t)&&(r[e]=t.bind(n))}if(i){let t=i.call(n,n);wt(t)&&(e.data=fn(t))}if(Qi=!0,a)for(let e in a){let t=a[e],i=U({get:xt(t)?t.bind(n,n):xt(t.get)?t.get.bind(n,n):ut,set:!xt(t)&&xt(t.set)?t.set.bind(n):ut});Object.defineProperty(r,e,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e})}if(s)for(let e in s)sne(s[e],r,n,e);if(c){let e=xt(c)?c.call(n):c;Reflect.ownKeys(e).forEach(t=>{dr(t,e[t])})}u&&one(u,e,`c`);function ae(e,t){_t(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(ae(Vi,d),ae(Hi,f),ae(Ui,p),ae(ere,m),ae(Jte,h),ae(Yte,g),ae(Qte,w),ae(nre,S),ae(tre,C),ae(Wi,v),ae(Gi,b),ae(Ki,ee),_t(te))if(te.length){let t=e.exposed||={};te.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t,enumerable:!0})})}else e.exposed||={};x&&e.render===ut&&(e.render=x),ne!=null&&(e.inheritAttrs=ne),re&&(e.components=re),T&&(e.directives=T),ee&&Cr(e)}function ane(e,t,n=ut){_t(e)&&(e=Br(e));for(let n in e){let r=e[n],i;i=wt(r)?`default`in r?fr(r.from||n,r.default,!0):fr(r.from||n):fr(r),xn(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e}):t[n]=i}}function one(e,t,n){rr(_t(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function sne(e,t,n,r){let i=r.includes(`.`)?Ite(n,r):()=>n[r];if(St(e)){let n=t[e];xt(n)&&mr(i,n)}else if(xt(e))mr(i,e.bind(n));else if(wt(e))if(_t(e))e.forEach(e=>sne(e,t,n,r));else{let r=xt(e.handler)?e.handler.bind(n):t[e.handler];xt(r)&&mr(i,r,e)}}function Rr(e){let t=e.type,{mixins:n,extends:r}=t,{mixins:i,optionsCache:a,config:{optionMergeStrategies:o}}=e.appContext,s=a.get(t),c;return s?c=s:!i.length&&!n&&!r?c=t:(c={},i.length&&i.forEach(e=>zr(c,e,o,!0)),zr(c,t,o)),wt(t)&&a.set(t,c),c}function zr(e,t,n,r=!1){let{mixins:i,extends:a}=t;a&&zr(e,a,n,!0),i&&i.forEach(t=>zr(e,t,n,!0));for(let i in t)if(!(r&&i===`expose`)){let r=ire[i]||n&&n[i];e[i]=r?r(e[i],t[i]):t[i]}return e}function cne(e,t){return t?e?function(){return mt(xt(e)?e.call(this,this):e,xt(t)?t.call(this,this):t)}:t:e}function lne(e,t){return Hr(Br(e),Br(t))}function Br(e){if(_t(e)){let t={};for(let n=0;n{let c,l=ct,u;return Pte(()=>{let t=e[i];It(c,t)&&(c=t,s())}),{get(){return o(),n.get?n.get(c):c},set(e){let o=n.set?n.set(e):e;if(!It(o,c)&&!(l!==ct&&It(e,l)))return;let d=r.vnode.props;d&&(t in d||i in d||a in d)&&(`onUpdate:${t}`in d||`onUpdate:${i}`in d||`onUpdate:${a}`in d)||(c=e,s()),r.emit(`update:${t}`,o),It(e,o)&&It(e,l)&&!It(o,u)&&s(),l=e,u=o}}});return s[Symbol.iterator]=()=>{let e=0;return{next(){return e<2?{value:e++?o||ct:s,done:!1}:{done:!0}}}},s}function mne(e,t,...n){if(e.isUnmounted)return;let r=e.vnode.props||ct,i=n,a=t.startsWith(`update:`),o=a&&ea(r,t.slice(7));o&&(o.trim&&(i=n.map(e=>St(e)?e.trim():e)),o.number&&(i=n.map(zt)));let s,c=r[s=Ft(t)]||r[s=Ft(Mt(t))];!c&&a&&(c=r[s=Ft(Nt(t))]),c&&rr(c,e,6,i);let l=r[s+`Once`];if(l){if(!e.emitted)e.emitted={};else if(e.emitted[s])return;e.emitted[s]=!0,rr(l,e,6,i)}}function hne(e,t,n=!1){let r=n?ore:t.emitsCache,i=r.get(e);if(i!==void 0)return i;let a=e.emits,o={},s=!1;if(!xt(e)){let r=e=>{let n=hne(e,t,!0);n&&(s=!0,mt(o,n))};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!a&&!s?(wt(e)&&r.set(e,null),null):(_t(a)?a.forEach(e=>o[e]=null):mt(o,a),wt(e)&&r.set(e,o),o)}function Wr(e,t){return!e||!ft(t)?!1:(t=t.slice(2).replace(/Once$/,``),gt(e,t[0].toLowerCase()+t.slice(1))||gt(e,Nt(t))||gt(e,t))}function Gr(e){let{type:t,vnode:n,proxy:r,withProxy:i,propsOptions:[a],slots:o,attrs:s,emit:c,render:l,renderCache:u,props:d,data:f,setupState:p,ctx:m,inheritAttrs:h}=e,g=cr(e),_,v;try{if(n.shapeFlag&4){let e=i||r,t=e;_=ni(l.call(t,e,u,d,p,f,m)),v=s}else{let e=t;_=ni(e.length>1?e(d,{attrs:s,slots:o,emit:c}):e(d,null)),v=t.props?s:sre(s)}}catch(t){ma.length=0,ir(t,e,1),_=H(fa)}let y=_;if(v&&h!==!1){let e=Object.keys(v),{shapeFlag:t}=y;e.length&&t&7&&(a&&e.some(pt)&&(v=cre(v,a)),y=ei(y,v,!1,!0))}return n.dirs&&(y=ei(y,null,!1,!0),y.dirs=y.dirs?y.dirs.concat(n.dirs):n.dirs),n.transition&&xr(y,n.transition),_=y,cr(g),_}function gne(e,t,n){let{props:r,children:i,component:a}=e,{props:o,children:s,patchFlag:c}=t,l=a.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return r?_ne(r,o,l):!!o;if(c&8){let e=t.dynamicProps;for(let t=0;t0)&&!(o&16)){if(o&8){let n=e.vnode.dynamicProps;for(let r=0;r{c=!0;let[n,r]=Cne(e,t,!0);mt(o,n),r&&s.push(...r)};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}if(!a&&!c)return wt(e)&&r.set(e,lt),lt;if(_t(a))for(let e=0;e{if(e===t)return;e&&!Qr(e,t)&&(r=he(e),ue(e,i,a,!0),e=null),t.patchFlag===-2&&(c=!1,t.dynamicChildren=null);let{type:l,ref:u,shapeFlag:d}=t;switch(l){case da:g(e,t,n,r);break;case fa:_(e,t,n,r);break;case pa:e??v(t,n,r,o);break;case V:re(e,t,n,r,i,a,o,s,c);break;default:d&1?x(e,t,n,r,i,a,o,s,c):d&6?T(e,t,n,r,i,a,o,s,c):(d&64||d&128)&&l.process(e,t,n,r,i,a,o,s,c,ve)}u!=null&&i?Tr(u,e&&e.ref,a,t||e,!t):u==null&&e&&e.ref!=null&&Tr(e.ref,null,a,e,!0)},g=(e,t,n,i)=>{if(e==null)r(t.el=s(t.children),n,i);else{let n=t.el=e.el;t.children!==e.children&&l(n,t.children)}},_=(e,t,n,i)=>{e==null?r(t.el=c(t.children||``),n,i):t.el=e.el},v=(e,t,n,r)=>{[e.el,e.anchor]=m(e.children,t,n,r,e.el,e.anchor)},y=({el:e,anchor:t},n,i)=>{let a;for(;e&&e!==t;)a=f(e),r(e,n,i),e=a;r(t,n,i)},b=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=f(e),i(e),e=n;i(t)},x=(e,t,n,r,i,a,o,s,c)=>{if(t.type===`svg`?o=`svg`:t.type===`math`&&(o=`mathml`),e==null)S(t,n,r,i,a,o,s,c);else{let n=e.el&&e.el._isVueCE?e.el:null;try{n&&n._beginPatch(),ee(e,t,i,a,o,s,c)}finally{n&&n._endPatch()}}},S=(e,t,n,i,s,c,l,d)=>{let f,p,{props:m,shapeFlag:h,transition:g,dirs:_}=e;if(f=e.el=o(e.type,c,m&&m.is,m),h&8?u(f,e.children):h&16&&w(e.children,f,null,i,s,qr(e,c),l,d),_&&ur(e,null,i,`created`),C(f,e,e.scopeId,l,i),m){for(let e in m)e!==`value`&&!At(e)&&a(f,e,null,m[e],c,i);`value`in m&&a(f,`value`,null,m.value,c),(p=m.onVnodeBeforeMount)&&oi(p,i,e)}_&&ur(e,null,i,`beforeMount`);let v=One(s,g);v&&g.beforeEnter(f),r(f,t,n),((p=m&&m.onVnodeMounted)||v||_)&&la(()=>{p&&oi(p,i,e),v&&g.enter(f),_&&ur(e,null,i,`mounted`)},s)},C=(e,t,n,r,i)=>{if(n&&p(e,n),r)for(let t=0;t{for(let l=c;l{let c=t.el=e.el,{patchFlag:l,dynamicChildren:d,dirs:f}=t;l|=e.patchFlag&16;let p=e.props||ct,m=t.props||ct,h;if(n&&Jr(n,!1),(h=m.onVnodeBeforeUpdate)&&oi(h,n,t,e),f&&ur(t,e,n,`beforeUpdate`),n&&Jr(n,!0),(p.innerHTML&&m.innerHTML==null||p.textContent&&m.textContent==null)&&u(c,``),d?te(e.dynamicChildren,d,c,n,r,qr(t,i),o):s||oe(e,t,c,null,n,r,qr(t,i),o,!1),l>0){if(l&16)ne(c,p,m,n,i);else if(l&2&&p.class!==m.class&&a(c,`class`,null,m.class,i),l&4&&a(c,`style`,p.style,m.style,i),l&8){let e=t.dynamicProps;for(let t=0;t{h&&oi(h,n,t,e),f&&ur(t,e,n,`updated`)},r)},te=(e,t,n,r,i,a,o)=>{for(let s=0;s{if(t!==n){if(t!==ct)for(let o in t)!At(o)&&!(o in n)&&a(e,o,t[o],null,i,r);for(let o in n){if(At(o))continue;let s=n[o],c=t[o];s!==c&&o!==`value`&&a(e,o,c,s,i,r)}`value`in n&&a(e,`value`,t.value,n.value,i)}},re=(e,t,n,i,a,o,c,l,u)=>{let d=t.el=e?e.el:s(``),f=t.anchor=e?e.anchor:s(``),{patchFlag:p,dynamicChildren:m,slotScopeIds:h}=t;h&&(l=l?l.concat(h):h),e==null?(r(d,n,i),r(f,n,i),w(t.children||[],n,f,a,o,c,l,u)):p>0&&p&64&&m&&e.dynamicChildren&&e.dynamicChildren.length===m.length?(te(e.dynamicChildren,m,n,a,o,c,l),(t.key!=null||a&&t===a.subTree)&&Yr(e,t,!0)):oe(e,t,n,f,a,o,c,l,u)},T=(e,t,n,r,i,a,o,s,c)=>{t.slotScopeIds=s,e==null?t.shapeFlag&512?i.ctx.activate(t,n,r,o,c):ie(t,n,r,i,a,o,c):ae(e,t,c)},ie=(e,t,n,r,i,a,o)=>{let s=e.component=Lne(e,r,i);if(zi(e)&&(s.ctx.renderer=ve),zne(s,!1,o),s.asyncDep){if(i&&i.registerDep(s,E,o),!e.el){let r=s.subTree=H(fa);_(null,r,t,n),e.placeholder=r.el}}else E(s,e,t,n,i,a,o)},ae=(e,t,n)=>{let r=t.component=e.component;if(gne(e,t,n))if(r.asyncDep&&!r.asyncResolved){D(r,t,n);return}else r.next=t,r.update();else t.el=e.el,r.vnode=t},E=(e,t,n,r,i,a,o)=>{let s=()=>{if(e.isMounted){let{next:t,bu:n,u:r,parent:s,vnode:c}=e;{let n=Ane(e);if(n){t&&(t.el=c.el,D(e,t,o)),n.asyncDep.then(()=>{la(()=>{e.isUnmounted||l()},i)});return}}let u=t,f;Jr(e,!1),t?(t.el=c.el,D(e,t,o)):t=c,n&&Lt(n),(f=t.props&&t.props.onVnodeBeforeUpdate)&&oi(f,s,t,c),Jr(e,!0);let p=Gr(e),m=e.subTree;e.subTree=p,h(m,p,d(m.el),he(m),e,i,a),t.el=p.el,u===null&&yne(e,p.el),r&&la(r,i),(f=t.props&&t.props.onVnodeUpdated)&&la(()=>oi(f,s,t,c),i)}else{let o,{el:s,props:c}=t,{bm:l,m:u,parent:d,root:f,type:p}=e,m=Ri(t);if(Jr(e,!1),l&&Lt(l),!m&&(o=c&&c.onVnodeBeforeMount)&&oi(o,d,t),Jr(e,!0),s&&be){let t=()=>{e.subTree=Gr(e),be(s,e.subTree,e,i,null)};m&&p.__asyncHydrate?p.__asyncHydrate(s,e,t):t()}else{f.ce&&f.ce._hasShadowRoot()&&f.ce._injectChildStyle(p,e.parent?e.parent.type:void 0);let o=e.subTree=Gr(e);h(null,o,n,r,e,i,a),t.el=o.el}if(u&&la(u,i),!m&&(o=c&&c.onVnodeMounted)){let e=t;la(()=>oi(o,d,e),i)}(t.shapeFlag&256||d&&Ri(d.vnode)&&d.vnode.shapeFlag&256)&&e.a&&la(e.a,i),e.isMounted=!0,t=n=r=null}};e.scope.on();let c=e.effect=new kn(s);e.scope.off();let l=e.update=c.run.bind(c),u=e.job=c.runIfDirty.bind(c);u.i=e,u.id=e.uid,c.scheduler=()=>or(u),Jr(e,!0),l()},D=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,xne(e,t.props,r,n),fre(e,t.children,n),Qt(),jte(e),$t()},oe=(e,t,n,r,i,a,o,s,c=!1)=>{let l=e&&e.children,d=e?e.shapeFlag:0,f=t.children,{patchFlag:p,shapeFlag:m}=t;if(p>0){if(p&128){ce(l,f,n,r,i,a,o,s,c);return}else if(p&256){se(l,f,n,r,i,a,o,s,c);return}}m&8?(d&16&&me(l,i,a),f!==l&&u(n,f)):d&16?m&16?ce(l,f,n,r,i,a,o,s,c):me(l,i,a,!0):(d&8&&u(n,``),m&16&&w(f,n,r,i,a,o,s,c))},se=(e,t,n,r,i,a,o,s,c)=>{e||=lt,t||=lt;let l=e.length,u=t.length,d=Math.min(l,u),f;for(f=0;fu?me(e,i,a,!0,!1,d):w(t,n,r,i,a,o,s,c,d)},ce=(e,t,n,r,i,a,o,s,c)=>{let l=0,u=t.length,d=e.length-1,f=u-1;for(;l<=d&&l<=f;){let r=e[l],u=t[l]=c?ri(t[l]):ni(t[l]);if(Qr(r,u))h(r,u,n,null,i,a,o,s,c);else break;l++}for(;l<=d&&l<=f;){let r=e[d],l=t[f]=c?ri(t[f]):ni(t[f]);if(Qr(r,l))h(r,l,n,null,i,a,o,s,c);else break;d--,f--}if(l>d){if(l<=f){let e=f+1,d=ef)for(;l<=d;)ue(e[l],i,a,!0),l++;else{let p=l,m=l,g=new Map;for(l=m;l<=f;l++){let e=t[l]=c?ri(t[l]):ni(t[l]);e.key!=null&&g.set(e.key,l)}let _,v=0,y=f-m+1,b=!1,x=0,S=Array(y);for(l=0;l=y){ue(r,i,a,!0);continue}let u;if(r.key!=null)u=g.get(r.key);else for(_=m;_<=f;_++)if(S[_-m]===0&&Qr(r,t[_])){u=_;break}u===void 0?ue(r,i,a,!0):(S[u-m]=l+1,u>=x?x=u:b=!0,h(r,t[u],n,null,i,a,o,s,c),v++)}let C=b?kne(S):lt;for(_=C.length-1,l=y-1;l>=0;l--){let e=m+l,d=t[e],f=t[e+1],p=e+1{let{el:s,type:c,transition:l,children:u,shapeFlag:d}=e;if(d&6){le(e.component.subTree,t,n,a);return}if(d&128){e.suspense.move(t,n,a);return}if(d&64){c.move(e,t,n,ve);return}if(c===V){r(s,t,n);for(let e=0;el.enter(s),o);else{let{leave:a,delayLeave:o,afterLeave:c}=l,u=()=>{e.ctx.isUnmounted?i(s):r(s,t,n)},d=()=>{s._isLeaving&&s[Oi](!0),a(s,()=>{u(),c&&c()})};o?o(s,u,d):d()}else r(s,t,n)},ue=(e,t,n,r=!1,i=!1)=>{let{type:a,props:o,ref:s,children:c,dynamicChildren:l,shapeFlag:u,patchFlag:d,dirs:f,cacheIndex:p}=e;if(d===-2&&(i=!1),s!=null&&(Qt(),Tr(s,null,n,e,!0),$t()),p!=null&&(t.renderCache[p]=void 0),u&256){t.ctx.deactivate(e);return}let m=u&1&&f,h=!Ri(e),g;if(h&&(g=o&&o.onVnodeBeforeUnmount)&&oi(g,t,e),u&6)pe(e.component,n,r);else{if(u&128){e.suspense.unmount(n,r);return}m&&ur(e,null,t,`beforeUnmount`),u&64?e.type.remove(e,t,n,ve,r):l&&!l.hasOnce&&(a!==V||d>0&&d&64)?me(l,t,n,!1,!0):(a===V&&d&384||!i&&u&16)&&me(c,t,n),r&&de(e)}(h&&(g=o&&o.onVnodeUnmounted)||m)&&la(()=>{g&&oi(g,t,e),m&&ur(e,null,t,`unmounted`)},n)},de=e=>{let{type:t,el:n,anchor:r,transition:a}=e;if(t===V){fe(n,r);return}if(t===pa){b(e);return}let o=()=>{i(n),a&&!a.persisted&&a.afterLeave&&a.afterLeave()};if(e.shapeFlag&1&&a&&!a.persisted){let{leave:t,delayLeave:r}=a,i=()=>t(n,o);r?r(e.el,o,i):i()}else o()},fe=(e,t)=>{let n;for(;e!==t;)n=f(e),i(e),e=n;i(t)},pe=(e,t,n)=>{let{bum:r,scope:i,job:a,subTree:o,um:s,m:c,a:l}=e;jne(c),jne(l),r&&Lt(r),i.stop(),a&&(a.flags|=8,ue(o,e,t,n)),s&&la(s,t),la(()=>{e.isUnmounted=!0},t)},me=(e,t,n,r=!1,i=!1,a=0)=>{for(let o=a;o{if(e.shapeFlag&6)return he(e.component.subTree);if(e.shapeFlag&128)return e.suspense.next();let t=f(e.anchor||e.el),n=t&&t[yi];return n?f(n):t},ge=!1,_e=(e,t,n)=>{let r;e==null?t._vnode&&(ue(t._vnode,null,null,!0),r=t._vnode.component):h(t._vnode||null,e,t,null,null,null,n),t._vnode=e,ge||=(ge=!0,jte(r),sr(),!1)},ve={p:h,um:ue,m:le,r:de,mt:ie,mc:w,pc:oe,pbc:te,n:he,o:e},ye,be;return t&&([ye,be]=t(ve)),{render:_e,hydrate:ye,createApp:pne(_e,ye)}}function qr({type:e,props:t},n){return n===`svg`&&e===`foreignObject`||n===`mathml`&&e===`annotation-xml`&&t&&t.encoding&&t.encoding.includes(`html`)?void 0:n}function Jr({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function One(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Yr(e,t,n=!1){let r=e.children,i=t.children;if(_t(r)&&_t(i))for(let e=0;e>1,e[n[s]]0&&(t[r]=n[a-1]),n[a]=r)}}for(a=n.length,o=n[a-1];a-- >0;)n[a]=o,o=t[o];return n}function Ane(e){let t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Ane(t)}function jne(e){if(e)for(let t=0;t0?ha||lt:null,Pne(),ga>0&&ha&&ha.push(e),e}function I(e,t,n,r,i,a){return Fne(R(e,t,n,r,i,a,!0))}function L(e,t,n,r,i){return Fne(H(e,t,n,r,i,!0))}function Zr(e){return e?e.__v_isVNode===!0:!1}function Qr(e,t){return e.type===t.type&&e.key===t.key}function R(e,t=null,n=null,r=0,i=null,a=e===V?0:1,o=!1,s=!1){let c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&_a(t),ref:t&&va(t),scopeId:vi,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:a,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:_i};return s?(ii(c,n),a&128&&e.normalize(c)):n&&(c.shapeFlag|=St(n)?8:16),ga>0&&!o&&ha&&(c.patchFlag>0||a&6)&&c.patchFlag!==32&&ha.push(c),c}function Ine(e,t=null,n=null,r=0,i=null,a=!1){if((!e||e===Ji)&&(e=fa),Zr(e)){let r=ei(e,t,!0);return n&&ii(r,n),ga>0&&!a&&ha&&(r.shapeFlag&6?ha[ha.indexOf(e)]=r:ha.push(r)),r.patchFlag=-2,r}if(Gne(e)&&(e=e.__vccOpts),t){t=$r(t);let{class:e,style:n}=t;e&&!St(e)&&(t.class=O(e)),wt(n)&&(yn(n)&&!_t(n)&&(n=mt({},n)),t.style=it(n))}let o=St(e)?1:ua(e)?128:bi(e)?64:wt(e)?4:xt(e)?2:0;return R(e,t,n,r,i,o,a,!0)}function $r(e){return e?yn(e)||ra(e)?mt({},e):e:null}function ei(e,t,n=!1,r=!1){let{props:i,ref:a,patchFlag:o,children:s,transition:c}=e,l=t?ai(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&_a(l),ref:t&&t.ref?n&&a?_t(a)?a.concat(va(t)):[a,va(t)]:va(t):a,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:s,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==V?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&ei(e.ssContent),ssFallback:e.ssFallback&&ei(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&r&&xr(u,c.clone(u)),u}function z(e=` `,t=0){return H(da,null,e,t)}function ti(e,t){let n=H(pa,null,e);return n.staticCount=t,n}function B(e=``,t=!1){return t?(F(),L(fa,null,e)):H(fa,null,e)}function ni(e){return e==null||typeof e==`boolean`?H(fa):_t(e)?H(V,null,e.slice()):Zr(e)?ri(e):H(da,null,String(e))}function ri(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:ei(e)}function ii(e,t){let n=0,{shapeFlag:r}=e;if(t==null)t=null;else if(_t(t))n=16;else if(typeof t==`object`)if(r&65){let n=t.default;n&&(n._c&&(n._d=!1),ii(e,n()),n._c&&(n._d=!0));return}else{n=32;let r=t._;!r&&!ra(t)?t._ctx=_i:r===3&&_i&&(_i.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else xt(t)?(t={default:t,_ctx:_i},n=32):(t=String(t),r&64?(n=16,t=[z(t)]):n=8);e.children=t,e.shapeFlag|=n}function ai(...e){let t={};for(let n=0;n1?Une(e):null,i=Ca(e),a=nr(r,e,0,[e.props,n]),o=Tt(a);if($t(),i(),(o||e.sp)&&!Ri(e)&&Cr(e),o){if(a.then(wa,wa),t)return a.then(n=>{Vne(e,n,t)}).catch(t=>{ir(t,e,0)});e.asyncDep=a}else Vne(e,a,t)}else Hne(e,t)}function Vne(e,t,n){xt(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:wt(t)&&(e.setupState=ete(t)),Hne(e,n)}function Hne(e,t,n){let r=e.type;if(!e.render){if(!t&&hre&&!r.render){let t=r.template||Rr(e).template;if(t){let{isCustomElement:n,compilerOptions:i}=e.appContext.config,{delimiters:a,compilerOptions:o}=r;r.render=hre(t,mt(mt({isCustomElement:n,delimiters:a},i),o))}}e.render=r.render||ut,gre&&gre(e)}{let t=Ca(e);Qt();try{ine(e)}finally{$t(),t()}}}function Une(e){return{attrs:new Proxy(e.attrs,_re),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function si(e){return e.exposed?e.exposeProxy||=new Proxy(ete(Zee(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Xi)return Xi[n](e)},has(e,t){return t in e||t in Xi}}):e.proxy}function Wne(e,t=!0){return xt(e)?e.displayName||e.name:e.name||t&&e.__name}function Gne(e){return xt(e)&&`__vccOpts`in e}function ci(e,t,n){try{Xr(-1);let r=arguments.length;return r===2?wt(t)&&!_t(t)?Zr(t)?H(e,null,[t]):H(e,t):H(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&Zr(n)&&(n=[n]),H(e,t,n))}finally{Xr(1)}}var li,ui,di,fi,pi,mi,hi,gi,_i,vi,Kne,qne,yi,bi,xi,Si,Ci,wi,Ti,Ei,Di,Oi,ki,Ai,ji,Mi,Jne,Yne,Ni,Xne,Pi,Zne,Qne,Fi,Ii,Li,$ne,Ri,zi,Bi,Vi,Hi,Ui,ere,Wi,Gi,Ki,tre,nre,qi,Ji,Yi,Xi,Zi,rre,Qi,ire,are,$i,ea,ore,sre,cre,ta,na,ra,lre,ia,aa,ure,oa,sa,ca,dre,fre,la,ua,V,da,fa,pa,ma,ha,ga,_a,va,H,pre,mre,ya,ba,xa,Sa,Ca,wa,Ta,hre,gre,_re,U,vre,Ea=s((()=>{Ete(),Gt(),li=[],ui=-1,di=[],fi=null,pi=0,mi=Promise.resolve(),hi=null,gi=e=>e.id==null?e.flags&2?-1:1/0:e.id,_i=null,vi=null,Kne=Symbol.for(`v-scx`),qne=()=>fr(Kne),yi=Symbol(`_vte`),bi=e=>e.__isTeleport,xi=e=>e&&(e.disabled||e.disabled===``),Si=e=>e&&(e.defer||e.defer===``),Ci=e=>typeof SVGElement<`u`&&e instanceof SVGElement,wi=e=>typeof MathMLElement==`function`&&e instanceof MathMLElement,Ti=(e,t)=>{let n=e&&e.to;return St(n)?t?t(n):null:n},Ei={name:`Teleport`,__isTeleport:!0,process(e,t,n,r,i,a,o,s,c,l){let{mc:u,pc:d,pbc:f,o:{insert:p,querySelector:m,createText:h,createComment:g}}=l,_=xi(t.props),{shapeFlag:v,children:y,dynamicChildren:b}=t;if(e==null){let e=t.el=h(``),l=t.anchor=h(``);p(e,n,r),p(l,n,r);let d=(e,t)=>{v&16&&u(y,e,t,i,a,o,s,c)},f=()=>{let e=t.target=Ti(t.props,m),n=vr(e,t,h,p);e&&(o!==`svg`&&Ci(e)?o=`svg`:o!==`mathml`&&wi(e)&&(o=`mathml`),i&&i.isCE&&(i.ce._teleportTargets||(i.ce._teleportTargets=new Set)).add(e),_||(d(e,n),_r(t,!1)))};_&&(d(n,l),_r(t,!0)),Si(t.props)?(t.el.__isMounted=!1,la(()=>{f(),delete t.el.__isMounted},a)):f()}else{if(Si(t.props)&&e.el.__isMounted===!1){la(()=>{Ei.process(e,t,n,r,i,a,o,s,c,l)},a);return}t.el=e.el,t.targetStart=e.targetStart;let u=t.anchor=e.anchor,p=t.target=e.target,h=t.targetAnchor=e.targetAnchor,g=xi(e.props),v=g?n:p,y=g?u:h;if(o===`svg`||Ci(p)?o=`svg`:(o===`mathml`||wi(p))&&(o=`mathml`),b?(f(e.dynamicChildren,b,v,i,a,o,s),Yr(e,t,!0)):c||d(e,t,v,y,i,a,o,s,!1),_)g?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):gr(t,n,u,l,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){let e=t.target=Ti(t.props,m);e&&gr(t,e,null,l,0)}else g&&gr(t,p,h,l,1);_r(t,_)}},remove(e,t,n,{um:r,o:{remove:i}},a){let{shapeFlag:o,children:s,anchor:c,targetStart:l,targetAnchor:u,target:d,props:f}=e;if(d&&(i(l),i(u)),a&&i(c),o&16){let e=a||!xi(f);for(let i=0;i{let t=e.subTree;return t.component?Mi(t.component):t},Jne={name:`BaseTransition`,props:ji,setup(e,{slots:t}){let n=ba(),r=Rte();return()=>{let i=t.default&&Hte(t.default(),!0);if(!i||!i.length)return;let a=zte(i),o=bn(e),{mode:s}=o;if(r.isLeaving)return br(a);let c=Vte(a);if(!c)return br(a);let l=yr(c,o,r,n,e=>l=e);c.type!==fa&&xr(c,l);let u=n.subTree&&Vte(n.subTree);if(u&&u.type!==fa&&!Qr(u,c)&&Mi(n).type!==fa){let e=yr(u,o,r,n);if(xr(u,e),s===`out-in`&&c.type!==fa)return r.isLeaving=!0,e.afterLeave=()=>{r.isLeaving=!1,n.job.flags&8||n.update(),delete e.afterLeave,u=void 0},br(a);s===`in-out`&&c.type!==fa?e.delayLeave=(e,t,n)=>{let i=Bte(r,u);i[String(u.key)]=u,e[Oi]=()=>{t(),e[Oi]=void 0,delete l.delayedLeave,u=void 0},l.delayedLeave=()=>{n(),delete l.delayedLeave,u=void 0}}:u=void 0}else u&&=void 0;return a}}},Yne=Jne,Ni=new WeakMap,Xne=!1,Pi=()=>{Xne||=(console.error(`Hydration completed but contains mismatches.`),!0)},Zne=e=>e.namespaceURI.includes(`svg`)&&e.tagName!==`foreignObject`,Qne=e=>e.namespaceURI.includes(`MathML`),Fi=e=>{if(e.nodeType===1){if(Zne(e))return`svg`;if(Qne(e))return`mathml`}},Ii=e=>e.nodeType===8,Li=`data-allow-mismatch`,$ne={0:`text`,1:`children`,2:`class`,3:`style`,4:`attribute`},Bt().requestIdleCallback,Bt().cancelIdleCallback,Ri=e=>!!e.type.__asyncLoader,zi=e=>e.type.__isKeepAlive,Bi=e=>(t,n=ya)=>{(!Ta||e===`sp`)&&Or(e,(...e)=>t(...e),n)},Vi=Bi(`bm`),Hi=Bi(`m`),Ui=Bi(`bu`),ere=Bi(`u`),Wi=Bi(`bum`),Gi=Bi(`um`),Ki=Bi(`sp`),tre=Bi(`rtg`),nre=Bi(`rtc`),qi=`components`,Ji=Symbol.for(`v-ndc`),Yi=e=>e?Rne(e)?si(e):Yi(e.parent):null,Xi=mt(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Yi(e.parent),$root:e=>Yi(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Rr(e),$forceUpdate:e=>e.f||=()=>{or(e.update)},$nextTick:e=>e.n||=ar.bind(e.proxy),$watch:e=>Fte.bind(e)}),Zi=(e,t)=>e!==ct&&!e.__isScriptSetup&>(e,t),rre={get({_:e},t){if(t===`__v_skip`)return!0;let{ctx:n,setupState:r,data:i,props:a,accessCache:o,type:s,appContext:c}=e;if(t[0]!==`$`){let e=o[t];if(e!==void 0)switch(e){case 1:return r[t];case 2:return i[t];case 4:return n[t];case 3:return a[t]}else if(Zi(r,t))return o[t]=1,r[t];else if(i!==ct&>(i,t))return o[t]=2,i[t];else if(gt(a,t))return o[t]=3,a[t];else if(n!==ct&>(n,t))return o[t]=4,n[t];else Qi&&(o[t]=0)}let l=Xi[t],u,d;if(l)return t===`$attrs`&&en(e.attrs,`get`,``),l(e);if((u=s.__cssModules)&&(u=u[t]))return u;if(n!==ct&>(n,t))return o[t]=4,n[t];if(d=c.config.globalProperties,gt(d,t))return d[t]},set({_:e},t,n){let{data:r,setupState:i,ctx:a}=e;return Zi(i,t)?(i[t]=n,!0):r!==ct&>(r,t)?(r[t]=n,!0):gt(e.props,t)||t[0]===`$`&&t.slice(1)in e?!1:(a[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:i,props:a,type:o}},s){let c;return!!(n[s]||e!==ct&&s[0]!==`$`&>(e,s)||Zi(t,s)||gt(a,s)||gt(r,s)||gt(Xi,s)||gt(i.config.globalProperties,s)||(c=o.__cssModules)&&c[s])},defineProperty(e,t,n){return n.get==null?gt(n,`value`)&&this.set(e,t,n.value,null):e._.accessCache[t]=0,Reflect.defineProperty(e,t,n)}},Qi=!0,ire={data:cne,props:une,emits:une,methods:Hr,computed:Hr,beforeCreate:Vr,created:Vr,beforeMount:Vr,mounted:Vr,beforeUpdate:Vr,updated:Vr,beforeDestroy:Vr,beforeUnmount:Vr,destroyed:Vr,unmounted:Vr,activated:Vr,deactivated:Vr,errorCaptured:Vr,serverPrefetch:Vr,components:Hr,directives:Hr,watch:dne,provide:cne,inject:lne},are=0,$i=null,ea=(e,t)=>t===`modelValue`||t===`model-value`?e.modelModifiers:e[`${t}Modifiers`]||e[`${Mt(t)}Modifiers`]||e[`${Nt(t)}Modifiers`],ore=new WeakMap,sre=e=>{let t;for(let n in e)(n===`class`||n===`style`||ft(n))&&((t||={})[n]=e[n]);return t},cre=(e,t)=>{let n={};for(let r in e)(!pt(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n},ta={},na=()=>Object.create(ta),ra=e=>Object.getPrototypeOf(e)===ta,lre=new WeakMap,ia=e=>e===`_`||e===`_ctx`||e===`$stable`,aa=e=>_t(e)?e.map(ni):[ni(e)],ure=(e,t,n)=>{if(t._n)return t;let r=M((...e)=>aa(t(...e)),n);return r._c=!1,r},oa=(e,t,n)=>{let r=e._ctx;for(let n in e){if(ia(n))continue;let i=e[n];if(xt(i))t[n]=ure(n,i,r);else if(i!=null){let e=aa(i);t[n]=()=>e}}},sa=(e,t)=>{let n=aa(t);e.slots.default=()=>n},ca=(e,t,n)=>{for(let r in t)(n||!ia(r))&&(e[r]=t[r])},dre=(e,t,n)=>{let r=e.slots=na();if(e.vnode.shapeFlag&32){let e=t._;e?(ca(r,t,n),n&&Rt(r,`_`,e,!0)):oa(t,r)}else t&&sa(e,t)},fre=(e,t,n)=>{let{vnode:r,slots:i}=e,a=!0,o=ct;if(r.shapeFlag&32){let e=t._;e?n&&e===1?a=!1:ca(i,t,n):(a=!t.$stable,oa(t,i)),o=t}else t&&(sa(e,t),o={default:1});if(a)for(let e in i)!ia(e)&&o[e]==null&&delete i[e]},la=Nne,ua=e=>e.__isSuspense,V=Symbol.for(`v-fgt`),da=Symbol.for(`v-txt`),fa=Symbol.for(`v-cmt`),pa=Symbol.for(`v-stc`),ma=[],ha=null,ga=1,_a=({key:e})=>e??null,va=({ref:e,ref_key:t,ref_for:n})=>(typeof e==`number`&&(e=``+e),e==null?null:St(e)||xn(e)||xt(e)?{i:_i,r:e,k:t,f:!!n}:e),H=Ine,pre=fne(),mre=0,ya=null,ba=()=>ya||_i;{let e=Bt(),t=(t,n)=>{let r;return(r=e[t])||(r=e[t]=[]),r.push(n),e=>{r.length>1?r.forEach(t=>t(e)):r[0](e)}};xa=t(`__VUE_INSTANCE_SETTERS__`,e=>ya=e),Sa=t(`__VUE_SSR_SETTERS__`,e=>Ta=e)}Ca=e=>{let t=ya;return xa(e),e.scope.on(),()=>{e.scope.off(),xa(t)}},wa=()=>{ya&&ya.scope.off(),xa(null)},Ta=!1,_re={get(e,t){return en(e,`get`,``),e[t]}},U=(e,t)=>ite(e,t,Ta),vre=`3.5.30`}));function yre(e){let t={};for(let n in e)n in Ga||(t[n]=e[n]);if(e.css===!1)return t;let{name:n=`v`,type:r,duration:i,enterFromClass:a=`${n}-enter-from`,enterActiveClass:o=`${n}-enter-active`,enterToClass:s=`${n}-enter-to`,appearFromClass:c=a,appearActiveClass:l=o,appearToClass:u=s,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:f=`${n}-leave-active`,leaveToClass:p=`${n}-leave-to`}=e,m=bre(i),h=m&&m[0],g=m&&m[1],{onBeforeEnter:_,onEnter:v,onEnterCancelled:y,onLeave:b,onLeaveCancelled:x,onBeforeAppear:S=_,onAppear:C=v,onAppearCancelled:w=y}=t,ee=(e,t,n,r)=>{e._enterCancelled=r,ka(e,t?u:s),ka(e,t?l:o),n&&n()},te=(e,t)=>{e._isLeaving=!1,ka(e,d),ka(e,p),ka(e,f),t&&t()},ne=e=>(t,n)=>{let i=e?C:v,o=()=>ee(t,e,n);qa(i,[t,o]),xre(()=>{ka(t,e?c:a),Oa(t,e?u:s),Ja(i)||Sre(t,r,h,o)})};return mt(t,{onBeforeEnter(e){qa(_,[e]),Oa(e,a),Oa(e,o)},onBeforeAppear(e){qa(S,[e]),Oa(e,c),Oa(e,l)},onEnter:ne(!1),onAppear:ne(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>te(e,t);Oa(e,d),e._enterCancelled?(Oa(e,f),Ere(e)):(Ere(e),Oa(e,f)),xre(()=>{e._isLeaving&&(ka(e,d),Oa(e,p),Ja(b)||Sre(e,r,g,n))}),qa(b,[e,n])},onEnterCancelled(e){ee(e,!1,void 0,!0),qa(y,[e])},onAppearCancelled(e){ee(e,!0,void 0,!0),qa(w,[e])},onLeaveCancelled(e){te(e),qa(x,[e])}})}function bre(e){if(e==null)return null;if(wt(e))return[Da(e.enter),Da(e.leave)];{let t=Da(e);return[t,t]}}function Da(e){return Aee(e)}function Oa(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[Wa]||(e[Wa]=new Set)).add(t)}function ka(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[Wa];n&&(n.delete(t),n.size||(e[Wa]=void 0))}function xre(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}function Sre(e,t,n,r){let i=e._endId=++nie,a=()=>{i===e._endId&&r()};if(n!=null)return setTimeout(a,n);let{type:o,timeout:s,propCount:c}=Cre(e,t);if(!o)return r();let l=o+`end`,u=0,d=()=>{e.removeEventListener(l,f),a()},f=t=>{t.target===e&&++u>=c&&d()};setTimeout(()=>{u(n[e]||``).split(`, `),i=r(`${Ha}Delay`),a=r(`${Ha}Duration`),o=wre(i,a),s=r(`${Ua}Delay`),c=r(`${Ua}Duration`),l=wre(s,c),u=null,d=0,f=0;t===Ha?o>0&&(u=Ha,d=o,f=a.length):t===Ua?l>0&&(u=Ua,d=l,f=c.length):(d=Math.max(o,l),u=d>0?o>l?Ha:Ua:null,f=u?u===Ha?a.length:c.length:0);let p=u===Ha&&/\b(?:transform|all)(?:,|$)/.test(r(`${Ha}Property`).toString());return{type:u,timeout:d,propCount:f,hasTransform:p}}function wre(e,t){for(;e.lengthTre(t)+Tre(e[n])))}function Tre(e){return e===`auto`?0:Number(e.slice(0,-1).replace(`,`,`.`))*1e3}function Ere(e){return(e?e.ownerDocument:document).body.offsetHeight}function Dre(e,t,n){let r=e[Wa];r&&(t=(t?[t,...r]:[...r]).join(` `)),t==null?e.removeAttribute(`class`):n?e.setAttribute(`class`,t):e.className=t}function Aa(e,t){e.style.display=t?e[Ya]:`none`,e[Xa]=!t}function Ore(e){let t=ba();if(!t)return;let n=t.ut=(n=e(t.proxy))=>{Array.from(document.querySelectorAll(`[data-v-owner="${t.uid}"]`)).forEach(e=>Ma(e,n))},r=()=>{let r=e(t.proxy);t.ce?Ma(t.ce,r):ja(t.subTree,r),n(r)};Ui(()=>{Ate(r)}),Hi(()=>{mr(r,ut,{flush:`post`});let e=new MutationObserver(r);e.observe(t.subTree.el.parentNode,{childList:!0}),Gi(()=>e.disconnect())})}function ja(e,t){if(e.shapeFlag&128){let n=e.suspense;e=n.activeBranch,n.pendingBranch&&!n.isHydrating&&n.effects.push(()=>{ja(n.activeBranch,t)})}for(;e.component;)e=e.component.subTree;if(e.shapeFlag&1&&e.el)Ma(e.el,t);else if(e.type===V)e.children.forEach(e=>ja(e,t));else if(e.type===pa){let{el:n,anchor:r}=e;for(;n&&(Ma(n,t),n!==r);)n=n.nextSibling}}function Ma(e,t){if(e.nodeType===1){let n=e.style,r=``;for(let e in t){let i=Tee(t[e]);n.setProperty(`--${e}`,i),r+=`--${e}: ${i};`}n[Qa]=r}}function kre(e,t,n){let r=e.style,i=St(n),a=!1;if(n&&!i){if(t)if(St(t))for(let e of t.split(`;`)){let t=e.slice(0,e.indexOf(`:`)).trim();n[t]??Na(r,t,``)}else for(let e in t)n[e]??Na(r,e,``);for(let e in n)e===`display`&&(a=!0),Na(r,e,n[e])}else if(i){if(t!==n){let e=r[Qa];e&&(n+=`;`+e),r.cssText=n,a=rie.test(n)}}else t&&e.removeAttribute(`style`);Ya in e&&(e[Ya]=a?r.display:``,e[Xa]&&(r.display=`none`))}function Na(e,t,n){if(_t(n))n.forEach(n=>Na(e,t,n));else if(n??=``,t.startsWith(`--`))e.setProperty(t,n);else{let r=Are(e,t);$a.test(n)?e.setProperty(Nt(r),n.replace($a,``),`important`):e[r]=n}}function Are(e,t){let n=to[t];if(n)return n;let r=Mt(t);if(r!==`filter`&&r in e)return to[t]=r;r=Pt(r);for(let n=0;n{if(!e._vts)e._vts=Date.now();else if(e._vts<=n.attached)return;rr(Lre(e,n.value),t,5,[e])};return n.value=e,n.attached=aie(),n}function Lre(e,t){if(_t(t)){let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}else return t}function Rre(e,t,n,r){if(r)return!!(t===`innerHTML`||t===`textContent`||t in e&&oo(t)&&xt(n));if(t===`spellcheck`||t===`draggable`||t===`translate`||t===`autocorrect`||t===`sandbox`&&e.tagName===`IFRAME`||t===`form`||t===`list`&&e.tagName===`INPUT`||t===`type`&&e.tagName===`TEXTAREA`)return!1;if(t===`width`||t===`height`){let t=e.tagName;if(t===`IMG`||t===`VIDEO`||t===`CANVAS`||t===`SOURCE`)return!1}return oo(t)&&St(n)?!1:t in e}function zre(e,t){let n=e._def.props;if(!n)return!1;let r=Mt(t);return Array.isArray(n)?n.some(e=>Mt(e)===r):Object.keys(n).some(e=>Mt(e)===r)}function Bre(e){e.target.composing=!0}function Vre(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event(`input`)))}function Hre(e,t,n){return t&&(e=e.trim()),n&&(e=zt(e)),e}function Ure(e,{value:t,oldValue:n},r){e._modelValue=t;let i;if(_t(t))i=st(t,r.props.value)>-1;else if(yt(t))i=t.has(r.props.value);else{if(t===n)return;i=ot(t,Gre(e,!0))}e.checked!==i&&(e.checked=i)}function Wre(e,t){let n=e.multiple,r=_t(t);if(!(n&&!r&&!yt(t))){for(let i=0,a=e.options.length;iString(e)===String(o)):a.selected=st(t,o)>-1}else a.selected=t.has(o);else if(ot(Fa(a),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Fa(e){return`_value`in e?e._value:e.value}function Gre(e,t){let n=t?`_trueValue`:`_falseValue`;return n in e?e[n]:t}function Kre(e,t){switch(e){case`SELECT`:return cie;case`TEXTAREA`:return lo;default:switch(t){case`checkbox`:return uo;case`radio`:return sie;default:return lo}}}function Ia(e,t,n,r,i){let a=Kre(e.tagName,n.props&&n.props.type)[i];a&&a(e,t,n,r)}function qre(){return go||=Tne(ho)}function Jre(){return go=_o?go:Ene(ho),_o=!0,go}function Yre(e){if(e instanceof SVGElement)return`svg`;if(typeof MathMLElement==`function`&&e instanceof MathMLElement)return`mathml`}function Xre(e){return St(e)?document.querySelector(e):e}var La,Ra,za,Zre,Qre,Ba,Va,$re,Ha,Ua,Wa,Ga,eie,tie,Ka,qa,Ja,nie,Ya,Xa,Za,Qa,rie,$a,eo,to,no,ro,io,ao,iie,aie,oo,oie,so,co,lo,uo,sie,cie,fo,lie,uie,po,die,mo,ho,go,_o,fie,vo,pie,mie=s((()=>{if(Ea(),Ea(),Gt(),La=void 0,Ra=typeof window<`u`&&window.trustedTypes,Ra)try{La=Ra.createPolicy(`vue`,{createHTML:e=>e})}catch{}za=La?e=>La.createHTML(e):e=>e,Zre=`http://www.w3.org/2000/svg`,Qre=`http://www.w3.org/1998/Math/MathML`,Ba=typeof document<`u`?document:null,Va=Ba&&Ba.createElement(`template`),$re={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i=t===`svg`?Ba.createElementNS(Zre,e):t===`mathml`?Ba.createElementNS(Qre,e):n?Ba.createElement(e,{is:n}):Ba.createElement(e);return e===`select`&&r&&r.multiple!=null&&i.setAttribute(`multiple`,r.multiple),i},createText:e=>Ba.createTextNode(e),createComment:e=>Ba.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ba.querySelector(e),setScopeId(e,t){e.setAttribute(t,``)},insertStaticContent(e,t,n,r,i,a){let o=n?n.previousSibling:t.lastChild;if(i&&(i===a||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===a||!(i=i.nextSibling)););else{Va.innerHTML=za(r===`svg`?`${e}`:r===`mathml`?`${e}`:e);let i=Va.content;if(r===`svg`||r===`mathml`){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Ha=`transition`,Ua=`animation`,Wa=Symbol(`_vtc`),Ga={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},eie=mt({},ji,Ga),tie=e=>(e.displayName=`Transition`,e.props=eie,e),Ka=tie((e,{slots:t})=>ci(Yne,yre(e),t)),qa=(e,t=[])=>{_t(e)?e.forEach(e=>e(...t)):e&&e(...t)},Ja=e=>e?_t(e)?e.some(e=>e.length>1):e.length>1:!1,nie=0,Ya=Symbol(`_vod`),Xa=Symbol(`_vsh`),Za={name:`show`,beforeMount(e,{value:t},{transition:n}){e[Ya]=e.style.display===`none`?``:e.style.display,n&&t?n.beforeEnter(e):Aa(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),Aa(e,!0),r.enter(e)):r.leave(e,()=>{Aa(e,!1)}):Aa(e,t))},beforeUnmount(e,{value:t}){Aa(e,t)}},Qa=Symbol(``),rie=/(?:^|;)\s*display\s*:/,$a=/\s*!important$/,eo=[`Webkit`,`Moz`,`ms`],to={},no=`http://www.w3.org/1999/xlink`,ro=Symbol(`_vei`),io=/(?:Once|Passive|Capture)$/,ao=0,iie=Promise.resolve(),aie=()=>ao||=(iie.then(()=>ao=0),Date.now()),oo=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,oie=(e,t,n,r,i,a)=>{let o=i===`svg`;t===`class`?Dre(e,r,o):t===`style`?kre(e,n,r):ft(t)?pt(t)||Pre(e,t,n,r,a):(t[0]===`.`?(t=t.slice(1),!0):t[0]===`^`?(t=t.slice(1),!1):Rre(e,t,r,o))?(Mre(e,t,r),!e.tagName.includes(`-`)&&(t===`value`||t===`checked`||t===`selected`)&&jre(e,t,r,o,a,t!==`value`)):e._isVueCE&&(zre(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!St(r)))?Mre(e,Mt(t),r,a,t):(t===`true-value`?e._trueValue=r:t===`false-value`&&(e._falseValue=r),jre(e,t,r,o))},so=e=>{let t=e.props[`onUpdate:modelValue`]||!1;return _t(t)?e=>Lt(t,e):t},co=Symbol(`_assign`),lo={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[co]=so(i);let a=r||i.props&&i.props.type===`number`;Pa(e,t?`change`:`input`,t=>{t.target.composing||e[co](Hre(e.value,n,a))}),(n||a)&&Pa(e,`change`,()=>{e.value=Hre(e.value,n,a)}),t||(Pa(e,`compositionstart`,Bre),Pa(e,`compositionend`,Vre),Pa(e,`change`,Vre))},mounted(e,{value:t}){e.value=t??``},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:a}},o){if(e[co]=so(o),e.composing)return;let s=(a||e.type===`number`)&&!/^0\d/.test(e.value)?zt(e.value):e.value,c=t??``;s!==c&&(document.activeElement===e&&e.type!==`range`&&(r&&t===n||i&&e.value.trim()===c)||(e.value=c))}},uo={deep:!0,created(e,t,n){e[co]=so(n),Pa(e,`change`,()=>{let t=e._modelValue,n=Fa(e),r=e.checked,i=e[co];if(_t(t)){let e=st(t,n),a=e!==-1;if(r&&!a)i(t.concat(n));else if(!r&&a){let n=[...t];n.splice(e,1),i(n)}}else if(yt(t)){let e=new Set(t);r?e.add(n):e.delete(n),i(e)}else i(Gre(e,r))})},mounted:Ure,beforeUpdate(e,t,n){e[co]=so(n),Ure(e,t,n)}},sie={created(e,{value:t},n){e.checked=ot(t,n.props.value),e[co]=so(n),Pa(e,`change`,()=>{e[co](Fa(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[co]=so(r),t!==n&&(e.checked=ot(t,r.props.value))}},cie={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=yt(t);Pa(e,`change`,()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?zt(Fa(e)):Fa(e));e[co](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,ar(()=>{e._assigning=!1})}),e[co]=so(r)},mounted(e,{value:t}){Wre(e,t)},beforeUpdate(e,t,n){e[co]=so(n)},updated(e,{value:t}){e._assigning||Wre(e,t)}},fo={created(e,t,n){Ia(e,t,n,null,`created`)},mounted(e,t,n){Ia(e,t,n,null,`mounted`)},beforeUpdate(e,t,n,r){Ia(e,t,n,r,`beforeUpdate`)},updated(e,t,n,r){Ia(e,t,n,r,`updated`)}},lie=[`ctrl`,`shift`,`alt`,`meta`],uie={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>`button`in e&&e.button!==0,middle:e=>`button`in e&&e.button!==1,right:e=>`button`in e&&e.button!==2,exact:(e,t)=>lie.some(n=>e[`${n}Key`]&&!t.includes(n))},po=(e,t)=>{if(!e)return e;let n=e._withMods||={},r=t.join(`.`);return n[r]||(n[r]=((n,...r)=>{for(let e=0;e{let n=e._withKeys||={},r=t.join(`.`);return n[r]||(n[r]=(n=>{if(!(`key`in n))return;let r=Nt(n.key);if(t.some(e=>e===r||die[e]===r))return e(n)}))},ho=mt({patchProp:oie},$re),_o=!1,fie=((...e)=>{qre().render(...e)}),vo=((...e)=>{let t=qre().createApp(...e),{mount:n}=t;return t.mount=e=>{let r=Xre(e);if(!r)return;let i=t._component;!xt(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent=``);let a=n(r,!1,Yre(r));return r instanceof Element&&(r.removeAttribute(`v-cloak`),r.setAttribute(`data-v-app`,``)),a},t}),pie=((...e)=>{let t=Jre().createApp(...e),{mount:n}=t;return t.mount=e=>{let t=Xre(e);if(t)return n(t,!0,Yre(t))},t})})),W=s((()=>{mie()}));W();var hie=(e,t)=>xn(t)?Cn(t):t;W();var gie=`usehead`;function _ie(e){return{install(t){t.config.globalProperties.$unhead=e,t.config.globalProperties.$head=e,t.provide(gie,e)}}.install}function vie(){if(Nte()){let e=fr(gie);if(e)return e}throw Error(`useHead() was called without provide context, ensure you call it through the setup() function.`)}function yie(e,t={}){let n=t.head||vie();return n.ssr?n.push(e||{},t):bie(n,e,t)}function bie(e,t,n={}){let r=A(!1),i;return pr(()=>{let a=r.value?{}:qe(t,hie);i?i.patch(a):i=e.push(a,n)}),ba()&&(Wi(()=>{i.dispose()}),Yte(()=>{r.value=!0}),Jte(()=>{r.value=!1})),i}function xie(e={},t={}){(t.head||vie()).use(bee);let{title:n,titleTemplate:r,...i}=e;return yie({title:n,titleTemplate:r,_flatMeta:i},t)}W();function Sie(e={}){let t=lee({domOptions:{render:uee(()=>cee(t),e=>setTimeout(e,0))},...e});return t.install=_ie(t),t}function yo(e,t,n){let r=n.initialDeps??[],i;return()=>{var a;let o;n.key&&n.debug?.call(n)&&(o=Date.now());let s=e();if(!(s.length!==r.length||s.some((e,t)=>r[t]!==e)))return i;r=s;let c;if(n.key&&n.debug?.call(n)&&(c=Date.now()),i=t(...s),n.key&&n.debug?.call(n)){let e=Math.round((Date.now()-o)*100)/100,t=Math.round((Date.now()-c)*100)/100,r=t/16,i=(e,t)=>{for(e=String(e);e.length{Cie=(e,t)=>Math.abs(e-t)<1,wie=(e,t,n)=>{let r;return function(...i){e.clearTimeout(r),r=e.setTimeout(()=>t.apply(this,i),n)}}}));function Eie({measurements:e,outerSize:t,scrollOffset:n}){let r=e.length-1,i=So(0,r,t=>e[t].start,n),a=i;for(;a{Tie(),Die=e=>e,Oie=e=>{let t=Math.max(e.startIndex-e.overscan,0),n=Math.min(e.endIndex+e.overscan,e.count-1),r=[];for(let e=t;e<=n;e++)r.push(e);return r},kie=(e,t)=>{let n=e.scrollElement;if(!n)return;let r=e.targetWindow;if(!r)return;let i=e=>{let{width:n,height:r}=e;t({width:Math.round(n),height:Math.round(r)})};if(i(n.getBoundingClientRect()),!r.ResizeObserver)return()=>{};let a=new r.ResizeObserver(e=>{let t=e[0];if(t?.borderBoxSize){let e=t.borderBoxSize[0];if(e){i({width:e.inlineSize,height:e.blockSize});return}}i(n.getBoundingClientRect())});return a.observe(n,{box:`border-box`}),()=>{a.unobserve(n)}},xo={passive:!0},Aie=typeof window>`u`?!0:`onscrollend`in window,jie=(e,t)=>{let n=e.scrollElement;if(!n)return;let r=e.targetWindow;if(!r)return;let i=0,a=Aie?()=>void 0:wie(r,()=>{t(i,!1)},e.options.isScrollingResetDelay),o=r=>()=>{i=n[e.options.horizontal?`scrollLeft`:`scrollTop`],a(),t(i,r)},s=o(!0),c=o(!1);return c(),n.addEventListener(`scroll`,s,xo),n.addEventListener(`scrollend`,c,xo),()=>{n.removeEventListener(`scroll`,s),n.removeEventListener(`scrollend`,c)}},Mie=(e,t,n)=>{if(t?.borderBoxSize){let e=t.borderBoxSize[0];if(e)return Math.round(e[n.options.horizontal?`inlineSize`:`blockSize`])}return Math.round(e.getBoundingClientRect()[n.options.horizontal?`width`:`height`])},Nie=(e,{adjustments:t=0,behavior:n},r)=>{var i,a;let o=e+t;(a=(i=r.scrollElement)?.scrollTo)==null||a.call(i,{[r.options.horizontal?`left`:`top`]:o,behavior:n})},Pie=class{constructor(e){this.unsubs=[],this.scrollElement=null,this.targetWindow=null,this.isScrolling=!1,this.scrollToIndexTimeoutId=null,this.measurementsCache=[],this.itemSizeCache=new Map,this.pendingMeasuredCacheIndexes=[],this.scrollRect=null,this.scrollOffset=null,this.scrollDirection=null,this.scrollAdjustments=0,this.elementsCache=new Map,this.observer=(()=>{let e=null,t=()=>e||(!this.targetWindow||!this.targetWindow.ResizeObserver?null:e=new this.targetWindow.ResizeObserver(e=>{e.forEach(e=>{this._measureElement(e.target,e)})}));return{disconnect:()=>t()?.disconnect(),observe:e=>t()?.observe(e,{box:`border-box`}),unobserve:e=>t()?.unobserve(e)}})(),this.range=null,this.setOptions=e=>{Object.entries(e).forEach(([t,n])=>{n===void 0&&delete e[t]}),this.options={debug:!1,initialOffset:0,overscan:1,paddingStart:0,paddingEnd:0,scrollPaddingStart:0,scrollPaddingEnd:0,horizontal:!1,getItemKey:Die,rangeExtractor:Oie,onChange:()=>{},measureElement:Mie,initialRect:{width:0,height:0},scrollMargin:0,gap:0,indexAttribute:`data-index`,initialMeasurementsCache:[],lanes:1,isScrollingResetDelay:150,enabled:!0,...e}},this.notify=(e,t)=>{var n,r;let{startIndex:i,endIndex:a}=this.range??{startIndex:void 0,endIndex:void 0},o=this.calculateRange();(e||i!==o?.startIndex||a!==o?.endIndex)&&((r=(n=this.options).onChange)==null||r.call(n,this,t))},this.cleanup=()=>{this.unsubs.filter(Boolean).forEach(e=>e()),this.unsubs=[],this.scrollElement=null,this.targetWindow=null,this.observer.disconnect(),this.elementsCache.clear()},this._didMount=()=>()=>{this.cleanup()},this._willUpdate=()=>{let e=this.options.enabled?this.options.getScrollElement():null;if(this.scrollElement!==e){if(this.cleanup(),!e){this.notify(!1,!1);return}this.scrollElement=e,this.scrollElement&&`ownerDocument`in this.scrollElement?this.targetWindow=this.scrollElement.ownerDocument.defaultView:this.targetWindow=this.scrollElement?.window??null,this._scrollToOffset(this.getScrollOffset(),{adjustments:void 0,behavior:void 0}),this.unsubs.push(this.options.observeElementRect(this,e=>{this.scrollRect=e,this.notify(!1,!1)})),this.unsubs.push(this.options.observeElementOffset(this,(e,t)=>{this.scrollAdjustments=0,this.scrollDirection=t?this.getScrollOffset()this.options.enabled?(this.scrollRect=this.scrollRect??this.options.initialRect,this.scrollRect[this.options.horizontal?`width`:`height`]):(this.scrollRect=null,0),this.getScrollOffset=()=>this.options.enabled?(this.scrollOffset=this.scrollOffset??(typeof this.options.initialOffset==`function`?this.options.initialOffset():this.options.initialOffset),this.scrollOffset):(this.scrollOffset=null,0),this.getFurthestMeasurement=(e,t)=>{let n=new Map,r=new Map;for(let i=t-1;i>=0;i--){let t=e[i];if(n.has(t.lane))continue;let a=r.get(t.lane);if(a==null||t.end>a.end?r.set(t.lane,t):t.ende.end===t.end?e.index-t.index:e.end-t.end)[0]:void 0},this.getMeasurementOptions=yo(()=>[this.options.count,this.options.paddingStart,this.options.scrollMargin,this.options.getItemKey,this.options.enabled],(e,t,n,r,i)=>(this.pendingMeasuredCacheIndexes=[],{count:e,paddingStart:t,scrollMargin:n,getItemKey:r,enabled:i}),{key:!1}),this.getMeasurements=yo(()=>[this.getMeasurementOptions(),this.itemSizeCache],({count:e,paddingStart:t,scrollMargin:n,getItemKey:r,enabled:i},a)=>{if(!i)return this.measurementsCache=[],this.itemSizeCache.clear(),[];this.measurementsCache.length===0&&(this.measurementsCache=this.options.initialMeasurementsCache,this.measurementsCache.forEach(e=>{this.itemSizeCache.set(e.key,e.size)}));let o=this.pendingMeasuredCacheIndexes.length>0?Math.min(...this.pendingMeasuredCacheIndexes):0;this.pendingMeasuredCacheIndexes=[];let s=this.measurementsCache.slice(0,o);for(let i=o;i{let t=r(i),n=this.elementsCache.get(t);if(!e){n&&(this.observer.unobserve(n),this.elementsCache.delete(t));return}n!==e&&(n&&this.observer.unobserve(n),this.observer.observe(e),this.elementsCache.set(t,e)),e.isConnected&&this.resizeItem(i,this.options.measureElement(e,void 0,this))};let o=r(i),c=this.options.lanes===1?s[i-1]:this.getFurthestMeasurement(s,i),l=c?c.end+this.options.gap:t+n,u=a.get(o),d=typeof u==`number`?u:this.options.estimateSize(i),f=l+d,p=c?c.lane:i%this.options.lanes;s[i]={index:i,start:l,size:d,end:f,key:o,lane:p,measureElement:e}}return this.measurementsCache=s,s},{key:!1,debug:()=>this.options.debug}),this.calculateRange=yo(()=>[this.getMeasurements(),this.getSize(),this.getScrollOffset()],(e,t,n)=>this.range=e.length>0&&t>0?Eie({measurements:e,outerSize:t,scrollOffset:n}):null,{key:!1,debug:()=>this.options.debug}),this.getIndexes=yo(()=>[this.options.rangeExtractor,this.calculateRange(),this.options.overscan,this.options.count],(e,t,n,r)=>t===null?[]:e({startIndex:t.startIndex,endIndex:t.endIndex,overscan:n,count:r}),{key:!1,debug:()=>this.options.debug}),this.indexFromElement=e=>{let t=this.options.indexAttribute,n=e.getAttribute(t);return n?parseInt(n,10):(console.warn(`Missing attribute name '${t}={index}' on measured element.`),-1)},this._measureElement=(e,t)=>{let n=this.indexFromElement(e),r=this.getMeasurements()[n];if(!r||!e.isConnected){this.elementsCache.forEach((t,n)=>{t===e&&(this.observer.unobserve(e),this.elementsCache.delete(n))});return}let i=this.elementsCache.get(r.key);i!==e&&(i&&this.observer.unobserve(i),this.observer.observe(e),this.elementsCache.set(r.key,e)),this.resizeItem(n,this.options.measureElement(e,t,this))},this.resizeItem=(e,t)=>{let n=this.getMeasurements()[e];if(!n)return;let r=t-(this.itemSizeCache.get(n.key)??n.size);r!==0&&((this.shouldAdjustScrollPositionOnItemSizeChange===void 0?n.start{e&&this._measureElement(e,void 0)},this.getVirtualItems=yo(()=>[this.getIndexes(),this.getMeasurements()],(e,t)=>{let n=[];for(let r=0,i=e.length;rthis.options.debug}),this.getVirtualItemForOffset=e=>{let t=this.getMeasurements();if(t.length!==0)return bo(t[So(0,t.length-1,e=>bo(t[e]).start,e)])},this.getOffsetForAlignment=(e,t)=>{let n=this.getSize(),r=this.getScrollOffset();t===`auto`&&(t=e<=r?`start`:e>=r+n?`end`:`start`),t===`start`?e=e:t===`end`?e-=n:t===`center`&&(e-=n/2);let i=this.options.horizontal?`scrollWidth`:`scrollHeight`,a=(this.scrollElement?`document`in this.scrollElement?this.scrollElement.document.documentElement[i]:this.scrollElement[i]:0)-n;return Math.max(Math.min(a,e),0)},this.getOffsetForIndex=(e,t=`auto`)=>{e=Math.max(0,Math.min(e,this.options.count-1));let n=this.getMeasurements()[e];if(!n)return;let r=this.getSize(),i=this.getScrollOffset();if(t===`auto`)if(n.end>=i+r-this.options.scrollPaddingEnd)t=`end`;else if(n.start<=i+this.options.scrollPaddingStart)t=`start`;else return[i,t];let a=t===`end`?n.end+this.options.scrollPaddingEnd:n.start-this.options.scrollPaddingStart;return[this.getOffsetForAlignment(a,t),t]},this.isDynamicMode=()=>this.elementsCache.size>0,this.cancelScrollToIndex=()=>{this.scrollToIndexTimeoutId!==null&&this.targetWindow&&(this.targetWindow.clearTimeout(this.scrollToIndexTimeoutId),this.scrollToIndexTimeoutId=null)},this.scrollToOffset=(e,{align:t=`start`,behavior:n}={})=>{this.cancelScrollToIndex(),n===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size."),this._scrollToOffset(this.getOffsetForAlignment(e,t),{adjustments:void 0,behavior:n})},this.scrollToIndex=(e,{align:t=`auto`,behavior:n}={})=>{e=Math.max(0,Math.min(e,this.options.count-1)),this.cancelScrollToIndex(),n===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size.");let r=this.getOffsetForIndex(e,t);if(!r)return;let[i,a]=r;this._scrollToOffset(i,{adjustments:void 0,behavior:n}),n!==`smooth`&&this.isDynamicMode()&&this.targetWindow&&(this.scrollToIndexTimeoutId=this.targetWindow.setTimeout(()=>{if(this.scrollToIndexTimeoutId=null,this.elementsCache.has(this.options.getItemKey(e))){let[t]=bo(this.getOffsetForIndex(e,a));Cie(t,this.getScrollOffset())||this.scrollToIndex(e,{align:a,behavior:n})}else this.scrollToIndex(e,{align:a,behavior:n})}))},this.scrollBy=(e,{behavior:t}={})=>{this.cancelScrollToIndex(),t===`smooth`&&this.isDynamicMode()&&console.warn("The `smooth` scroll behavior is not fully supported with dynamic size."),this._scrollToOffset(this.getScrollOffset()+e,{adjustments:void 0,behavior:t})},this.getTotalSize=()=>{let e=this.getMeasurements(),t;return t=e.length===0?this.options.paddingStart:this.options.lanes===1?e[e.length-1]?.end??0:Math.max(...e.slice(-this.options.lanes).map(e=>e.end)),t-this.options.scrollMargin+this.options.paddingEnd},this._scrollToOffset=(e,{adjustments:t,behavior:n})=>{this.options.scrollToFn(e,{behavior:n,adjustments:t},this)},this.measure=()=>{var e,t;this.itemSizeCache=new Map,(t=(e=this.options).onChange)==null||t.call(e,this,!1)},this.setOptions(e)}},So=(e,t,n,r)=>{for(;e<=t;){let i=(e+t)/2|0,a=n(i);if(ar)t=i-1;else return i}return e>0?e-1:0}}));function Iie(e){let t=new Pie(j(e)),n=Sn(t),r=t._didMount();return mr(()=>j(e).getScrollElement(),e=>{e&&t._willUpdate()},{immediate:!0}),mr(()=>j(e),e=>{t.setOptions({...e,onChange:(t,r)=>{var i;$ee(n),(i=e.onChange)==null||i.call(e,t,r)}}),t._willUpdate(),$ee(n)},{immediate:!0}),qt(r),n}function Lie(e){return Iie(U(()=>({observeElementRect:kie,observeElementOffset:jie,scrollToFn:Nie,...j(e)})))}var Rie=s((()=>{Fie(),Fie(),W()}));function Co(e,t,n){let r=A(n?.value),i=U(()=>e.value!==void 0);return[U(()=>i.value?e.value:r.value),function(e){return i.value||(r.value=e),t?.(e)}]}var wo=s((()=>{W()}));function To(e){typeof queueMicrotask==`function`?queueMicrotask(e):Promise.resolve().then(e).catch(e=>setTimeout(()=>{throw e}))}var Eo=s((()=>{}));function Do(){let e=[],t={addEventListener(e,n,r,i){return e.addEventListener(n,r,i),t.add(()=>e.removeEventListener(n,r,i))},requestAnimationFrame(...e){let n=requestAnimationFrame(...e);t.add(()=>cancelAnimationFrame(n))},nextFrame(...e){t.requestAnimationFrame(()=>{t.requestAnimationFrame(...e)})},setTimeout(...e){let n=setTimeout(...e);t.add(()=>clearTimeout(n))},microTask(...e){let n={current:!0};return To(()=>{n.current&&e[0]()}),t.add(()=>{n.current=!1})},style(e,t,n){let r=e.style.getPropertyValue(t);return Object.assign(e.style,{[t]:n}),this.add(()=>{Object.assign(e.style,{[t]:r})})},group(e){let t=Do();return e(t),this.add(()=>t.dispose())},add(t){return e.push(t),()=>{let n=e.indexOf(t);if(n>=0)for(let t of e.splice(n,1))t()}},dispose(){for(let t of e.splice(0))t()}};return t}var Oo=s((()=>{Eo()}));function zie(){let e=Do();return Gi(()=>e.dispose()),e}var Bie=s((()=>{W(),Oo()}));function Vie(){let e=zie();return t=>{e.dispose(),e.nextFrame(t)}}var Hie=s((()=>{Bie()}));function Uie(e){dr(ko,e)}var ko,Wie,Ao,jo=s((()=>{W(),ko=Symbol(`headlessui.useid`),Wie=0,Ao=Sr??function(){return fr(ko,()=>`${++Wie}`)()}}));function G(e){if(e==null||e.value==null)return null;let t=e.value.$el??e.value;return t instanceof Node?t:null}var Mo=s((()=>{}));function No(e,t,...n){if(e in t){let r=t[e];return typeof r==`function`?r(...n):r}let r=Error(`Tried to handle "${e}" but there is no handler defined. Only defined handlers are: ${Object.keys(t).map(e=>`"${e}"`).join(`, `)}.`);throw Error.captureStackTrace&&Error.captureStackTrace(r,No),r}var Po=s((()=>{})),Gie,Kie,Fo,qie,Io,Lo=s((()=>{Gie=Object.defineProperty,Kie=(e,t,n)=>t in e?Gie(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,Fo=(e,t,n)=>(Kie(e,typeof t==`symbol`?t:t+``,n),n),qie=class{constructor(){Fo(this,`current`,this.detect()),Fo(this,`currentId`,0)}set(e){this.current!==e&&(this.currentId=0,this.current=e)}reset(){this.set(this.detect())}nextId(){return++this.currentId}get isServer(){return this.current===`server`}get isClient(){return this.current===`client`}detect(){return typeof window>`u`||typeof document>`u`?`server`:`client`}},Io=new qie}));function Ro(e){if(Io.isServer)return null;if(e instanceof Node)return e.ownerDocument;if(e!=null&&e.hasOwnProperty(`value`)){let t=G(e);if(t)return t.ownerDocument}return document}var zo=s((()=>{Mo(),Lo()}));function Bo(e=document.body){return e==null?[]:Array.from(e.querySelectorAll(Go)).sort((e,t)=>Math.sign((e.tabIndex||2**53-1)-(t.tabIndex||2**53-1)))}function Vo(e,t=0){return e===Ro(e)?.body?!1:No(t,{0(){return e.matches(Go)},1(){let t=e;for(;t!==null;){if(t.matches(Go))return!0;t=t.parentElement}return!1}})}function Jie(e){let t=Ro(e);ar(()=>{t&&!Vo(t.activeElement,0)&&Ho(e)})}function Ho(e){e?.focus({preventScroll:!0})}function Yie(e){return(e?.matches)?.call(e,$ie)??!1}function Uo(e,t=e=>e){return e.slice().sort((e,n)=>{let r=t(e),i=t(n);if(r===null||i===null)return 0;let a=r.compareDocumentPosition(i);return a&Node.DOCUMENT_POSITION_FOLLOWING?-1:a&Node.DOCUMENT_POSITION_PRECEDING?1:0})}function Xie(e,t){return Wo(Bo(),t,{relativeTo:e})}function Wo(e,t,{sorted:n=!0,relativeTo:r=null,skipElements:i=[]}={}){let a=(Array.isArray(e)?e.length>0?e[0].ownerDocument:document:e?.ownerDocument)??document,o=Array.isArray(e)?n?Uo(e):e:Bo(e);i.length>0&&o.length>1&&(o=o.filter(e=>!i.includes(e))),r??=a.activeElement;let s=(()=>{if(t&5)return 1;if(t&10)return-1;throw Error(`Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last`)})(),c=(()=>{if(t&1)return 0;if(t&2)return Math.max(0,o.indexOf(r))-1;if(t&4)return Math.max(0,o.indexOf(r))+1;if(t&8)return o.length-1;throw Error(`Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last`)})(),l=t&32?{preventScroll:!0}:{},u=0,d=o.length,f;do{if(u>=d||u+d<=0)return 0;let e=c+u;if(t&16)e=(e+d)%d;else{if(e<0)return 3;if(e>=d)return 1}f=o[e],f?.focus(l),u+=s}while(f!==a.activeElement);return t&6&&Yie(f)&&f.select(),2}var Go,Ko,qo,Zie,Jo,Qie,$ie,Yo=s((()=>{W(),Po(),zo(),Go=[`[contentEditable=true]`,`[tabindex]`,`a[href]`,`area[href]`,`button:not([disabled])`,`iframe`,`input:not([disabled])`,`select:not([disabled])`,`textarea:not([disabled])`].map(e=>`${e}:not([tabindex='-1'])`).join(`,`),Ko=(e=>(e[e.First=1]=`First`,e[e.Previous=2]=`Previous`,e[e.Next=4]=`Next`,e[e.Last=8]=`Last`,e[e.WrapAround=16]=`WrapAround`,e[e.NoScroll=32]=`NoScroll`,e))(Ko||{}),qo=(e=>(e[e.Error=0]=`Error`,e[e.Overflow=1]=`Overflow`,e[e.Success=2]=`Success`,e[e.Underflow=3]=`Underflow`,e))(qo||{}),Zie=(e=>(e[e.Previous=-1]=`Previous`,e[e.Next=1]=`Next`,e))(Zie||{}),Jo=(e=>(e[e.Strict=0]=`Strict`,e[e.Loose=1]=`Loose`,e))(Jo||{}),Qie=(e=>(e[e.Keyboard=0]=`Keyboard`,e[e.Mouse=1]=`Mouse`,e))(Qie||{}),typeof window<`u`&&typeof document<`u`&&(document.addEventListener(`keydown`,e=>{e.metaKey||e.altKey||e.ctrlKey||(document.documentElement.dataset.headlessuiFocusVisible=``)},!0),document.addEventListener(`click`,e=>{e.detail===1?delete document.documentElement.dataset.headlessuiFocusVisible:e.detail===0&&(document.documentElement.dataset.headlessuiFocusVisible=``)},!0)),$ie=[`textarea`,`input`].join(`,`)}));function eae(){return/iPhone/gi.test(window.navigator.platform)||/Mac/gi.test(window.navigator.platform)&&window.navigator.maxTouchPoints>0}function tae(){return/Android/gi.test(window.navigator.userAgent)}function nae(){return eae()||tae()}var Xo=s((()=>{}));function Zo(e,t,n){Io.isServer||pr(r=>{document.addEventListener(e,t,n),r(()=>document.removeEventListener(e,t,n))})}var rae=s((()=>{W(),Lo()}));function iae(e,t,n){Io.isServer||pr(r=>{window.addEventListener(e,t,n),r(()=>window.removeEventListener(e,t,n))})}var aae=s((()=>{W(),Lo()}));function Qo(e,t,n=U(()=>!0)){function r(r,i){if(!n.value||r.defaultPrevented)return;let a=i(r);if(a===null||!a.getRootNode().contains(a))return;let o=function e(t){return typeof t==`function`?e(t()):Array.isArray(t)||t instanceof Set?t:[t]}(e);for(let e of o){if(e===null)continue;let t=e instanceof HTMLElement?e:G(e);if(t!=null&&t.contains(a)||r.composed&&r.composedPath().includes(t))return}return!Vo(a,Jo.Loose)&&a.tabIndex!==-1&&r.preventDefault(),t(r,a)}let i=A(null);Zo(`pointerdown`,e=>{n.value&&(i.value=e.composedPath?.call(e)?.[0]||e.target)},!0),Zo(`mousedown`,e=>{n.value&&(i.value=e.composedPath?.call(e)?.[0]||e.target)},!0),Zo(`click`,e=>{nae()||(i.value&&=(r(e,()=>i.value),null))},!0),Zo(`touchend`,e=>r(e,()=>e.target instanceof HTMLElement?e.target:null),!0),iae(`blur`,e=>r(e,()=>window.document.activeElement instanceof HTMLIFrameElement?window.document.activeElement:null),!0)}var $o=s((()=>{W(),Mo(),Yo(),Xo(),rae(),aae()}));function oae(e,t){if(e)return e;let n=t??`button`;if(typeof n==`string`&&n.toLowerCase()===`button`)return`button`}function es(e,t){let n=A(oae(e.value.type,e.value.as));return Hi(()=>{n.value=oae(e.value.type,e.value.as)}),pr(()=>{var e;n.value||G(t)&&G(t)instanceof HTMLButtonElement&&!((e=G(t))!=null&&e.hasAttribute(`type`))&&(n.value=`button`)}),n}var ts=s((()=>{W(),Mo()}));function sae(e){return[e.screenX,e.screenY]}function ns(){let e=A([-1,-1]);return{wasMoved(t){let n=sae(t);return e.value[0]===n[0]&&e.value[1]===n[1]?!1:(e.value=n,!0)},update(t){e.value=sae(t)}}}var rs=s((()=>{W()}));function is({container:e,accept:t,walk:n,enabled:r}){pr(()=>{let i=e.value;if(!i||r!==void 0&&!r.value)return;let a=Ro(e);if(!a)return;let o=Object.assign(e=>t(e),{acceptNode:t}),s=a.createTreeWalker(i,NodeFilter.SHOW_ELEMENT,o,!1);for(;s.nextNode();)n(s.currentNode)})}var as=s((()=>{W(),zo()}));function os({visible:e=!0,features:t=0,ourProps:n,theirProps:r,...i}){var a;let o=lae(r,n),s=Object.assign(i,{props:o});return e||t&2&&o.static?ss(s):t&1?No((a=o.unmount)==null||a?0:1,{0(){return null},1(){return ss({...i,props:{...o,hidden:!0,style:{display:`none`}}})}}):ss(s)}function ss({props:e,attrs:t,slots:n,slot:r,name:i}){let{as:a,...o}=ls(e,[`unmount`,`static`]),s=n.default?.call(n,r),c={};if(r){let e=!1,t=[];for(let[n,i]of Object.entries(r))typeof i==`boolean`&&(e=!0),i===!0&&t.push(n);e&&(c[`data-headlessui-state`]=t.join(` `))}if(a===`template`){if(s=cae(s??[]),Object.keys(o).length>0||Object.keys(t).length>0){let[e,...n]=s??[];if(!uae(e)||n.length>0)throw Error([`Passing props on "template"!`,``,`The current component <${i} /> is rendering a "template".`,`However we need to passthrough the following props:`,Object.keys(o).concat(Object.keys(t)).map(e=>e.trim()).filter((e,t,n)=>n.indexOf(e)===t).sort((e,t)=>e.localeCompare(t)).map(e=>` - ${e}`).join(` -`),``,`You can apply a few solutions:`,['Add an `as="..."` prop, to ensure that we render an actual element instead of a "template".',`Render a single element as the child so that we can forward the props onto that element.`].map(e=>` - ${e}`).join(` -`)].join(` -`));let r=lae(e.props??{},o,c),a=ei(e,r,!0);for(let e in r)e.startsWith(`on`)&&(a.props||={},a.props[e]=r[e]);return a}return Array.isArray(s)&&s.length===1?s[0]:s}return ci(a,Object.assign({},o,c),{default:()=>s})}function cae(e){return e.flatMap(e=>e.type===V?cae(e.children):[e])}function lae(...e){if(e.length===0)return{};if(e.length===1)return e[0];let t={},n={};for(let r of e)for(let e in r)e.startsWith(`on`)&&typeof r[e]==`function`?(n[e]??(n[e]=[]),n[e].push(r[e])):t[e]=r[e];if(t.disabled||t[`aria-disabled`])return Object.assign(t,Object.fromEntries(Object.keys(n).map(e=>[e,void 0])));for(let e in n)Object.assign(t,{[e](t,...r){let i=n[e];for(let e of i){if(t instanceof Event&&t.defaultPrevented)return;e(t,...r)}}});return t}function cs(e){let t=Object.assign({},e);for(let e in t)t[e]===void 0&&delete t[e];return t}function ls(e,t=[]){let n=Object.assign({},e);for(let e of t)e in n&&delete n[e];return n}function uae(e){return e==null?!1:typeof e.type==`string`||typeof e.type==`object`||typeof e.type==`function`}var us,ds,fs=s((()=>{W(),Po(),us=(e=>(e[e.None=0]=`None`,e[e.RenderStrategy=1]=`RenderStrategy`,e[e.Static=2]=`Static`,e))(us||{}),ds=(e=>(e[e.Unmount=0]=`Unmount`,e[e.Hidden=1]=`Hidden`,e))(ds||{})})),ps,ms,hs=s((()=>{W(),fs(),ps=(e=>(e[e.None=1]=`None`,e[e.Focusable=2]=`Focusable`,e[e.Hidden=4]=`Hidden`,e))(ps||{}),ms=N({name:`Hidden`,props:{as:{type:[Object,String],default:`div`},features:{type:Number,default:1}},setup(e,{slots:t,attrs:n}){return()=>{let{features:r,...i}=e;return os({ourProps:{"aria-hidden":(r&2)==2?!0:i[`aria-hidden`]??void 0,hidden:(r&4)==4?!0:void 0,style:{position:`fixed`,top:1,left:1,width:1,height:0,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,borderWidth:`0`,...(r&4)==4&&(r&2)!=2&&{display:`none`}}},theirProps:i,slot:{},attrs:n,slots:t,name:`Hidden`})}}})}));function dae(){return gs()!==null}function gs(){return fr(vs,null)}function _s(e){dr(vs,e)}var vs,ys,bs=s((()=>{W(),vs=Symbol(`Context`),ys=(e=>(e[e.Open=1]=`Open`,e[e.Closed=2]=`Closed`,e[e.Closing=4]=`Closing`,e[e.Opening=8]=`Opening`,e))(ys||{})})),xs,Ss=s((()=>{xs=(e=>(e.Space=` `,e.Enter=`Enter`,e.Escape=`Escape`,e.Backspace=`Backspace`,e.Delete=`Delete`,e.ArrowLeft=`ArrowLeft`,e.ArrowUp=`ArrowUp`,e.ArrowRight=`ArrowRight`,e.ArrowDown=`ArrowDown`,e.Home=`Home`,e.End=`End`,e.PageUp=`PageUp`,e.PageDown=`PageDown`,e.Tab=`Tab`,e))(xs||{})})),Cs,fae=s((()=>{Cs=(e=>(e[e.Left=0]=`Left`,e[e.Right=2]=`Right`,e))(Cs||{})}));function pae(e){function t(){document.readyState!==`loading`&&(e(),document.removeEventListener(`DOMContentLoaded`,t))}typeof window<`u`&&typeof document<`u`&&(document.addEventListener(`DOMContentLoaded`,t),t())}var mae=s((()=>{})),ws,hae=s((()=>{mae(),ws=[],pae(()=>{function e(e){e.target instanceof HTMLElement&&e.target!==document.body&&ws[0]!==e.target&&(ws.unshift(e.target),ws=ws.filter(e=>e!=null&&e.isConnected),ws.splice(10))}window.addEventListener(`click`,e,{capture:!0}),window.addEventListener(`mousedown`,e,{capture:!0}),window.addEventListener(`focus`,e,{capture:!0}),document.body.addEventListener(`click`,e,{capture:!0}),document.body.addEventListener(`mousedown`,e,{capture:!0}),document.body.addEventListener(`focus`,e,{capture:!0})})}));function gae(e){throw Error(`Unexpected object: `+e)}function Ts(e,t){let n=t.resolveItems();if(n.length<=0)return null;let r=t.resolveActiveIndex(),i=r??-1;switch(e.focus){case 0:for(let e=0;e=0;--e)if(!t.resolveDisabled(n[e],e,n))return e;return r;case 2:for(let e=i+1;e=0;--e)if(!t.resolveDisabled(n[e],e,n))return e;return r;case 4:for(let r=0;r{Es=(e=>(e[e.First=0]=`First`,e[e.Previous=1]=`Previous`,e[e.Next=2]=`Next`,e[e.Last=3]=`Last`,e[e.Specific=4]=`Specific`,e[e.Nothing=5]=`Nothing`,e))(Es||{})}));function Os(e={},t=null,n=[]){for(let[r,i]of Object.entries(e))vae(n,_ae(t,r),i);return n}function _ae(e,t){return e?e+`[`+t+`]`:t}function vae(e,t,n){if(Array.isArray(n))for(let[r,i]of n.entries())vae(e,_ae(t,r.toString()),i);else n instanceof Date?e.push([t,n.toISOString()]):typeof n==`boolean`?e.push([t,n?`1`:`0`]):typeof n==`string`?e.push([t,n]):typeof n==`number`?e.push([t,`${n}`]):n==null?e.push([t,``]):Os(n,t,e)}function yae(e){var t;let n=e?.form??e.closest(`form`);if(n){for(let t of n.elements)if(t!==e&&(t.tagName===`INPUT`&&t.type===`submit`||t.tagName===`BUTTON`&&t.type===`submit`||t.nodeName===`INPUT`&&t.type===`image`)){t.click();return}(t=n.requestSubmit)==null||t.call(n)}}var ks=s((()=>{}));function bae(e,t){return e===t}function As(e){let t=fr(js,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,As),t}return t}var xae,Sae,Cae,js,Ms,wae,Tae=s((()=>{Rie(),W(),wo(),Hie(),jo(),$o(),ts(),rs(),as(),hs(),bs(),Ss(),fae(),hae(),Ds(),Oo(),Mo(),Yo(),ks(),Po(),zo(),Xo(),fs(),xae=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(xae||{}),Sae=(e=>(e[e.Single=0]=`Single`,e[e.Multi=1]=`Multi`,e))(Sae||{}),Cae=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Focus=1]=`Focus`,e[e.Other=2]=`Other`,e))(Cae||{}),js=Symbol(`ComboboxContext`),Ms=Symbol(`VirtualContext`),wae=N({name:`VirtualProvider`,setup(e,{slots:t}){let n=As(`VirtualProvider`),r=U(()=>{let e=G(n.optionsRef);if(!e)return{start:0,end:0};let t=window.getComputedStyle(e);return{start:parseFloat(t.paddingBlockStart||t.paddingTop),end:parseFloat(t.paddingBlockEnd||t.paddingBottom)}}),i=Lie(U(()=>({scrollPaddingStart:r.value.start,scrollPaddingEnd:r.value.end,count:n.virtual.value.options.length,estimateSize(){return 40},getScrollElement(){return G(n.optionsRef)},overscan:12}))),a=U(()=>n.virtual.value?.options),o=A(0);return mr([a],()=>{o.value+=1}),dr(Ms,n.virtual.value?i:null),()=>[ci(`div`,{style:{position:`relative`,width:`100%`,height:`${i.value.getTotalSize()}px`},ref:e=>{if(e){if(typeof process<`u`&&process.env.JEST_WORKER_ID!==void 0||n.activationTrigger.value===0)return;n.activeOptionIndex.value!==null&&n.virtual.value.options.length>n.activeOptionIndex.value&&i.value.scrollToIndex(n.activeOptionIndex.value)}}},i.value.getVirtualItems().map(e=>ei(t.default({option:n.virtual.value.options[e.index],open:n.comboboxState.value===0})[0],{key:`${o.value}-${e.index}`,"data-index":e.index,"aria-setsize":n.virtual.value.options.length,"aria-posinset":e.index+1,style:{position:`absolute`,top:0,left:0,transform:`translateY(${e.start}px)`,overflowAnchor:`none`}})))]}}),N({name:`Combobox`,emits:{"update:modelValue":e=>!0},props:{as:{type:[Object,String],default:`template`},disabled:{type:[Boolean],default:!1},by:{type:[String,Function],nullable:!0,default:null},modelValue:{type:[Object,String,Number,Boolean],default:void 0},defaultValue:{type:[Object,String,Number,Boolean],default:void 0},form:{type:String,optional:!0},name:{type:String,optional:!0},nullable:{type:Boolean,default:!1},multiple:{type:[Boolean],default:!1},immediate:{type:[Boolean],default:!1},virtual:{type:Object,default:null}},inheritAttrs:!1,setup(e,{slots:t,attrs:n,emit:r}){let i=A(1),a=A(null),o=A(null),s=A(null),c=A(null),l=A({static:!1,hold:!1}),u=A([]),d=A(null),f=A(2),p=A(!1);function m(e=e=>e){let t=d.value===null?null:u.value[d.value],n=e(u.value.slice()),r=n.length>0&&n[0].dataRef.order.value!==null?n.sort((e,t)=>e.dataRef.order.value-t.dataRef.order.value):Uo(n,e=>G(e.dataRef.domRef)),i=t?r.indexOf(t):null;return i===-1&&(i=null),{options:r,activeOptionIndex:i}}let h=U(()=>e.multiple?1:0),g=U(()=>e.nullable),[_,v]=Co(U(()=>e.modelValue),e=>r(`update:modelValue`,e),U(()=>e.defaultValue)),y=U(()=>_.value===void 0?No(h.value,{1:[],0:void 0}):_.value),b=null,x=null;function S(e){return No(h.value,{0(){return v?.(e)},1:()=>{let t=bn(C.value.value).slice(),n=bn(e),r=t.findIndex(e=>C.compare(n,bn(e)));return r===-1?t.push(n):t.splice(r,1),v?.(t)}})}mr([U(()=>{})],([e],[t])=>{if(C.virtual.value&&e&&t&&d.value!==null){let n=e.indexOf(t[d.value]);n===-1?d.value=null:d.value=n}});let C={comboboxState:i,value:y,mode:h,compare(t,n){if(typeof e.by==`string`){let r=e.by;return t?.[r]===n?.[r]}return e.by===null?bae(t,n):e.by(t,n)},calculateIndex(t){return C.virtual.value?e.by===null?C.virtual.value.options.indexOf(t):C.virtual.value.options.findIndex(e=>C.compare(e,t)):u.value.findIndex(e=>C.compare(e.dataRef.value,t))},defaultValue:U(()=>e.defaultValue),nullable:g,immediate:U(()=>!1),virtual:U(()=>null),inputRef:o,labelRef:a,buttonRef:s,optionsRef:c,disabled:U(()=>e.disabled),options:u,change(e){v(e)},activeOptionIndex:U(()=>{if(p.value&&d.value===null&&(C.virtual.value?C.virtual.value.options.length>0:u.value.length>0)){if(C.virtual.value){let e=C.virtual.value.options.findIndex(e=>{var t;return!((t=C.virtual.value)!=null&&t.disabled(e))});if(e!==-1)return e}let e=u.value.findIndex(e=>!e.dataRef.disabled);if(e!==-1)return e}return d.value}),activationTrigger:f,optionsPropsRef:l,closeCombobox(){p.value=!1,!e.disabled&&i.value!==1&&(i.value=1,d.value=null)},openCombobox(){if(p.value=!0,!e.disabled&&i.value!==0){if(C.value.value){let e=C.calculateIndex(C.value.value);e!==-1&&(d.value=e)}i.value=0}},setActivationTrigger(e){f.value=e},goToOption(t,n,r){p.value=!1,b!==null&&cancelAnimationFrame(b),b=requestAnimationFrame(()=>{if(e.disabled||c.value&&!l.value.static&&i.value===1)return;if(C.virtual.value){d.value=t===Es.Specific?n:Ts({focus:t},{resolveItems:()=>C.virtual.value.options,resolveActiveIndex:()=>C.activeOptionIndex.value??C.virtual.value.options.findIndex(e=>{var t;return!((t=C.virtual.value)!=null&&t.disabled(e))})??null,resolveDisabled:e=>C.virtual.value.disabled(e),resolveId(){throw Error(`Function not implemented.`)}}),f.value=r??2;return}let a=m();if(a.activeOptionIndex===null){let e=a.options.findIndex(e=>!e.dataRef.disabled);e!==-1&&(a.activeOptionIndex=e)}d.value=t===Es.Specific?n:Ts({focus:t},{resolveItems:()=>a.options,resolveActiveIndex:()=>a.activeOptionIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled}),f.value=r??2,u.value=a.options})},selectOption(e){let t=u.value.find(t=>t.id===e);if(!t)return;let{dataRef:n}=t;S(n.value)},selectActiveOption(){if(C.activeOptionIndex.value!==null){if(C.virtual.value)S(C.virtual.value.options[C.activeOptionIndex.value]);else{let{dataRef:e}=u.value[C.activeOptionIndex.value];S(e.value)}C.goToOption(Es.Specific,C.activeOptionIndex.value)}},registerOption(e,t){let n=fn({id:e,dataRef:t});if(C.virtual.value){u.value.push(n);return}x&&cancelAnimationFrame(x);let r=m(e=>(e.push(n),e));d.value===null&&C.isSelected(t.value.value)&&(r.activeOptionIndex=r.options.indexOf(n)),u.value=r.options,d.value=r.activeOptionIndex,f.value=2,r.options.some(e=>!G(e.dataRef.domRef))&&(x=requestAnimationFrame(()=>{let e=m();u.value=e.options,d.value=e.activeOptionIndex}))},unregisterOption(e,t){if(b!==null&&cancelAnimationFrame(b),t&&(p.value=!0),C.virtual.value){u.value=u.value.filter(t=>t.id!==e);return}let n=m(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});u.value=n.options,d.value=n.activeOptionIndex,f.value=2},isSelected(e){return No(h.value,{0:()=>C.compare(bn(C.value.value),bn(e)),1:()=>bn(C.value.value).some(t=>C.compare(bn(t),bn(e)))})},isActive(e){return d.value===C.calculateIndex(e)}};Qo([o,s,c],()=>C.closeCombobox(),U(()=>i.value===0)),dr(js,C),_s(U(()=>No(i.value,{0:ys.Open,1:ys.Closed})));let w=U(()=>G(o)?.closest(`form`));return Hi(()=>{mr([w],()=>{if(!w.value||e.defaultValue===void 0)return;function t(){C.change(e.defaultValue)}return w.value.addEventListener(`reset`,t),()=>{var e;(e=w.value)==null||e.removeEventListener(`reset`,t)}},{immediate:!0})}),()=>{let{name:r,disabled:a,form:o,...s}=e,c={open:i.value===0,disabled:a,activeIndex:C.activeOptionIndex.value,activeOption:C.activeOptionIndex.value===null?null:C.virtual.value?C.virtual.value.options[C.activeOptionIndex.value??0]:C.options.value[C.activeOptionIndex.value]?.dataRef.value??null,value:y.value};return ci(V,[...r!=null&&y.value!=null?Os({[r]:y.value}).map(([e,t])=>ci(ms,cs({features:ps.Hidden,key:e,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,form:o,disabled:a,name:e,value:t}))):[],os({theirProps:{...n,...ls(s,[`by`,`defaultValue`,`immediate`,`modelValue`,`multiple`,`nullable`,`onUpdate:modelValue`,`virtual`])},ourProps:{},slot:c,slots:t,attrs:n,name:`Combobox`})])}}}),N({name:`ComboboxLabel`,props:{as:{type:[Object,String],default:`label`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-combobox-label-${Ao()}`,i=As(`ComboboxLabel`);function a(){var e;(e=G(i.inputRef))==null||e.focus({preventScroll:!0})}return()=>{let o={open:i.comboboxState.value===0,disabled:i.disabled.value},{...s}=e;return os({ourProps:{id:r,ref:i.labelRef,onClick:a},theirProps:s,slot:o,attrs:t,slots:n,name:`ComboboxLabel`})}}}),N({name:`ComboboxButton`,props:{as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-combobox-button-${Ao()}`,a=As(`ComboboxButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){a.disabled.value||(a.comboboxState.value===0?a.closeCombobox():(e.preventDefault(),a.openCombobox()),ar(()=>G(a.inputRef)?.focus({preventScroll:!0})))}function s(e){switch(e.key){case xs.ArrowDown:e.preventDefault(),e.stopPropagation(),a.comboboxState.value===1&&a.openCombobox(),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return;case xs.ArrowUp:e.preventDefault(),e.stopPropagation(),a.comboboxState.value===1&&(a.openCombobox(),ar(()=>{a.value.value||a.goToOption(Es.Last)})),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return;case xs.Escape:if(a.comboboxState.value!==0)return;e.preventDefault(),a.optionsRef.value&&!a.optionsPropsRef.value.static&&e.stopPropagation(),a.closeCombobox(),ar(()=>a.inputRef.value?.focus({preventScroll:!0}));return}}let c=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.comboboxState.value===0,disabled:a.disabled.value,value:a.value.value},{...l}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:c.value,tabindex:`-1`,"aria-haspopup":`listbox`,"aria-controls":G(a.optionsRef)?.id,"aria-expanded":a.comboboxState.value===0,"aria-labelledby":a.labelRef.value?[G(a.labelRef)?.id,i].join(` `):void 0,disabled:a.disabled.value===!0?!0:void 0,onKeydown:s,onClick:o},theirProps:l,slot:r,attrs:t,slots:n,name:`ComboboxButton`})}}}),N({name:`ComboboxInput`,props:{as:{type:[Object,String],default:`input`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},displayValue:{type:Function},defaultValue:{type:String,default:void 0},id:{type:String,default:null}},emits:{change:e=>!0},setup(e,{emit:t,attrs:n,slots:r,expose:i}){let a=e.id??`headlessui-combobox-input-${Ao()}`,o=As(`ComboboxInput`),s=U(()=>Ro(G(o.inputRef))),c={value:!1};i({el:o.inputRef,$el:o.inputRef});function l(){o.change(null);let e=G(o.optionsRef);e&&(e.scrollTop=0),o.goToOption(Es.Nothing)}let u=U(()=>{let t=o.value.value;return G(o.inputRef)?e.displayValue!==void 0&&t!==void 0?e.displayValue(t)??``:typeof t==`string`?t:``:``});Hi(()=>{mr([u,o.comboboxState,s],([e,t],[n,r])=>{if(c.value)return;let i=G(o.inputRef);i&&((r===0&&t===1||e!==n)&&(i.value=e),requestAnimationFrame(()=>{if(c.value||!i||s.value?.activeElement!==i)return;let{selectionStart:e,selectionEnd:t}=i;Math.abs((t??0)-(e??0))===0&&e===0&&i.setSelectionRange(i.value.length,i.value.length)}))},{immediate:!0}),mr([o.comboboxState],([e],[t])=>{if(e===0&&t===1){if(c.value)return;let e=G(o.inputRef);if(!e)return;let t=e.value,{selectionStart:n,selectionEnd:r,selectionDirection:i}=e;e.value=``,e.value=t,i===null?e.setSelectionRange(n,r):e.setSelectionRange(n,r,i)}})});let d=A(!1);function f(){d.value=!0}function p(){Do().nextFrame(()=>{d.value=!1})}let m=Vie();function h(e){switch(c.value=!0,m(()=>{c.value=!1}),e.key){case xs.Enter:if(c.value=!1,o.comboboxState.value!==0||d.value)return;if(e.preventDefault(),e.stopPropagation(),o.activeOptionIndex.value===null){o.closeCombobox();return}o.selectActiveOption(),o.mode.value===0&&o.closeCombobox();break;case xs.ArrowDown:return c.value=!1,e.preventDefault(),e.stopPropagation(),No(o.comboboxState.value,{0:()=>o.goToOption(Es.Next),1:()=>o.openCombobox()});case xs.ArrowUp:return c.value=!1,e.preventDefault(),e.stopPropagation(),No(o.comboboxState.value,{0:()=>o.goToOption(Es.Previous),1:()=>{o.openCombobox(),ar(()=>{o.value.value||o.goToOption(Es.Last)})}});case xs.Home:if(e.shiftKey)break;return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.First);case xs.PageUp:return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.First);case xs.End:if(e.shiftKey)break;return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.Last);case xs.PageDown:return c.value=!1,e.preventDefault(),e.stopPropagation(),o.goToOption(Es.Last);case xs.Escape:if(c.value=!1,o.comboboxState.value!==0)return;e.preventDefault(),o.optionsRef.value&&!o.optionsPropsRef.value.static&&e.stopPropagation(),o.nullable.value&&o.mode.value===0&&o.value.value===null&&l(),o.closeCombobox();break;case xs.Tab:if(c.value=!1,o.comboboxState.value!==0)return;o.mode.value===0&&o.activationTrigger.value!==1&&o.selectActiveOption(),o.closeCombobox();break}}function g(e){t(`change`,e),o.nullable.value&&o.mode.value===0&&e.target.value===``&&l(),o.openCombobox()}function _(e){var t,n;let r=e.relatedTarget??ws.find(t=>t!==e.currentTarget);if(c.value=!1,!((t=G(o.optionsRef))!=null&&t.contains(r))&&!((n=G(o.buttonRef))!=null&&n.contains(r))&&o.comboboxState.value===0)return e.preventDefault(),o.mode.value===0&&(o.nullable.value&&o.value.value===null?l():o.activationTrigger.value!==1&&o.selectActiveOption()),o.closeCombobox()}function v(e){var t,n;let r=e.relatedTarget??ws.find(t=>t!==e.currentTarget);(t=G(o.buttonRef))!=null&&t.contains(r)||(n=G(o.optionsRef))!=null&&n.contains(r)||o.disabled.value||o.immediate.value&&o.comboboxState.value!==0&&(o.openCombobox(),Do().nextFrame(()=>{o.setActivationTrigger(1)}))}let y=U(()=>e.defaultValue??(o.defaultValue.value===void 0?null:e.displayValue?.call(e,o.defaultValue.value))??o.defaultValue.value??``);return()=>{let t={open:o.comboboxState.value===0},{displayValue:i,onChange:s,...c}=e;return os({ourProps:{"aria-controls":o.optionsRef.value?.id,"aria-expanded":o.comboboxState.value===0,"aria-activedescendant":o.activeOptionIndex.value===null?void 0:o.virtual.value?o.options.value.find(e=>!o.virtual.value.disabled(e.dataRef.value)&&o.compare(e.dataRef.value,o.virtual.value.options[o.activeOptionIndex.value]))?.id:o.options.value[o.activeOptionIndex.value]?.id,"aria-labelledby":G(o.labelRef)?.id??G(o.buttonRef)?.id,"aria-autocomplete":`list`,id:a,onCompositionstart:f,onCompositionend:p,onKeydown:h,onInput:g,onFocus:v,onBlur:_,role:`combobox`,type:n.type??`text`,tabIndex:0,ref:o.inputRef,defaultValue:y.value,disabled:o.disabled.value===!0?!0:void 0},theirProps:c,slot:t,attrs:n,slots:r,features:us.RenderStrategy|us.Static,name:`ComboboxInput`})}}}),N({name:`ComboboxOptions`,props:{as:{type:[Object,String],default:`ul`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},hold:{type:[Boolean],default:!1}},setup(e,{attrs:t,slots:n,expose:r}){let i=As(`ComboboxOptions`),a=`headlessui-combobox-options-${Ao()}`;r({el:i.optionsRef,$el:i.optionsRef}),pr(()=>{i.optionsPropsRef.value.static=e.static}),pr(()=>{i.optionsPropsRef.value.hold=e.hold});let o=gs(),s=U(()=>o===null?i.comboboxState.value===0:(o.value&ys.Open)===ys.Open);is({container:U(()=>G(i.optionsRef)),enabled:U(()=>i.comboboxState.value===0),accept(e){return e.getAttribute(`role`)===`option`?NodeFilter.FILTER_REJECT:e.hasAttribute(`role`)?NodeFilter.FILTER_SKIP:NodeFilter.FILTER_ACCEPT},walk(e){e.setAttribute(`role`,`none`)}});function c(e){e.preventDefault()}return()=>{let r={open:i.comboboxState.value===0};return os({ourProps:{"aria-labelledby":G(i.labelRef)?.id??G(i.buttonRef)?.id,id:a,ref:i.optionsRef,role:`listbox`,"aria-multiselectable":i.mode.value===1?!0:void 0,onMousedown:c},theirProps:ls(e,[`hold`]),slot:r,attrs:t,slots:i.virtual.value&&i.comboboxState.value===0?{...n,default:()=>[ci(wae,{},n.default)]}:n,features:us.RenderStrategy|us.Static,visible:s.value,name:`ComboboxOptions`})}}}),N({name:`ComboboxOption`,props:{as:{type:[Object,String],default:`li`},value:{type:[Object,String,Number,Boolean]},disabled:{type:Boolean,default:!1},order:{type:[Number],default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=As(`ComboboxOption`),a=`headlessui-combobox-option-${Ao()}`,o=A(null),s=U(()=>e.disabled);r({el:o,$el:o});let c=U(()=>i.virtual.value?i.activeOptionIndex.value===i.calculateIndex(e.value):i.activeOptionIndex.value===null?!1:i.options.value[i.activeOptionIndex.value]?.id===a),l=U(()=>i.isSelected(e.value)),u=fr(Ms,null),d=U(()=>({disabled:e.disabled,value:e.value,domRef:o,order:U(()=>e.order)}));Hi(()=>i.registerOption(a,d)),Gi(()=>i.unregisterOption(a,c.value)),pr(()=>{let e=G(o);e&&u?.value.measureElement(e)}),pr(()=>{i.comboboxState.value===0&&c.value&&(i.virtual.value||i.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})}))});function f(e){e.preventDefault(),e.button===Cs.Left&&(s.value||(i.selectOption(a),nae()||requestAnimationFrame(()=>G(i.inputRef)?.focus({preventScroll:!0})),i.mode.value===0&&i.closeCombobox()))}function p(){var t;if(e.disabled||(t=i.virtual.value)!=null&&t.disabled(e.value))return i.goToOption(Es.Nothing);let n=i.calculateIndex(e.value);i.goToOption(Es.Specific,n)}let m=ns();function h(e){m.update(e)}function g(t){var n;if(!m.wasMoved(t)||e.disabled||(n=i.virtual.value)!=null&&n.disabled(e.value)||c.value)return;let r=i.calculateIndex(e.value);i.goToOption(Es.Specific,r,0)}function _(t){var n;m.wasMoved(t)&&(e.disabled||(n=i.virtual.value)!=null&&n.disabled(e.value)||c.value&&(i.optionsPropsRef.value.hold||i.goToOption(Es.Nothing)))}return()=>{let{disabled:r}=e,i={active:c.value,selected:l.value,disabled:r};return os({ourProps:{id:a,ref:o,role:`option`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,"aria-selected":l.value,disabled:void 0,onMousedown:f,onFocus:p,onPointerenter:h,onMouseenter:h,onPointermove:g,onMousemove:g,onPointerleave:_,onMouseleave:_},theirProps:ls(e,[`order`,`value`]),slot:i,attrs:n,slots:t,name:`ComboboxOption`})}}})}));function Ns(e,t,n,r){Io.isServer||pr(i=>{e??=window,e.addEventListener(t,n,r),i(()=>e.removeEventListener(t,n,r))})}var Ps=s((()=>{W(),Lo()}));function Fs(){let e=A(0);return iae(`keydown`,t=>{t.key===`Tab`&&(e.value=t.shiftKey?1:0)}),e}var Is,Eae=s((()=>{W(),aae(),Is=(e=>(e[e.Forwards=0]=`Forwards`,e[e.Backwards=1]=`Backwards`,e))(Is||{})}));function Dae(e){if(!e)return new Set;if(typeof e==`function`)return new Set(e());let t=new Set;for(let n of e.value){let e=G(n);e instanceof HTMLElement&&t.add(e)}return t}function Oae(e){let t=A(ws.slice());return mr([e],([e],[n])=>{n===!0&&e===!1?To(()=>{t.value.splice(0)}):n===!1&&e===!0&&(t.value=ws.slice())},{flush:`post`}),()=>t.value.find(e=>e!=null&&e.isConnected)??null}function kae({ownerDocument:e},t){let n=Oae(t);Hi(()=>{pr(()=>{t.value||e.value?.activeElement===e.value?.body&&Ho(n())},{flush:`post`})}),Gi(()=>{t.value&&Ho(n())})}function Aae({ownerDocument:e,container:t,initialFocus:n},r){let i=A(null),a=A(!1);return Hi(()=>a.value=!0),Gi(()=>a.value=!1),Hi(()=>{mr([t,n,r],(o,s)=>{if(o.every((e,t)=>s?.[t]===e)||!r.value)return;let c=G(t);c&&To(()=>{if(!a.value)return;let t=G(n),r=e.value?.activeElement;if(t){if(t===r){i.value=r;return}}else if(c.contains(r)){i.value=r;return}t?Ho(t):Wo(c,Ko.First|Ko.NoScroll)===qo.Error&&console.warn(`There are no focusable elements inside the `),i.value=e.value?.activeElement})},{immediate:!0,flush:`post`})}),i}function jae({ownerDocument:e,container:t,containers:n,previousActiveElement:r},i){Ns(e.value?.defaultView,`focus`,e=>{if(!i.value)return;let a=Dae(n);G(t)instanceof HTMLElement&&a.add(G(t));let o=r.value;if(!o)return;let s=e.target;s&&s instanceof HTMLElement?Mae(a,s)?(r.value=s,Ho(s)):(e.preventDefault(),e.stopPropagation(),Ho(o)):Ho(r.value)},!0)}function Mae(e,t){for(let n of e)if(n.contains(t))return!0;return!1}var Ls,Rs,Nae=s((()=>{W(),Ps(),Eae(),hs(),hae(),Mo(),Yo(),Po(),Eo(),zo(),fs(),Ls=(e=>(e[e.None=1]=`None`,e[e.InitialFocus=2]=`InitialFocus`,e[e.TabLock=4]=`TabLock`,e[e.FocusLock=8]=`FocusLock`,e[e.RestoreFocus=16]=`RestoreFocus`,e[e.All=30]=`All`,e))(Ls||{}),Rs=Object.assign(N({name:`FocusTrap`,props:{as:{type:[Object,String],default:`div`},initialFocus:{type:Object,default:null},features:{type:Number,default:30},containers:{type:[Object,Function],default:A(new Set)}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=A(null);r({el:i,$el:i});let a=U(()=>Ro(i)),o=A(!1);Hi(()=>o.value=!0),Gi(()=>o.value=!1),kae({ownerDocument:a},U(()=>o.value&&!!(e.features&16)));let s=Aae({ownerDocument:a,container:i,initialFocus:U(()=>e.initialFocus)},U(()=>o.value&&!!(e.features&2)));jae({ownerDocument:a,container:i,containers:e.containers,previousActiveElement:s},U(()=>o.value&&!!(e.features&8)));let c=Fs();function l(e){let t=G(i);t&&(e=>e())(()=>{No(c.value,{[Is.Forwards]:()=>{Wo(t,Ko.First,{skipElements:[e.relatedTarget]})},[Is.Backwards]:()=>{Wo(t,Ko.Last,{skipElements:[e.relatedTarget]})}})})}let u=A(!1);function d(e){e.key===`Tab`&&(u.value=!0,requestAnimationFrame(()=>{u.value=!1}))}function f(t){if(!o.value)return;let n=Dae(e.containers);G(i)instanceof HTMLElement&&n.add(G(i));let r=t.relatedTarget;r instanceof HTMLElement&&r.dataset.headlessuiFocusGuard!==`true`&&(Mae(n,r)||(u.value?Wo(G(i),No(c.value,{[Is.Forwards]:()=>Ko.Next,[Is.Backwards]:()=>Ko.Previous})|Ko.WrapAround,{relativeTo:t.target}):t.target instanceof HTMLElement&&Ho(t.target)))}return()=>{let r={},a={ref:i,onKeydown:d,onFocusout:f},{features:o,initialFocus:s,containers:c,...u}=e;return ci(V,[!!(o&4)&&ci(ms,{as:`button`,type:`button`,"data-headlessui-focus-guard":!0,onFocus:l,features:ps.Focusable}),os({ourProps:a,theirProps:{...t,...u},slot:r,attrs:t,slots:n,name:`FocusTrap`}),!!(o&4)&&ci(ms,{as:`button`,type:`button`,"data-headlessui-focus-guard":!0,onFocus:l,features:ps.Focusable})])}}}),{features:Ls})}));function Pae(e){let t=Sn(e.getSnapshot());return Gi(e.subscribe(()=>{t.value=e.getSnapshot()})),t}var Fae=s((()=>{W()}));function Iae(e,t){let n=e(),r=new Set;return{getSnapshot(){return n},subscribe(e){return r.add(e),()=>r.delete(e)},dispatch(e,...i){let a=t[e].call(n,...i);a&&(n=a,r.forEach(e=>e()))}}}var Lae=s((()=>{}));function Rae(){let e;return{before({doc:t}){let n=t.documentElement;e=(t.defaultView??window).innerWidth-n.clientWidth},after({doc:t,d:n}){let r=t.documentElement,i=r.clientWidth-r.offsetWidth,a=e-i;n.style(r,`paddingRight`,`${a}px`)}}}var zae=s((()=>{}));function Bae(){return eae()?{before({doc:e,d:t,meta:n}){function r(e){return n.containers.flatMap(e=>e()).some(t=>t.contains(e))}t.microTask(()=>{if(window.getComputedStyle(e.documentElement).scrollBehavior!==`auto`){let n=Do();n.style(e.documentElement,`scrollBehavior`,`auto`),t.add(()=>t.microTask(()=>n.dispose()))}let n=window.scrollY??window.pageYOffset,i=null;t.addEventListener(e,`click`,t=>{if(t.target instanceof HTMLElement)try{let n=t.target.closest(`a`);if(!n)return;let{hash:a}=new URL(n.href),o=e.querySelector(a);o&&!r(o)&&(i=o)}catch{}},!0),t.addEventListener(e,`touchstart`,e=>{if(e.target instanceof HTMLElement)if(r(e.target)){let n=e.target;for(;n.parentElement&&r(n.parentElement);)n=n.parentElement;t.style(n,`overscrollBehavior`,`contain`)}else t.style(e.target,`touchAction`,`none`)}),t.addEventListener(e,`touchmove`,e=>{if(e.target instanceof HTMLElement){if(e.target.tagName===`INPUT`)return;if(r(e.target)){let t=e.target;for(;t.parentElement&&t.dataset.headlessuiPortal!==``&&!(t.scrollHeight>t.clientHeight||t.scrollWidth>t.clientWidth);)t=t.parentElement;t.dataset.headlessuiPortal===``&&e.preventDefault()}else e.preventDefault()}},{passive:!1}),t.add(()=>{n!==(window.scrollY??window.pageYOffset)&&window.scrollTo(0,n),i&&i.isConnected&&(i.scrollIntoView({block:`nearest`}),i=null)})})}}:{}}var Vae=s((()=>{Oo(),Xo()}));function Hae(){return{before({doc:e,d:t}){t.style(e.documentElement,`overflow`,`hidden`)}}}var Uae=s((()=>{}));function Wae(e){let t={};for(let n of e)Object.assign(t,n(t));return t}var zs,Gae=s((()=>{Oo(),Lae(),zae(),Vae(),Uae(),zs=Iae(()=>new Map,{PUSH(e,t){let n=this.get(e)??{doc:e,count:0,d:Do(),meta:new Set};return n.count++,n.meta.add(t),this.set(e,n),this},POP(e,t){let n=this.get(e);return n&&(n.count--,n.meta.delete(t)),this},SCROLL_PREVENT({doc:e,d:t,meta:n}){let r={doc:e,d:t,meta:Wae(n)},i=[Bae(),Rae(),Hae()];i.forEach(({before:e})=>e?.(r)),i.forEach(({after:e})=>e?.(r))},SCROLL_ALLOW({d:e}){e.dispose()},TEARDOWN({doc:e}){this.delete(e)}}),zs.subscribe(()=>{let e=zs.getSnapshot(),t=new Map;for(let[n]of e)t.set(n,n.documentElement.style.overflow);for(let n of e.values()){let e=t.get(n.doc)===`hidden`,r=n.count!==0;(r&&!e||!r&&e)&&zs.dispatch(n.count>0?`SCROLL_PREVENT`:`SCROLL_ALLOW`,n),n.count===0&&zs.dispatch(`TEARDOWN`,n)}})}));function Kae(e,t,n){let r=Pae(zs),i=U(()=>{let t=e.value?r.value.get(e.value):void 0;return t?t.count>0:!1});return mr([e,t],([e,t],[r],i)=>{if(!e||!t)return;zs.dispatch(`PUSH`,e,n);let a=!1;i(()=>{a||=(zs.dispatch(`POP`,r??e,n),!0)})},{immediate:!0}),i}var qae=s((()=>{W(),Fae(),Gae()}));function Jae(e,t=A(!0)){pr(n=>{if(!t.value)return;let r=G(e);if(!r)return;n(function(){if(!r)return;let e=Vs.get(r)??1;if(e===1?Vs.delete(r):Vs.set(r,e-1),e!==1)return;let t=Bs.get(r);t&&(t[`aria-hidden`]===null?r.removeAttribute(`aria-hidden`):r.setAttribute(`aria-hidden`,t[`aria-hidden`]),r.inert=t.inert,Bs.delete(r))});let i=Vs.get(r)??0;Vs.set(r,i+1),i===0&&(Bs.set(r,{"aria-hidden":r.getAttribute(`aria-hidden`),inert:r.inert}),r.setAttribute(`aria-hidden`,`true`),r.inert=!0)})}var Bs,Vs,Yae=s((()=>{W(),Mo(),Bs=new Map,Vs=new Map}));function Xae({defaultContainers:e=[],portals:t,mainTreeNodeRef:n}={}){let r=A(null),i=Ro(r);function a(){let n=[];for(let t of e)t!==null&&(t instanceof HTMLElement?n.push(t):`value`in t&&t.value instanceof HTMLElement&&n.push(t.value));if(t!=null&&t.value)for(let e of t.value)n.push(e);for(let e of i?.querySelectorAll(`html > *, body > *`)??[])e!==document.body&&e!==document.head&&e instanceof HTMLElement&&e.id!==`headlessui-portal-root`&&(e.contains(G(r))||e.contains(G(r)?.getRootNode()?.host)||n.some(t=>e.contains(t))||n.push(e));return n}return{resolveContainers:a,contains(e){return a().some(t=>t.contains(e))},mainTreeNodeRef:r,MainTreeNode(){return n==null?ci(ms,{features:ps.Hidden,ref:r}):null}}}function Zae(){let e=A(null);return{mainTreeNodeRef:e,MainTreeNode(){return ci(ms,{features:ps.Hidden,ref:e})}}}var Qae=s((()=>{W(),hs(),Mo(),zo()}));function $ae(){return fr(Hs,!1)}var Hs,Us,eoe=s((()=>{W(),fs(),Hs=Symbol(`ForcePortalRootContext`),Us=N({name:`ForcePortalRoot`,props:{as:{type:[Object,String],default:`template`},force:{type:Boolean,default:!1}},setup(e,{slots:t,attrs:n}){return dr(Hs,e.force),()=>{let{force:r,...i}=e;return os({theirProps:i,ourProps:{},slot:{},slots:t,attrs:n,name:`ForcePortalRoot`})}}})}));function toe(){return fr(Ws,()=>{})}function noe({type:e,enabled:t,element:n,onUpdate:r}){let i=toe();function a(...e){r?.(...e),i(...e)}Hi(()=>{mr(t,(t,r)=>{t?a(0,e,n):r===!0&&a(1,e,n)},{immediate:!0,flush:`sync`})}),Gi(()=>{t.value&&a(1,e,n)}),dr(Ws,a)}var Ws,Gs,roe=s((()=>{W(),Ws=Symbol(`StackContext`),Gs=(e=>(e[e.Add=0]=`Add`,e[e.Remove=1]=`Remove`,e))(Gs||{})}));function ioe(){let e=fr(qs,null);if(e===null)throw Error(`Missing parent`);return e}function Ks({slot:e=A({}),name:t=`Description`,props:n={}}={}){let r=A([]);function i(e){return r.value.push(e),()=>{let t=r.value.indexOf(e);t!==-1&&r.value.splice(t,1)}}return dr(qs,{register:i,slot:e,name:t,props:n}),U(()=>r.value.length>0?r.value.join(` `):void 0)}var qs,Js=s((()=>{W(),jo(),fs(),qs=Symbol(`DescriptionContext`),N({name:`Description`,props:{as:{type:[Object,String],default:`p`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-description-${Ao()}`,i=ioe();return Hi(()=>Gi(i.register(r))),()=>{let{name:a=`Description`,slot:o=A({}),props:s={}}=i,{...c}=e;return os({ourProps:{...Object.entries(s).reduce((e,[t,n])=>Object.assign(e,{[t]:j(n)}),{}),id:r},theirProps:c,slot:o.value,attrs:t,slots:n,name:a})}}})}));function aoe(e){let t=Ro(e);if(!t){if(e===null)return null;throw Error(`[Headless UI]: Cannot find ownerDocument for contextElement: ${e}`)}let n=t.getElementById(`headlessui-portal-root`);if(n)return n;let r=t.createElement(`div`);return r.setAttribute(`id`,`headlessui-portal-root`),t.body.appendChild(r)}function ooe(e){return Ys.get(e)??0}function soe(e,t){let n=t(ooe(e));return n<=0?Ys.delete(e):Ys.set(e,n),n}function coe(){let e=fr(Zs,null),t=A([]);function n(n){return t.value.push(n),e&&e.register(n),()=>r(n)}function r(n){let r=t.value.indexOf(n);r!==-1&&t.value.splice(r,1),e&&e.unregister(n)}let i={register:n,unregister:r,portals:t};return[t,N({name:`PortalWrapper`,setup(e,{slots:t}){return dr(Zs,i),()=>t.default?.call(t)}})]}var Ys,Xs,Zs,Qs,loe,uoe=s((()=>{W(),eoe(),Mo(),zo(),fs(),Ys=new WeakMap,Xs=N({name:`Portal`,props:{as:{type:[Object,String],default:`div`}},setup(e,{slots:t,attrs:n}){let r=A(null),i=U(()=>Ro(r)),a=$ae(),o=fr(Qs,null),s=A(a===!0||o==null?aoe(r.value):o.resolveTarget());s.value&&soe(s.value,e=>e+1);let c=A(!1);Hi(()=>{c.value=!0}),pr(()=>{a||o!=null&&(s.value=o.resolveTarget())});let l=fr(Zs,null),u=!1,d=ba();return mr(r,()=>{if(u||!l)return;let e=G(r);e&&(Gi(l.register(e),d),u=!0)}),Gi(()=>{var e;let t=i.value?.getElementById(`headlessui-portal-root`);!t||s.value!==t||soe(s.value,e=>e-1)||s.value.children.length>0||(e=s.value.parentElement)==null||e.removeChild(s.value)}),()=>{if(!c.value||s.value===null)return null;let i={ref:r,"data-headlessui-portal":``};return ci(Di,{to:s.value},os({ourProps:i,theirProps:e,slot:{},attrs:n,slots:t,name:`Portal`}))}}}),Zs=Symbol(`PortalParentContext`),Qs=Symbol(`PortalGroupContext`),loe=N({name:`PortalGroup`,props:{as:{type:[Object,String],default:`template`},target:{type:Object,default:null}},setup(e,{attrs:t,slots:n}){return dr(Qs,fn({resolveTarget(){return e.target}})),()=>{let{target:r,...i}=e;return os({theirProps:i,ourProps:{},slot:{},attrs:t,slots:n,name:`PortalGroup`})}}})}));function $s(e){let t=fr(ec,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,$s),t}return t}var doe,ec,tc,foe,poe,moe,hoe=s((()=>{W(),Nae(),qae(),Ps(),jo(),Yae(),$o(),Qae(),bs(),eoe(),roe(),Ss(),Mo(),Po(),zo(),fs(),Js(),uoe(),doe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(doe||{}),ec=Symbol(`DialogContext`),tc=`DC8F892D-2EBD-447C-A4C8-A03058436FF4`,foe=N({name:`Dialog`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},open:{type:[Boolean,String],default:tc},initialFocus:{type:Object,default:null},id:{type:String,default:null},role:{type:String,default:`dialog`}},emits:{close:e=>!0},setup(e,{emit:t,attrs:n,slots:r,expose:i}){let a=e.id??`headlessui-dialog-${Ao()}`,o=A(!1);Hi(()=>{o.value=!0});let s=!1,c=U(()=>e.role===`dialog`||e.role===`alertdialog`?e.role:(s||(s=!0,console.warn(`Invalid role [${c}] passed to . Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`)),`dialog`)),l=A(0),u=gs(),d=U(()=>e.open===tc&&u!==null?(u.value&ys.Open)===ys.Open:e.open),f=A(null),p=U(()=>Ro(f));if(i({el:f,$el:f}),!(e.open!==tc||u!==null))throw Error("You forgot to provide an `open` prop to the `Dialog`.");if(typeof d.value!=`boolean`)throw Error(`You provided an \`open\` prop to the \`Dialog\`, but the value is not a boolean. Received: ${d.value===tc?void 0:e.open}`);let m=U(()=>o.value&&d.value?0:1),h=U(()=>m.value===0),g=U(()=>l.value>1),_=fr(ec,null)!==null,[v,y]=coe(),{resolveContainers:b,mainTreeNodeRef:x,MainTreeNode:S}=Xae({portals:v,defaultContainers:[U(()=>T.panelRef.value??f.value)]}),C=U(()=>g.value?`parent`:`leaf`),w=U(()=>u===null?!1:(u.value&ys.Closing)===ys.Closing),ee=U(()=>_||w.value?!1:h.value);Jae(U(()=>Array.from(p.value?.querySelectorAll(`body > *`)??[]).find(e=>e.id===`headlessui-portal-root`?!1:e.contains(G(x))&&e instanceof HTMLElement)??null),ee);let te=U(()=>g.value?!0:h.value);Jae(U(()=>Array.from(p.value?.querySelectorAll(`[data-headlessui-portal]`)??[]).find(e=>e.contains(G(x))&&e instanceof HTMLElement)??null),te),noe({type:`Dialog`,enabled:U(()=>m.value===0),element:f,onUpdate:(e,t)=>{if(t===`Dialog`)return No(e,{[Gs.Add]:()=>l.value+=1,[Gs.Remove]:()=>--l.value})}});let ne=Ks({name:`DialogDescription`,slot:U(()=>({open:d.value}))}),re=A(null),T={titleId:re,panelRef:A(null),dialogState:m,setTitleId(e){re.value!==e&&(re.value=e)},close(){t(`close`,!1)}};dr(ec,T),Qo(b,(e,t)=>{e.preventDefault(),T.close(),ar(()=>t?.focus())},U(()=>!(!h.value||g.value)));let ie=U(()=>!(g.value||m.value!==0));return Ns(p.value?.defaultView,`keydown`,e=>{ie.value&&(e.defaultPrevented||e.key===xs.Escape&&(e.preventDefault(),e.stopPropagation(),T.close()))}),Kae(p,U(()=>!(w.value||m.value!==0||_)),e=>({containers:[...e.containers??[],b]})),pr(e=>{if(m.value!==0)return;let t=G(f);if(!t)return;let n=new ResizeObserver(e=>{for(let t of e){let e=t.target.getBoundingClientRect();e.x===0&&e.y===0&&e.width===0&&e.height===0&&T.close()}});n.observe(t),e(()=>n.disconnect())}),()=>{let{open:t,initialFocus:i,...o}=e,s={...n,ref:f,id:a,role:c.value,"aria-modal":m.value===0?!0:void 0,"aria-labelledby":re.value,"aria-describedby":ne.value},l={open:m.value===0};return ci(Us,{force:!0},()=>[ci(Xs,()=>ci(loe,{target:f.value},()=>ci(Us,{force:!1},()=>ci(Rs,{initialFocus:i,containers:b,features:h.value?No(C.value,{parent:Rs.features.RestoreFocus,leaf:Rs.features.All&~Rs.features.FocusLock}):Rs.features.None},()=>ci(y,{},()=>os({ourProps:s,theirProps:{...o,...n},slot:l,attrs:n,slots:r,visible:m.value===0,features:us.RenderStrategy|us.Static,name:`Dialog`})))))),ci(S)])}}}),N({name:`DialogOverlay`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-dialog-overlay-${Ao()}`,i=$s(`DialogOverlay`);function a(e){e.target===e.currentTarget&&(e.preventDefault(),e.stopPropagation(),i.close())}return()=>{let{...o}=e;return os({ourProps:{id:r,"aria-hidden":!0,onClick:a},theirProps:o,slot:{open:i.dialogState.value===0},attrs:t,slots:n,name:`DialogOverlay`})}}}),N({name:`DialogBackdrop`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-dialog-backdrop-${Ao()}`,a=$s(`DialogBackdrop`),o=A(null);return r({el:o,$el:o}),Hi(()=>{if(a.panelRef.value===null)throw Error(`A component is being used, but a component is missing.`)}),()=>{let{...r}=e,s={id:i,ref:o,"aria-hidden":!0};return ci(Us,{force:!0},()=>ci(Xs,()=>os({ourProps:s,theirProps:{...t,...r},slot:{open:a.dialogState.value===0},attrs:t,slots:n,name:`DialogBackdrop`})))}}}),poe=N({name:`DialogPanel`,props:{as:{type:[Object,String],default:`div`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-dialog-panel-${Ao()}`,a=$s(`DialogPanel`);r({el:a.panelRef,$el:a.panelRef});function o(e){e.stopPropagation()}return()=>{let{...r}=e;return os({ourProps:{id:i,ref:a.panelRef,onClick:o},theirProps:r,slot:{open:a.dialogState.value===0},attrs:t,slots:n,name:`DialogPanel`})}}}),moe=N({name:`DialogTitle`,props:{as:{type:[Object,String],default:`h2`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-dialog-title-${Ao()}`,i=$s(`DialogTitle`);return Hi(()=>{i.setTitleId(r),Gi(()=>i.setTitleId(null))}),()=>{let{...a}=e;return os({ourProps:{id:r},theirProps:a,slot:{open:i.dialogState.value===0},attrs:t,slots:n,name:`DialogTitle`})}}})}));function nc(e){let t=fr(rc,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,nc),t}return t}function goe(){return fr(ic,null)}var _oe,rc,ic,ac,oc,sc,voe=s((()=>{W(),jo(),ts(),bs(),Ss(),Mo(),Po(),fs(),_oe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(_oe||{}),rc=Symbol(`DisclosureContext`),ic=Symbol(`DisclosurePanelContext`),ac=N({name:`Disclosure`,props:{as:{type:[Object,String],default:`template`},defaultOpen:{type:[Boolean],default:!1}},setup(e,{slots:t,attrs:n}){let r=A(e.defaultOpen?0:1),i=A(null),a=A(null),o={buttonId:A(`headlessui-disclosure-button-${Ao()}`),panelId:A(`headlessui-disclosure-panel-${Ao()}`),disclosureState:r,panel:i,button:a,toggleDisclosure(){r.value=No(r.value,{0:1,1:0})},closeDisclosure(){r.value!==1&&(r.value=1)},close(e){o.closeDisclosure(),(e?e instanceof HTMLElement?e:e.value instanceof HTMLElement?G(e):G(o.button):G(o.button))?.focus()}};return dr(rc,o),_s(U(()=>No(r.value,{0:ys.Open,1:ys.Closed}))),()=>{let{defaultOpen:i,...a}=e;return os({theirProps:a,ourProps:{},slot:{open:r.value===0,close:o.close},slots:t,attrs:n,name:`Disclosure`})}}}),oc=N({name:`DisclosureButton`,props:{as:{type:[Object,String],default:`button`},disabled:{type:[Boolean],default:!1},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=nc(`DisclosureButton`),a=goe(),o=U(()=>a===null?!1:a.value===i.panelId.value);Hi(()=>{o.value||e.id!==null&&(i.buttonId.value=e.id)}),Gi(()=>{o.value||(i.buttonId.value=null)});let s=A(null);r({el:s,$el:s}),o.value||pr(()=>{i.button.value=s.value});let c=es(U(()=>({as:e.as,type:t.type})),s);function l(){var t;e.disabled||(o.value?(i.toggleDisclosure(),(t=G(i.button))==null||t.focus()):i.toggleDisclosure())}function u(t){var n;if(!e.disabled)if(o.value)switch(t.key){case xs.Space:case xs.Enter:t.preventDefault(),t.stopPropagation(),i.toggleDisclosure(),(n=G(i.button))==null||n.focus();break}else switch(t.key){case xs.Space:case xs.Enter:t.preventDefault(),t.stopPropagation(),i.toggleDisclosure();break}}function d(e){switch(e.key){case xs.Space:e.preventDefault();break}}return()=>{let r={open:i.disclosureState.value===0},{id:a,...f}=e;return os({ourProps:o.value?{ref:s,type:c.value,onClick:l,onKeydown:u}:{id:i.buttonId.value??a,ref:s,type:c.value,"aria-expanded":i.disclosureState.value===0,"aria-controls":i.disclosureState.value===0||G(i.panel)?i.panelId.value:void 0,disabled:e.disabled?!0:void 0,onClick:l,onKeydown:u,onKeyup:d},theirProps:f,slot:r,attrs:t,slots:n,name:`DisclosureButton`})}}}),sc=N({name:`DisclosurePanel`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=nc(`DisclosurePanel`);Hi(()=>{e.id!==null&&(i.panelId.value=e.id)}),Gi(()=>{i.panelId.value=null}),r({el:i.panel,$el:i.panel}),dr(ic,i.panelId);let a=gs(),o=U(()=>a===null?i.disclosureState.value===0:(a.value&ys.Open)===ys.Open);return()=>{let r={open:i.disclosureState.value===0,close:i.close},{id:a,...s}=e;return os({ourProps:{id:i.panelId.value??a,ref:i.panel},theirProps:s,slot:r,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:o.value,name:`DisclosurePanel`})}}})}));function yoe(e){let t=e.innerText??``,n=e.cloneNode(!0);if(!(n instanceof HTMLElement))return t;let r=!1;for(let e of n.querySelectorAll(`[hidden],[aria-hidden],[role="img"]`))e.remove(),r=!0;let i=r?n.innerText??``:t;return cc.test(i)&&(i=i.replace(cc,``)),i}function boe(e){let t=e.getAttribute(`aria-label`);if(typeof t==`string`)return t.trim();let n=e.getAttribute(`aria-labelledby`);if(n){let e=n.split(` `).map(e=>{let t=document.getElementById(e);if(t){let e=t.getAttribute(`aria-label`);return typeof e==`string`?e.trim():yoe(t).trim()}return null}).filter(Boolean);if(e.length>0)return e.join(`, `)}return yoe(e).trim()}var cc,xoe=s((()=>{cc=/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g}));function Soe(e){let t=A(``),n=A(``);return()=>{let r=G(e);if(!r)return``;let i=r.innerText;if(t.value===i)return n.value;let a=boe(r).trim().toLowerCase();return t.value=i,n.value=a,a}}var Coe=s((()=>{W(),Mo(),xoe()}));function woe(e,t){return e===t}function Toe(e){requestAnimationFrame(()=>requestAnimationFrame(e))}function lc(e){let t=fr(uc,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,lc),t}return t}var Eoe,Doe,Ooe,uc,koe,Aoe,joe,Moe,Noe,Poe=s((()=>{W(),wo(),jo(),$o(),ts(),Coe(),rs(),hs(),bs(),Ss(),Ds(),Mo(),Yo(),ks(),Po(),fs(),Eoe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Eoe||{}),Doe=(e=>(e[e.Single=0]=`Single`,e[e.Multi=1]=`Multi`,e))(Doe||{}),Ooe=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Other=1]=`Other`,e))(Ooe||{}),uc=Symbol(`ListboxContext`),koe=N({name:`Listbox`,emits:{"update:modelValue":e=>!0},props:{as:{type:[Object,String],default:`template`},disabled:{type:[Boolean],default:!1},by:{type:[String,Function],default:()=>woe},horizontal:{type:[Boolean],default:!1},modelValue:{type:[Object,String,Number,Boolean],default:void 0},defaultValue:{type:[Object,String,Number,Boolean],default:void 0},form:{type:String,optional:!0},name:{type:String,optional:!0},multiple:{type:[Boolean],default:!1}},inheritAttrs:!1,setup(e,{slots:t,attrs:n,emit:r}){let i=A(1),a=A(null),o=A(null),s=A(null),c=A([]),l=A(``),u=A(null),d=A(1);function f(e=e=>e){let t=u.value===null?null:c.value[u.value],n=Uo(e(c.value.slice()),e=>G(e.dataRef.domRef)),r=t?n.indexOf(t):null;return r===-1&&(r=null),{options:n,activeOptionIndex:r}}let p=U(()=>e.multiple?1:0),[m,h]=Co(U(()=>e.modelValue),e=>r(`update:modelValue`,e),U(()=>e.defaultValue)),g=U(()=>m.value===void 0?No(p.value,{1:[],0:void 0}):m.value),_={listboxState:i,value:g,mode:p,compare(t,n){if(typeof e.by==`string`){let r=e.by;return t?.[r]===n?.[r]}return e.by(t,n)},orientation:U(()=>e.horizontal?`horizontal`:`vertical`),labelRef:a,buttonRef:o,optionsRef:s,disabled:U(()=>e.disabled),options:c,searchQuery:l,activeOptionIndex:u,activationTrigger:d,closeListbox(){e.disabled||i.value!==1&&(i.value=1,u.value=null)},openListbox(){e.disabled||i.value!==0&&(i.value=0)},goToOption(t,n,r){if(e.disabled||i.value===1)return;let a=f(),o=Ts(t===Es.Specific?{focus:Es.Specific,id:n}:{focus:t},{resolveItems:()=>a.options,resolveActiveIndex:()=>a.activeOptionIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled});l.value=``,u.value=o,d.value=r??1,c.value=a.options},search(t){if(e.disabled||i.value===1)return;let n=l.value===``?1:0;l.value+=t.toLowerCase();let r=(u.value===null?c.value:c.value.slice(u.value+n).concat(c.value.slice(0,u.value+n))).find(e=>e.dataRef.textValue.startsWith(l.value)&&!e.dataRef.disabled),a=r?c.value.indexOf(r):-1;a===-1||a===u.value||(u.value=a,d.value=1)},clearSearch(){e.disabled||i.value!==1&&l.value!==``&&(l.value=``)},registerOption(e,t){let n=f(n=>[...n,{id:e,dataRef:t}]);c.value=n.options,u.value=n.activeOptionIndex},unregisterOption(e){let t=f(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});c.value=t.options,u.value=t.activeOptionIndex,d.value=1},theirOnChange(t){e.disabled||h(t)},select(t){e.disabled||h(No(p.value,{0:()=>t,1:()=>{let e=bn(_.value.value).slice(),n=bn(t),r=e.findIndex(e=>_.compare(n,bn(e)));return r===-1?e.push(n):e.splice(r,1),e}}))}};Qo([o,s],(e,t)=>{var n;_.closeListbox(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(o))==null||n.focus())},U(()=>i.value===0)),dr(uc,_),_s(U(()=>No(i.value,{0:ys.Open,1:ys.Closed})));let v=U(()=>G(o)?.closest(`form`));return Hi(()=>{mr([v],()=>{if(!v.value||e.defaultValue===void 0)return;function t(){_.theirOnChange(e.defaultValue)}return v.value.addEventListener(`reset`,t),()=>{var e;(e=v.value)==null||e.removeEventListener(`reset`,t)}},{immediate:!0})}),()=>{let{name:r,modelValue:a,disabled:o,form:s,...c}=e,l={open:i.value===0,disabled:o,value:g.value};return ci(V,[...r!=null&&g.value!=null?Os({[r]:g.value}).map(([e,t])=>ci(ms,cs({features:ps.Hidden,key:e,as:`input`,type:`hidden`,hidden:!0,readOnly:!0,form:s,disabled:o,name:e,value:t}))):[],os({ourProps:{},theirProps:{...n,...ls(c,[`defaultValue`,`onUpdate:modelValue`,`horizontal`,`multiple`,`by`])},slot:l,slots:t,attrs:n,name:`Listbox`})])}}}),Aoe=N({name:`ListboxLabel`,props:{as:{type:[Object,String],default:`label`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n}){let r=e.id??`headlessui-listbox-label-${Ao()}`,i=lc(`ListboxLabel`);function a(){var e;(e=G(i.buttonRef))==null||e.focus({preventScroll:!0})}return()=>{let o={open:i.listboxState.value===0,disabled:i.disabled.value},{...s}=e;return os({ourProps:{id:r,ref:i.labelRef,onClick:a},theirProps:s,slot:o,attrs:t,slots:n,name:`ListboxLabel`})}}}),joe=N({name:`ListboxButton`,props:{as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-listbox-button-${Ao()}`,a=lc(`ListboxButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){switch(e.key){case xs.Space:case xs.Enter:case xs.ArrowDown:e.preventDefault(),a.openListbox(),ar(()=>{var e;(e=G(a.optionsRef))==null||e.focus({preventScroll:!0}),a.value.value||a.goToOption(Es.First)});break;case xs.ArrowUp:e.preventDefault(),a.openListbox(),ar(()=>{var e;(e=G(a.optionsRef))==null||e.focus({preventScroll:!0}),a.value.value||a.goToOption(Es.Last)});break}}function s(e){switch(e.key){case xs.Space:e.preventDefault();break}}function c(e){a.disabled.value||(a.listboxState.value===0?(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}))):(e.preventDefault(),a.openListbox(),Toe(()=>G(a.optionsRef)?.focus({preventScroll:!0}))))}let l=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.listboxState.value===0,disabled:a.disabled.value,value:a.value.value},{...u}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:l.value,"aria-haspopup":`listbox`,"aria-controls":G(a.optionsRef)?.id,"aria-expanded":a.listboxState.value===0,"aria-labelledby":a.labelRef.value?[G(a.labelRef)?.id,i].join(` `):void 0,disabled:a.disabled.value===!0?!0:void 0,onKeydown:o,onKeyup:s,onClick:c},theirProps:u,slot:r,attrs:t,slots:n,name:`ListboxButton`})}}}),Moe=N({name:`ListboxOptions`,props:{as:{type:[Object,String],default:`ul`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-listbox-options-${Ao()}`,a=lc(`ListboxOptions`),o=A(null);r({el:a.optionsRef,$el:a.optionsRef});function s(e){switch(o.value&&clearTimeout(o.value),e.key){case xs.Space:if(a.searchQuery.value!==``)return e.preventDefault(),e.stopPropagation(),a.search(e.key);case xs.Enter:if(e.preventDefault(),e.stopPropagation(),a.activeOptionIndex.value!==null){let e=a.options.value[a.activeOptionIndex.value];a.select(e.dataRef.value)}a.mode.value===0&&(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0})));break;case No(a.orientation.value,{vertical:xs.ArrowDown,horizontal:xs.ArrowRight}):return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Next);case No(a.orientation.value,{vertical:xs.ArrowUp,horizontal:xs.ArrowLeft}):return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Previous);case xs.Home:case xs.PageUp:return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.First);case xs.End:case xs.PageDown:return e.preventDefault(),e.stopPropagation(),a.goToOption(Es.Last);case xs.Escape:e.preventDefault(),e.stopPropagation(),a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}));break;case xs.Tab:e.preventDefault(),e.stopPropagation();break;default:e.key.length===1&&(a.search(e.key),o.value=setTimeout(()=>a.clearSearch(),350));break}}let c=gs(),l=U(()=>c===null?a.listboxState.value===0:(c.value&ys.Open)===ys.Open);return()=>{var r;let o={open:a.listboxState.value===0},{...c}=e;return os({ourProps:{"aria-activedescendant":a.activeOptionIndex.value===null||(r=a.options.value[a.activeOptionIndex.value])==null?void 0:r.id,"aria-multiselectable":a.mode.value===1?!0:void 0,"aria-labelledby":G(a.buttonRef)?.id,"aria-orientation":a.orientation.value,id:i,onKeydown:s,role:`listbox`,tabIndex:0,ref:a.optionsRef},theirProps:c,slot:o,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:l.value,name:`ListboxOptions`})}}}),Noe=N({name:`ListboxOption`,props:{as:{type:[Object,String],default:`li`},value:{type:[Object,String,Number,Boolean]},disabled:{type:Boolean,default:!1},id:{type:String,default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=e.id??`headlessui-listbox-option-${Ao()}`,a=lc(`ListboxOption`),o=A(null);r({el:o,$el:o});let s=U(()=>a.activeOptionIndex.value===null?!1:a.options.value[a.activeOptionIndex.value].id===i),c=U(()=>No(a.mode.value,{0:()=>a.compare(bn(a.value.value),bn(e.value)),1:()=>bn(a.value.value).some(t=>a.compare(bn(t),bn(e.value)))})),l=U(()=>No(a.mode.value,{1:()=>{let e=bn(a.value.value);return a.options.value.find(t=>e.some(e=>a.compare(bn(e),bn(t.dataRef.value))))?.id===i},0:()=>c.value})),u=Soe(o),d=U(()=>({disabled:e.disabled,value:e.value,get textValue(){return u()},domRef:o}));Hi(()=>a.registerOption(i,d)),Gi(()=>a.unregisterOption(i)),Hi(()=>{mr([a.listboxState,c],()=>{a.listboxState.value===0&&c.value&&No(a.mode.value,{1:()=>{l.value&&a.goToOption(Es.Specific,i)},0:()=>{a.goToOption(Es.Specific,i)}})},{immediate:!0})}),pr(()=>{a.listboxState.value===0&&s.value&&a.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})})});function f(t){if(e.disabled)return t.preventDefault();a.select(e.value),a.mode.value===0&&(a.closeListbox(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0})))}function p(){if(e.disabled)return a.goToOption(Es.Nothing);a.goToOption(Es.Specific,i)}let m=ns();function h(e){m.update(e)}function g(t){m.wasMoved(t)&&(e.disabled||s.value||a.goToOption(Es.Specific,i,0))}function _(t){m.wasMoved(t)&&(e.disabled||s.value&&a.goToOption(Es.Nothing))}return()=>{let{disabled:r}=e,a={active:s.value,selected:c.value,disabled:r},{value:l,disabled:u,...d}=e;return os({ourProps:{id:i,ref:o,role:`option`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,"aria-selected":c.value,disabled:void 0,onClick:f,onFocus:p,onPointerenter:h,onMouseenter:h,onPointermove:g,onMousemove:g,onPointerleave:_,onMouseleave:_},theirProps:d,slot:a,attrs:n,slots:t,name:`ListboxOption`})}}})}));function Foe(e){requestAnimationFrame(()=>requestAnimationFrame(e))}function dc(e){let t=fr(fc,null);if(t===null){let t=Error(`<${e} /> is missing a parent component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,dc),t}return t}var Ioe,Loe,fc,Roe,zoe,Boe,Voe,Hoe=s((()=>{W(),jo(),$o(),ts(),Coe(),rs(),as(),bs(),Ss(),Ds(),Mo(),Yo(),Po(),fs(),Ioe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Ioe||{}),Loe=(e=>(e[e.Pointer=0]=`Pointer`,e[e.Other=1]=`Other`,e))(Loe||{}),fc=Symbol(`MenuContext`),Roe=N({name:`Menu`,props:{as:{type:[Object,String],default:`template`}},setup(e,{slots:t,attrs:n}){let r=A(1),i=A(null),a=A(null),o=A([]),s=A(``),c=A(null),l=A(1);function u(e=e=>e){let t=c.value===null?null:o.value[c.value],n=Uo(e(o.value.slice()),e=>G(e.dataRef.domRef)),r=t?n.indexOf(t):null;return r===-1&&(r=null),{items:n,activeItemIndex:r}}let d={menuState:r,buttonRef:i,itemsRef:a,items:o,searchQuery:s,activeItemIndex:c,activationTrigger:l,closeMenu:()=>{r.value=1,c.value=null},openMenu:()=>r.value=0,goToItem(e,t,n){let r=u(),i=Ts(e===Es.Specific?{focus:Es.Specific,id:t}:{focus:e},{resolveItems:()=>r.items,resolveActiveIndex:()=>r.activeItemIndex,resolveId:e=>e.id,resolveDisabled:e=>e.dataRef.disabled});s.value=``,c.value=i,l.value=n??1,o.value=r.items},search(e){let t=s.value===``?1:0;s.value+=e.toLowerCase();let n=(c.value===null?o.value:o.value.slice(c.value+t).concat(o.value.slice(0,c.value+t))).find(e=>e.dataRef.textValue.startsWith(s.value)&&!e.dataRef.disabled),r=n?o.value.indexOf(n):-1;r===-1||r===c.value||(c.value=r,l.value=1)},clearSearch(){s.value=``},registerItem(e,t){let n=u(n=>[...n,{id:e,dataRef:t}]);o.value=n.items,c.value=n.activeItemIndex,l.value=1},unregisterItem(e){let t=u(t=>{let n=t.findIndex(t=>t.id===e);return n!==-1&&t.splice(n,1),t});o.value=t.items,c.value=t.activeItemIndex,l.value=1}};return Qo([i,a],(e,t)=>{var n;d.closeMenu(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(i))==null||n.focus())},U(()=>r.value===0)),dr(fc,d),_s(U(()=>No(r.value,{0:ys.Open,1:ys.Closed}))),()=>os({ourProps:{},theirProps:e,slot:{open:r.value===0,close:d.closeMenu},slots:t,attrs:n,name:`Menu`})}}),zoe=N({name:`MenuButton`,props:{disabled:{type:Boolean,default:!1},as:{type:[Object,String],default:`button`},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-menu-button-${Ao()}`,a=dc(`MenuButton`);r({el:a.buttonRef,$el:a.buttonRef});function o(e){switch(e.key){case xs.Space:case xs.Enter:case xs.ArrowDown:e.preventDefault(),e.stopPropagation(),a.openMenu(),ar(()=>{var e;(e=G(a.itemsRef))==null||e.focus({preventScroll:!0}),a.goToItem(Es.First)});break;case xs.ArrowUp:e.preventDefault(),e.stopPropagation(),a.openMenu(),ar(()=>{var e;(e=G(a.itemsRef))==null||e.focus({preventScroll:!0}),a.goToItem(Es.Last)});break}}function s(e){switch(e.key){case xs.Space:e.preventDefault();break}}function c(t){e.disabled||(a.menuState.value===0?(a.closeMenu(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}))):(t.preventDefault(),a.openMenu(),Foe(()=>G(a.itemsRef)?.focus({preventScroll:!0}))))}let l=es(U(()=>({as:e.as,type:t.type})),a.buttonRef);return()=>{let r={open:a.menuState.value===0},{...u}=e;return os({ourProps:{ref:a.buttonRef,id:i,type:l.value,"aria-haspopup":`menu`,"aria-controls":G(a.itemsRef)?.id,"aria-expanded":a.menuState.value===0,onKeydown:o,onKeyup:s,onClick:c},theirProps:u,slot:r,attrs:t,slots:n,name:`MenuButton`})}}}),Boe=N({name:`MenuItems`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},id:{type:String,default:null}},setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-menu-items-${Ao()}`,a=dc(`MenuItems`),o=A(null);r({el:a.itemsRef,$el:a.itemsRef}),is({container:U(()=>G(a.itemsRef)),enabled:U(()=>a.menuState.value===0),accept(e){return e.getAttribute(`role`)===`menuitem`?NodeFilter.FILTER_REJECT:e.hasAttribute(`role`)?NodeFilter.FILTER_SKIP:NodeFilter.FILTER_ACCEPT},walk(e){e.setAttribute(`role`,`none`)}});function s(e){var t;switch(o.value&&clearTimeout(o.value),e.key){case xs.Space:if(a.searchQuery.value!==``)return e.preventDefault(),e.stopPropagation(),a.search(e.key);case xs.Enter:if(e.preventDefault(),e.stopPropagation(),a.activeItemIndex.value!==null){let e=a.items.value[a.activeItemIndex.value];(t=G(e.dataRef.domRef))==null||t.click()}a.closeMenu(),Jie(G(a.buttonRef));break;case xs.ArrowDown:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Next);case xs.ArrowUp:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Previous);case xs.Home:case xs.PageUp:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.First);case xs.End:case xs.PageDown:return e.preventDefault(),e.stopPropagation(),a.goToItem(Es.Last);case xs.Escape:e.preventDefault(),e.stopPropagation(),a.closeMenu(),ar(()=>G(a.buttonRef)?.focus({preventScroll:!0}));break;case xs.Tab:e.preventDefault(),e.stopPropagation(),a.closeMenu(),ar(()=>Xie(G(a.buttonRef),e.shiftKey?Ko.Previous:Ko.Next));break;default:e.key.length===1&&(a.search(e.key),o.value=setTimeout(()=>a.clearSearch(),350));break}}function c(e){switch(e.key){case xs.Space:e.preventDefault();break}}let l=gs(),u=U(()=>l===null?a.menuState.value===0:(l.value&ys.Open)===ys.Open);return()=>{var r;let o={open:a.menuState.value===0},{...l}=e;return os({ourProps:{"aria-activedescendant":a.activeItemIndex.value===null||(r=a.items.value[a.activeItemIndex.value])==null?void 0:r.id,"aria-labelledby":G(a.buttonRef)?.id,id:i,onKeydown:s,onKeyup:c,role:`menu`,tabIndex:0,ref:a.itemsRef},theirProps:l,slot:o,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:u.value,name:`MenuItems`})}}}),Voe=N({name:`MenuItem`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`template`},disabled:{type:Boolean,default:!1},id:{type:String,default:null}},setup(e,{slots:t,attrs:n,expose:r}){let i=e.id??`headlessui-menu-item-${Ao()}`,a=dc(`MenuItem`),o=A(null);r({el:o,$el:o});let s=U(()=>a.activeItemIndex.value===null?!1:a.items.value[a.activeItemIndex.value].id===i),c=Soe(o),l=U(()=>({disabled:e.disabled,get textValue(){return c()},domRef:o}));Hi(()=>a.registerItem(i,l)),Gi(()=>a.unregisterItem(i)),pr(()=>{a.menuState.value===0&&s.value&&a.activationTrigger.value!==0&&ar(()=>{var e;return((e=G(o))?.scrollIntoView)?.call(e,{block:`nearest`})})});function u(t){if(e.disabled)return t.preventDefault();a.closeMenu(),Jie(G(a.buttonRef))}function d(){if(e.disabled)return a.goToItem(Es.Nothing);a.goToItem(Es.Specific,i)}let f=ns();function p(e){f.update(e)}function m(t){f.wasMoved(t)&&(e.disabled||s.value||a.goToItem(Es.Specific,i,0))}function h(t){f.wasMoved(t)&&(e.disabled||s.value&&a.goToItem(Es.Nothing))}return()=>{let{disabled:r,...c}=e,l={active:s.value,disabled:r,close:a.closeMenu};return os({ourProps:{id:i,ref:o,role:`menuitem`,tabIndex:r===!0?void 0:-1,"aria-disabled":r===!0?!0:void 0,onClick:u,onFocus:d,onPointerenter:p,onMouseenter:p,onPointermove:m,onMousemove:m,onPointerleave:h,onMouseleave:h},theirProps:{...n,...c},slot:l,attrs:n,slots:t,name:`MenuItem`})}}})}));function pc(e){let t=fr(mc,null);if(t===null){let t=Error(`<${e} /> is missing a parent <${_c.name} /> component.`);throw Error.captureStackTrace&&Error.captureStackTrace(t,pc),t}return t}function Uoe(){return fr(hc,null)}function Woe(){return fr(gc,null)}var Goe,mc,hc,gc,_c,vc,yc,Koe=s((()=>{W(),uoe(),Ps(),jo(),$o(),ts(),Qae(),Eae(),hs(),bs(),Ss(),Mo(),Yo(),Po(),zo(),fs(),Goe=(e=>(e[e.Open=0]=`Open`,e[e.Closed=1]=`Closed`,e))(Goe||{}),mc=Symbol(`PopoverContext`),hc=Symbol(`PopoverGroupContext`),gc=Symbol(`PopoverPanelContext`),_c=N({name:`Popover`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`}},setup(e,{slots:t,attrs:n,expose:r}){let i=A(null);r({el:i,$el:i});let a=A(1),o=A(null),s=A(null),c=A(null),l=A(null),u=U(()=>Ro(i)),d=U(()=>{var e,t;if(!G(o)||!G(l))return!1;for(let e of document.querySelectorAll(`body > *`))if(Number(e?.contains(G(o)))^Number(e?.contains(G(l))))return!0;let n=Bo(),r=n.indexOf(G(o)),i=(r+n.length-1)%n.length,a=(r+1)%n.length,s=n[i],c=n[a];return!((e=G(l))!=null&&e.contains(s))&&!((t=G(l))!=null&&t.contains(c))}),f={popoverState:a,buttonId:A(null),panelId:A(null),panel:l,button:o,isPortalled:d,beforePanelSentinel:s,afterPanelSentinel:c,togglePopover(){a.value=No(a.value,{0:1,1:0})},closePopover(){a.value!==1&&(a.value=1)},close(e){f.closePopover(),(e?e instanceof HTMLElement?e:e.value instanceof HTMLElement?G(e):G(f.button):G(f.button))?.focus()}};dr(mc,f),_s(U(()=>No(a.value,{0:ys.Open,1:ys.Closed})));let p={buttonId:f.buttonId,panelId:f.panelId,close(){f.closePopover()}},m=Uoe(),h=m?.registerPopover,[g,_]=coe(),v=Xae({mainTreeNodeRef:m?.mainTreeNodeRef,portals:g,defaultContainers:[o,l]});function y(){return m?.isFocusWithinPopoverGroup()??(u.value?.activeElement&&(G(o)?.contains(u.value.activeElement)||G(l)?.contains(u.value.activeElement)))}return pr(()=>h?.(p)),Ns(u.value?.defaultView,`focus`,e=>{var t,n;e.target!==window&&e.target instanceof HTMLElement&&a.value===0&&(y()||o&&l&&(v.contains(e.target)||(t=G(f.beforePanelSentinel))!=null&&t.contains(e.target)||(n=G(f.afterPanelSentinel))!=null&&n.contains(e.target)||f.closePopover()))},!0),Qo(v.resolveContainers,(e,t)=>{var n;f.closePopover(),Vo(t,Jo.Loose)||(e.preventDefault(),(n=G(o))==null||n.focus())},U(()=>a.value===0)),()=>{let r={open:a.value===0,close:f.close};return ci(V,[ci(_,{},()=>os({theirProps:{...e,...n},ourProps:{ref:i},slot:r,slots:t,attrs:n,name:`Popover`})),ci(v.MainTreeNode)])}}}),vc=N({name:`PopoverButton`,props:{as:{type:[Object,String],default:`button`},disabled:{type:[Boolean],default:!1},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-popover-button-${Ao()}`,a=pc(`PopoverButton`),o=U(()=>Ro(a.button));r({el:a.button,$el:a.button}),Hi(()=>{a.buttonId.value=i}),Gi(()=>{a.buttonId.value=null});let s=Uoe()?.closeOthers,c=Woe(),l=U(()=>c===null?!1:c.value===a.panelId.value),u=A(null),d=`headlessui-focus-sentinel-${Ao()}`;l.value||pr(()=>{a.button.value=G(u)});let f=es(U(()=>({as:e.as,type:t.type})),u);function p(e){var t,n,r,i,c;if(l.value){if(a.popoverState.value===1)return;switch(e.key){case xs.Space:case xs.Enter:e.preventDefault(),(n=(t=e.target).click)==null||n.call(t),a.closePopover(),(r=G(a.button))==null||r.focus();break}}else switch(e.key){case xs.Space:case xs.Enter:e.preventDefault(),e.stopPropagation(),a.popoverState.value===1&&s?.(a.buttonId.value),a.togglePopover();break;case xs.Escape:if(a.popoverState.value!==0)return s?.(a.buttonId.value);if(!G(a.button)||(i=o.value)!=null&&i.activeElement&&!((c=G(a.button))!=null&&c.contains(o.value.activeElement)))return;e.preventDefault(),e.stopPropagation(),a.closePopover();break}}function m(e){l.value||e.key===xs.Space&&e.preventDefault()}function h(t){var n,r;e.disabled||(l.value?(a.closePopover(),(n=G(a.button))==null||n.focus()):(t.preventDefault(),t.stopPropagation(),a.popoverState.value===1&&s?.(a.buttonId.value),a.togglePopover(),(r=G(a.button))==null||r.focus()))}function g(e){e.preventDefault(),e.stopPropagation()}let _=Fs();function v(){let e=G(a.panel);if(!e)return;function t(){No(_.value,{[Is.Forwards]:()=>Wo(e,Ko.First),[Is.Backwards]:()=>Wo(e,Ko.Last)})===qo.Error&&Wo(Bo().filter(e=>e.dataset.headlessuiFocusGuard!==`true`),No(_.value,{[Is.Forwards]:Ko.Next,[Is.Backwards]:Ko.Previous}),{relativeTo:G(a.button)})}t()}return()=>{let r=a.popoverState.value===0,o={open:r},{...s}=e;return ci(V,[os({ourProps:l.value?{ref:u,type:f.value,onKeydown:p,onClick:h}:{ref:u,id:i,type:f.value,"aria-expanded":a.popoverState.value===0,"aria-controls":G(a.panel)?a.panelId.value:void 0,disabled:e.disabled?!0:void 0,onKeydown:p,onKeyup:m,onClick:h,onMousedown:g},theirProps:{...t,...s},slot:o,attrs:t,slots:n,name:`PopoverButton`}),r&&!l.value&&a.isPortalled.value&&ci(ms,{id:d,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:v})])}}}),N({name:`PopoverOverlay`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0}},setup(e,{attrs:t,slots:n}){let r=pc(`PopoverOverlay`),i=`headlessui-popover-overlay-${Ao()}`,a=gs(),o=U(()=>a===null?r.popoverState.value===0:(a.value&ys.Open)===ys.Open);function s(){r.closePopover()}return()=>{let a={open:r.popoverState.value===0};return os({ourProps:{id:i,"aria-hidden":!0,onClick:s},theirProps:e,slot:a,attrs:t,slots:n,features:us.RenderStrategy|us.Static,visible:o.value,name:`PopoverOverlay`})}}}),yc=N({name:`PopoverPanel`,props:{as:{type:[Object,String],default:`div`},static:{type:Boolean,default:!1},unmount:{type:Boolean,default:!0},focus:{type:Boolean,default:!1},id:{type:String,default:null}},inheritAttrs:!1,setup(e,{attrs:t,slots:n,expose:r}){let i=e.id??`headlessui-popover-panel-${Ao()}`,{focus:a}=e,o=pc(`PopoverPanel`),s=U(()=>Ro(o.panel)),c=`headlessui-focus-sentinel-before-${Ao()}`,l=`headlessui-focus-sentinel-after-${Ao()}`;r({el:o.panel,$el:o.panel}),Hi(()=>{o.panelId.value=i}),Gi(()=>{o.panelId.value=null}),dr(gc,o.panelId),pr(()=>{var e;if(!a||o.popoverState.value!==0||!o.panel)return;let t=s.value?.activeElement;(e=G(o.panel))!=null&&e.contains(t)||Wo(G(o.panel),Ko.First)});let u=gs(),d=U(()=>u===null?o.popoverState.value===0:(u.value&ys.Open)===ys.Open);function f(e){var t,n;switch(e.key){case xs.Escape:if(o.popoverState.value!==0||!G(o.panel)||s.value&&!((t=G(o.panel))!=null&&t.contains(s.value.activeElement)))return;e.preventDefault(),e.stopPropagation(),o.closePopover(),(n=G(o.button))==null||n.focus();break}}function p(e){var t,n,r,i,a;let s=e.relatedTarget;s&&G(o.panel)&&((t=G(o.panel))!=null&&t.contains(s)||(o.closePopover(),((r=(n=G(o.beforePanelSentinel))?.contains)!=null&&r.call(n,s)||(a=(i=G(o.afterPanelSentinel))?.contains)!=null&&a.call(i,s))&&s.focus({preventScroll:!0})))}let m=Fs();function h(){let e=G(o.panel);if(!e)return;function t(){No(m.value,{[Is.Forwards]:()=>{var t;Wo(e,Ko.First)===qo.Error&&((t=G(o.afterPanelSentinel))==null||t.focus())},[Is.Backwards]:()=>{var e;(e=G(o.button))==null||e.focus({preventScroll:!0})}})}t()}function g(){let e=G(o.panel);if(!e)return;function t(){No(m.value,{[Is.Forwards]:()=>{let e=G(o.button),t=G(o.panel);if(!e)return;let n=Bo(),r=n.indexOf(e),i=n.slice(0,r+1),a=[...n.slice(r+1),...i];for(let e of a.slice())if(e.dataset.headlessuiFocusGuard===`true`||t!=null&&t.contains(e)){let t=a.indexOf(e);t!==-1&&a.splice(t,1)}Wo(a,Ko.First,{sorted:!1})},[Is.Backwards]:()=>{var t;Wo(e,Ko.Previous)===qo.Error&&((t=G(o.button))==null||t.focus())}})}t()}return()=>{let r={open:o.popoverState.value===0,close:o.close},{focus:s,...u}=e;return os({ourProps:{ref:o.panel,id:i,onKeydown:f,onFocusout:a&&o.popoverState.value===0?p:void 0,tabIndex:-1},theirProps:{...t,...u},attrs:t,slot:r,slots:{...n,default:(...e)=>[ci(V,[d.value&&o.isPortalled.value&&ci(ms,{id:c,ref:o.beforePanelSentinel,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:h}),n.default?.call(n,...e),d.value&&o.isPortalled.value&&ci(ms,{id:l,ref:o.afterPanelSentinel,features:ps.Focusable,"data-headlessui-focus-guard":!0,as:`button`,type:`button`,onFocus:g})])]},features:us.RenderStrategy|us.Static,visible:d.value,name:`PopoverPanel`})}}}),N({name:`PopoverGroup`,inheritAttrs:!1,props:{as:{type:[Object,String],default:`div`}},setup(e,{attrs:t,slots:n,expose:r}){let i=A(null),a=Sn([]),o=U(()=>Ro(i)),s=Zae();r({el:i,$el:i});function c(e){let t=a.value.indexOf(e);t!==-1&&a.value.splice(t,1)}function l(e){return a.value.push(e),()=>{c(e)}}function u(){var e;let t=o.value;if(!t)return!1;let n=t.activeElement;return(e=G(i))!=null&&e.contains(n)?!0:a.value.some(e=>t.getElementById(e.buttonId.value)?.contains(n)||t.getElementById(e.panelId.value)?.contains(n))}function d(e){for(let t of a.value)t.buttonId.value!==e&&t.close()}return dr(hc,{registerPopover:l,unregisterPopover:c,isFocusWithinPopoverGroup:u,closeOthers:d,mainTreeNodeRef:s.mainTreeNodeRef}),()=>ci(V,[os({ourProps:{ref:i},theirProps:{...e,...t},slot:{},attrs:t,slots:n,name:`PopoverGroup`}),ci(s.MainTreeNode)])}})}));function qoe(){let e=fr(xc,null);if(e===null){let e=Error(`You used a